【C# 设计模式】观察者模式
概览
总结
如果使用C#语言,其事件和委托本身就是观察者模式的基本实现。除此之外,属性修改通知以及属性依赖等也是观察者模式的用途之一,在WinForm或者WPF中,通常将集合类控件,绑定到集合上,当集合数据发生变化时,绑定的控件能够得到通知,并且能够自动刷新界面。
在C#中使用观察者模式,除了常用的event事件之外,还可以通过IObservable
在现实世界中,许多对象并不是独立存在的,其中一个对象的行为发生改变可能会导致一个或者多个其他对象的行为也发生改变。例如,某种商品的物价上涨时会导致部分商家高兴,而消费者伤心;还有,当我们开车到交叉路口时,遇到红灯会停,遇到绿灯会行。这样的例子还有很多,例如,股票价格与股民、微信公众号与微信用户、气象局的天气预报与听众、小偷与警察等。
在软件世界也是这样,例如,Excel 中的数据与折线图、饼状图、柱状图之间的关系;MVC 模式中的模型与视图的关系;事件模型中的事件源与事件处理者。所有这些,如果用观察者模式来实现就非常方便。
模式的定义与特点
观察者(Observer)模式的定义:指多个对象间存在一对多的依赖关系,当一个对象的状态发生改变时,所有依赖于它的对象都得到通知并被自动更新。这种模式有时又称作发布-订阅模式(Publish-Subscribe)、模型-视图(Model-View)模式、源 监听器(Source-Listener)或者从属者模式,它是对象行为型模式。
- 模型(Model) -发布者是数据源,模型层, 程序员编写程序应有的功能(实现算法等等)、数据库专家进行数据管理和数据库设计(可以实现具体的功能)。
- 视图(View) -监听者是视图层, 界面设计人员进行图形界面设计。
观察者模式是一种对象行为型模式,其主要优点如下。
- 降低了目标与观察者之间的耦合关系,两者之间是抽象耦合关系。符合依赖倒置原则。
- 目标与观察者之间建立了一套触发机制。
它的主要缺点如下。
- 目标与观察者之间的依赖关系并没有完全解除,而且有可能出现循环引用。
- 当观察者对象很多时,通知的发布会花费很多时间,影响程序的效率。
C#观察者模式的实现
C#的设计者通过关键字event来简化对观察者模式的使用。它的基本用法是,首先使用event关键字定义事件,然后注册事件回调方法EventHandler,回调方法通常包含两个参数,一个object类型的sender和一个继承自EventArgs的参数,该参数携带一些触发事件的必要信息。
event事件其实是对委托的包装。对event的包装成为EventHandler,有泛型和非泛型版本,泛型主要是继承自EventArgs的类型。
下面举个例子说明:假设人病了需要去看医生。首先要定义看医生时要提供的信息,在这个例子中只需要告诉医生住址(假设是在发达的资本主义国家,医生上门服务)。
首先定义一个继承自EventArgs的类,用来存储基本信息。
class FallsIllEventArgs : EventArgs { public string Address; }
然后实现Person类:
class Person { public event EventHandlerFallsIll; public void CatchACold() { FallsIll?.Invoke(this, new FallsIllEventArgs { Address = "123 Zhong shan Road" }); } }
可以看到,这里使用了EventHandler这个泛型委托,然后使用event关键字对其进行了封装。当调用CatchACold方法时,检查是否有对FallsIll事件的注册,如果有,就触发事件。在Main函数中,使用如下:
static void Main(string[] args) { Person person = new Person(); person.FallsIll += CallDoctor; person.CatchACold(); } private static void CallDoctor(object sender, FallsIllEventArgs e) { Console.WriteLine($"A doctor has been called to {e.Address}"); }
这就是最简单的一个观察者模式。当调用Person的CatchACode方法时,就会检查是否有人注册过FallsIll事件,如果有,就触发该事件的回调,在这里就是CallDoctor方法,委托的额外信息会存储在第二个参数里。
一个事件可以被多个对象订阅,只需要简单使用+=即可,也可以使用-=取消订阅。当所有的订阅都被取消后,对象的FallsIll字段会被设置为null。
弱事件模式
当对一个对象的引用超出必要时长时,.NET程序也会产生内存泄漏。比如将某个对象设置为null,但其仍然存活,比如下面这个例子:
定义一个Button对象,包含Clicked事件:
class Button { public event EventHandler Clicked; public void Fire() { Clicked?.Invoke(this, EventArgs.Empty); } }
现在,假设在Windows对象中,有这个Button,为了简单,在Windows构造函数里传入Button对象:
class Windows { public Windows(Button btn) { btn.Clicked += ButtonOnClick; } private void ButtonOnClick(object sender, EventArgs e) { Console.WriteLine("Button clicked (windows handler)"); } ~Windows() { Console.WriteLine("Window finalized"); } }
上面的代码看起来很无聊。这里我们先实例化一个Button和Windows,然后将windows设置为null,执行垃圾回收,那么现在windows对象仍然存活吗?
var btn = new Button(); var windows = new Windows(btn); WeakReference windowsRef = new WeakReference(windows); btn.Fire(); windows = null; FireGC(); Console.WriteLine($"Is windows alive after GC?{windowsRef.IsAlive}"); btn = null; FireGC(); Console.WriteLine($"Is windows alive after GC?{windowsRef.IsAlive}"); FireGC方法如下: private static void FireGC() { Console.WriteLine("Starting GC"); GC.Collect(); GC.WaitForPendingFinalizers(); GC.Collect(); Console.WriteLine("GC is done!"); }
运行结果如下:
Button clicked (windows handler)
Starting GC
GC is done!
Is windows alive after GC? True
Button clicked (windows handler)
Starting GC
Window finalized
GC is done!
Is windows alive after GC?False
可以看到,将windows设置为null,经过GC之后,对象仍然存活。因为windows里引用了button对象,并且注册了button对象的Clicked事件,事件源会持有对事件Handler所在对象的强引用从而阻碍GC回收它,这样事件handler对象的生命周期受到了事件源对象的影响。
紧接着,如果再将button设置为null,再执行GC,这是windows才会被释放。
还有一个更简单的方法,那就是使用System.Windows里提供的WeakEventManager类,来关联事件及回调方法。
public Windows(Button btn) { WeakEventManager
其他地方不变,再次运行上述结果,可以看到,在第一次就可以看到对象就被释放了。
需要注意的是WeakEventManager是WPF框架中引入的,目前不在.NET Core中,想要是用这个方法需要将项目设置为.NET Framework 4.5及以上,另外,以上实验需要在Release环境下进行,Debug环境为了调试,对象不会被回收。
属性观察者
属性会发生变化,如何在属性发生变化的时候发出通知呢?我们以Persion对象的Age属性为例说明,假设我们定义了如下属性。
class Person { public int Age { get; set; } }
当Age发生变化时,我们需要得到通知,有一种做法是,每隔100毫秒去检查一下是否发生变化,但这种方法太笨了,不够smart。
我们需要做的其实是在设置Age的时候,如果Age值发生变化,就会触发事件,所以需要定义一个私有的age字段,然后在属性的set里做一些判断和处理。
class Person { private int age; public int Age { get { return age; } set { //todo:something here age = value; } } }
判断新的value值跟旧的age是否相等,如果不相等,则触发提醒。除了手动硬编码自己实现外,有现成的接口INotifyPropertyChanged,这接口里只有一个事件。
public interface INotifyPropertyChanged { event PropertyChangedEventHandler PropertyChanged; }
所以,在Person里,我们只需要触发PropertyChanged这个事件即可。可以添加一个方法,对这个事件触发进行简化和包装。
class Person : INotifyPropertyChanged { public event PropertyChangedEventHandler PropertyChanged; protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } }
这里用到了[CallerMemberName]这一个特性(Attribute),他告诉编译器,在编译的时候,会把当前的成员名称赋值给propertyName字段(还有一种是在代码里调用比如MethodBase.GetCurrentMethod() 可以获取当前方法名称 )。使用方法如下:
public int Age { get { return age; } set { if (age == value) return; age = value; OnPropertyChanged(); } }
在Main函数中,调用如下:
static void Main(string[] args) { var p = new Person(); p.PropertyChanged += (sender, eventArgs) => { Console.WriteLine($"{eventArgs.PropertyName} changed"); }; p.Age = 15; p.Age++; }
运行后,可以看到事件被触发了两次,第一次是Age从0改为了15,第二次是从15改成了16。
依赖问题
在Excel中,如果一个单元格里的函数引用了其他单元格,那么当这个被引用的单元格的值发生改变时,单元格的函数会重新计算以获取最新的值。
这种情况在属性中也存在,有些类的某个属性发送变化,但是这个变化会影响其他部分,也会触发响应的属性改变事件,跟Excel有内置的重新计算机制不一样,在.NET里需要我们自己来处理这种问题。为了说明问题,现在假设只有年龄大于等于16岁,才有投票权,我们需要在用户投票权发生变更时收到通知。
public bool CanVote => Age >= 16;
可以看到CanVote属性是只读的,所以只有在Age的设置的时候来触发了,所以代码变成了下面这样:
public int Age { get { return age; } set { if (age == value) return; var oldCanVote = CanVote; age = value; OnPropertyChanged(); if (oldCanVote == CanVote) { OnPropertyChanged(nameof(CanVote)); } } }
代码首先记录在没有修改Age之前的CanVote存在oldCanVote里,在修改完Age之后,比较最新的CanVote和之前oldCanVote是否相等,如果不相等,触发OnPropertyChanged事件,参数为CanVote属性。这段代码能实现需求,但是代码太丑陋,充满了Bad Smell,现在Age属性只是影响了CanVote,如果还会影响到其他属性怎么办,上面的代码面对扩展非常不友好。
一种办法是,新建一个基类,来处理所有的提醒。我们这里定义了一个Dictionary来保存属性,以及影响到的其他属性。
class PropertyNotificationSupport : INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
private readonly Dictionary<string, HashSet<string>> affectedBy = new Dictionary<string, HashSet<string>>();
}
接着,实现OnPropertyChanged方法,来触发PropertyChanged事件。
protected virtual void OnPropertyChanged([CallerMemberName] string propertyName = null) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); foreach (var affect in affectedBy.Keys) { if (affectedBy[affect].Contains(propertyName)) { OnPropertyChanged(affect); } } }
现在,不仅触发当前正在变动的属性,而且还要触发依赖当前属性的所有其他属性。现在问题是,如何描述CanVote对Age的依赖,如何填充affectedBy结构呢?需要额外的手动去填充吗?这种方法需要做太多的工作。这里有一种方法是,使用表达式树,关于表达式树我之前写过几篇文章C#表达式树:基本用法,构建动态查询,用表达式树替代反射,可以参考。
如果我们将CanVote这个只读属性定义为一颗表达式树,那么我们就可以遍历表达式树,自动发现所有对其他属性的依赖,这种肯定比之前的笨方法好。
我们可以在PropertyNotificationSupport基类中,新建一个名为Property方法,参数为属性名称和一个表达式树Expression
protected FuncProperty (String name, Expression > expr) { Console.WriteLine($"Creating computed property for expression {expr} "); var visitor = new MemberAccessVisitor(GetType()); visitor.Visit(expr); if (visitor.PropertyNames.Any()) { if (!affectedBy.ContainsKey(name)) { affectedBy.Add(name, new HashSet<string>()); } foreach (var propName in visitor.PropertyNames) { if (propName != name) { affectedBy[name].Add(propName); } } } return expr.Compile(); }
这个方法的目的不仅在于将表达式树编译为可执行的形式,他还是用MemberAccessVisitor类来遍历表达式树,找到所有依赖的其他属性,并将其加入到affectedBy字典中。这个MemberAccessVisitor本身是访问者模式,可以作为一个私有的内部类来实现,实现如下:
class MemberAccessVisitor : ExpressionVisitor { private readonly Type declaringType; public IList<string> PropertyNames = new List<string>(); public MemberAccessVisitor(Type declaringType) { this.declaringType = declaringType; } public override Expression Visit(Expression expr) { if (expr != null && expr.NodeType == ExpressionType.MemberAccess) { var memberExpr = (MemberExpression)expr; if (memberExpr.Member.DeclaringType == declaringType) { PropertyNames.Add(memberExpr.Member.Name); } } return base.Visit(expr); } }
这个方法就是遍历表达式,然后找出所有的成员,比如Age属性,然后将属性名称加入到List中返回。
最后,用法如下:
class Person : PropertyNotificationSupport { private int age; public int Age { get { return age; } set { if (age == value) return; age = value; OnPropertyChanged(); } } private readonly Func<bool> canVote; public bool CanVote => canVote(); public Person() { canVote = Property(nameof(CanVote), () => Age >= 16); } }
重新运行Main函数,可以看到每当Age属性发生改变的时候,CanVote就会发生“改变”,CanVote不是真正的“值”发生改变,而是需要“重新计算”。上面这种方法看起来过于复杂,但这种方法解决了属性依赖问题,并且对性能的影响非常小。唯一的问题是,对于“相互依赖”比如A依赖B,B又依赖A,或者“循环依赖”这种会有问题。
事件流
当谈到观察者(Observer)模式时会注意到,在最新的.NET Framework中添加了两个接口:IObserver
IObservable
备注:把 IObservable.Subscribe(...) 与 x.event+=... 是一样的。
该方法的唯一参数为IObserver
还有一点需要注意的是,IObservable
第二个就是IObserver
- OnNext(T),该方法当新事件产生时会被出发。
- OnCompleted(),当事件源没有数据产生时触发。
- OnError(),当事件源产生错误时触发。
再次需要注意,这只是一个接口,如何实现完全取决与我们,比如可以完全忽略OnCompleted()和OnError()方法。
使用这两个接口,可以重写上述的生病看医生的例子。首先,我们需要来封装一下事件订阅,需要实现备忘录模式的对象并且实现IDisposable接口,这样能处理取消订阅的场景。
class Subscription : IDisposable { private Person person; public IObserverObserver; public Subscription(Person pers, IObserver observer) { person = pers; Observer = observer; } public void Dispose() { person.subscriptions.Remove(this); } }
这个Subscription是Person类的一个内部类,要支持事件流模式就会使得代码变得复杂。现在来看Person类,需要实现IObservable
class Event { } class FallIllEvent : Event { public string Address; }
现在,可以让Person实现该类型的IObservable
class Person: IObservable{ private readonly HashSet subscriptions = new HashSet (); public IDisposable Subscribe(IObserver observer) { var subscription = new Subscription(this, observer); subscriptions.Add(subscription); return subscription; } public void CatchACode() { foreach (var sub in subscriptions) { sub.Observer.OnNext(new FallIllEvent() { Address = "123 Zhongsan Road" }); } } class Subscription : IDisposable { private Person person; public IObserver Observer; public Subscription(Person pers, IObserver observer) { person = pers; Observer = observer; } public void Dispose() { person.subscriptions.Remove(this); } } }
可以看到,这一版代码,比之前简单的使用event关键字实现的事件模式要复杂很多。但是这种事件流的方式有很多优点。比如,我们可以使用我们自己的策略来处理重复订阅,比如当一个订阅者试图多次重复订阅某一个事件时,可以考虑是触发多次,还是只触发一次。需要注意的是,这里的HashSet
“麻烦”还没结束,需要注意的是,订阅者还需要实现IObserver
class Program : IObserver{ public Program() { var person = new Person(); var sub = person.Subscribe(this); person.CatchACode(); } static void Main(string[] args) { new Program(); Console.ReadLine(); } public void OnNext(Event value) { if (value is FallIllEvent args) { Console.WriteLine($"A doctor has been called to {args.Address}"); } } public void OnError(Exception error) { //Ignore } public void OnCompleted() { //Ignore } }
这里多一嘴,我们可以使用Observable.Subscribe()静态方法来简化事件的订阅,但Observable(没有T)类,是Reactive Extensions类库里的实现,如果要实现,需要安装该类库。
上述就是我们不使用event关键字,使用.NET 自带的IObservable
var person = new Person(); var sub = person.Subscribe(this); person.OfType() .Subscribe(args => WriteLine($"A doctor has been called to {args.Address}"));
Observable集合
如果将一个List
这两个集合类型表现起来就跟Collection
需要注意的是BindingList
C#代码
IObservable
Weather weather = new Weather() { Teapreture=new WeatherData { temperature="25度"} }; Person wanglin = new Person() { Name="王玲"}; Person mulan = new Person() { Name = "木兰" }; weather.Subscribe(wanglin);///Subscribe 等同于 事件的"+" weather.Subscribe(mulan);// weather.NotifyTeapreture();//公告温度 Console.Read(); ////// 订阅者 /// public class Person : IObserver {//关注温度 public string Name { get; set; } public void OnCompleted() { Console.WriteLine(" "); } public void OnError(Exception error) { throw new NotImplementedException(); } public void OnNext(WeatherData value) { Console.WriteLine($"我{Name}知道今天的天气了,是{value.temperature}"); } } /// /// 数据 /// public class WeatherData { /// /// 气温 /// public string temperature { get; set; } /// /// 湿度 /// public string humility { get; set; } /// /// 气压 /// public string pressure { get; set; } } /// /// 发布者 /// public class Weather : IObservable { //提供温度 数据 public WeatherData Teapreture { get; set; } private List > subsribers = new List >(); public IDisposable Subscribe(IObserver observer) { if (!subsribers.Contains(observer)) { subsribers.Add(observer); } return new UnSubsribe(this, observer); } class UnSubsribe : IDisposable { private Weather weather; private IObserver observer; public UnSubsribe(Weather weather, IObserver observer) { this.weather = weather; this.observer = observer; } public void Dispose() { weather.subsribers.Remove(observer); } } public void NotifyTeapreture() { foreach (var item in subsribers) { item.OnNext(Teapreture); } } public void OnComplete() { foreach (var item in subsribers) { item.OnCompleted(); } } }