你也许使用事件编程已经多年了,但是迁移到.NET框架组件时仍需要重新检查它们的内部工作,因为在.NET框架组件中事件位于委托(delegate)的顶层。你对委托了解得越深,使用事件编程时就越能利用其强大的功能。 在使用通用语言运行(common language runtime,CLR)的事件驱动框架(例如Windows Forms或ASP.NET)工作时,了解事件在低层是怎样工作的很重要。本文的目标就是让你了解事件在低层是怎样工作的。
事件到底是什么 事件仅仅是一种软件模式(pattern),在事件中通知源对一个或多个处理方法进行回调(callback)。因此事件与接口(interface)和委托(delegate)相似,因为它们都提供了一条途径来设计使用回调方法的应用程序。但是事件生产率更高,因为它比接口和委托更易使用。事件让编译器和Visual Studio .NET集成开发环境在后台为你做了很多工作。
包含事件的设计是基于一个事件源和一个或多个处理程序的。事件源可以是类或对象,事件处理程序是绑定到某个处理方法的委托对象。图1在较高层次显示了数据源与处理方法的联系。

图1.事件源和处理程序
每个事件都根据特定的委托类型定义。对于每个事件源定义的事件,都有一个基于事件下面的委托类型的专用字段,该字段用于跟踪多点传送的委托对象。事件源也提供了一个公共的注册方法,让你可以注册希望的事件处理程序。
当你建立一个事件处理程序(一个委托对象)并把它与事件源一起注册时,事件源简单地把新的事件处理程序添加到列表的结尾。接着事件源能使用专用字段调用多点传送委托对象的Invoke方法,该方法将执行所有已注册的事件处理程序。
事件真正好的地方是大多数设置工作已经被开发环境完成。你将看到,Visual Basic .NET编译器帮助你在定义事件时自动的添加一个私有字段和一个公共注册方法。你也会看到Visual Studio .NET通过自动生成处理方法的框架定义的代码生成器为你提供了更多帮助。
使用事件编程 由于在.NET中的事件建立在委托的顶层,所有它们下面的通道细节与早期版本的Visual Basic工作方式有很大的不同。但是Visual Basic .NET语言的设计者为了保持与早期Visual Basic版本语言的一致性做了大量的工作。在很多情况中,事件编程使用与原来相近的语法。例如,你将使用Event、 RaiseEvent和WithEvents等关键字,它们的行为与早期版本的相同。
我们建立一个简单的基于事件的回调设计。首先我需要使用Event关键字在类的定义中定义一个事件。事件必须根据特定的委托类型来定义。下面是一个委托类型定义和使用它定义事件的类:
Delegate Sub LargeWithdrawHandler(ByVal Amount As Decimal)
Class BankAccount Public Event LargeWithdraw As LargeWithdrawHandler '省略了其它成员 End Class |
在上面的例子中,LargeWithdraw事件被定义为一个实例成员。该设计中BankAccount对象将作为事件源。如果你希望用类代替对象作为事件源,你可以使用Shared关键字把事件定义为共享成员。
当使用事件编程时,肯定编译器在后台做了大量的工作也很重要。例如,当编译BankAccount类的定义时编译器做了什么?图2显示了使用ILDasm.exe(中间语言反编译器)查看类的定义结果。

