大內高手專欄:

揭開訊息迴圈的神秘面紗

作者:蔡學鏞

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
圖 1:Windows 的訊息迴圈

  1. 從 tWinMain 內,程式進入主訊息迴圈
  2. 訊息迴圈從訊息佇列(Message Queue)中取得一個訊息(透過呼叫 GetMessage())。每個執行中的程式都有一個屬於自己的訊息佇列。
  3. 訊息迴圈根據訊息內容來決定訊息應該送給那個 Windows Procedure(WndProc),這就稱為訊息發派(Message Dispatch)。通常『每一種』視窗或控件(control)都有一個的 Windows Procedure,來處理該種視窗/控件的行為。
  4. Windows Procedure 根據訊息內容來決定應該呼叫那個函式(利用 Switch/Case 語法)。
  5. Windows Procedure 處理完,控制權回到訊息迴圈。繼續進行 2、3、4、5 的動作。
  6. 當訊息佇列為空的時候,GetMessage() 無法取得任何訊息,就會進入 Idle(空閒)狀態,進入睡眠狀態(而不是 Busy Waiting)。當訊息佇列不再為空的時候,程式會自動醒過來,繼續進行 2、3、4、5 的動作。
  7. 當取得的訊息是 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
圖 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);
        }
    }
}
  1. 在 Main() 中,利用 Application.Run() 來將 Form1 視窗顯示出來,並進入訊息迴圈。程式的執行過程中,Application.Run() 一直未結束。
  2. OS 在此 Process 的訊息佇列內放進一個 WM_PAINT 訊息,好讓視窗被顯示出來。
  3. WM_PAINT 被 Application.Run() 內的訊息迴圈取出來,發派到 WndProc()。由於多型(Polymorphism)的因素,此次調用(invoke)到的 WndProc() 是屬於 Form1 的 WndProc(),也就是上述程式中註解(comment)1 的地方,而不是調用到 Control.WndProc()。
  4. 在 Form1.WndProc() 的最後,有調用 base.WndProc(),這實際上調用到 Control.WndProc()。
  5. Control.WndProc() 從 Message 參數中得知此訊息是 WM_PAINT,於是調用 OnPaint()。由於多型的因素,此次調用到的 OnPaint() 是屬於 Form1 的 OnPaint(),也就是上述程式中註解 2 的地方,而不是調用到 Control.OnPaint()。
  6. 在 Form1.OnPaint() 的最後,有調用 base.OnPaint(),這實際上調用到 Control.OnPaint()。
  7. 我們曾經在 Form1 的建構式(constructor)中將 Form1_Paint() 與 Form1_Paint2() 登記成為 Paint 事件處理函式(Event Handler)。Control.OnPaint() 會去依序去呼叫這兩個函式,也就是上述程式中註解 3 與 4 的地方。

關於 delegate 和 event,如果讀者想知道更多詳細的內容,可以參考我在前年(2003)發表的文章〈函數指針進化論〉。

幹嘛知道這麼多?

拜工具之賜,現在的程式員很幸福,可以在糊里糊塗的情況下寫出程式來。不過這樣的程式員恐怕競爭力不強,畢竟將元件(component)拖放(drag and drop)到畫面上,再設定元件屬性的工作,稱不上有太大的難度。只有深入瞭解內部原理,才能讓自己對技術融會貫通,也才能讓程式員之路走得更穩健、更長久。

意見與支援

 您有任何問題、意見或建議嗎?您可以透過下列電子郵件與作者連絡:
 xy.cai@msa.hinet.net

更多資訊

想知道大內高手專欄的其他文章嗎?請至此專欄所有列表