VBA 类模块系列之十二——揭开事件的真相以及使用WithEvents

上篇介绍到,当类的使用者在给类的属性赋值的时候,如果赋的值不符合类的设计者规定的范围,那么作为类的设计者,为了 … 继续阅读“VBA 类模块系列之十二——揭开事件的真相以及使用WithEvents”

上篇介绍到,当类的使用者在给类的属性赋值的时候,如果赋的值不符合类的设计者规定的范围,那么作为类的设计者,为了类的健壮性,必须让类主动抛出一个异常(运行时错误)给到类的使用者。

你对“类主动”3个字有何种感受?

还记得我们为我们的第一个类Person设计了姓名(Name),性别(Gender),出生日期(DOB)属性吗?这些属性,我们既可以读取属性值,又能重新赋予它们新的值。这个过程,是谁在主动?我们看到,给这些属性赋值,以及读取这些属性值的代码,都在模块1中。模块1是Person类的使用者。所以,这个过程,是类的使用者主动在调用属性,类的使用者还能主动调用类的方法(例如Speak)。

我为什么对“类主动”还是“类的使用者主动”这个问题这么有兴趣?谈过恋爱的人都知道,如果你心仪某个女孩子,你肯定先想方设法要到她的微信,然后发消息虚寒问暖(比如,你叫什么名字呀?赵冰冰。你是什么性别啊?女性。出生日期呢?X年X月。)如果对方都是这样,有一句答一句,从不主动向你发送任何消息,你肯定是垂头丧气的。突然有一天,她主动发来消息:“下周我过生日”。你估计会欣喜若狂一晚上。这就是主动发送消息较之被动回应的不同之处。

在《系列之二——什么是类》中,我们谈到了类的接口是由属性,方法,事件组成。属性和方法,都是类的被动元素,你什么时候问我,我就什么时候告诉你,你不问,我也懒得说。而事件(Events)却恰好相反,作为类的使用者,你冷不丁的会突然收到类发过来一句消息:“下周我过生日”。有了事件,这场恋爱谈起来就有意思了,否则,她跟充气娃娃有什么区别?^_^

接下来是要给我们的第一个类Person添加一个能主动发送“下周我过生日”消息的事件吗?呃,还没到时候。

本篇讲到现在,我们只是讲清了事件的真相之一:事件的传递方向(类 -> 类的使用者)。(而属性和方法的传递方向:类的使用者 ->类)。我们还需要了解其他几个真相。

先从大家最熟悉的开始:作为一个有一定经验的Access VBA开发人员,你肯定给某个窗体(窗体1)上的命令按钮(Command0)编写过单击事件响应(Click)代码,比如:

'窗体1
Option Compare Database
Option Explicit


Private Sub Command0_Click()
    MsgBox "你好,我是窗体1"
End Sub

这里,我们清点一下:有2个对象:窗体1,命令按钮Command0,窗体1使用命令按钮Command0来接受用户鼠标的单击信息,从而执行一段预定的代码,完成一定的功能。命令按钮是Access的一个内置控件,它是一个类。窗体(窗体1)是命令按钮(Command0)类的使用者。命令按钮(Command0)这个类能向它的使用者(窗体1)发送很多消息事件(Events),比如,单击(Click),双击(DblClick),获得焦点(GotFocus)等。

稍等,你说获得焦点(GotFocus)这个事件我怎么这么陌生?我怎么从来没用过呢?你是怎么知道命令按钮还有这个事件呢?命令按钮还有哪些事件?呃,如果你有这样的问题,那我得给你补补Access VBA初级的内容了。

Alt+F11进到IDE界面,双击进入窗体1的代码模块,在代码模块窗口的左上角和右上角,分别都有一个下拉框,先选择左上角的下拉框,如图下图所示:

这个下拉框中列出了当前窗体类模块(Form_窗体1)中,可以接收到事件的对象。我们选择Command0。然后点开右上角的下拉框,如下图所示:

这个下拉框中,列出了Command0这个对象能够发送出来的消息事件(Events)。你不熟悉的“GotFocus”也在其中。

命令按钮是Access内置的控件,它也是一个类,但是我们看不到它的类代码,所以

  1. 我们不清楚命令按钮(Command0)是怎样将一个事件(比如Click)发送(Raise)出来
  2. 我们也不清楚窗体1是怎样接收到这个事件的

