Observer
Motivation
- To be informed when status changed
- To be informed when certain things happened.
- Listen to events and get notified when they occurs
.NET has following builtin types to perform observer pattern.
INotifyPropertyChanged
INotifyPropertyChanging
IObservable<T>
IObserver<T>
ObervableCollection<T>
BindingList<T>
NOTE
Observer is the object to be informed when event occurs, informed by passing event args. Observable is the object passes the event args when performing something.
Builtin Event
C# has builtin event support. Use +=
to subscribe a method.
NOTE
Event handler is the Observer Player
is the Observable
Player player = new() { Id = 1 };
Player enemy = new() { Id = 2 };
player.OnHurt += (object? sender, PlayerOnHurtArgs args) => // event handler method is the Observer
Console.WriteLine($"Player {(sender as Player)?.Id ?? -1} get hurt by damage {args.Damage} from player {args.Id}");
player.GetHurt(1, enemy);
class Player
{
public int Id { get; init; }
public int Health { get; private set; } = 100;
public event EventHandler<PlayerOnHurtArgs>? OnHurt;
public void GetHurt(int damage, Player another)
{
Health -= damage;
OnHurt?.Invoke(this, new(damage, another.Id));
}
}
record PlayerOnHurtArgs(int Damage, int Id);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Weak Event
When a method of an instance was added to an event, the containing object of the method will never be collected by GC.
Player player = new() { Id = 1 };
Player? enemy = new() { Id = 2 };
player.OnHurt += enemy.OnHurtHandler; // subscribes a handler from another instance
WeakReference<Player> enemyRef = new(enemy);
enemy = null;
GC.Collect(); // should enemy be collected?
Console.WriteLine($"enemy is {(enemyRef.TryGetTarget(out _) ? "alive" : "collected")}"); // <- enemy is alive
class Player
{
public int Id { get; init; }
public int Health { get; private set; } = 100;
public event EventHandler<PlayerOnHurtArgs>? OnHurt;
public void GetHurt(int damage, Player enemy)
{
Health -= damage;
OnHurt?.Invoke(this, new(damage, enemy.Id));
}
public void OnHurtHandler(object? sender, PlayerOnHurtArgs args)
{
Console.WriteLine($"Player {(sender as Player)?.Id ?? -1} get hurt by damage {args.Damage} from player {args.Id}");
}
}
record PlayerOnHurtArgs(int Damage, int Id);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
That's why weak event pattern matters.
Player player = new() { Id = 1 };
Player? enemy = new() { Id = 2 };
player.OnHurt += enemy.OnHurtHandler; // subscribes a handler from another instance
WeakEventManager<Player, PlayerOnHurtArgs>.AddHandler(player. "OnHurt", enemy.OnHurtHandler)
WeakReference<Player> enemyRef = new(enemy);
enemy = null;
GC.Collect(); // should enemy be collected?
Console.WriteLine($"enemy is {(enemyRef.TryGetTarget(out _) ? "alive" : "collected")}"); // <- enemy is collected
class Player
{
public int Id { get; init; }
public int Health { get; private set; } = 100;
public event EventHandler<PlayerOnHurtArgs>? OnHurt;
public void GetHurt(int damage, Player enemy)
{
Health -= damage;
OnHurt?.Invoke(this, new(damage, enemy.Id));
}
public void OnHurtHandler(object? sender, PlayerOnHurtArgs args)
{
Console.WriteLine($"Player {(sender as Player)?.Id ?? -1} get hurt by damage {args.Damage} from player {args.Id}");
}
}
record PlayerOnHurtArgs(int Damage, int Id);
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
WARNING
WeakEventManager
is not available in .NET Core
Observer and Observable
The major downside of event in C# is event leak, which happens when event handlers remain subscribed and prevent objects from being garbage collected, leading to memory leaks.
.NET has builtin types to implement the same event mechanism with System.IObserver<T>
and System.IObservable<T>
Observable and Observer do not know each other, the mediator is certain EventArgs
. Observer handles notifications by different kinds of EventArgs
.
- Observable: adds Observers
- Subscription: as a
IDisposable
pair of Observer and Observable, will be stored on a collection inside Observable.Dispose
acts like-=
Subscribe
acts like+=
- Subscription: as a
- Observer: independently handles different scenarios.
OnNext
,OnError
.etc
Player player = new() { Id = 1 };
Player? enemy = new() { Id = 2 };
var subscriber = new PlayerObserver();
using var _ = player.Subscribe(subscriber);
player.Attack(enemy, 100);
class Player : IObservable<PlayerEventArgs>
{
public int Id { get; init; }
public int Health { get; private set; } = 100;
private readonly HashSet<Subscription> subscriptions = []; // events as collections
// subscribes an event just like `+=` when using builtin event
public IDisposable Subscribe(IObserver<PlayerEventArgs> observer)
{
var subscription = new Subscription(this, observer);
subscriptions.Add(subscription);
return subscription;
}
public void Attack(Player enemy, int damage)
{
// ...
foreach (var sub in subscriptions)
{
sub.Observer.OnNext(new OnAttackEventArgs { EnemyId = enemy.Id, Damage = damage, PlayerId = Id });
}
}
// a subscription should know which one is being subscribed and who is the observer.
// observer take a `PlayerEventArgs` which is a base EventArgs, allowing different type of subscription to be inside `subscriptions`
private class Subscription(Player player, IObserver<PlayerEventArgs> observer) : IDisposable
{
private readonly Player player = player;
public IObserver<PlayerEventArgs> Observer { get; } = observer;
public void Dispose()
{
player.subscriptions.Remove(this);
}
}
}
class PlayerEventArgs
{
public int PlayerId { get; set; }
}
class OnAttackEventArgs : PlayerEventArgs
{
public int EnemyId { get; set; }
public int Damage { get; set; }
}
class PlayerObserver : IObserver<PlayerEventArgs>
{
public void OnCompleted() { }
public void OnError(Exception error) { }
public void OnNext(PlayerEventArgs value)
{
if (value is OnAttackEventArgs args)
{
Console.WriteLine($"Enemy id: {args.EnemyId} was attacked by player id: {args.PlayerId} with damage {args.Damage}");
}
// ...
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
Observable Collection
BindingList<T>
is a collection type with builtin event to tracked on collection manipulations.
using System.ComponentModel;
new Weather().Measure();
class Weather
{
public BindingList<float> Tempretures { get; } = [];
public Weather()
{
// BindingList has a builtin event on manipulation
Tempretures.ListChanged += (sender, args) =>
{
if (args.ListChangedType == ListChangedType.ItemAdded)
{
var newtempreture = (sender as BindingList<float>)?[args.NewIndex];
Console.WriteLine($"New tempreture {newtempreture} degree has been added.");
}
};
}
public void Measure()
{
Tempretures.Add(Random.Shared.NextSingle() * 100);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
WARNING
BindingList<T>
can only track manipulations, can't track on status. For those purposes, you should add custom events.
Property Observer
Use INotifyPropertyChanged
for watching properties. INotifyPropertyChanged
implies the object implementing this interface should have a ability to be informed when certain property changed. The event handler generated takes PropertyChangedEventArgs
which has only a property named as PropertyName
.
using System.ComponentModel;
Player player = new() { Id = 1 };
Player? enemy = new() { Id = 2 };
player.Attack(enemy, 100);
class Player : INotifyPropertyChanged
{
public int Id { get; init; }
public int Health { get; private set; } = 100;
public event PropertyChangedEventHandler? PropertyChanged;
public Player()
{
PropertyChanged += (sender, args) =>
{
Console.WriteLine($"Property `{args.PropertyName}` of {(sender as Player)?.Id ?? -1} changed!");
};
}
public void Attack(Player enemy, int damage)
{
enemy.Health -= damage;
Console.WriteLine($"enemy {Id} been attacked by player {enemy.Id} with damage {damage}");
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Health)));
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Bidirectional Observer
A bidirectional observer means two objects subscribe each other, will get notification on no matter which side. So, a common approach is implementing INotifyPropertyChanged
and append event handlers for both.
using System.ComponentModel;
using System.Runtime.CompilerServices;
var init = "Hello";
View view = new() { InnerText = init };
TextBlock textBlock = new() { Text = init };
view.PropertyChanged += (sender, args) =>
{
if (args.PropertyName == nameof(View.InnerText))
{
Console.WriteLine($"Property {typeof(View).Name}.{nameof(View.InnerText)} has changed.");
textBlock.Text = view.InnerText; // also updates for another side
}
};
textBlock.PropertyChanged += (sender, args) =>
{
if (args.PropertyName == nameof(TextBlock.Text))
{
Console.WriteLine($"Property {typeof(TextBlock).Name}.{nameof(TextBlock.Text)} has changed.");
view.InnerText = textBlock.Text; // also updates for another side
}
};
view.InnerText = "World";
// Property View.InnerText has changed.
// Property TextBlock.Text has changed.
Console.WriteLine(view.InnerText); // <- World
Console.WriteLine(textBlock.Text); // <- World
class TextBlock : INotifyPropertyChanged
{
private string? text;
public string? Text
{
get => text;
set
{
if (value == text) return;
text = value;
OnPropertyChanged();
}
}
public event PropertyChangedEventHandler? PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
class View : INotifyPropertyChanged
{
private string? innerText;
public string? InnerText
{
get => innerText;
set
{
if (value == innerText) return;
innerText = value;
OnPropertyChanged();
}
}
public event PropertyChangedEventHandler? PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
NOTE
An interesting part is, bidirectional observer above does not cause stack overflow. simply because a guardian if(value == prop) return
is inside setter.
Bidirectional Binding
Previous example shows a very tedious implementation for bidirectional observer, we don't really want to hard code everything for each pair of object we have. So, a custom generic class for performing the mechanism if required.
using System.ComponentModel;
using System.Linq.Expressions;
using System.Reflection;
using System.Runtime.CompilerServices;
var init = "Hello";
View view = new() { InnerText = init };
TextBlock textBlock = new() { Text = init };
var _ = new BidirectionalBinding<View, TextBlock>(
view,
v => v.InnerText, // selects which property to track
textBlock,
t => t.Text
);
view.InnerText = "World";
Console.WriteLine(view.InnerText); // <- World
Console.WriteLine(textBlock.Text); // <- World
class BidirectionalBinding<TFirst, TSecond>
where TFirst : INotifyPropertyChanged // both should be `INotifyPropertyChanged`
where TSecond : INotifyPropertyChanged
{
public BidirectionalBinding(
TFirst first,
Expression<Func<TFirst, object?>> firstSelector,
TSecond second,
Expression<Func<TSecond, object?>> secondSelector)
{
if (firstSelector.Body is MemberExpression firExpr && secondSelector.Body is MemberExpression secExpr)
{
if (firExpr.Member is PropertyInfo firProp && secExpr.Member is PropertyInfo secProp)
{
first.PropertyChanged += (sender, args) => secProp.SetValue(second, firProp.GetValue(first));
second.PropertyChanged += (sender, args) => firProp.SetValue(first, secProp.GetValue(second));
}
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
NOTE
BidirectionalBinding
should actually be a IDisposable
, but I failed to find a way to deleted the event handlers.
Property Dependencies (WIP)
On problem of property observer is, multiple property tracking is possible unless they have dependencies among.
Given a example of a property for conditional status IsDying
which is dependent on property Health
.
Once Health
changed, IsDying
might changed too, but would need a extra check to make sure it doesn't notify on each change of Health
. But this will explode when you have many many dependent properties.
using System.ComponentModel;
using System.Runtime.CompilerServices;
Player p = new();
p.Health -= 99;
// Property Health changed
// Property IsDying changed
class Player : INotifyPropertyChanged
{
private int health = 100;
public int Health
{
get => health;
set
{
if (value == health) return;
var isDying = IsDying;
health = value;
OnPropertyChanged();
if (isDying != IsDying)
OnPropertyChanged(nameof(IsDying));
}
}
public bool IsDying => Health < 5;
public Player()
{
PropertyChanged += (sender, args) =>
Console.WriteLine($"Property {args.PropertyName} changed.");
}
public event PropertyChangedEventHandler? PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new(propertyName));
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
We'll have to use a base class to handle the default behaviors. Each property might have multiple dependencies and each dependency should be unique since they're inside the same class. So a map as Dictionary<string, HashSet<string>>
appears here and the OnPropertyChanged
should walk each property recursively.
abstract class PropertyNotificationSupport : INotifyPropertyChanged
{
private readonly Dictionary<string, HashSet<string>> _depedendcyMap = []; // stores property name and its dependent properties.
public event PropertyChangedEventHandler? PropertyChanged;
protected virtual void OnPropertyChanged([CallerMemberName] string? propertyName = null)
{
PropertyChanged?.Invoke(this, new(propertyName));
foreach (var prop in _depedendcyMap.Keys)
{
// for props relied on the coming property
// notify them recursively
if (_depedendcyMap[propertyName].Contains(prop))
OnPropertyChanged(prop); // recursive call on other property dependencies
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Yet another target is not only tracking the single property name but also sending with which property change causes the change of another property. Which means the literal property dependency graph.
The solution is using Expression
, to assign both evaluation logic and name of the property with one shot. We should have a method inside base class to do that.
private readonly Func<bool> _isDying;
public bool IsDying => _isDying();
public Player()
{
// implement `Property` method in the base class.
IsDying = Property(nameof(IsDying), () => Health < 5);
}
2
3
4
5
6
7