【C#】详解C#事件
目录结构:
在这篇Blog中,笔者会详细阐述C#中事件的使用。
1.事件基本介绍
C#中定义了事件成员的类型,允许类型通知其它类型发生了特定的事情。事件是基于委托为基础的,说白了就是对委托的封装,委托就是一种回调方法的机制,笔者认为设计事件就是为了能够更好地理解面向对象。
事件(Event) 基本上说是一个用户操作,如按键、点击、鼠标移动等等,或者是一些出现如系统生成的通知。应用程序需要在事件发生时响应事件。例如,中断。事件是用于进程间通信。
为了更好地理解事件,这里笔者描述一个场景:有一个按钮,当双击该按钮的时候,很有可能希望其他的动作也被触发。
如图:
圆圈1,表示第一步:首先把CallBacker1的callback1()方法和CallBacker2的callbacker2()方法注册到Button的DoubleClick事件中。
圆圈2,表示第二步:引发Button的DoubleClick。
圆圈3,表示第三步:触发在注册在Button的DoubleClick事件中的所有回调方法。
下面笔者将会按照上面的情景来讲解C#中事件的知识点。
1.1 定义事件类型
事件引发时,引发事件的对象可能希望向接受事件通知的对象传递一些附加信息。根据约定,这种类应该从System.EventArgs派生,而且类名以EventArgs结束。
这里笔者定义一个NewButtonClickEventArgs类,用来容纳被点击按钮的文本信息,
class NewButtonClickEventArgs : EventArgs{ private readonly String text; public NewButtonClickEventArgs(String text) { this.text = text; } public String Text { get { return text; } } }
EventArgs类在Microsoft .NET Framework中定义,EventArgs是一个基类型。
EventArgs的源码如下:
[Serializable] [System.Runtime.InteropServices.ComVisible(true)] public class EventArgs { public static readonly EventArgs Empty = new EventArgs(); public EventArgs() { } }
可以看出EventArgs的类型非常简单,不会附加任何传递信息,主要目的是作为其他类型的基类。当然,如果时间不需要传递任何附加信息,那么就可以用该类。
1.2 定义事件成员
在C#中定义事件成员使用event关键字,每个事件成员几乎都会指定以下信息:
a.可访问性标识符。
b.委托类型,以及需要委托的原型。
c.事件名称
例如:
sealed class Button { //定义事件成员 public event EventHandler<NewButtonClickEventArgs> DoubleClick; ... }
我们指定了EventHanler泛型委托,该委托的元数据如下:
public delegate void EventHandler<TEventArgs>(object sender, TEventArgs e)
1.3 定义引发事件的方法
按照约定,类要定义一个受保护的虚方法,但是如果该类是密封的,那么该方法就应该声明为私有的和非虚的。
sealed class Button { ... //定义引发事件的方法 private void OnDoubleClick(NewButtonClickEventArgs e) { EventHandler<NewButtonClickEventArgs> temp = Volatile.Read(ref DoubleClick); if (temp != null) { temp(this,e); } } ... }
1.3.1 以线程安全的方式引发事件
在上面定义引发事件的方法中,我们使用了如下的代码:
EventHandler<NewButtonClickEventArgs> temp = Volatile.Read(ref DoubleClick);
相信在事件的调用中,经常都会看到如上形式的代码,接下来笔者将会讲解原因:
在.net Framework刚发布的时建议开发者使用如下的形式引发事件:
if(DoubleClick!=null){ DoubleClick(this,e); }
这样做的问题是,虽然当前线程检查出了DoubleClick不为空,但有可能存在如下情况,当前线程在检查了DoubleClick不为空后,在还没调用DoubleClick之前,其它线程修改了DoubleClick,比如移除了委托链上的所有方法,那么当前线程再次调用DoubleClick的时候,就有可能NullReferenceException。
这是一个竞态问题,我们可以修改如下形式:
EventHandler<NewButtonClickEventArgs> temp=DoubleClick; if(temp!=null){ temp(this,e); }
它的思路是,把DoubleClick复制到临时变量temp中,这样的话,即使其他线程改变了DoubleClick事件,那么也不会出错。
但是如果编译器擅自做主,进行优化,移除临时变量temp,那么上面的方式就和第一种方式就没什么区别,仍然有可能抛出NullReferenceException异常。然而,编译器是理解这种这种模式的,不会把temp优化掉优化,所以这是一种安全的方式。
上面之所以能够安全调用,是因为编译器能够“理解”正确,一般情况下,我们是不太知道编译器是如何理解的,所以能够强制提醒一下编译器就更好了,如下的方法:
EventHandler<NewButtonClickEventArgs> temp = Volatile.Read(ref DoubleClick); if (temp != null) { temp(this, e); }
这里使用了Volatile类的Read方法,以线程安全的方式把DoubleClick复制到temp变量中,这样的话,编译器绝不会吧temp变量优化掉。
1.4. 登记事件关注
上面我们已经定义好了事件,接下来就是登记事件关注。
定义如下类:
class CallBacker{ public CallBacker(Button btn){ btn.DoubleClick += CallBack; } public void CallBack(Object sender,NewButtonClickEventArgs args){ Console.WriteLine("按钮文本为:"+args.Text); } }
在这个类的构造方法中,我们完成对DoubleClick事件的关注。
到这里我们就完成一个简单的事件过程,完整代码如下:
class NewButtonClickEventArgs : EventArgs{ private readonly String text; public NewButtonClickEventArgs(String text) { this.text = text; } public String Text { get { return text; } } } sealed class Button { //定义事件成员 public event EventHandler<NewButtonClickEventArgs> DoubleClick; // 定义引发事件的方法 public void OnDoubleClick(NewButtonClickEventArgs e) { EventHandler<NewButtonClickEventArgs> temp = Volatile.Read(ref DoubleClick); if (temp != null) { temp(this, e); } } } class CallBacker{ public CallBacker(Button btn){ btn.DoubleClick += CallBack; } public void CallBack(Object sender,NewButtonClickEventArgs args){ Console.WriteLine("按钮文本为:"+args.Text); } }
2 揭秘事件
为了弄清楚事件到底是什么,我们编译如下C#代码:
namespace ConsoleApplication2 { class Program { //定义委托 delegate void MyDelegate(Object obj); //定义事件 static event MyDelegate MyEvent; static void Main(string[] args) { MyEvent += Test1;//注册方法 MyEvent += Test2;//注册方法 MyEvent(new Object());//调用 Console.ReadLine(); } static void Test1(Object obj) { Console.WriteLine("test1"); } static void Test2(Object obj) { Console.WriteLine("test2"); } } }
我们在编译上面的C#代码后,用ildasm工具打开它,可以看到如下这样:
除了所定义的成员,还多了一个类(MyDelegate),一个字段(MyEvent),两个方法(add_MyEvent、remove_MyEvent)。其中类是由委托转化而来,这里不做详细参数,详情可以参见C#详解委托。
一个事件的声明是可以转化为一个代理字段的声明加上添加、删除两种方法的事件操作。上面的MyEvent事件与MyEvent字段、add_MyEvent方法、remove_MyEvent方法关联起来了。
再打开MyEvent事件的IL的IL代码,可以看到出现这样
可以看出,事件的addOn和removeOn分别被重定向到了类中的add_MyEvent和remove_MyEvent方法上。
笔者认为之所以要利用代理字段,原因很有可能是CLS不直接支持事件参与运行,因为说到底,事件还是属于引用类型变量。
3 显式实现事件
3.1 为什么需要显式实现事件
在最开始我们已经知道了事件是基于委托的,也就是说事件是对委托的封装,一个事件的底层肯定有一个委托列表做支撑。
在System.Windows.Forms.Control类型中定义了大约70个事件,
假如Control类型在实现事件时,允许编译器生成add和remove访问器方法以及委托字段(每个事件都生成一个维护委托的委托列表),那么每个Control仅为事件就要多准备70个字段,这是非常浪费内存的。
然而这种情况,是确实存在的。
例如有如下代码:
namespace ConsoleApplication2 { class Program { //定义委托 delegate void MyDelegate(Object obj); //定义事件 static event MyDelegate MyEvent1; static event MyDelegate MyEvent2; static void Main(string[] args) { } } }
编译后,再使用ildasm工具打开,可以看到如下情况:
可以看出,我们定义了两个事件就出现了两个字段和四个方法,和上面对比不难发现,每当多定义一个事件,那么编译器就会为其新创建一个字段和两个方法。可想而知,如果定义70个事件会怎么样。
如果定义一种事件能够被其他事件所共用就好了,接下来将讨论如何实现这个思路。
3.2 显式实现事件的实现
为了高效率的存储委托,公开了事件的每个对象都要维护一个集合(数据字典),集合将某种形式的事件标识符作为键(Key),将委托列表作为值(Value)。新对象构造时,这个集合是空白的。登记对一个事件的关注时,会在集合列表中查找该事件的标识符,如果存在这个标识符,就将新委托对象和旧委托对象合并。如果不存在,那么就添加当前委托对象和标识符到集合列表中。
这样一来,我们就免去了自定义事件的步骤,按照上面的思想,将委托链表和某些键关联起来存储在集合中,当我们需要操作某些委托列表时,直接通过对应的键从集合列表中取出对应的委托链就可以了。这个过程未使用过事件,性能更高效。
//在使用EventSet类时,作为Key使用。 public sealed class EventKey { } public sealed class EventSet { //该字典用户维护 EventKey -> Delegate 的映射 private readonly Dictionary<EventKey, Delegate> m_events = new Dictionary<EventKey, Delegate>(); //添加 EventKey -> Delegate 的映射(如果不存在) //将新委托合并到旧委托中去(如果已经存在该EventKey的映射) public void Add(EventKey eventKey, Delegate handler) { Monitor.Enter(m_events); Delegate d; m_events.TryGetValue(eventKey,out d); m_events[eventKey] = Delegate.Combine(d,handler); Monitor.Exit(m_events); } //从eventKey映射的Delegate中删除hanlder委托 //在删除最后一个委托后,同时删除 eventKey -> Delegate的映射 public void remove(EventKey eventKey, Delegate handler) { Monitor.Enter(m_events); Delegate d; if (m_events.TryGetValue(eventKey, out d)) { d = Delegate.Remove(d,handler); if (d == null) { //没有委托了 m_events.Remove(eventKey); } } Monitor.Exit(m_events); } //为指定eventKey映射的委托触发 public void Raise(EventKey eventKey,Object sender,EventArgs e) { Monitor.Enter(m_events); Delegate d; m_events.TryGetValue(eventKey,out d); Monitor.Exit(m_events); if (d != null) { //以对象数组的形式传递参数,如果参数不匹配DynamicInvoke会抛出异常。 d.DynamicInvoke(sender,e); } } }
上面定义了两个类EventKey和EventSet,其中EventKey是用于维护EventSet的私有数据字典的(利用EventKey对象的Hash值),EventSet中定义了三个方法Add,Remove,Raise,这三个方法都利用Monitor类的同步访问对象机制来操作字典表。
在定义好维护委托列表的类后,我们就可以按照如下的栗子来使用了:
class FooEventArgs : EventArgs { } class TypeWithLotsOfEvents { private readonly EventSet m_eventSet = new EventSet(); protected static readonly EventKey s_fooEventKey = new EventKey(); //使派生类也能够访问 protected EventSet EventSet { get{return m_eventSet;} } //定义事件访问器 public event EventHandler<FooEventArgs> Foo { add { m_eventSet.Add(s_fooEventKey,value); } remove { m_eventSet.remove(s_fooEventKey, value); } } //定义触发事件的受保护的虚方法 protected virtual void OnFoo(FooEventArgs e) { m_eventSet.Raise(s_fooEventKey,this,e); } //定义将输入转化为这个事件的方法 public void SimulateFoo() { OnFoo(new FooEventArgs()); } }
调用代码:
public sealed class Program { static void Main(String[] args) { TypeWithLotsOfEvents typeWithLotsOfEvents = new TypeWithLotsOfEvents(); typeWithLotsOfEvents.Foo += HandlerFooEvent; typeWithLotsOfEvents.SimulateFoo(); } static void HandlerFooEvent(Object obj, FooEventArgs e) { Console.WriteLine("here arrived ..."); } }