观察者模式,简单来说就是一个组件在状态发生变化的时候,可以通知其它组件。观察者模式用途很广,比如在UI界面中,当绑定的数据对象发送改变,UI会跟着发送改变。观察者模式是一种很常见和必要的模式,C#的设计者通过关键字event来简化对观察者模式的使用。它的基本用法是,首先使用event关键字定义事件,然后注册事件回调方法EventHandler,回调方法通常包含两个参数,一个object类型的sender和一个继承自EventArgs的参数,该参数携带一些触发事件的必要信息。

    event事件其实是对委托的包装,就像Action和Func是对lamda表达式的包装一样。对event的包装成为EventHandler,有泛型和非泛型版本,泛型主要是继承自EventArgs的类型。

    下面举个例子说明:假设人病了需要去看医生。首先要定义看医生时要提供的信息,在这个例子中只需要告诉医生住址(假设是在发达的资本主义国家,医生上门服务)。

    首先定义一个继承自EventArgs的类,用来存储基本信息。

class FallsIllEventArgs : EventArgs
{
    public string Address;
}

    然后实现Person类:

class Person
{
    public event EventHandler<FallsIllEventArgs> FallsIll;

    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<Button, EventArgs>.AddHandler(btn, "Clicked", ButtonOnClick);
}

     其他地方不变,再次运行上述结果,可以看到,在第一次就可以看到对象就被释放了。

     需要注意的是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<Func<T>>,这个表达式树用来根据其他属性计算出当前属性的值。