我们唯一清楚的是,可以为Command0的单击事件编写响应代码子程序(Private Sub Commaond0_Click()),当单击事件被Command0对象发送出来的时候,窗体1接收到这个事件,然后响应该事件的子程序自动被执行。

事件的发起方(比如Command0),我们称之为事件源(Event Source),事件的接收方(比如窗体1),我们称之为事件监听者(Event Sink)(Sink这个词的本意是下沉,沉没,很难翻译成容易理解的中文,所以我就意译为事件监听者)这就是事件的真相之二:事件包含两方:事件发起方(事件源),事件接收方(事件监听者)。

我们可以拿电台收音机来类比一下。事件源就好比一个电台,它向外发送电波,事件监听者就好比收音机,可以接收到电台发出来的电波,收听到音乐新闻等。一个事件源发送出来的电波,可以被很多台收音机接收到。这是一个生活常识,但是,事件源发送出来的事件,能被多个事件监听者接收到吗?

我直接给出答案:可以。这就是事件的真相之三:一个事件源发送的事件可以被多个事件监听者接收到。能演示一下吗?必须的。

我们新建一个窗体2,让窗体2也能接收到窗体1上的命令按钮Command0发送出来的单击(Click)事件。

为了让窗体2能建立与窗体1上的按钮Command0之间的联系,我们要在窗体2的头部定义一个命令按钮对象变量mCmd。然后想办法将这个对象变量指向Command0。

'窗体2
Option Compare Database
Option Explicit

Private mCmd As CommandButton

如何让mCmd指向Command0?我们知道,Access中所有被打开的窗体,都会被自动添加到一个叫做Forms的集合中。所以当窗体1被打开后,我们可以用Forms(“窗体1”)来引用到内存中的窗体1,然后用Forms(“窗体1”).Command0,或者Forms(“窗体1”).Controls(“Command0”)来引用到窗体1上的Command0。这件事最好是在窗体2加载的时候完成,所以有了下面的代码:

'窗体2
Option Compare Database
Option Explicit

Private mCmd As CommandButton

Private Sub Form_Load()
    Set mCmd = Forms("窗体1").Controls("Command0")
End Sub

当然这段代码要想执行成功,必须得先将窗体1打开加载到内存才行。先不管错误处理代码。

现在我们有了一个指向窗体1上的命令按钮Command0的对象变量mCmd。我们就可以随心所欲的来调用Command0这个对象的任何属性和方法,但是必须由mCmd主动发起。

但是,Command0发送出来的事件,能窗体2中的mCmd接收到吗?

能不能我不知道,但是我知道,如果能,那么窗体2的代码模块窗口左上角的下拉框中,如果有mCmd这个对象变量,那么就说明mCmd可以接收到事件,就可以为它接收到的事件编写响应子程序。你点一下左下拉框看看,mCmd并不在其中。

这里我们要向大家介绍VBA中的一个新的关键字:WithEvents。(字面意思是携带事件)它是专门用来解决我们目前遇到的问题的:现在mCmd已经指向了一个命令按钮对象Command0,如果要向让mCmd能接收到Command0发送出来的事件,就可以在定义mCmd的时候,将WithEvents关键字添加到mCmd前面。我们修改mCmd的定义如下:

'窗体2
Option Compare Database
Option Explicit

Private WithEvents mCmd As CommandButton

Private Sub Form_Load()
    Set mCmd = Forms("窗体1").Controls("Command0")
End Sub

然后我们再来看看左下拉框中的选项,如下图所示:

在mCmd之前加了关键字“WithEvents”之后,左上下拉框中就出现了mCmd选项,说明我们可以为mCmd对象变量编写事件响应子程序了。我们在下拉框中选择mCmd,然后点开右下拉框,如下图所示:

右下拉框与窗体1中Command0的右下拉框列示的内容是一样的。说明mCmd现在可以接收它所指向的对象(Command0)发送出来的所有事件了。我们只对单击事件感兴趣,所以编码如下:

'窗体2
Option Compare Database
Option Explicit

Private WithEvents mCmd As CommandButton

Private Sub Form_Load()
    Set mCmd = Forms("窗体1").Controls("Command0")
End Sub

Private Sub Form_Unload(Cancel As Integer)
    Set mCmd = Nothing
End Sub

Private Sub mCmd_Click()
    MsgBox "你好,我是窗体2"
End Sub

当然我们在窗体2卸载的时候,将mCmd指向Command0的指针清除。要养成主动释放指针的好习惯。

