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:

 

圖-1 程式使用 CPU 的時間是由 Windows 來主控的

 

儘管主動模式的程式在 Windows 底下不再有問題,但筆者必須強調的是事件驅動模式才是 Windows 程式的主流,為什麼呢?在 Windows 的多工作業環境底下,螢幕的輸出、鍵盤的輸入、滑鼠的輸入…等,都必須由 Windows 來統籌管理,如果每個程式都想主動控制這些輸出與輸入裝置,勢必造成你搶我搶的混亂現象,反觀事件驅動模式是以物件為核心,程式的執行是把負責不同工作的物件派到 Windows 的工作環境底下,接著這些物件就會靜候 Windows 所產生的事件,然後加以處理,由於事件是由 Windows 統籌管理以及產生的,因此不同程式之間便能夠在 Windows 的環境底下共同生存及工作。

 

除了從多工作業的角度來看,事件驅動模式的程式也比較容易管理,由於我們會引用不同的物件以處理不同的工作,因此便比較不會把某一項工作的程式寫得太大,而造成將來偵錯及維護的困難。

 


VB 的事件回溯到 Windows 的訊息

 

以上是從 VB 的角度來看事件,儘管筆者說 Windows 會產生事件以驅動物件,但比較正確的說法是 Windows 先產生訊息,經由 VB 的轉化才成為事件,然後才驅動物件的,如圖-2。

 

圖-2 事件的產生乃源自於 Windows 的訊息

 

但是 VB 是如何把訊息轉化成事件的呢?而訊息又是什麼東西呢?請回憶我們在 46 期介紹的 hWnd(handle of window),它代表視窗的唯一識別碼,由於許多物件都有 hWnd 這個屬性,所以我們可以把物件表示成圖-3,而視窗是 Windows 傳遞訊息的對象,這便使得 VB 得以將訊息轉化成事件,進而驅動物件中的事件程序。

 

圖-3 物件與視窗的關係

 

至於 VB 怎樣把訊息轉化成事件的呢?在視窗的內部,有一樣最重要的東西稱為「視窗程序」(window procedure),它的用途是接收來自 Windows 的訊息,當 VB 建立物件(視窗)時,也會提供一個視窗程序,用以接受來自 Windows 的訊息、處理訊息、並且會將某些訊息轉化成為事件,進而驅動物件的事件程序,如圖-4,只是它始終是隱藏的,所以對大部分的 VB 程式設計人員來說,並不曉得有這一號人物存在。

 

圖-4 將訊息轉化成事件的藏鏡人是視窗程序

 


認識視窗程序

 

關於視窗程序,如果表示成 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

 

首先來看視窗程序的四個參數:

  1. hWnd:handle of window,這個不必再解釋了吧!
  2. 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_???? 敘述來判斷收到的訊息是否為某一種訊息,進而加以處理。
  3. wParam 及 lParam:這兩個參數稱為訊息參數,其意義會隨著訊息(參數二 Msg)的不同而有所不同,想瞭解每一種訊息參數的意義,則必須查閱 SDK(Software Development Kits) 參考手冊,不過 SDK 參考手冊蠻貴的,但您可以在以下兩個地方尋得 SDK 的線上參考手冊:(1) VC++ (2) MSDN/CD (Microsoft Developer Network/CD)。
  4.  

至於視窗程序之中最值得注意的地方是 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 範例移植到程式中,可進入筆者的網站下載完成的程式)

 

圖-5 VB5 的 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,筆者則稱之為視窗程序的插隊遊戲,要玩插隊遊戲,還有一些細節要注意。

 

圖-6 在原有的視窗程序之前插入另一個視窗程序

 

記錄原視窗程序的位址

 

由於我們必須將不處理的訊息丟回原視窗程序,所以呼叫:

 

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 程式中有幾件事值得注意:

 

  1. Option Explicit 敘述:撰寫呼叫 Windows API 的程式時,最好利用此一敘述禁止使用未宣告的變數,因為呼叫 Windows API 時,一旦資料型別不符合就很容易產生錯誤。
  2. prevWndProc 變數:用來記錄原視窗程序的位址。
  3. 視窗程序內容:在此一視窗程序中,暫時不處理任何訊息,因此直接呼叫 CallWindowProc 將訊息傳給原視窗程序,此外也利用 Debug.Print 將視窗程序的幾個參數顯示出來,藉以瞭解訊息傳遞給視窗程序的情況。
  4.  

接著請檢視 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 的墊底工作。



學 VB(Visual Basic) 找王國榮