【.NET】| 总结/Edison Zhou
本文为第九篇,我们会对.NET的事件相关考点进行基础复习,全文会以Q/A的形式展现,即以面试题的形式来描述。
开头
事件这一名称对于我们.NET码农来说肯定不会陌生,各种技术框架例如WindowsForm、ASP.NET WebForm都会有事件这一名词,并且所有的定义都基本相同。在.NET中,事件和委托在本质上并没有太多的差异,实际环境下事件的运用却比委托更加广泛。
1 能说说事件如何使用吗?
在Microsoft的产品文档上这样来定义的事件:事件是一种使对象或类能够提供通知的成员。客户端可以通过提供事件处理程序为相应的事件添加可执行代码。设计和使用事件的全过程大概包括以下几个步骤:
下面我们来按照规范的步骤来展示一个通过控制台输出事件的使用示例:
① 定义一个控制台事件ConsoleEvent的参数类型ConsoleEventArgs
/// <summary>/// 自定义一个事件参数类型/// </summary>public class ConsoleEventArgs : EventArgs{ // 控制台输出的消息 private string message; public string Message { get { return message; } } public ConsoleEventArgs() : base() { this.message = string.Empty; } public ConsoleEventArgs(string message) : base() { this.message = message; }}② 定义一个控制台事件的管理者,在其中定义了事件类型的私有成员ConsoleEvent,并定义了事件的发送方法SendConsoleEvent
/// <summary>/// 管理控制台,在输出前发送输出事件/// </summary>public class ConsoleManager{ // 定义控制台事件成员对象 public event EventHandler<ConsoleEventArgs> ConsoleEvent; /// <summary> /// 控制台输出 /// </summary> public void ConsoleOutput(string message) { // 发送事件 ConsoleEventArgs args = new ConsoleEventArgs(message); SendConsoleEvent(args); // 输出消息 Console.WriteLine(message); } /// <summary> /// 负责发送事件 /// </summary> /// <param name="args">事件的参数</param> protected virtual void SendConsoleEvent(ConsoleEventArgs args) { // 定义一个临时的引用变量,确保多线程访问时不会发生问题 EventHandler<ConsoleEventArgs> temp = ConsoleEvent; if (temp != null) { temp(this, args); } }}③ 定义了事件的订阅者Log,在其中通过控制台时间的管理类公开的事件成员订阅其输出事件ConsoleEvent
/// <summary>/// 日志类型,负责订阅控制台输出事件/// </summary>public class Log{ // 日志文件 private const string logFile = @"C:\TestLog.txt"; public Log(ConsoleManager cm) { // 订阅控制台输出事件 cm.ConsoleEvent += this.WriteLog; } /// <summary> /// 事件处理方法,注意参数固定模式 /// </summary> /// <param name="sender">事件的发送者</param> /// <param name="args">事件的参数</param> private void WriteLog(object sender, EventArgs args) { // 文件不存在的话则创建新文件 if (!File.Exists(logFile)) { using (FileStream fs = File.Create(logFile)) { } } FileInfo fi = new FileInfo(logFile); using (StreamWriter sw = fi.AppendText()) { ConsoleEventArgs cea = args as ConsoleEventArgs; sw.WriteLine(DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss") + "|" + sender.ToString() + "|" + cea.Message); } }}④ 在Main方法中进行测试:
public class Program{ public static void Main(string[] args){ // 控制台事件管理者 ConsoleManager cm = new ConsoleManager(); // 控制台事件订阅者 Log log = new Log(cm); cm.ConsoleOutput("测试控制台输出事件"); cm.ConsoleOutput("测试控制台输出事件"); cm.ConsoleOutput("测试控制台输出事件"); Console.ReadKey(); }}
当该程序执行时,ConsoleManager负责在控制台输出测试的字符串消息,与此同时,订阅了控制台输出事件的Log类对象会在指定的日志文件中写入这些字符串消息。可以看出,这是一个典型的观察者模式的应用,也可以说事件为观察者模式提供了便利的实现基础。
2 事件 和 委托 有什么关系?
事件的定义和使用方式与委托极其类似,那么二者又是何关系呢?
经常听人说,委托的本质是一个类型,而事件的本质是一个特殊的委托类型的实例。关于这个解释,最好的办法莫过于通过查看原代码和编译后的IL代码进行分析。
① 回顾刚刚的代码,在ConsoleManager类中定义了一个事件成员
public event EventHandler<ConsoleEventArgs> ConsoleEvent;EventHandler 是.NET框架中提供的一种标准的事件模式,它是一个特殊的泛型委托类型,通过查看元数据可以验证这一点:
[Serializable]public delegate void EventHandler<TEventArgs>(object sender, TEventArgs e);正如上面代码所示,我们定义一个事件时,实际上是定义了一个特定的委托成员实例。该委托没有返回值,并且有两个参数:一个事件源和一个事件参数。而当事件的使用者订阅该事件时,其本质就是将事件的处理方法加入到委托链之中。
② 下面通过Reflector来查看一下事件ConsoleEvent的IL代码(中间代码),可以更方便地看到这一点:
首先,查看EventHandler的IL代码,可以看到在C#编译器编译delegate代码时,编译后是成为了一个class。
其次,当C#编译器编译event代码时,会首先为类型添加一个EventHandler<T>的委托实例对象,然后为其增加一对add/remove方法用来实现从委托链中添加和移除方法的功能。
通过查看add_ConsoleEvent的IL代码,可以清楚地看到订阅事件的本质是调用Delegate的Combine方法将事件处理方法绑定到委托链中。
L_0000: ldarg.0 L_0001: ldfld class [mscorlib]System.EventHandler`1<class ConsoleEventDemo.ConsoleEventArgs> ConsoleEventDemo.ConsoleManager::ConsoleEventL_0006: stloc.0 L_0007: ldloc.0 L_0008: stloc.1 L_0009: ldloc.1 L_000a: ldarg.1 L_000b: call class [mscorlib]System.Delegate [mscorlib]System.Delegate::Combine(class [mscorlib]System.Delegate, class [mscorlib]System.Delegate)L_0010: castclass [mscorlib]System.EventHandler`1<class ConsoleEventDemo.ConsoleEventArgs>L_0015: stloc.2 L_0016: ldarg.0 L_0017: ldflda class [mscorlib]System.EventHandler`1<class ConsoleEventDemo.ConsoleEventArgs> ConsoleEventDemo.ConsoleManager::ConsoleEvent总结:事件是一个特殊的委托实例,提供了两个供订阅事件和取消订阅的方法:add_event 和 remove_event,其本质都是基于委托链来实现。
3 如何设计一个带有多个事件的类型?
多事件的类型在实际应用中并不少见,尤其是在一些用户界面的类型中(例如在WindowsForm中的各种控件)。这些类型动辄将包含数十个事件,如果为每一个事件都添加一个事件成员,将导致无论使用者是否用到所有事件,每个类型对象都将占有很大的内存,那么对于系统的性能影响将不言而喻。事实上,.NET的开发小组运用了一种比较巧妙的方式来避免这一困境。
Solution:当某个类型具有相对较多的事件时,我们可以考虑显示地设计订阅、取消订阅事件的方法,并且把所有的委托链表存储在一个集合之中。这样做就能避免在类型中定义大量的委托成员而导致类型过大。
下面通过一个具体的实例来说明这一设计:
① 定义包含大量事件的类型之一:使用EventHandlerList成员来存储所有事件
public partial class MultiEventClass{ // EventHandlerList包含了一个委托链表的容器,实现了多事件存放在一个容器之中的包装,它使用的是链表数据结构 private EventHandlerList events; public MultiEventClass() { // 初始化EventHandlerList events = new EventHandlerList(); } // 释放EventHandlerList public void Dispose() { events.Dispose(); }}② 定义包含大量事件的类型之二:申明多个具体的事件
public partial class MultiEventClass{ #region event1 // 事件1的委托原型 public delegate void Event1Handler(object sender, EventArgs e); // 事件1的静态Key protected static readonly object Event1Key = new object(); // 订阅事件和取消订阅 // 注意:EventHandlerList并不提供线程同步,所以加上线程同步属性 public event Event1Handler Event1 { [MethodImpl(MethodImplOptions.Synchronized)] add { events.AddHandler(Event1Key, value); } [MethodImpl(MethodImplOptions.Synchronized)] remove { events.RemoveHandler(Event1Key, value); } } // 触发事件1 protected virtual void OnEvent1(EventArgs e) { events[Event1Key].DynamicInvoke(this, e); } // 简单地触发事件1,以便于测试 public void RiseEvent1() { OnEvent1(EventArgs.Empty); } #endregion #region event2 // 事件2的委托原型 public delegate void Event2Handler(object sender, EventArgs e); // 事件2的静态Key protected static readonly object Event2Key = new object(); // 订阅事件和取消订阅 // 注意:EventHandlerList并不提供线程同步,所以加上线程同步属性 public event Event2Handler Event2 { [MethodImpl(MethodImplOptions.Synchronized)] add { events.AddHandler(Event2Key, value); } [MethodImpl(MethodImplOptions.Synchronized)] remove { events.RemoveHandler(Event2Key, value); } } // 触发事件2 protected virtual void OnEvent2(EventArgs e) { events[Event2Key].DynamicInvoke(this, e); } // 简单地触发事件2,以便于测试 public void RiseEvent2() { OnEvent2(EventArgs.Empty); } #endregion}③ 定义事件的订阅者(它对多事件类型内部的构造一无所知)
public class Customer{ public Customer(MultiEventClass events) { // 订阅事件1 events.Event1 += Event1Handler; // 订阅事件2 events.Event2 += Event2Handler; } // 事件1的回调方法 private void Event1Handler(object sender, EventArgs e) { Console.WriteLine("事件1被触发"); } // 事件2的回调方法 private void Event2Handler(object sender, EventArgs e) { Console.WriteLine("事件2被触发"); }}④ 编写入口方法来测试多事件的触发
public class Program{ public static void Main(string[] args) { using(MultiEventClass mec = new MultiEventClass()) { Customer customer = new Customer(mec); mec.RiseEvent1(); mec.RiseEvent2(); } Console.ReadKey(); }}最终运行结果如下图所示:
总结EventHandlerList的用法,在多事件类型中为每一个事件都定义了一套成员,包括事件的委托原型、事件的订阅和取消订阅方法,在实际应用中,可能需要定义事件专用的参数类型。这样的设计主旨在于改动包含多事件的类型,而订阅事件的客户并不会察觉这样的改动。设计本身不在于减少代码量,而在于有效减少多事件类型对象的大小。
4 使用事件模拟:猫叫 -> 老鼠逃跑 & 主人惊醒
这是一个典型的观察者模式的应用场景,事件的发源在于猫叫这个动作,在猫叫之后,老鼠开始逃跑,而主人则会从睡梦中惊醒。可以发现,主人和老鼠这两个类型的动作相互之间没有联系,但都是由猫叫这一事件触发的。
设计的大致思路在于,猫类包含并维护一个猫叫的动作,主人和老鼠的对象实例需要订阅猫叫这一事件,保证猫叫这一事件发生时主人和老鼠可以执行相应的动作。
(1)设计猫类,为其定义一个猫叫的事件CatCryEvent:
public class Cat{ private string name; // 猫叫的事件 public event EventHandler<CatCryEventArgs> CatCryEvent; public Cat(string name) { this.name = name; } // 触发猫叫事件 public void CatCry() { // 初始化事件参数 CatCryEventArgs args = new CatCryEventArgs(name); Console.WriteLine(args); // 开始触发事件 CatCryEvent(this, args); }}public class CatCryEventArgs : EventArgs{ private string catName; public CatCryEventArgs(string catName) : base() { this.catName = catName; } public override string ToString() { string message = string.Format("{0}叫了", catName); return message; }}(2)设计老鼠类,在其构造方法中订阅猫叫事件,并提供对应的处理方法
public class Mouse{ private string name; // 在构造方法中订阅事件 public Mouse(string name, Cat cat) { this.name = name; cat.CatCryEvent += CatCryEventHandler; } // 猫叫的处理方法 private void CatCryEventHandler(object sender, CatCryEventArgs e) { Run(); } // 逃跑方法 private void Run() { Console.WriteLine("{0}逃走了:我勒个去,赶紧跑啊!", name); }}(3)设计主人类,在其构造犯法中订阅猫叫事件,并提供对应的处理方法
public class Master{ private string name; // 在构造方法中订阅事件 public Master(string name, Cat cat) { this.name = name; cat.CatCryEvent += CatCryEventHandler; } // 针对猫叫的处理方法 private void CatCryEventHandler(object sender, CatCryEventArgs e) { WakeUp(); } // 具体的处理方法——惊醒 private void WakeUp() { Console.WriteLine("{0}醒了:我勒个去,叫个锤子!", name); }}(4)最后在Main方法中进行场景的模拟:
public class Program{ public static void Main(string[] args) { Cat cat = new Cat("假老练"); Mouse mouse1 = new Mouse("风车车", cat); Mouse mouse2 = new Mouse("米奇妙", cat); Master master = new Master("李扯火", cat); // 毛开始叫了,老鼠和主人有不同的反应 cat.CatCry(); Console.ReadKey(); }}这里定义了一只猫,两只老鼠与一个主人,当猫的CatCry方法被执行到时,会触发猫叫事件CatCryEvent,此时就会通知所有这一事件的订阅者。
本场景的关键之处就在于主人和老鼠的动作应该完全由猫叫来触发。
下面是场景模拟代码的运行结果:
总结
本文总结复习了.NET的事件相关的重要知识点,下一篇会总结.NET中反射相关的重要知识点,欢迎继续关注!
参考资料(全是经典)
朱毅,《进入IT企业必读的200个.NET面试题》
张子阳,《.NET之美:.NET关键技术深入解析》
王涛,《你必须知道的.NET(第二版)》