窗体2的代码写好了,我们来测试一下,看看窗体1上的命令按钮Command0发送的单击事件,是否也能被窗体2中的mCmd接收到。

先打开窗体1,然后打开窗体2。最后单击窗体1上的Command0按钮。显示的截图如下:

这样就验证了窗体2中的mCmd也接收到了Command0的单击事件!

窗体1和窗体2都可以接收到Command0的单击事件,让我们有一种错觉,似乎所有的模块都可以接收到事件。其实不是的,模块1(标准模块)就无法接收到事件。我们试一下看看先,在模块1中头部位置写如下代码:

'模块1 测试代码
Option Compare Database
Option Explicit

Private WithEvents mCmd As CommandButton

'以下代码省略
'

我们编译一下跳出错误:

WithEvents被高亮选中,编译提示:仅在模块中有效。标准模块不是模块?这里提示翻译得不准确,应该是:仅在类模块和窗体模块(或报表模块)中有效。而类模块和窗体模块都是类模块。所以这里,我们从试错的角度给大家揭示事件的真相之四:事件接收方(监听者)必须是类对象

为什么事件的接收方(事件监听者)必须是类对象?

这个与事件传播到事件监听者(可能有多个)的底层实现有关。“传播”这个词是从结果上来描述的,在代码层面,只有一个词:“调用”。那么事件是如何从事件源传播到所有的事件监听者那里的?原来,事件源(Event Source)含有一个连接点容器(Connection Point Container),该容器中都是连接点对象(Connection Point Object),每一个连接点对象都指向一个事件监听者(Event Sink),当事件源对象中某个事件被触发(Raise),传播这个事件给每个事件监听者,就变成了遍历连接点容器中的所有对象,然后调用每个对象所指向的事件监听者的事件响应程序,于是,事件得以在全部事件监听者中传播。

了解了事件的底层实现机制,事件监听者(Event Sink)必须是类对象就很好理解了。因为事件源的连接点容器中的连接点对象,是指向事件监听者的,什么东西能被一个对象(变量)所指向?很简单,这个东西肯定是内存中的一个对象。所以事件监听者必须是类对象。WithEvents关键字只能出现在类模块中(包含窗体模块和报表模块)

最后总结一下本篇的要点:

  • 事件的真相之一:事件的传递方向(类 -> 类的使用者)(而属性和方法的调用方向:类的使用者 ->类)
  • 事件的真相之二:事件包含两方:事件发起方(事件源),事件接收方(事件监听者)。
  • 事件的真相之三:一个事件源发送的事件可以被多个事件监听者接收到。
  • 事件的真相之四:事件监听者必须是类对象。
  • 定义对象变量的时候,使用WithEvents修饰关键字,能接收对象变量指向的对象发送的事件。

作为一个有一定经验的Access VBA开发者,你早就知道如何为对象的事件编写事件响应程序。通过本篇的介绍,你应该对事件有更深入的了解,并且学会了如何接收其他类对象发送出来的事件(使用WithEvent关键字),那么关于事件,你就只剩下两块内容:在类中定义自己的事件,和在类中发起你定义的事件。下一篇将介绍该内容。

最后,按惯例,将最终全部代码贴给大家。

'窗体1
Option Compare Database
Option Explicit

Private Sub Command0_Click()
    MsgBox "你好,我是窗体1"
End Sub
'窗体2
Option Compare Database
Option Explicit

Private WithEvents mCmd As CommandButton

Private Sub Form_Load()
    Set mCmd = Forms("窗体1").Controls("Command0")
End Sub

Private Sub Form_Unload(Cancel As Integer)
    Set mCmd = Nothing
End Sub

Private Sub mCmd_Click()
    MsgBox "你好,我是窗体2"
End Sub

注意操作顺序:先打开窗体1,然后窗体2,最后点窗体1按钮Command0。

后记:有群友(河北-SQL Designer)建议将类的术语统一起来,与其他面向对象编程的语言相一致。非常感谢他的建议。我仔细斟酌之后,发现使用统一的术语,反而让意思更加晦涩难懂。所以就还是保持原样了。敬请谅解。

《VBA 类模块系列之十二——揭开事件的真相以及使用WithEvents》有2个想法

  1. 写第十三篇的时候,突然发现事件还有第4个真相忘记了交代。特做了添加修改。
    事件的真相之四:事件监听者(Event Sink)必须是类对象。

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注