VB 与 Windows API 讲座(叁)转载
code demo download
VB 与 Windows API 讲座(叁)
Windows 的讯息系统
VB 程式有两种工作模式—主动模式及事件驱动模式,主动模式与传统的 DOS 程式很像,程式载入系统後就会一直执行,直到结束为止,事件驱动模式则会在程式载入系统後,先暂时停止执行,直到事件发生时,才驱动对应的事件程序而执行某段程式,执行此段程式之後,又会暂停执行,等待下一次事件的发生。
主动模式的程式在 Windows 3.1 底下是被禁止的,主要的原因是 Windows 3.1 不是真正的多工作业系统,而为了让不同程式能够同时执行,Windows 规定每一个程式必须不定时地呼叫 Yield、GetMessage、PeekMessage 等 API 函数(VB 程式则是呼叫 DoEvents),而当程式呼叫这些函数时,Windows 就会趁此一时机把 CPU 交给其他程式使用,也因此让每一个程式都有机会使用 CPU 而达到多工作业的目的。
但如果有某一个程式忽略了此一规定,它就会一直霸占着 CPU 不放,而使得整个 Windows 的作业环境像当掉一样,不过这个问题到了 Windows 95 及 NT 之後,已经不再存在,因为 Windows 95 及 NT 具有主动切换各个程式使用 CPU 权利的能力,所以即使某一程式进入了无穷回圈,Windows 依然会在一小段时间後将 CPU 的使用权转移给其他程式,我们可以把 Windows 与程式之间的关系表示成图-1:
尽管主动模式的程式在 Windows 底下不再有问题,但笔者必须强调的是事件驱动模式才是 Windows 程式的主流,为什麽呢?在 Windows 的多工作业环境底下,萤幕的输出、键盘的输入、滑鼠的输入…等,都必须由 Windows 来统筹管理,如果每个程式都想主动控制这些输出与输入装置,势必造成你抢我抢的混乱现象,反观事件驱动模式是以物件为核心,程式的执行是把负责不同工作的物件派到 Windows 的工作环境底下,接着这些物件就会静候 Windows 所产生的事件,然後加以处理,由於事件是由 Windows 统筹管理以及产生的,因此不同程式之间便能够在 Windows 的环境底下共同生存及工作。
除了从多工作业的角度来看,事件驱动模式的程式也比较容易管理,由於我们会引用不同的物件以处理不同的工作,因此便比较不会把某一项工作的程式写得太大,而造成将来侦错及维护的困难。
从 VB 的事件回溯到 Windows 的讯息
以上是从 VB 的角度来看事件,尽管笔者说 Windows 会产生事件以驱动物件,但比较正确的说法是 Windows 先产生讯息,经由 VB 的转化才成为事件,然後才驱动物件的,如图-2。
但是 VB 是如何把讯息转化成事件的呢?而讯息又是什麽东西呢?请回忆我们在 46 期介绍的 hWnd(handle of window),它代表视窗的唯一识别码,由於许多物件都有 hWnd 这个属性,所以我们可以把物件表示成图-3,而视窗是 Windows 传递讯息的对象,这便使得 VB 得以将讯息转化成事件,进而驱动物件中的事件程序。
至於 VB 怎样把讯息转化成事件的呢?在视窗的内部,有一样最重要的东西称为「视窗程序」(window procedure),它的用途是接收来自 Windows 的讯息,当 VB 建立物件(视窗)时,也会提供一个视窗程序,用以接受来自 Windows 的讯息、处理讯息、并且会将某些讯息转化成为事件,进而驱动物件的事件程序,如图-4,只是它始终是隐藏的,所以对大部分的 VB 程式设计人员来说,并不晓得有这一号人物存在。
认识视窗程序
关於视窗程序,如果表示成 VB 的函数,则如下:
Function WndProcName(ByVal hWnd As Long, ByVal Msg As Long, ByVal wParam As Long, ByVal lParam As Long) As Long Select Case Msg Case WM_???? ' 讯息-1 ... Case WM_???? ' 讯息-2 ... Case Else ret = DefWindowProc(hWnd, Msg, wParam, lParam)
End Select WndProcName = ret End Function
首先来看视窗程序的四个参数:
- hWnd:handle of window,这个不必再解释了吧!
- Msg:讯息编号,用来表示 Windows 所传递过来的是怎样的讯息。在 Win32api.txt 档案中,我们可以找到许多以 WM_(表示 Window Message) 为开头的常数定义,例如「Public Const WM_LBUTTONDOWN = &H201」,其中常数符号部分(例如 LBUTTONDOWN 表示 Left Button Down)可以让我们望文生义,而常数数值部分(&H201)则是讯息的编号。而视窗程序会利用 If Msg = WM_???? 或 Select Case Msg/Case WM_???? 叙述来判断收到的讯息是否为某一种讯息,进而加以处理。
- wParam 及 lParam:这两个参数称为讯息参数,其意义会随着讯息(参数二 Msg)的不同而有所不同,想了解每一种讯息参数的意义,则必须查阅 SDK(Software Development Kits) 参考手册,不过 SDK 参考手册蛮贵的,但您可以在以下两个地方寻得 SDK 的线上参考手册:(1) VC++ (2) MSDN/CD (Microsoft Developer Network/CD)。
至於视窗程序之中最值得注意的地方是 DefWindowProc API 函数,这是 Windows 为了减少视窗程序的负担所提供的函数,由於 Windows 可能传递给视窗程序的讯息多达数百种,而大部分讯息是不需要特别处理的,此时若呼叫 DefWindowProc,可将讯息交由 API 代为处理。
VB 提供的视窗程序
如果是自己撰写视窗程序,可以自由地处理收到的讯息,不过在 VB 程式中,视窗程序是由 VB 提供的,以下让笔者先来介绍 VB 所提供的视窗程序是如何处理讯息、以及如何产生事件以驱动物件的。
刚才笔者说过 Windows 的讯息多达数百种,对 VB 所提供的视窗程序而言,也一样不处理那麽多讯息,所以大部分的情况都是呼叫 DefWindowProc 交由 API 代劳。但由於 VB 提供的物件都含有某些事件,因此当 VB 的视窗程序收到讯息时,会判断此一讯息是否应转化成为事件,例如 LBUTTONDOWN 讯息应转化成为 MouseDown 事件,如果是,则判断程式中是否含有该事件的事件程序(例如 Form_MouseDown),如果有,则呼叫此一事件程序,参考图-4 就不难了解其间的运作过程。
笔者觉得视窗程序与事件程序在概念上并没有太大的差别,但不可否认的 VB 的视窗程序会吃掉了许多讯息(也就是说这些讯息并不会产生对应的事件),VB 这麽做也不是没有道理,只保留最为常用的讯息可减少程式设计人员的负担,而实际上,也可以提升程式的执行效能。不过话说回来,因为许多讯息被 VB 吃掉了,这也使得 VB 程式无法完成某些特殊的功能,过去 VB 一直被批评为容易使用但功能不足,这是重要的原因,不过这种现象到了 VB 5.0 以後,有了重大的改变,请继续读下去,稍後笔者就会说明。
让 VB 程式具有 Callback 的能力
想要强化 VB 的视窗程序是可能的,不过这必须先了解 Callback 的工作模式。
请回顾图-4,Windows 会传递讯息给视窗程序,但何谓传递呢?其实就是 Windows 准备好 hWnd、Msg、wParam、lParam 参数,然後呼叫 VB 所提供的函数(视窗程序),这原本是没什麽特别的事情,但由於平常都是由应用程式(或 VB)来呼叫 Windows 的,而讯息的传递则是由 Windows 倒回来呼叫应用程式,故称为 Callback。
想要强化 VB 的视窗程序首先必撰写好强化的程序,并且把这个程序设定成 Windows 可以 Callback 的程序,此时最重要的一件事情是告诉 Windows 此一程序的「位址」,但 VB 4.0 以前的版本却无法取得程序的位址,因此举凡与 Callback 有关的程式设计都与 VB 程式无缘,不过到5.0 版以後,VB 提供了 AddressOf 叙述可供程式取得程序的位址,也因此为 VB 程式开启了 Callback 的後门。
Callback 程式范例
接下来让笔者举个 VB 程式的 Callback 范例,首先请开启 VB 的线上手册(选取 VB 功能表的「说明/线上手册」),然後寻找「AddressOf」关键字,在找到的几个主题中,请选取「AddressOf 运算子范例」,然後依照指示将范例移植到程式中,最後执行程式,结果此一程式会列举系统中的字型,并且显示在表单上的 ListBox 中,如图-5。(注:如果您无法顺利将 AddressOf 范例移植到程式中,可进入笔者的网站下载完成的程式)
此一范例的重点在於 EnumFontFamilies API 函数的呼叫(位於 Module1 的 FillListWithFonts 的副程式),EnumFontFamilies 的用途是列举系统的所有字型,但由於系统的字型数目是可变的,因此 Windows 规定呼叫此一函数时必须传入一个 Callback 程序让 Windows 把列举得到的字型一一传递给这个程序,为了将 Callback 程序的位址传给 Windows,我们可以发现 EnumFontFamilies 的参数叁如下:
AddressOf EnumFontFamProc
作用就是取得 EnumFontFamProc 程序的位址。
强化 VB 的视窗程序
由於 VB 的视窗程序是隐藏起来的,所以我们无法改变其中的程式码,但如果已知 hWnd,那麽 Windows 允许我们换掉既有的视窗程序,假设我们想将 Form1 预设的视窗程序改成我们所撰写的视窗程序(假设名称是 WndProc),则呼叫的 API 如下:
ret = SetWindowLong(Form1.hWnd, GWL_WNDPROC, AddressOf WndProc)
如此一来,VB 预设给 Form1 的视窗程序就会失效,而取而代之的是 WndProc 视窗程序,所以接下来就由 WndProc 来接收 Windows 的讯息了。
视窗程序的插队游戏
利用以上方法来改变 Form 的视窗程序,乍看之下,好像还不错,但实际却很危险,为什麽呢?想想,当我们想换掉某一个视窗程序时,至少要提供与原视窗程序相同的功能,而 VB 的视窗程序到底提供了哪些功能,我们并不清楚,所谓破坏容易建设难啊!解决此一问题,其根本原理请参考图-6,作法上是在原视窗程序前面插入另一个视窗程序,用以撷取我们希望处理的讯息,进而达到强化原视窗程序的效果,至於不处理的讯息,则呼叫 CallWindowProc 将讯息交由原视窗程序来处理,如此一来便不至於破坏原视窗程序的功能。此一作法 Windows 称之为 SubClassing,笔者则称之为视窗程序的插队游戏,要玩插队游戏,还有一些细节要注意。
记录原视窗程序的位址
由於我们必须将不处理的讯息丢回原视窗程序,所以呼叫:
ret = SetWindowLong(Form1.hWnd, GWL_WNDPROC, AddressOf WndProc)
改变 Form1 的视窗程序之前,必须先记录原视窗程序的位址,方法如下:
Dim prevWndProc As Long
prevWndProc = GetWindowLong(Form1.hWnd, GWL_WNDPROC)
传回值 prevWndProc 即为 Form1 原视窗程序的位址。
插队用视窗程序的写法
首先假设插队视窗程序的目的只为了插队,但插队之後什麽是也不做,那麽此一视窗程序收到讯息时,就原原本本地呼叫原视窗程序,则 WndProc 视窗程序如下:
Function WndProc(ByVal hWnd As Long, ByVal Msg As Long, ByVal wParam As Long, ByVal lParam As Long) As Long
WndProc = CallWindowProc(prevWndProc, hWnd, Msg, wParam, lParam)
End Function
比较值得注意的是 CallWindowProc 的第一个参数必须传入原视窗程序的位址 prevWndProc。
如果想要在插队的视窗程序中处理某些讯息,则结构如下:
Function WndProc(ByVal hWnd As Long, ByVal Msg As Long, ByVal wParam As Long, ByVal lParam As Long) As Long
Select Case Msg Case WM_xxxx ... 处理 WM_xxxx 的程式 Case WM_xxxx ... 处理 WM_xxxx 的程式 Case Else WndProc = CallWindowProc(prevWndProc, hWnd, Msg, wParam, lParam) End Select
End Function
取消插队行为
一旦发生插队的行为,在视窗结束以前,一定要取消插队行为,否则程式会当掉,此时须将原视窗程序的位址设定回 Form1,如下:
ret = SetWindowLong(Form1.hWnd, GWL_WNDPROC, prevWndProc)
实例解析解说
最後请直接参考笔者所完成的范例 WndProc.vbp,您可以进入笔者的网站下载此一程式,首先请检视 WndProc.bas 档案,如下:(笔者省略了 API 函数的宣告)
Option Explicit
Public prevWndProc As Long
Function WndProc(ByVal hWnd As Long, ByVal Msg As Long, ByVal wParam As Long, ByVal lParam As Long) As Long
Debug.Print hWnd, Msg, wParam, lParam
WndProc = CallWindowProc(prevWndProc, hWnd, Msg, wParam, lParam)
End Function
在此一 .bas 程式中有几件事值得注意:
- Option Explicit 叙述:撰写呼叫 Windows API 的程式时,最好利用此一叙述禁止使用未宣告的变数,因为呼叫 Windows API 时,一旦资料型别不符合就很容易产生错误。
- prevWndProc 变数:用来记录原视窗程序的位址。
- 视窗程序内容:在此一视窗程序中,暂时不处理任何讯息,因此直接呼叫 CallWindowProc 将讯息传给原视窗程序,此外也利用 Debug.Print 将视窗程序的几个参数显示出来,藉以了解讯息传递给视窗程序的情况。
接着请检视 WndProc.frm 档案,如下:
Option Explicit Private Sub Form_Load() Dim ret As Long prevWndProc = GetWindowLong(Me.hWnd, GWL_WNDPROC) ret = SetWindowLong(Me.hWnd, GWL_WNDPROC, AddressOf WndProc) End Sub
Private Sub Form_Unload(Cancel As Integer) Dim ret As Long ret = SetWindowLong(Me.hWnd, GWL_WNDPROC, prevWndProc) End Sub
此一 .frm 程式的重点在於 Form_Load 及 Form_Unload 事件程序,其中笔者在 Form_Load 事件程序中(也就是 Form 载入时),将 WndProc.bas 的 WndProc 视窗程序插在原视窗程序之前,而在 Form_Unload 事件程序中则将原视窗程序还原回来。(特别注意:想结束程式,请按下 Form 的关闭钮,不要使用 VB 的结束钮,否则 Form_Unload 将不会被执行到)
强化视窗程序的实例
延续前面的 WndProc.vbp 程式,让我们来观察两个强化 VB 原视窗程序的实例。
利用 WM_QUERYOPEN 讯息禁止视窗还原
WM_QUERYOPEN 讯息发生於视窗由「最小化」还原时,在此笔者想利用此一讯息让视窗一直保留在最小化的状态,首先假设我们不利用此一讯息,而想利用 VB 既有的事件来达成相同的目的,那麽想到的方法是在 Form_Resize 事件程序中撰写以下程式:
Private Sub Form_Resize() If WindowState <> vbMinimized Then WindowState = vbMinimized End If End Sub
这一段程式确实可以让视窗一直保持在最小化的状态,但使用者将视窗还原时,视窗会被先还原,然後才再缩小,结果可以看到视窗被还原然後又被缩小的过程。如果我们利用 WM_QUERYOPEN 讯息,则 WndProc 视窗程序只要如下修改即可:
Function WndProc(ByVal hWnd As Long, ByVal Msg As Long, ByVal wParam As Long, ByVal lParam As Long) As Long
If Msg = WM_QUERYOPEN Then ' 吃掉此一讯息,不再传递给原视窗程序 ' 也就是不让原视窗程序得到视窗还原的讯息 Else WndProc = CallWindowProc(prevWndProc, hWnd, Msg, wParam, lParam) End If End Function
以上程式十分简单,只要在 WM_QUERYOPEN 讯息发生时,不再呼叫原视窗程序,使得原视窗程序不知有 WM_QUERYOPEN 讯息发生,於是便不会由最小化还原回来。此一完成之程式笔者收录於 queryopn.vbp 专案中。
非显示区的滑鼠事件
在 VB 既有的事件中,只含有视窗「显示区」(client rect)的滑鼠事件,当我们把滑鼠移到非显示区(例如视窗标题区),则收不到 MouseMove 事件,假设我们的需求是:「滑鼠移到视窗上面时,即让视窗变成使用中」,若单纯使用事件程序来撰写程式,则如下:
Private Sub Form_MouseMove(Button As Integer, Shift As Integer, X As Single, Y As Single)
Me.SetFocus
End Sub
但这个程式有点小瑕疵,那就是滑鼠移到非显示区,而没有移到显示区时,程式并没有作用,为了让程式能够收到滑鼠移到非显示区的讯息,我们可以撰写以下的视窗程序:
Function WndProc(ByVal hwnd As Long, ByVal Msg As Long, ByVal wParam As Long, ByVal lParam As Long) As Long
Dim ret As Long If Msg = WM_NCMOUSEMOVE Then Form1.SetFocus End If WndProc = CallWindowProc(prevWndProc, hwnd, Msg, wParam, lParam) End Function
在以上程式中,判断 Msg 是否等於 WM_NCMOUSEMOVE 讯息,便可得知使用者是否将滑鼠移到了非显示区,而由於我们不想改变视窗的行为,所以紧接着又呼叫了 CallWindowProc 将讯息传给原视窗程序。以上完成之程式笔者收录於 ncfocus.vbp 专案中。
结语:平心而论
虽然笔者本期介绍了视窗程序的强化功能,但说实在话,平常自己写程式时,却很少这麽做,主要原因是笔者觉得搞了一些花样,未必会得到使用者的认同,倒不如采用使用者已经习惯的标准操作方式。这麽说来,笔者本期好像讲了半天的废话,浪费了 Run!PC 宝贵的篇幅,其实不然,讯息的运作模式在 Windows 的程式设计中是很重要的观念,要使 Windows API 善尽其用,讯息的运作模式绝对不可不知,除了讯息之外,Callback 的功能则让 VB 向前跨了一大步,它除了应用在视窗程序的插队之外,呼叫许多 Windows API 也少不了它,上一期笔者所写的「萤幕保护程式」也曾经利用 Callback 的功能将侦测滑鼠与键盘的程式挂在系统之下,本期虽然没有介绍什麽花俏的程式,但建议您不妨把它当作使用 Windows API 的垫底工作。