揭開訊息迴圈的神秘面紗
作者:蔡學鏞
2005 年 3 月
如果你是這幾年才開始寫 Windows 程式,那麼你大概不太有機會看到訊息迴圈(Message Loop)。在早年直接用 Win32/Win16 API 寫程式的時代,訊息迴圈卻是我們必須搞懂的第一個觀念。現在,不管你用是 Windows 上面的哪一套 Application Framework(MFC、VCL、VB、.NET Framework),甚至 Unix、Linux、MacOS X 上面的 Application Framework,都不太容易看到訊息迴圈。事實上,訊息迴圈依然存在,只是被這些 Application Framework 包裝起來,深深地埋藏在某個角落。這些 Application Framework 設計得很好,所以,多數的時候,我們不太需要知道訊息迴圈。也因此,訊息迴圈漸漸被遺忘,正如同電影「靈異拼圖」(The Forgotten)一樣,一提起訊息迴圈,年輕一輩的程式員還一臉疑惑「什麼是訊息迴圈?」
不認識訊息迴圈,不見得全然是壞事,多數的時候,甚至可以被視為是一種進步,畢竟好的封裝(Encapsulation)不正是應該如此。但是,在真正需要直接用到訊息迴圈來解決某些特殊問題的時候(儘管這樣的機會不高),卻又怎麼都沒想到訊息迴圈,這可就不妙了。
本文章試圖喚起大家對於訊息迴圈的回憶,也試圖解釋訊息迴圈如何被封裝進 .NET Framework 的 Windows Forms 中。雖然 Windows Forms 將這一切都藏起來,但是也留下許多空間,讓我們可以自行處理 Win32 的訊息。希望哪一天,當你真正需要直接用到訊息迴圈時,這篇文章能夠起一點作用。
傳統的 Windows 程式
傳統的 Windows 程式,只利用 Win32/Win16 API 撰寫,下面是一個程式範例,為了節省篇幅,我將其中許多程式碼省略:
// 程式進入點 int APIENTRY _tWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPTSTR lpCmdLine, int nCmdShow) { // TODO: 在此置入程式碼。 MSG msg; // 執行應用程式初始設定: if (!InitInstance (hInstance, nCmdShow)) { return FALSE; } // 主訊息迴圈: while (GetMessage(&msg, NULL, 0, 0)) { TranslateMessage(&msg); DispatchMessage(&msg); } return (int) msg.wParam; } // // 函式: WndProc(HWND, unsigned, WORD, LONG) // // 用途: 處理主視窗的訊息。 // // WM_COMMAND - 處理應用程式功能表 // WM_PAINT - 繪製主視窗 // WM_DESTROY - 傳送結束訊息然後返回 // // LRESULT CALLBACK WndProc( HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) { int wmId, wmEvent; PAINTSTRUCT ps; HDC hdc; switch (message) { case WM_COMMAND: wmId = LOWORD(wParam); wmEvent = HIWORD(wParam); // 剖析功能表選取項目: switch (wmId) { case IDM_ABOUT: DialogBox(hInst, (LPCTSTR)IDD_ABOUTBOX, hWnd, (DLGPROC)About); break; case IDM_EXIT: DestroyWindow(hWnd); break; default: return DefWindowProc(hWnd, message, wParam, lParam); } break; case WM_PAINT: hdc = BeginPaint(hWnd, &ps); // TODO: 在此加入任何繪圖程式碼... EndPaint(hWnd, &ps); break; case WM_DESTROY: PostQuitMessage(0); break; default: return DefWindowProc(hWnd, message, wParam, lParam); } return 0; } // [關於] 方塊的訊息處理常式。 LRESULT CALLBACK About(HWND hDlg, UINT message, WPARAM wParam, LPARAM lParam) { switch (message) { case WM_INITDIALOG: return TRUE; case WM_COMMAND: if (LOWORD(wParam) == IDOK || LOWORD(wParam) == IDCANCEL) { EndDialog(hDlg, LOWORD(wParam)); return TRUE; } break; } return FALSE; }
為了幫助理解此程式,我繪製了下面的示意圖:
圖 1:Windows 的訊息迴圈
- 從 tWinMain 內,程式進入主訊息迴圈
- 訊息迴圈從訊息佇列(Message Queue)中取得一個訊息(透過呼叫 GetMessage())。每個執行中的程式都有一個屬於自己的訊息佇列。
- 訊息迴圈根據訊息內容來決定訊息應該送給那個 Windows Procedure(WndProc),這就稱為訊息發派(Message Dispatch)。通常『每一種』視窗或控件(control)都有一個的 Windows Procedure,來處理該種視窗/控件的行為。
- Windows Procedure 根據訊息內容來決定應該呼叫那個函式(利用 Switch/Case 語法)。
- Windows Procedure 處理完,控制權回到訊息迴圈。繼續進行 2、3、4、5 的動作。
- 當訊息佇列為空的時候,GetMessage() 無法取得任何訊息,就會進入 Idle(空閒)狀態,進入睡眠狀態(而不是 Busy Waiting)。當訊息佇列不再為空的時候,程式會自動醒過來,繼續進行 2、3、4、5 的動作。
- 當取得的訊息是 WM_QUIT,GetMessage() 就會得到 0 的返回值,因而離開訊息迴圈,程式結束。程式會利用呼叫 PostQuitMessage() 來將 WM_QUIT 放置進訊息佇列中,來造成稍後結束,而不會直接貿然跳離開迴圈來結束。
雖名為佇列(queue),但是訊息佇列中的訊息並非總是先進先出(First In First Out,FIFO),有一些特例:
- 只要訊息佇列中有 WM_QUIT,就會先取出 WM_QUIT,導致程式結束。
- 只有在沒有其他訊息的時候,WM_PAINT 和 WM_TIMER 才會被取出。且多個 WM_PAINT 可能會被合併成一個,WM_TIMER 也是如此。
- 利用 TranslateMessage() 來處理訊息,可能會造成新訊息的產生。例如:TranslateMessage() 可以辨識出 WM_KEYDOWN(按鍵按下)加上 WM_KEYUP(按鍵放開)就產生 WM_CHAR(字元輸入)。
何謂訊息
滑鼠移動、按鍵被按下、視窗被關閉 …,這些都會產生訊息。在 Windows 作業系統中,訊息是以下面的資料結構存在的(定義在 WinUser.h 檔案中):
typedef struct tagMSG { HWND hwnd; UINT message; WPARAM wParam; LPARAM lParam; DWORD time; POINT pt; } MSG;
訊息內有六個資訊,分別是:
- hwnd:此訊息應該屬於那個視窗/控件,每個視窗/控件都有一個 hwnd 的編號。訊息迴圈會根據此資訊,將訊息送到正確視窗/控件的 Windows Procedure。
- message:訊息種類的 ID。Windows 預先定義了許多的訊息 ID,例如 15 號代表視窗/控件需要重繪。為了方便編程,WinUser.h 檔案將這些 ID 定義成容易理解的巨集(Macro),例如 WM_PAINT(視窗需重繪)就等於 15,WM_CREATE(視窗被建立)就等於 1,WM_DESTROY(視窗被結束)就等於 2。其中 WM 是 Windows Message 的縮寫。
- wParam 與 lParam:有些 message 本身需要攜帶更多的資訊,這些資訊就放在 wParam 與 lParam 中。例如 WM_COMMAND 表示選單(menu)內的選項(menu item)被選取,至於是哪一個選項被選取,則必須看 wParam 內的值來得知。
- time 與 pt:訊息發生當時的時間與滑鼠位置。
.NET Framework 如何封裝訊息迴圈
.NET Framework 的 Windows Forms 將訊息迴圈封裝起來,以方便我們使用。請對照圖 1 和圖 2,以瞭解封裝方式。本節中所提到的類別(class),都是屬於 System.Windows.Forms 名稱空間(namespace)。
圖 2
簡單歸納如下:訊息迴圈被封裝進了 Application 類別的 Run() 靜態方法中;Windows Procedure 被封裝進了 NativeWindow 與 Control 類別中;個別的訊息處理動作被封裝進 Control 類別的 OnXyz()(例如 OnPaint())。我們可以覆蓋(override)OnXyz(),來提供我們自己的程式。也可以利用.NET的事件(event)機制,在 Xyz 事件上,加入我們的事件處理函式(Event Handler)。Control 類別的 OnXyz() 會主動呼叫 Xyz 事件的處理函式。
請注意,因為 Xyz 的事件處理函式是由 Control 類別的 OnXyz() 方法所呼叫的,所以當你覆寫 OnXyz() 方法時,不要忘了呼叫 Control 類別的 OnXyz()(除非你有特殊需求),否則 Xyz 事件處理函式將會沒有作用。只要呼叫 base.OnXyz(),就可以呼叫到 Control 類別的 OnXyz() 方法,如下所示:
protected override void OnPaint(PaintEventArgs e) { // TODO: 加入 Form1.OnPaint 實作 base.OnPaint (e); }
我們可以利用覆寫 Control 類別的 OnXyz(),來決定該訊息發生時要做些什麼。同理,我們甚至可以覆寫 Control 與 NativeWindow 類別的 WndProc(),來定義 Windows Procedure。
再次提醒你,因為 OnXyz() 系列方法是由 Control 類別的 WndProc() 所呼叫的,所以當你覆寫 WndProc() 時,不要忘了呼叫 Control 類別的 WndProc()(除非你有特殊需求),否則 OnXyz() 系列方法(以及 Xyz 事件處理函式)將會沒有作用。只要呼叫 base.WndProc(),就可以呼叫到 Control 類別的 WndProc(),如下所示:
protected override void WndProc(ref Message m) { // TODO: 加入 Form1.WndProc 實作 base.WndProc (ref m); }
你可能也注意到了,WndProc() 需要一個 Message 類別的參數,這正是 MSG 被封裝成 .NET 版本的結果。
一個 Windows Forms 的範例
為了讓讀者更加瞭解實際的狀況,我用下面的實例範例作說明:
using System; using System.Drawing; using System.Collections; using System.ComponentModel; using System.Windows.Forms; using System.Data; namespace WindowsApplication1 { ////// Form1 的摘要描述。 /// public class Form1 : Form { /// /// 設計工具所需的變數。 /// private Container components = null; public Form1() { AutoScaleBaseSize = new Size(5, 15); ClientSize = new Size(292, 266); Name = "Form1"; Text = "Form1"; Paint += new PaintEventHandler(this.Form1_Paint); Paint += new PaintEventHandler(this.Form1_Paint2); } /// /// 應用程式的主進入點。 /// [STAThread] static void Main() { Application.Run(new Form1()); } protected override void OnPaint(PaintEventArgs e) { // 2 base.OnPaint (e); } private void Form1_Paint(object sender, PaintEventArgs e) { // 3 } private void Form1_Paint2(object sender, PaintEventArgs e) { // 4 } protected override void WndProc(ref Message m) { // 1 base.WndProc (ref m); } } }
- 在 Main() 中,利用 Application.Run() 來將 Form1 視窗顯示出來,並進入訊息迴圈。程式的執行過程中,Application.Run() 一直未結束。
- OS 在此 Process 的訊息佇列內放進一個 WM_PAINT 訊息,好讓視窗被顯示出來。
- WM_PAINT 被 Application.Run() 內的訊息迴圈取出來,發派到 WndProc()。由於多型(Polymorphism)的因素,此次調用(invoke)到的 WndProc() 是屬於 Form1 的 WndProc(),也就是上述程式中註解(comment)1 的地方,而不是調用到 Control.WndProc()。
- 在 Form1.WndProc() 的最後,有調用 base.WndProc(),這實際上調用到 Control.WndProc()。
- Control.WndProc() 從 Message 參數中得知此訊息是 WM_PAINT,於是調用 OnPaint()。由於多型的因素,此次調用到的 OnPaint() 是屬於 Form1 的 OnPaint(),也就是上述程式中註解 2 的地方,而不是調用到 Control.OnPaint()。
- 在 Form1.OnPaint() 的最後,有調用 base.OnPaint(),這實際上調用到 Control.OnPaint()。
- 我們曾經在 Form1 的建構式(constructor)中將 Form1_Paint() 與 Form1_Paint2() 登記成為 Paint 事件處理函式(Event Handler)。Control.OnPaint() 會去依序去呼叫這兩個函式,也就是上述程式中註解 3 與 4 的地方。
關於 delegate 和 event,如果讀者想知道更多詳細的內容,可以參考我在前年(2003)發表的文章〈函數指針進化論〉。
幹嘛知道這麼多?
拜工具之賜,現在的程式員很幸福,可以在糊里糊塗的情況下寫出程式來。不過這樣的程式員恐怕競爭力不強,畢竟將元件(component)拖放(drag and drop)到畫面上,再設定元件屬性的工作,稱不上有太大的難度。只有深入瞭解內部原理,才能讓自己對技術融會貫通,也才能讓程式員之路走得更穩健、更長久。