protected Func<T> Property<T>(String name, Expression<Func<T>> 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<T>和IObservable<T>,这俩接口恰好是跟随Reactive Extensions(RX)一起发布的,这表示他们最初是用来处理响应流而存在的。这里不讨论Reactive Extension(具体可以看我之前写的 Reactive Extension入门系列文章),但这两个接口是值的考察一下的。

    IObservable<T>接口跟普通的.NET 事件非常相似,区别在于,普通的.NET事件使用+=的方式来订阅,但IObservable<T>接口需要我们实现Subscribe()方法。该方法的唯一参数为IObserver<T>接口。需要注意的是IObserver<t>是一个接口,跟事件或者委托不同,没有实现存储的机制,可以非常灵活的来处理。

    还有一点需要注意的是,IObservable<T>接口提供了取消订阅的方法。Subscribe()方法返回了一个IDisposable类型,他通常是一个实现了备忘录模式的类型,并且有Dispose()方法,可以用来取消订阅。

    第二个就是IObserver<T>接口。他被设计用来实现基于推送的通知,他有三个方法:

  • OnNext(T),该方法当新事件产生时会被出发。
  • OnCompleted(),当事件源没有数据产生时触发。
  • OnError(),当事件源产生错误时触发。

    再次需要注意,这只是一个接口,如何实现完全取决与我们,比如可以完全忽略OnCompleted()和OnError()方法。

    使用这两个接口,可以重写上述的生病看医生的例子。首先,我们需要来封装一下事件订阅,需要实现备忘录模式的对象并且实现IDisposable接口,这样能处理取消订阅的场景。

class Subscription : IDisposable
{
    private Person person;
    public IObserver<Event> Observer;

    public Subscription(Person pers, IObserver<Event> observer)
    {
        person = pers;
        Observer = observer;
    }

    public void Dispose()
    {
        person.subscriptions.Remove(this);
    }
}

    这个Subscription是Person类的一个内部类,要支持事件流模式就会使得代码变得复杂。现在来看Person类,需要实现IObservable<T>接口。T是什么类型呢?和传统的事件不一样,这里没有要求必须继承自EventArgs,在这里我们先定义一个基类Event,里面什么都不做,然后定义一个子类FallIllEvent,里面有一个Address字段,表示生病后需要通知医生到达的地址。

class Event
{
}

class FallIllEvent : Event
{
    public string Address;
}

    现在,可以让Person实现该类型的IObservable<Event>接口,并且在接口的Subscribe()方法里接收IObserver<Event>接口参数。Person的具体实现如下:

class Person: IObservable<Event>
{
    private readonly HashSet<Subscription> subscriptions = new HashSet<Subscription>();

    public IDisposable Subscribe(IObserver<Event> 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<Event> Observer;

        public Subscription(Person pers, IObserver<Event> observer)
        {
            person = pers;
            Observer = observer;
        }

        public void Dispose()
        {
            person.subscriptions.Remove(this);
        }
    }
}

    可以看到,这一版代码,比之前简单的使用event关键字实现的事件模式要复杂很多。但是这种事件流的方式有很多优点。比如,我们可以使用我们自己的策略来处理重复订阅,比如当一个订阅者试图多次重复订阅某一个事件时,可以考虑是触发多次,还是只触发一次。需要注意的是,这里的HashSet<Subscription>是非线程安全的,这意味着如果同时调用Subscribe()方法和CatchACode方法时,可能涉及到线程安全问题。可以用锁,或者使用更简单的方法比如ImmutableList等来实现,这里略过。

    “麻烦”还没结束,需要注意的是,订阅者还需要实现IObserver<Event>接口,来处理OnNext,OnCompleted,OnError回调。

class Program : IObserver<Event>
{
    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<T>和IObserver<T>,手动实现观察者模式的方法。最大的优点就是,这种由IObservable产生的事件流模式很容易跟ReactiveExtension(Rx)框架中的一些操作合并起来使用,比如,使用System.Reactive,整个上面的Program可以简化为下面这样,非常方便。

var person = new Person();
var sub = person.Subscribe(this);
person.OfType<FallsIllEvent>()
.Subscribe(args => WriteLine($"A doctor has been called to {args.Address}"));

Observable集合


    如果将一个List<T>绑定到WinForm或者WPF中的ListBox,当List发送改变的时候,UI不会更新,这是因为List<T>并不支持Observer模式。当然如果单个对象发生改变,但整个List并没有事件能够向外通知其内容发送了改变。诚然我们可以对List进行包装,在Add或者Remove方法里添加事件通知,可以但没必要,在WinForm和WPF中都有Observable集合,他们分别是BindingList<T>和ObservableCollection<T>。

    这两个集合类型表现起来就跟Collection<T>一样,但是他提供了额外的通知。比如,对于UI组件,当绑定的集合发送改变时,UI会自动进行更新。ObservableCollection<T>实现了INotifyCollectionChanged接口,所以他有CollectionChanged事件。这个时间会告知集合发生了什么改变,并且会提供旧的和新的元素,以及旧的和新的元素在集合中的位置,换句话说,根据事件我们能够正确的重新绘制ListBox或者其他集合组件。

    需要注意的是BindingList<T>和ObservableCollection<T>都不是线程安全的集合,因此在多线程读写集合的时候,需要特别注意。一种方式是继承自Observable集合,然后对一些写入操作加锁。另外一种方法是继承自ConcurrentBag<T>,然后实现INotifyCollectionChanged接口。相对而言,第一种方式简单一些。

总结


    如果使用C#语言,其事件和委托本身就是观察者模式的基本实现。除此之外,属性修改通知以及属性依赖等也是观察者模式的用途之一,在WinForm或者WPF中,通常将集合类控件,绑定到集合上,当集合数据发生变化时,绑定的控件能够得到通知,并且能够自动刷新界面。

    在C#中使用观察者模式,除了常用的event事件之外,还可以通过IObservable<T>和IObserver<T>两个接口来实现事件流模式,看起来有些复杂,但是这种方式有很多优点,并且很容易能跟Reactive Extensions框架配合。最后讲述了Observerable集合BindingList<T>和ObservableCollection<T>,他们不是线程安全的,使用的时候需要注意多线程读写的问题,这两个集合通常跟配套的支持集合的控件来绑定使用,这样能做到当集合数据发生变化时,对应的界面能够自动刷新。