图2. ILDasm中的类定义
当你定义事件时,编译器在类的定义中产生四个成员。第一个成员是基于委托类型的私有字段,它用于跟踪一个委托对象的引用。编译器产生的该私有字段的名字是事件的名字加上"Event"标识。这意味着建立LargeWithdraw事件的结果是建立了名为LargeWithdrawEvent的私有字段。
编译器也产生了两个方法帮助注册和取消注册作为事件处理程序服务的委托对象。这两个方法的命名使用了标准的命名转换。注册事件处理程序的方法的名字加了"add_"前缀,取消注册的方法前面加了"remove_"前缀。因此为LargeWithdraw事件建立的这两个方法名称为add_LargeWithdraw 和remove_LargeWithdraw。
Visual Basic .NET编译器为add_LargeWithdraw产生了实现代码,它接收以一个委托对象作为参数并通过调用委托类的Combine方法将它添加到处理程序列表。编译器也产生remove_LargeWithdraw的实现代码,它通过调用委托类的Remove方法从列表中删除一个处理方法。
添加到类定义中的第四个成员表现了事件本身。你能在图2中定位名为LargeWithdraw的事件成员。它有一个向下的三角形。但是你必须注意这个事件成员不是一个真的与其它三个相似的物理事件,它是一个元数据成员。
该元数据事件成员是有价值的,因为它通知编译器和其它开发工具该类支持.NET框架中的事件注册标准模式。该事件成员也包含注册和反注册方法的名字。这使Visual Basic .NET和C#等可管理语言的编译器能在编译时就发现注册方法的名称。
当Visual Basic .NET发现类定义中包含事件时,它自动在产生事件处理程序的注册代码时生成该处理方法的框架定义。
在讨论引发事件前,我将讲解建立用于定义事件的委托类型所涉及的限制。定义事件的委托类型不能有返回值,你必须使用Sub关键字而不能使用Function关键字:
'能被事件使用 Delegate Sub BaggageHandler() Delegate Sub MailHandler(ItemID As Integer)
'不能被事件使用 Delegate Function QuoteOfTheDayHandler(Funny As Boolean) As String |
该限制有一个很好的原因。在绑定到多个处理方法的多点传送委托中使用返回值非常困难。多点传送委托的Invoke调用返回的值是调用列表中最后一个处理方法的值。可是捕获列表中前面的处理方法的返回值就不直接了,消除捕获多个返回值的需求使事件更容易使用。
引发事件 现在我们修改BankAccount类,使取款总额超过5000元限制时产生一个事件。引发LargeWithdraw事件的最简单途径使在某个方法、属性或构造函数的执行中使用RaiseEvent关键字。该语法与Visual Basic早期版本的语法相似,下面的代码从Withdraw方法中引发LargeWithdraw事件:
Class BankAccount
Public Event LargeWithdraw As LargeWithdrawHandler Sub Withdraw(ByVal Amount As Decimal) '如果需要的话就发送通知 If (Amount > 5000) Then RaiseEvent LargeWithdraw(Amount) End If '执行撤消 End Sub End Class |
尽管语法与Visual Basic早期版本相同,但是引发事件时发生的事情却不同了。当你使用RaiseEvent关键字引发一个事件时,Visual Basic .NET编译器产生必要的代码来运行每个事件处理程序。例如,你知道编译下面的代码时会发生什么吗?
| RaiseEvent LargeWithdraw(Amount) |
Visual Basic .NET编译器扩充表达式成为代码,代码调用保存多点传送委托对象的私有字段上的Invoke方法。就是说,使用RaiseEvent关键字的效果与下面的代码相同:
If (Not LargeWithdrawEvent Is Nothing) Then LargeWithdrawEvent.Invoke(Amount) End If |
注意Visual Basic .NET编译器产生的代码实施一个检查以确保LargeWithdrawEvent字段包含一个有效的对象引用。这是因为LargeWithdrawEvent字段的值将为空,直到第一个处理方法被注册。因此,产生的代码不会试图调用Invoke,除非至少有一个处理方法被注册了。
你能够观察事件的引发。无论你使用RaiseEvent关键字或者程序中直接访问编译器自动产生的LargeWithdrawEvent字段都没有关系。两种访问产生相同的代码:
'这段代码 RaiseEvent LargeWithdraw(Amount)
'与下面的代码相同 If (Not LargeWithdrawEvent Is Nothing) Then LargeWithdrawEvent.Invoke(Amount) End If |
大多数情况下使用RaiseEvent关键字,因为这种语法的输入少、代码简洁。但是当你需要更多控制时,可能会使用到LargeWithdrawEvent字段的显式调用。
假设BankAccount对象有三个已经注册的事件处理程序用于接收LargeWithdraw事件的通知。如果你使用RaiseEvent关键字触发事件并且调用列表中的第二个事件处理程序出现了错误会发生什么事情呢?包含RaiseEvent语句的代码行将收到一个运行时错误,但是你没有办法调试是哪一个事件处理程序产生的。另外,第二个事件处理方法产生的异常没有办法处理并按希望继续运行第三个事件处理程序。
Sub Withdraw(ByVal Amount As Decimal) '如果需要的话就发送通知 If (Amount > 5000) AndAslo (Not LargeWithdrawEvent Is Nothing) Then Dim handler As LargeWithdrawHandler For Each handler In LargeWithdrawEvent.GetInvocationList() Try handler.Invoke(Amount) Catch ex As Exception '处理异常 End Try Next End If '执行撤消 End Sub |
图3.使用私有委托字段
但是如果编程访问私有的LargeWithdrawEvent字段,你就可以使用更好的方式处理一个事件处理方法产生的异常。查看图3中的代码,你会发现,处理较低层次并且对私有委托字段编程提供了额外的控制能力。你可以轻易地处理异常并继续执行列表后面地事件处理程序。这种技术比RaiseEvent语法明显的好处,RaiseEvent语法中事件处理方法产生的异常阻止了列表后面的其它事件处理程序的执行。
建立和注册事件处理程序 现在你已经知道怎样定义和引发事件了,下面讲解怎样建立事件处理程序并使用给定的源对它进行注册。在Visual Basic .NET中有两种途径可以达到这个目标。第一种技术是我们知道的动态事件绑定并且包含AddHandler关键字的使用。 第二种技术是静态事件绑定并且包含与Visual Basic相似的WithEvents关键字。本文中我将使用动态事件绑定,下面将讲解动态事件绑定是怎样工作的。
要记住事件处理程序是委托对象。因此你通过作为事件基础的委托类型实例化委托对象来建立事件处理程序。当你建立委托对象时,你必须把它绑定到作为事件处理程序服务的目标处理方法。
一旦建立了事件处理程序,你就必须通过调用事件源上特定的注册方法用特定的事件注册它。回想一下,LargeWithdraw事件的注册方法叫做add_LargeWithdraw。当你调用add_LargeWithdraw方法并传递一个委托对象作为参数时,事件源把委托对象添加到接收事件通知的事件处理程序列表中。
事件注册容易弄混的是你永远不能直接的调用类似add_LargeWithdraw的注册方法。事实上,如果你试图用名字访问事件注册方法,Visual Basic .NET编译器将产生一个实时编译错误。作为代替,你能使用包含在AddHandler语句中的代替语法。当你使用AddHandler语句时,Visual Basic .NET编译器为你产生调用事件注册方法的代码。
让我们看一个使用动态事件注册的例子。假定你在AccountHandlers类中写了下面一组共享方法:
Class AccountHandlers
Shared Sub LogWithdraw(ByVal Amount As Decimal) '把撤消信息写入日志文件 End Sub
Shared Sub GetApproval(ByVal Amount As Decimal) '阻塞直到管理者批准 End Sub End Class |
如果你想把这些方法作为BankAccount类中的LargeWithdraw事件的事件处理程序应该怎么做呢?我们从建立一个绑定到LargeWithdraw的事件处理程序开始。首先你必须建立作为事件处理程序服务的委托对象:
Dim handler1 As LargeWithdrawHandler handler1 = AddressOf AccountHandlers.LogWithdraw |
下一步,你必须使用AddHandler语句与事件源一起注册该委托对象。当你使用AddHandler语句注册事件处理程序时,必须传递两个参数:
| AddHandler <event>, <delegate object> |
AddHandler所需要的第一个参数是计算类或对象的事件的表达式,第二个参数是一个联系事件处理程序的委托对象的引用。下面是使用AddHandler语句与BankAccount对象的LargeWithdraw事件一起注册事件处理程序的例子:
'建立银行帐号对象 Dim account1 As New BankAccount()
'建立并注册一个事件处理程序 Dim handler1 As LargeWithdrawHandler handler1 = AddressOf AccountHandlers.LogWithdraw AddHandler account1.LargeWithdraw, handler1 |
当你为LargeWithdraw事件使用AddHandler注册一个事件处理程序时,Visual Basic .NET编译器扩展这些代码来调用注册方法add_LargeWithdraw。一旦包含AddHandler语句的代码执行了,事件处理程序便就位了并且为通知做好了准备。因此无论什么时候BankAccount对象引发一个LargeWithdraw事件,LargeWithdraw方法都将执行。
在最后一个例子中,我使用长语法来说明当你建立和注册一个事件处理程序时到底发生了什么事情。但是,一旦你明白事情是怎样的,你也许会感激使用了更简洁的语法实现了相同的目标:
'建立银行帐号对象 Dim account1 As New BankAccount()
'注册事件处理程序 AddHandler account1.LargeWithdraw, AddressOf AccountHandlers.LogWithdraw AddHandler account1.LargeWithdraw, AddressOf AccountHandlers.GetApproval |
因为AddHandler语句期待一个委托对象作为第二个参数,你能使用速记语法--AddressOf操作符后紧跟目标处理方法的名字。当Visual Basic .NET编译器看到该语法时,它接着产生额外的代码来建立作为事件处理程序服务的委托对象。
Visual Basic .NET 语言中的AddHandler语句的补充是RemoveHandler语句。RemoveHandler需要的参数与AddHandler的相同,它的效果相反。它通过事件源调用remove_LargeWithdraw方法从已注册的处理方法列表中删除目标处理方法。
Dim account1 As New BankAccount()
'注册事件处理程序 AddHandler account1.LargeWithdraw, AddressOf AccountHandlers.LogWithdraw
'删除事件处理程序注册 RemoveHandler account1.LargeWithdraw, AddressOf AccountHandlers.LogWithdraw |
你已经看到了实现使用事件的回调设计需要的所有步骤了。图4中的代码显示了一个完整的应用程序,在该程序中已经注册了两个事件处理程序从BankAccount对象的LargeWithdraw事件接收回调通知。
Delegate Sub LargeWithdrawHandler(ByVal Amount As Decimal)
Class BankAccount Public Event LargeWithdraw As LargeWithdrawHandler Sub Withdraw(ByVal Amount As Decimal) '如果需要的话就发送通知 If (Amount > 5000) Then RaiseEvent LargeWithdraw(Amount) End If '执行撤消 End Sub End Class
Class AccountHandlers Shared Sub LogWithdraw(ByVal Amount As Decimal) '把撤消信息写入日志文件 End Sub Shared Sub GetApproval(ByVal Amount As Decimal) '阻塞直到管理者批准 End Sub End Class
Module MyApp Sub Main() '建立银行帐号对象 Dim account1 As New BankAccount() '注册事件处理程序 AddHandler account1.LargeWithdraw, _ AddressOf AccountHandlers.LogWithdraw AddHandler account1.LargeWithdraw, _ AddressOf AccountHandlers.GetApproval '做一些触发回调的事情 account1.Withdraw(5001) End Sub End Module |
图4.用于回调通知的基于事件的设计
结论 尽管使用事件的动机和一些语法与早期版本的Visual Basic相比仍然没有改变,但是你不得不承认情况有很大不同了。你能看到,你对如何响应事件的控制力比以前大多了。如果你将使用委托编程,这就很实际了。