2012年7月21日,北京因暴雨灾害导致劳命伤财。这个事情过去后,“自然灾害预警”系统又一次被人们提起,它就是将自然现象前交发送通知给人们,这个过程能很好地解释C#语言中的事件。在上一节《C#基础知识梳理系列五:委托与事件(上)》我们主要讨论了与委托相关的知识,包括委托的内部实现、委托链等。那么事件与委托是什么关系呢?事件又是如何工作的呢?这些将是这节主要讨论的内容。
气象局可以利用移动通信平台向人们的手机以短信的方式发送天气情况,只要你的手机在开机状态,在它向周围寻找基站并注册的这个过程就是订阅者对发布者发布的事件的订阅过程,正常情况下,只要你的号码不欠费,基站就允许你注册成功,事实上,即使你的号码欠费,你的手机也是在基站注册成功的,只是运营商拒绝向你提供服务,当移动平台有需要发送的预警信息时,就会向所有已经在基站注册成功的手机发布预警信息数据(如果他愿意,当然也可以向欠费手机发送。)。C#编程中的事件跟上面的注册过程相似,事件也是类型的一个成员,它的运行机制是以委托为基础的,定义了事件成员的类型可以向注册该事件的类或对象发出消息通知。引发事件的类被称为“发布者”,接收事件的类被称为“客户”或订阅者,从发布者到订阅者被传送的数据称为“事件消息”。像上面所说的移动通信平台就是事件的发布者,人们的手机就是事件的订阅者,预警信息数据就是事件消息。
发布者知道何时引发事件,订阅者知道如何响应处理事件。一个发布者可以拥有多个订阅者,此时发布者维护了一个向自己注册的客户的列表,一个订阅者也可以同时向多个发布者进行事件注册。当事件发生时,所有向该事件注册的客户都会接到通知,客户程序会以委托的回调方法接收它所订阅的通知。
事件是用关键字event来定义的,还可以给事件指定一个访问修饰符,一般是public,另外,一个委托类型是指定要调用的方法,通常事件的名称是以event 结尾的。如下:
public class SMS { public event EventHandler SMSEevent; }
.NET Framework建议我们在定义事件的时候应该定义符合.NET Framework准则的事件,这是更规范的事件模式。那么这个准则有什么要求呢?
(1) 如果在引发事件后,事件发出通知的消息是有自定义数据的,那么应该将这些事件消息专门封装到一个类中进行打包传送,通常这个类继承于System.EventArgs。例如:
public class SMSEventArgs : EventArgs { public string ToPhoneNo { get; set; } public string Message { get; set; } }
当然如果没有事件消息,也可以不定义这个消息包装器。
(2) 定义一个委托,用来包装一个回调函数,当事件引发时,可以通过这个委托来发出通知。如下是.NET Framework已经定义好的一个返回类型为void不包含事件数据的委托原型:
public delegate void EventHandler(object sender, EventArgs e);
当然,我们也可以定义自己的包含事件数据的委托,尽管委托的定义允许其有返回值,但事件模型要求必须定义返回类型为void的委托供事件使用。如下:
public delegate void SMSEventHandler(object sender, SMSEventArgs e);
在委托原型中我们发现有一个参数 object sender,这是事件模型要求的,它是引发事件的对象,并且要求是object 类型,之所以要求是object类型,是方便于继承,因为促使事件引发的对象可能是很多种类型。另外,尽量保证事件数据参数名为e,这样更方便使用者理解,如:SMSEventArgs e。
.NET Framework还提供了泛型版本的事件委托:
public delegate void EventHandler<TEventArgs>(object sender, TEventArgs e);
这样在使用此委托时,就可以指定任意类型的事件数据包装器类型。当你想使用一个事件定义在多种场合下传递多种事件数据类型的时候,泛型委托很有用。如下:
public event EventHandler<SMSEventArgs> SMSEevent2;
(3)事件设计准则还建议我们在事件所在的类型中定义一个用于引发事件的方法来通知事件的登记对象,这个方法应该是受保护的虚方法,在派生类中可以重写此方法来控制对事件通知的发出。该方法通常以On开头,如下:
protected virtual void OnSMSEevent() { EventHandler handler = this.SMSEevent; if (handler != null) { handler(this, null); } } //如果有事件消息数据,则可以如下定义: protected virtual void OnSMSEevent2(SMSEventArgs e) { EventHandler<SMSEventArgs> handler = this.SMSEevent2; if (handler != null) { handler(this, e); } }
这个方法接收一个SMSEventArgs e参数作为事件消息数据,将被发送给已登记的客户。
在该方法内,我们建议定义一个临时变量handler来存储当前事件的引用,然后对其进行判空,以防止在多线程的情况下事件还没有登录客户对象。
(4)在必要的时候,可以引用上述定义的事件。它通常是在一个方法中来调用(3)中所定义的方法OnXXX来引发一个事件,如下:
public void SendSMS(string toPhoneNo, string message) { //发送短信 if (!string.IsNullOrEmpty(toPhoneNo)) { SMSEventArgs args = new SMSEventArgs(); args.ToPhoneNo = toPhoneNo; args.Message = message; OnSMSEevent2(args); } }
有关事件的定义都已经基本完成了,完整的短信处理程序代码如下:
View Code
public class SMS
{
public event EventHandler SMSEevent;
public event EventHandler<SMSEventArgs> SMSEevent2;
protected virtual void OnSMSEevent()
{
EventHandler handler = this.SMSEevent;
if (handler != null)
{
handler(this, null);
}
}
protected virtual void OnSMSEevent2(SMSEventArgs e)
{
EventHandler<SMSEventArgs> handler = this.SMSEevent2;
if (handler != null)
{
handler(this, e);
}
}
public void SendSMS(string toPhoneNo, string message)
{
//发送短信
if (!string.IsNullOrEmpty(toPhoneNo))
{
SMSEventArgs args = new SMSEventArgs();
args.ToPhoneNo = toPhoneNo;
args.Message = message;
OnSMSEevent2(args);
}
}
}
接下来我们来定义客户程序,就是像事件注册登录的客户对象。这个比较简单,提供一个回调方法,然后把这个方法注册给事件,这个过程是通过委托来实现的,如下代码:
public class Code_05_04 { public Code_05_04(SMS sms) { sms.SMSEevent += new EventHandler(sms_SMSEevent); sms.SMSEevent2 += new EventHandler<SMSEventArgs>(sms_SMSEevent2); } void sms_SMSEevent(object sender, EventArgs e) { Console.WriteLine("已经接到事件通知"); } void sms_SMSEevent2(object sender, SMSEventArgs e) { Console.WriteLine(e.ToPhoneNo + ":" + e.Message); } } //当然可以向一个事件注册多个回调,如下: sms.SMSEevent2 += new EventHandler<SMSEventArgs>(sms_SMSEevent2_Other); void sms_SMSEevent2_Other(object sender, SMSEventArgs e) { Console.WriteLine("Other:" + e.ToPhoneNo + ":" + e.Message); }
到目前为止,关于事件及登记对象的定义已经完成了,下面我们来看一下编译器是如何处理事件的。先看事件public event EventHandler SMSEevent;的定义:
关于这个事件SMSEevent的定义,C#编译器自动生成了一个对应的字段private EventHandler SMSEevent;这个字段是具有对应委托类型的字段,它引用的是一个委托列表的头部,当事件引用时,会通知这个委托列表中的成员。观察该类构造可知,该委托被初始化为null,也就是当没有事件登录成员时,该委托为null,这也就说明了我们为什么在OnSMSEevent方法内要进行判空操作了。
C#编译器同时生成了相关的两个方法add_SMSEevent和remove_SMSEevent。在前面的章节《C#基础知识梳理系列三:C#类成员:常量、字段、属性》中讲解过关于属性的定义,C#编译器是自动生成了get和set访问器方法,这里跟它相似。
我们来看一下add_SMSEevent方法的定义:
public void add_SMSEevent(EventHandler value) { EventHandler handler2; EventHandler sMSEevent = this.SMSEevent; do { handler2 = sMSEevent; EventHandler handler3 = (EventHandler) Delegate.Combine(handler2, value); sMSEevent = Interlocked.CompareExchange<EventHandler>(ref this.SMSEevent, handler3, handler2); } while (sMSEevent != handler2); }
首先可以看到的是它是一个以循环的方式向事件追加委托,其次,它调用了Interlocked的静态方法CompareExchange以线程安全的方式进行操作(Interlocked类被定义在System.Threading命名空间,其目的是为多个线程共享的变量提供原子操作。),方法内部还是调用Delegate.Combine方法来进行追加委托,因为这个事件在内部已经被当作委托(EventHandler)来看待的,从其字段定义可以看出。
有追加就有对应的移除,来看一下移除一个委托的方法remove_SMSEevent:
public void remove_SMSEevent(EventHandler value) { EventHandler handler2; EventHandler sMSEevent = this.SMSEevent; do { handler2 = sMSEevent; EventHandler handler3 = (EventHandler) Delegate.Remove(handler2, value); sMSEevent = Interlocked.CompareExchange<EventHandler>(ref this.SMSEevent, handler3, handler2); } while (sMSEevent != handler2); }
移除委托和上面的追加委托代码结构相似,只是它是调用Delegate.Remove方法从委托链中移除一个委托,也是线程安全的。
编译器有一个潜规则,那就是对事件的这两个访问器方法的命名都是有固定前缀,分别为add_和remove_,跟属性的get_和set_方法相似。
还有一点,在内部是把事件当作委托看待,那么委托的追加和移除简写方式+=/-=在对事件也是同样适用的,并且要求使用+=/-=的方式来注册和移除监听,当然,你也可以通过反射来动态为事件添加事件处理程序,而不使用+=/-=操作符。VS提供了对+=的快捷键处理,使用时,在对象的事件名后打上+=,VS的智能提示会提示“按Tab键插入”,按两次Tab键后VS会自动生成事件处理程序,方便!体贴!
最后,我们来看一下如何在代码中引用事件(部分代码在上面已经定义):
void TestEvent() { SMS sms = new SMS(); Code_05_04 worker = new Code_05_04(sms); sms.SendSMS("1383838438", "自然灾害已经离开地球,请放心生活。"); }
回顾上面的代码,SMS类的方法SendSMS在内部会调用OnSMSEevent、OnSMSEevent2方法来引发事件,接着所有该事件的登记对象都会收到通知。如图:
最后要说明一点的是,事件的通知是同步进行的,感兴趣的同志可以自己写代码观察一下。