【轉】Delphi 的消息機制淺探

Delphi 的消息機制淺探

savetime2k@yahoo.com  2004.1.9


我從去年 12 月上旬開始等待李維的《Inside VCL》。我當時的計劃是,在這本書的指導下深刻學習 Delphi。到了 12 月底,書尚未出來,我不肯再等,開始閱讀 VCL 源代碼。在讀完 TObject、TPersistant 和 TComponent 的代碼以後,我發現仍是不清楚 Delphi 對象究竟是怎樣被建立的。因而我查看 Delphi 生成的彙編代碼,終於理解了對象建立的整個過程(這裏要特別感謝 book523 的幫助)。

此後我就開始學習 Delphi VCL 的消息處理機制。自從我寫下《Delphi的對象機制淺探》,至今正好一個星期,我也基本上把 Delphi VCL 的消息處理框架讀完了。個人學習方法就是閱讀源代碼,一開始比較艱苦,後來線索逐漸清晰起來。在此把本身對 Delphi VCL 消息機制的理解記錄下來,便於從此的複習,也給初學 Delphi 或沒有時間閱讀 VCL 源代碼的朋友參考(畢竟沒有幾個程序員像我這樣有時間 :)。因爲學習時間較短,必定會有錯誤,請你們指正。

我在分析 VCL 消息機制的過程當中,基本上只考查了三個類 TObject、TControl 和 TWinControl。雖然我沒有閱讀上層類(如 TForm)的代碼,但我認爲這些都是實現的細節。我相信 VCL 消息系統中最關鍵的東西都在這三個類中。綱舉而目張,掌握基礎類的消息處理方法以後再讀其餘類的消息處理過程就容易得多了。

要想讀懂本文,最低配置爲:
    瞭解 Win32 消息循環和窗口過程
    基本瞭解 TObject、TControl 和 TWinControl 實現的內容
    熟悉 Delphi 對象的重載與多態

推薦配置爲:
    熟悉 Win32 SDK 編程
    熟悉 Delphi 的對象機制
    熟悉 Delphi 內嵌彙編語言

推薦閱讀:
    《Delphi 的原子世界》
        http://www.codelphi.com/
    《VCL窗口函數註冊機制研究手記,兼與MFC比較》
        http://www.delphibbs.com/delphibbs/dispq.asp?lid=584889
    《Delphi的對象機制淺探》
        http://www.delphibbs.com/delphibbs/dispq.asp?LID=2390131

本文排版格式爲:
    正文由窗口自動換行;全部代碼以 80 字符爲邊界;中英文字符以空格符分隔。


(做者保留對本文的全部權利,未經做者贊成請勿在在任何公共媒體轉載。)



目  錄
===============================================================================
⊙ 一個 GUI Application 的執行過程:消息循環的創建
⊙ TWinControl.Create、註冊窗口過程和建立窗口
⊙ 補充知識:TWndMethod 概述
⊙ VCL 的消息處理從 TWinControl.MainWndProc 開始
⊙ TWinControl.WndProc
⊙ TControl.WndProc
⊙ TObject.Dispatch
⊙ TWinControl.DefaultHandler
⊙ TControl.Perform 和 TWinControl.Broadcast
⊙ TWinControl.WMPaint
⊙ 以 TWinControl 爲例描述消息傳遞的路徑
===============================================================================



正  文
===============================================================================
⊙ 一個 GUI Application 的執行過程:消息循環的創建
===============================================================================
一般一個 Win32 GUI 應用程序是圍繞着消息循環的處理而運行的。在一個標準的 C 語言 Win32 GUI 程序中,主程序段都會出現如下代碼:

while (GetMessage(&msg, NULL, 0, 0))  // GetMessage 第二個參數爲 NULL,
                                      // 表示接收全部應用程序產生的窗口消息
{
    TranslateMessage(&msg);      // 轉換消息中的字符集
    DispatchMessage(&msg);       // 把 msg 參數傳遞給 lpfnWndProc
}

lpfnWndProc 是 Win32 API 定義的回調函數的地址,其原型以下:
int __stdcall WndProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam);

Windows 回調函數(callback function) 也一般被稱爲窗口過程(window procedure),本文隨意使用這兩個名稱,表明一樣的意義。

應用程序使用 GetMessage 不斷檢查應用程序的消息隊列中是否有消息到達。若是發現了消息,則調用 TranslateMessage。TranslateMessage 主要是作字符消息本地化的工做,不是關鍵的函數。而後調用 DispatchMessage(&msg)。DispatchMessage(&msg) 使用 msg 爲參數調用已建立的窗口的回調函數(WndClass.lpfnWndProc)。lpfnWndProc 是由用戶設計的消息處理方法。

當 GetMessage 在應用程序的消息隊列中發現一條 WM_QUIT 消息時,GetMessage 返回 False,消息循環才告結束,一般應用程序在這時清理資源後也結束運行。

使用最原始的 Win32 API 編寫的應用程序的執行過程是很容易理解的,可是用 Delphi VCL 組件封裝消息系統,並非容易的事。首先,Delphi 是一種面向對象的程序設計語言,不但要把 Win32 的消息處理過程封裝在對象的各個繼承類中,讓應用程序的使用者方便地調用,也要讓 VCL 組件的開發者有拓展消息處理的空間。其次,Delphi 的對象模型中全部的類方法都是對象相關的(也就是傳遞了一個隱含的參數 Self),因此 Delphi 對象的方法不能直接被 Windows 回調。Delphi VCL 必須用其餘的方法讓 Windows 回調到對象的消息處理函數。

讓咱們跟蹤一個標準的 Delphi Application 的執行過程,查看 Delphi 是如何開始一個消息循環的。

program Project1;
begin
  Application.Initialize;
  Application.CreateForm(TForm1, Form1);
  Application.Run;
end.

在 Project1 的 Application.Initialize 以前,Delphi 編譯器會自動插入一行代碼:
SysInit._InitExe。_InitExe 主要是初始化 HInstance 和模塊信息表等。而後 _InitExe 調用 System._StartExe。System._StartExe 調用 System.InitUnit;System.InitUnit 調用項目中全部被包含單元的 Initialization 段的代碼;其中有 Controls.Initialization 段,這個段比較關鍵。在這段代碼中創建了 Mouse、Screen 和 Application 三個關鍵的全局對象。

Application.Create 調用 Application.CreateHandle。Application.CreateHandle 創建一個窗口,並設置 Application.WndProc 爲回調函數(這裏使用了 MakeObjectInstance 方法,後面再談)。Application.WndProc 主要處理一些應用程序級別的消息。

我第一次跟蹤應用程序的執行時沒有發現 Application 對象的建立過程,原來在 SysInit._InitExe 中被隱含調用了。若是你想跟蹤這個過程,不要設置斷點,直接按 F7 就發現了。

而後纔到了 Project1 的第 1 句: Application.Initialize;
這個函數只有一句代碼:

  if InitProc <> nil then TProcedure(InitProc);

也就是說若是用戶想在應用程序的執行前運行一個特定的過程,能夠設置 InitProc 指向該過程。(爲何用戶不在 Application.Initialize 以前或在單元的 Initliazation 段中直接運行這個特定的過程呢?一個可能的答案是:若是元件設計者但願在應用程序的代碼執行以前執行一個過程,而且這個過程必須在其餘單元的 Initialization 執行完成以後執行[好比說 Application 對象必須建立],則只能使用這個過程指針來實現。)

而後是 Project1 的第 2 句:     Application.CreateForm(TForm1, Form1);
這句的主要做用是建立 TForm1 對象,而後把 Application.MainForm 設置爲 TForm1。

最後是 Project1 的第 3 句:     Application.Run;
TApplication.Run 調用 TApplication.HandleMessage 處理消息。Application.HandleMessage 的代碼也只有一行:

  if not ProcessMessage(Msg) then Idle(Msg);

TApplication.ProcessMessage 才真正開始創建消息循環。ProcessMessage 使用 PeekMessage API 代替 GetMessage 獲取消息隊列中的消息。使用 PeekMessage 的好處是 PeekMessage 發現消息隊列中沒有消息時會當即返回,這樣就爲 HandleMessage 函數執行 Idle(Msg) 提供了依據。

ProcessMessage 在處理消息循環的時候還特別處理了 HintMsg、MDIMsg、KeyMsg、DlgMsg 等特殊消息,因此在 Delphi 中不多再看到純 Win32 SDK 編程中的要區分 Dialog Window、MDI Window 的處理,這些都被封裝到 TForm 中去了(其實 Win32 SDK 中的 Dialog 也是隻是 Microsoft 專門寫了一個窗口過程和一組函數方便用戶界面的設計,其內部運做過程與一個普通窗口無異)。

function TApplication.ProcessMessage(var Msg: TMsg): Boolean;
var
  Handled: Boolean;
begin
  Result := False;
  if PeekMessage(Msg, 0, 0, 0, PM_REMOVE) then  // 從消息隊列獲取消息
  begin
    Result := True;
    if Msg.Message <> WM_QUIT then
    begin
      Handled := False;  // Handled 表示 Application.OnMessage 是否已經處理過
                         // 當前消息。
                         // 若是用戶設置了Application.OnMessage 事件句柄,
                         // 則先調用 Application.OnMessage
      if Assigned(FOnMessage) then FOnMessage(Msg, Handled); 
      if not IsHintMsg(Msg) and not Handled and not IsMDIMsg(Msg) and
        not IsKeyMsg(Msg) and not IsDlgMsg(Msg) then 
                         // 思考:not Handled 爲何不放在最前?
      begin
        TranslateMessage(Msg);                // 處理字符轉換
        DispatchMessage(Msg);                 // 調用 WndClass.lpfnWndProc
      end;
    end
    else
      FTerminate := True;                     // 收到 WM_QUIT 時應用程序終止
                                              // (這裏只是設置一個終止標記)
  end;                                                         
end;

從上面的代碼來看,Delphi 應用程序的消息循環機制與標準 Win32 C 語言應用程序差很少。只是 Delphi 爲了方便用戶的使用設置了不少擴展空間,其反作用是消息處理會比純 C Win32 API 調用效率要低一些。

===============================================================================
⊙ TWinControl.Create、註冊窗口過程和建立窗口
===============================================================================
上面簡單討論了一個 Application 的創建到造成消息循環的過程,如今的問題是 Delphi 控件是如何封裝建立窗口這一過程的。由於只有創建了窗口,消息循環纔有意義。

讓咱們先回顧 Delphi VCL中幾個主要類的繼承架框:
  TObject           全部對象的基類  
  TPersistent       全部具備流特性對象的基類
  TComponent        全部能放在 Delphi Form Designer 上的對象的基類
  TControl          全部可視的對象的基類
  TWinControl       全部具備窗口句柄的對象基類

Delphi 是從 TWinControl 開始實現窗口相關的元件。所謂窗口,對於程序設計者來講,就是一個窗口句柄 HWND。TWinControl 有一個 FHandle 私有成員表明當前對象的窗口句柄,經過 TWinControl.Handle 屬性來訪問。

我第一次跟蹤 TWinControl.Create 過程時,居然沒有發現 CreateWindow API 被調用,說明 TWinControl 並非在對象建立時就創建 Windows 窗口。若是用戶使用 TWinControl.Create(Application) 之後,當即使用 Handle 訪問窗口會出現什麼狀況呢?

答案在 TWinControl.GetHandle 中,Handle 是一個只讀的窗口句柄:

  property TWinControl.Handle: HWnd read GetHandle;

TWinControl.GetHandle 代碼的內容是:一旦用戶要訪問 FHandle 成員,TWinControl.HandleNeeded 就會被調用。HandleNeeded 首先判斷 TWinControl.FHandle 是不是等於 0 (還記得嗎?任何對象調用構造函數之後全部對象成員的內存都被清零)。若是 FHandle 不等於 0,則直接返回 FHandle;若是 FHandle 等於 0,則說明窗口尚未被建立,這時 HandleNeeded 自動調用 TWinControl.CreateHandle 來建立一個 Handle。但 CreateHandle 只是個包裝函數,它首先調用 TWinControl.CreateWnd 來建立窗口,而後生成一些維護 VCL Control 運行的參數(我還沒細看)。CreateWnd 是一個重要的過程,它先調用 TWinControl.CreateParams 設置建立窗口的參數。(CreateParams 是個虛方法,也就是說程序員能夠重載這個函數,定義待建窗口的屬性。) CreateWnd 而後調用 TWinControl.CreateWindowHandle。CreateWindowHandle 纔是真正調用 CreateWindowEx API 建立窗口的函數。

夠麻煩吧,咱們能夠抱怨 Borland 爲何把事情弄得這麼複雜,但最終但願 Borland 這樣設計自有它的道理。上面的討論能夠總結爲 TWinControl 爲了爲了減小系統資源的佔用儘可能推遲創建窗口,只在某個方法須要調用到控件的窗口句柄時才真正建立窗口。這一般發生在窗口須要顯示的時候。一個窗口是否須要顯示經常發生在對 Parent 屬性 (在TControl 中定義) 賦值的時候。設置 Parent 屬性時,TControl.SetParent 方法會調用 TWinControl.RemoveControl 和 TWinControl.InsertControl 方法。InsertControl 調用 TWinControl.UpdateControlState。UpdateControlState 檢查 TWinControl.Showing 屬性來判斷是否要調用 TWinControl.UpdateShowing。UpdateShowing 必需要有一個窗口句柄,所以調用 TWinControl.CreateHandle 來建立窗口。

不過上面說的這些,只是繁雜而不艱深,還有不少關鍵的代碼沒有談到呢。

你可能發現有一個關鍵的東西被遺漏了,對,那就是窗口的回調函數。因爲 Delphi 創建一個窗口的回調過程太複雜了(而且是很是精巧的設計),只好單獨拿出來討論。

cheka 的《VCL窗口函數註冊機制研究手記,兼與MFC比較》一文中對 VCL 的窗口回調實現進行了深刻的分析,請參考:http://www.delphibbs.com/delphibbs/dispq.asp?lid=584889

我在此簡單介紹回調函數在 VCL 中的實現:

TWinControl.Create 的代碼中,第一句是 inherited,第二句是

  FObjectInstance := Classes.MakeObjectInstance(MainWndProc);

我想這段代碼可能嚇倒過不少人,若是沒有 cheka 的分析,不少人難以理解。可是你不必定真的要閱讀 MakeObjectInstance 的實現過程,你只要知道:

MakeObjectInstance 在內存中生成了一小段彙編代碼,這段代碼的內容就是一個標準的窗口過程。這段彙編代碼中同時存儲了兩個參數,一個是 MainWndProc 的地址,一個是 Self (對象的地址)。這段彙編代碼的功能就是使用 Self 參數調用 TWinControl.MainWndProc 函數。

MakeObjectInstance 返回後,這段代碼的地址存入了 TWinControl.FObjectInstance 私有成員中。

這樣,TWinControl.FObjectInstance 就能夠看成標準的窗口過程來用。你可能認爲 TWinControl 會直接把 TWinControl.FObjectInstance 註冊爲窗口類的回調函數(使用 RegisterClass API),但這樣作是不對的。由於一個 FObjectInstance 的彙編代碼內置了對象相關的參數(對象的地址 Self),因此不能用它做爲公共的回調函數註冊。TWinControl.CreateWnd 調用 CreateParams 得到要註冊的窗口類的資料,而後使用 Controls.pas 中的靜態函數 InitWndProc 做爲窗口回調函數進行窗口類的註冊。InitWndProc 的參數符合 Windows 回調函數的標準。InitWndProc 第一次被回調時就把新建窗口(注意不是窗口類)的回調函數替換爲對象的 TWinControl.FObjectInstance (這是一種 Windows subclassing 技術),而且使用 SetProp 把對象的地址保存在新建窗口的屬性表中,供 Delphi 的輔助函數讀取(好比 Controls.pas 中的 FindControl 函數)。

總之,TWinControl.FObjectInstance 最終是被註冊爲窗口回調函數了。

這樣,若是 TWinControl 對象所建立的窗口收到消息後(形象的說法),會被 Windows 回調 TWinControl.FObjectInstance,而 FObjectInstance 會呼叫該對象的 TWinControl.MainWndProc 函數。就這樣 VCL 完成了對象的消息處理過程與 Windows 要求的回調函數格式差別的轉換。注意,在轉換過程當中,Windows 回調時傳遞進來的第一個參數 HWND 被拋棄了。所以 Delphi 的組件必須使用 TWinControl.Handle (或 protected 中的 WindowHandle) 來獲得這個參數。Windows 回調函數須要傳回的返回值也被替換爲 TMessage 結構中的最後一個字段 Result。

爲了使你們更清楚窗口被回調的過程,我把從 DispatchMessage 開始到 TWinControl.MainWndProc 被調用的彙編代碼(你能夠把從 FObjectInstance.Code 開始至最後一行的代碼當作是一個標準的窗口回調函數):

DispatchMessage(&Msg)    // Application.Run 呼叫 DispatchMessage 通知
                         // Windows 準備回調 

Windows 準備回調 TWinControl.FObjectInstance 前在堆棧中設置參數:
            push LPARAM
            push WPARAM
            push UINT
            push HWND
            push (eip.Next)             ; 把Windows 回調前下一條語句的地址
                                        ; 保存在堆棧中
            jmp FObjectInstance.Code    ; 調用 TWinControl.FObjectInstance

FObjectInstance.Code 只有一句 call 指令:
call ObjectInstance.offset   
            push eip.Next
            jmp InstanceBlock.Code      ; 調用 InstanceBlock.Code

InstanceBlock.Code:
            pop ecx                     ; 將 eip.Next 的值存入 ecx, 用於
                                        ; 取 @MainWndProc 和 Self
            jmp StdWndProc              ; 跳轉至 StdWndProc

StdWndProc 的彙編代碼:
function StdWndProc(Window: HWND; Message, WParam: Longint;
  LParam: Longint): Longint; stdcall; assembler;
asm
            push ebp
            mov ebp, esp
        XOR     EAX,EAX
            xor eax, eax
        PUSH    EAX
            push eax                    ; 設置 Message.Result := 0
        PUSH    LParam                  ; 爲何 Borland 不從上面的堆棧中直接
            push dword ptr [ebp+$14]    ; 獲取這些參數而要從新 push 一遍?
        PUSH    WParam                  ; 由於 TMessage 的 Result 是
            push dword ptr [ebp+$10]    ; 記錄的最後一個字段,而回調函數的 HWND
        PUSH    Message                 ; 是第一個參數,沒有辦法兼容。
            push dword ptr [ebp+$0c]
        MOV     EDX,ESP
            mov edx, esp                ; 設置 Message 在堆棧中的地址爲
                                        ; MainWndProc 的參數
        MOV     EAX,[ECX].Longint[4]
            mov eax, [ecx+$04]          ; 設置 Self 爲 MainWndProc 的隱含參數
        CALL    [ECX].Pointer
            call dword ptr [ecx]        : 呼叫 TWinControl.MainWndProc(Self,
                                        ; @Message)
        ADD     ESP,12
            add esp, $0c
        POP     EAX
            pop eax
end;
            pop ebp
            ret $0010
            mov eax, eax

看不懂上面的彙編代碼,不影響對下文討論的理解。

===============================================================================
⊙ 補充知識:TWndMethod 概述
===============================================================================
寫這段基礎知識是由於我在閱讀 MakeObjectInstance(MainWndProc) 這句時不知道究竟傳遞了什麼東西給 MakeObjectInstance。弄清楚了 TWndMethod 類型的含義還能夠理解後面 VCL 消息系統中的一個小技巧。

  TWndMethod = procedure(var Message: TMessage) of object;

這句類型聲明的意思是:TWndMethod 是一種過程類型,它指向一個接收 TMessage 類型參數的過程,但它不是通常的靜態過程,它是對象相關(object related)的。TWndMethod 在內存中存儲爲一個指向過程的指針和一個對象的指針,因此佔用8個字節。TWndMethod類型的變量必須使用已實例化的對象來賦值。舉個例子:
  var
    SomeMethod: TWndMethod;
  begin
    SomeMethod := Form1.MainWndProc; // 正確。這時 SomeMethod 包含 MainWndProc
                                     // 和 Form1 的指針,能夠用 SomeMethod(Msg)
                                     // 來執行。
    SomeMethod := TForm.MainWndProc; // 錯誤!不能用類引用。
  end;

  若是把 TWndMethod變量賦值給虛方法會怎樣?舉例:
  var
    SomeMethod: TWndMethod;
  begin
    SomeMethod := Form1.WndProc;  // TForm.WndProc 是虛方法
  end;

這時,編譯器實現爲 SomeMethod 指向 Form1 對象虛方法表中的 WndProc 過程的地址和 Form1 對象的地址。也就是說編譯器正確地處理了虛方法的賦值。調用 SomeMethod(Message) 就等於調用 Form1.WndProc(Message)。

在可能被賦值的狀況下,對象方法最好不要設計爲有返回值的函數(function),而要設計爲過程(procedure)。緣由很簡單,把一個有返回值的對象方法賦值給 TWndMethod 變量,會形成編譯時的二義性。

===============================================================================
⊙ VCL 的消息處理從 TWinControl.MainWndProc 開始
===============================================================================
經過對 Application.Run、TWinControl.Create、TWinControl.Handle 和 TWinControl.CreateWnd 的討論,咱們如今能夠把焦點轉向 VCL 內部的消息處理過程。VCL 控件的消息源頭就是 TWinControl.MainWndProc 函數。(若是不能理解這一點,請從新閱讀上面的討論。)

讓咱們先看一下 MainWndProc 函數的代碼(異常處理的語句被我刪除):

procedure TWinControl.MainWndProc(var Message: TMessage);
begin
  WindowProc(Message);
end;

TWinControl.MainWndProc 以引用(也就是隱含傳地址)的方式接受一個 TMessage 類型的參數,TMessage 的定義以下(其中的WParam、LParam、Result 各有 HiWord 和 LoWord 的聯合字段,被我刪除了,省得代碼太長):

  TMessage = packed record
    Msg:    Cardinal;
    WParam: Longint;
    LParam: Longint;
    Result: Longint);
  end;

TMessage 中並無窗口句柄,由於這個句柄已經在窗口建立以後保存在 TWinControl.Handle 之中。TMessage.Msg 是消息的 ID 號,這個消息能夠是 Windows 標準消息、用戶定義的消息或 VCL 定義的 Control 消息等。WParam 和 LParam 與標準 Windows 回調函數中 wParam 和 lParam 的意義相同,Result 至關於標準 Windows 回調函數的返回值。

注意 MainWndProc 不是虛函數,因此它不能被 TWinControl 的繼承類重載。(思考:爲何 Borland 不將 MainWndProc 設計爲虛函數呢?)

MainWndProc 中創建兩層異常處理,用於釋放消息處理過程當中發生異常時的資源泄漏,並調用默認的異常處理過程。被異常處理包圍着的是 WindowProc(Message)。WindowProc 是 TControl(而不是 TWinControl) 的一個屬性(property):

  property WindowProc: TWndMethod read FWindowProc write FWindowProc;

WindowProc 的類型是 TWndMethod,因此它是一個對象相關的消息處理函數指針(請參考前面 TWndMethod 的介紹)。在 TControl.Create 中 FWindowProc 被賦值爲 WndProc。

WndProc 是 TControl 的一個函數,參數與 TWinControl.MainWndProc 相同:

  procedure TControl.WndProc(var Message: TMessage); virtual;

原來 MainWndProc 只是個代理函數,最終處理消息的是 TControl.WndProc 函數。

那麼 Borland 爲何要用一個 FWindowProc 來存儲這個 WndProc 函數,而不直接調用 WndProc 呢?我猜測多是基於效率的考慮。還記得上面 TWndMethod 的討論嗎?一個 TWndMethod 變量能夠被賦值爲一個虛函數,編譯器對此操做的實現是經過對象指針訪問到了對象的虛函數表,並把虛函數表項中的函數地址傳回。因爲 WndProc 是一個調用頻率很是高的函數(可能要用「百次/秒」或「千次/秒」來計算),因此若是每次調用 WndProc 都要訪問虛函數表將會浪費大量時間,所以在 TControl 的構造函數中就把 WndProc 的真正地址存儲在 WindowProc 中,之後調用 WindowProc 將就轉換爲靜態函數的調用,以加快處理速度。

===============================================================================
⊙ TWinControl.WndProc
===============================================================================
轉了層層彎,到如今咱們纔剛進入 VCL 消息系統處理開始的地方:WndProc 函數。如前所述,TWinControl.MainWndProc 接收到消息後並無處理消息,而是把消息傳遞給 WindowProc 處理。因爲 WindowProc 老是指向當前對象的 WndProc 函數的地址,咱們能夠簡單地認爲 WndProc 函數是 VCL 中第一個處理消息的函數,調用 WindowProc 只是效率問題。
 
WndProc 函數是個虛函數,在 TControl 中開始定義,在 TWinControl 中被重載。Borland 將 WndProc 設計爲虛函數就是爲了各繼承類可以接管消息處理,並把未處理的消息或加工過的消息傳遞到上一層類中處理。

這裏將消息處理的傳遞過程和對象的構造函數稍加對比:

對象的構造函數一般會在第一行代碼中使用 inherited 語句調用父類的構造函數以初始化父類定義的成員變量,父類也會在構造函數開頭調用祖父類的構造函數,如此遞歸,所以一個 TWinControl 對象的建立過程是 TComponent.Create -> TControl.Create -> TWinControl.Create。

而消息處理函數 WndProc 則是先處理本身想要的消息,而後看狀況是否要遞交到父類的 WndProc 中處理。因此消息的處理過程是 TWinControl.WndProc -> TControl.WndProc。

所以,若是要分析消息的處理過程,應該從子類的 WndProc 過程開始,而後纔是父類的  WndProc 過程。因爲 TWinControl 是第一個支持窗口建立的類,因此它的 WndProc 是很重要的,它實現了最基本的 VCL 消息處理。

TWinControl.WndProc 主要是預處理一些鍵盤、鼠標、窗口焦點消息,對於沒必要響應的消息,TWinControl.WndProc 直接返回,不然把消息傳遞至 TControl.WndProc 處理。

從 TWinControl.WndProc 摘抄一段看看:

    WM_KEYFIRST..WM_KEYLAST:
      if Dragging then Exit;      // 注意:使用 Exit 直接返回

這段代碼的意思是:若是當前組件正處於拖放狀態,則丟棄全部鍵盤消息。

再看一段:
    WM_MOUSEFIRST..WM_MOUSELAST:
      if IsControlMouseMsg(TWMMouse(Message)) then 
      begin
        { Check HandleAllocated because IsControlMouseMsg might have freed the
          window if user code executed something like Parent := nil. }
        if (Message.Result = 0) and HandleAllocated then
          DefWindowProc(Handle, Message.Msg, Message.wParam, Message.lParam);
          // DefWindowProc 是 Win32 API 中缺省處理消息的函數
        Exit;
      end;

這裏的 IsControlMouseMsg 很關鍵。讓咱們回憶一下:TControl 類的對象並無建立 Windows 窗口,它是怎樣接收到鼠標和重繪等消息的呢?原來這些消息就是由它的 Parent 窗口發送的。

在上面的代碼中,TWinControl.IsControlMouseMsg 判斷鼠標地址是否落在 TControl 類控件上,若是不是就返回否值。TWinControl 再調用 TControl.WndProc,TControl.WndProc 又調用了 TObject.Dispatch 方法,這是後話。

若是當前鼠標地址落在窗口上的 TControl 類控件上,則根據 TControl 對象的相對位置從新生成了鼠標消息,再調用 TControl.Perform 方法把加工過的鼠標消息直接發到 TControl.WndProc 處理。TControl.Perform 方法之後再談。

若是 TWinControl 的繼承類重載 WndProc 處鼠標消息,但不使用 inherited 把消息傳遞給父類處理,則會使從 TControl 繼承下來的對象不能收到鼠標消息。如今咱們來作個試驗,下面 Form1 上的 TSpeedButton 等非窗口控件不會發生 OnClick 等鼠標事件。

procedure TForm1.WndProc(var Message: TMessage); override;
begin
  case Message.Msg of
    WM_MOUSEFIRST..WM_MOUSELAST:
      begin
        DefWindowProc(Handle, Message.Msg, Message.WParam, Message.LParam);
        Exit; // 直接退出
      end;
  else
    inherited;
  end;
end;

TWinControl.WndProc 的最後一行代碼是:

  inherited WndProc(Message);

也就是調用 TControl.WndProc。讓咱們來看看 TControl.WndProc 作了些什麼。

===============================================================================
⊙ TControl.WndProc
===============================================================================
TControl.WndProc 主要實現的操做是:
    響應與 Form Designer 的交互(在設計期間)
    在控件不支持雙擊的狀況下把鼠標雙擊事件轉換成單擊
    判斷鼠標移動時是否須要顯示提示窗口(HintWindow)
    判斷控件是否設置爲 AutoDrag,若是是則執行控件的拖放處理
    調用 TControl.MouseWheelHandler 實現鼠標滾輪消息
    使用 TObject.Dispatch 調用 DMT 消息處理方法

TControl.WndProc 相對比較簡單,在此只隨便談談第二條。你是否有過這樣的使用經驗:在你快速雙擊某個軟件的 Button 時,只造成一次 Click 事件。因此若是你須要設計一個無論用戶用多快的速度點擊,都能生成一樣點擊次數 Click 事件的按鈕時,就須要參考 TControl.WndProc 處理鼠標消息的過程了。

TControl.WndProc 最後一行代碼是 Dispatch(Message),也就是說若是某個消息沒有被 TControl 之後的任何類處理,消息會被 Dispatch 處理。

TObject.Dispatch 是 Delphi VCL 消息體系中很是關鍵的方法。

===============================================================================
⊙ TObject.Dispatch
===============================================================================
TObject.Dispatch 是個虛函數,它的聲明以下:

  procedure TObject.Dispatch(var Message); virtual;

請注意它的參數雖然與 MainWndProc 和 WndProc 的參數類似,但它沒有規定參數的類型。這就是說,Dispatch 能夠接受任何形式的參數。

Delphi 的文檔指出:Message參數的前 2 個字節是 Message 的 ID(下文簡稱爲 MsgID),經過 MsgID 搜索對象的消息處理方法。

這段話並無爲咱們理解 Dispatch 方法提供更多的幫助,看來咱們必須經過閱讀源代碼來分析這個函數的運做過程。

TObject.Dispatch 雖然是個虛方法,但卻沒有被 TPersistent、TComponent、TControl、TWinControl、TForm 等後續類重載( TCommonDialog 調用了 TObject.Dispatch,但對於整個 VCL 消息系統並不重要),而且只由 TControl.WndProc 調用過。因此能夠簡單地認爲若是消息沒有在 WndProc 中被處理,則被 TObject.Dispatch 處理。

咱們很容易查覺到一個很重要的問題:MsgID 是 2 個字節,而 TMessage.Msg 是 4 個字節,若是 TControl.WndProc 把 TMessage 消息傳遞給 Dispatch 方法,是否是會造成錯誤的消息呢?

要解釋這個問題,必須先了解 Windows 消息的規則。因爲 Windows 操做系統的全部窗口都使用消息傳遞事件和信息,Microsoft 必須制定窗口消息的格式。若是每一個程序員都隨意定義消息 ID 值確定會產生混亂。Microsoft 把窗口消息分爲五個區段:

  0x00000000 至 WM_USER - 1             標準視窗消息,以 WM_ 爲前綴
  WM_USER    至 WM_APP  - 1             用戶自定義窗口類的消息
  WM_APP     至 0x0000BFFF              應用程序級的消息
  0x0000C000 至 0x0000FFFF              RegisterWindowMessage 生成的消息範圍
  0x00010000 至 0xFFFFFFFF              Microsoft 保留的消息,只由系統使用

  ( WM_USER = 0x00000400,  WM_APP = 0x00008000 )

發現問題的答案了嗎?原來應用程序真正可用的消息只有 0x00000000 至 0x0000FFFF,也就是消息 ID 只有低位 2 字節是有效的。(Borland 真是牛啊,連這也能想出來。)

因爲 Intel CPU 的內存存放規則是高位字節存放在高地址,低位字節存放在低地址,因此 Dispatch 的 Message 參數的第一個內存字節就是 LoWord(Message.Msg)。下圖是 Message參數的內存存放方式描述:

        |        | + Memory
        |--------|
        | HiWord |
        |--------|
        | LoWord | <-- [EDX]
        |--------|
        |        |
        |--------|
        |        |
        |--------| - Memory
        [ 圖示:Integer 類型的 MsgID 在內存中的分配(見 Dispatch 彙編代碼) ]
        (爲了簡單起見,我用 Word 爲內存單位而不是 Byte,但願不至於更難看懂)

如今能夠開始閱讀 TObject.Dispatch 的彙編代碼了(不懂彙編不要緊,後面會介紹具體的功能):

procedure TObject.Dispatch(var Message); virtual; 
asm
    PUSH    ESI            ; 保存 ESI
    MOV     SI,[EDX]       ; 把 MsgID 移入 SI (2 bytes)
                           ; 若是 MsgID 是Integer 類型,[EDX] = LoWord(MsgID),
                           ; 見上圖
    OR      SI,SI      
    JE      @@default      ; 若是 SI = 0,調用 DefaultHanlder
    CMP     SI,0C000H
    JAE     @@default      ; 若是 SI >= $C000,調用 DefaultHandler (注意這裏)
    PUSH    EAX            ; 保存對象的指針
    MOV     EAX,[EAX]      ; 找到對象的 VMT 指針
    CALL    GetDynaMethod  ; 調用對象的動態方法; 若是找到了動態方法 ZF = 0 ,
                           ; 沒找到 ZF = 1
                           ; 注:GetDynaMethod 是 System.pas 中的得到動態方法地
                           ; 址的彙編函數
    POP     EAX            ; 恢復 EAX 爲對象的指針
    JE      @@default      ; 若是沒找到相關的動態方法,調用 DefaultHandler     
    MOV     ECX,ESI        ; 把找到的動態方法指針存入 ECX
    POP     ESI            ; 恢復 ESI
    JMP     ECX            ; 調用對象的動態方法

@@default:
    POP     ESI            ; 恢復 ESI
    MOV     ECX,[EAX]      ; 把對象的 VMT 指針存入 ECX,以調用 DefaultHandler
    JMP     DWORD PTR [ECX] + VMTOFFSET TObject.DefaultHandler
end;

TObject.Dispatch 的執行過程是:
    把 MsgID 存入 SI,做爲動態方法的索引值
    若是 SI >= $C000,則調用 DefaultHandler(也就是全部 RegisterWindowMessage
        生成的消息ID 會直接被髮送到 DefaultHandler 中,後面會講一個實例)
    檢查是否有相對應的動態方法
    找到了動態方法,則執行該方法
    沒找到動態方法,則調用 DefaultHandler

原來以 message 關鍵字定義的對象方法就是動態方法,隨便從 TWinControl 中抓幾個消息處理函數出來:

    procedure WMSize(var Message: TWMSize); message WM_SIZE;
    procedure WMMove(var Message: TWMMove); message WM_MOVE;

到如今終於明白 WM_SIZE、WM_PAINT 方法的處理過程了吧。不可是 Windows 消息,連 Delphi 本身定義的消息也是以一樣的方式處理的:

    procedure CMEnabledChanged(var Message: TMessage); message CM_ENABLEDCHANGED;
    procedure CMFontChanged(var Message: TMessage); message CM_FONTCHANGED;

因此若是你本身針對某個控件定義了一個消息,你也能夠用 message 關鍵字定義處理該方法的函數,VCL 的消息系統會自動調用到你定義的函數。

因爲 Dispatch 的參數只以最前 2 個字節爲索引,而且自 MainWndProc 到 WndProc 到 Dispatch 都是以引用(傳遞地址)的方式來傳遞消息內容,你能夠將消息的結構設置爲任何結構,甚至能夠只有 MsgID —— 只要你在處理消息的函數中正確地訪問這些參數就行。

最關鍵的 Dispatch 方法告一段落,如今讓咱們看看 DefaultHandler 作了些什麼?

===============================================================================
⊙ TWinControl.DefaultHandler
===============================================================================
DispatchHandler 是從 TObject 就開始存在的,它的聲明以下:

  procedure TObject.DefaultHandler(var Message); virtual;

從名字也能夠看出該函數的大概目的:最終的消息處理函數。在 TObject 的定義中 DefaultHandler 並無代碼,DefaultHandler 是在須要處理消息的類(TControl)以後被重載的。

從上面的討論中已經知道 DefaultHandler 是由 TObject.Dispatch 調用的,因此 DefaultHandler 和 Dispatch 的參數類型同樣都是無類型的 var Message。

因爲 DefaultHandler 是個虛方法,因此執行流程是從子類到父類。在 TWinControl 和 TControl 的 DefaultHandler 中,仍然聽從 WndProc 的執行規則,也就是 TWinControl 沒處理的消息,再使用 inherited 調用 TControl.DefaultHandler 來處理。

在 TWinControl.DefaultHandler 中先是處理了一些不過重要的Windows 消息,如WM_CONTEXTMENU、WM_CTLCOLORMSGBOX等。而後作了兩件比較重要的工做:1、處理 RM_GetObjectInstance 消息;2、對全部未處理的窗口消息調用 TWinControl.FDefWndProc。
下面分別討論。

RM_GetObjectInstance 是應用程序啓動時自動使用 RegisterWindowMessage API 註冊的 Windows 系統級消息ID,也就是說這個消息到達 Dispatch 後會無條件地傳遞給 DefaultHandler(見 Dispatch 的分析)。TWinControl.DefaultHandler 發現這個消息就把 Self 指針設置爲返回值。在 Controls.pas 中有個函數 ObjectFromHWnd 使用窗口句柄得到 TWinControl 的句柄,就是使用這個消息實現的。不過這個消息是由 Delphi 內部使用,不能被應用程序使用。(思考:每次應用程序啓動都會調用 RegisterWindowMessage,若是電腦長期不停機,那麼 0xC000 - 0xFFFF 之間的消息 ID 是否會被耗盡?)

另外,TWinControl.DefaultHandler 在 TWinControl.FHandle 不爲 0 的狀況下,使用 CallWindowProc API 調用 TWndControl.FDefWndProc 窗口過程。FDefWndProc 是個指針,它是從哪裏初始化的呢?跟蹤一下,發現它是在 TWinControl.CreateWnd 中被設置爲以下值:

    FDefWndProc := Params.WindowClass.lpfnWndProc;

還記得前面討論的窗口建立過程嗎?TWinControl.CreateWnd 函數首先調用 TWinControl.CreateParams 得到待建立的窗口類的參數。CreateParams 把 WndClass.lpfnWndProc 設置爲 Windows 的默認回調函數 DefWindowProc API。但 CreateParams 是個虛函數,能夠被 TWinControl 的繼承類重載,所以程序員能夠指定一個本身設計的窗口過程。

因此 TWinControl.DefaultHandler 中調用 FDefWndProc 的意圖很明顯,就是能夠在 Win32 API 的層次上支持消息的處理(好比能夠從 C 語言寫的 DLL 中導入窗口過程給 VCL 控件),給程序員提供充足的彈性空間。

TWinControl.DefaultHandler 最後一行調用了 inherited,把消息傳遞給 TControl 來處理。

TControl.DefaultHandler 只處理了三個消息 WM_GETTEXT、WM_GETTEXTLENGTH、WM_SETTEXT。爲何要處理這個幾個看似不重要的消息呢?緣由是:Windows 系統中每一個窗口都有一個 WindowText 屬性,而 VCL 的 TControl 爲了模擬成窗口也存儲了一份保存在 FText 成員中,因此 TControl 在此接管這幾個消息。

TControl.DefaultHandler 並無調用 inherited,其實也沒有必要調用,由於 TControl 的祖先類都沒有實現 DefaultHandler 函數。能夠認爲 DefaultHandler 的執行到此爲止。

VCL 的消息流程至此爲止。

===============================================================================
⊙ TControl.Perform 和 TWinControl.Broadcast
===============================================================================
如今介紹 VCL 消息系統中兩個十分簡單但調用頻率很高的函數。

TControl.Perform 用於直接把消息送往控件的消息處理函數 WndProc。Perform 方法不是虛方法,它把參數從新組裝成一個 TMessage 類型,而後調用 WindowProc(還記得 WindowProc 的做用嗎?),並返回 Message.Result 給用戶。它的調用格式以下:

  function TControl.Perform(Msg: Cardinal; WParam, LParam: Longint): Longint;

Perform 常常用於通知控件某些事件發生,或獲得消息處理的結果,以下例:

  Perform(CM_ENABLEDCHANGED, 0, 0);
  Text := Perform(WM_GETTEXTLENGTH, 0, 0);

TWinControl.Broadcast 用於把消息廣播給每個子控件。它調用 TWinControl.Controls[] 數組中的全部對象的 WindowsProc 過程。

  procedure TWinControl.Broadcast(var Message);

注意 Broadcast 的參數是無類型的。雖然如此,在 Broadcast 函數體中會把消息轉換爲 TMessage 類型,也就是說 Broadcast 的參數必須是 TMessage 類型。那麼爲何要設計爲無類型的消息呢?緣由是 TMessage 有不少變體(Msg 和 Result 字段不會變,WParam 和 LParam 可設計爲其它數據類型),將 Broadcast 設計爲無類型參數可使程序員不用在調用前強制轉換參數,但調用時必須知道這一點。好比如下字符消息的變體,是和 TMessage 兼容的:

  TWMKey = packed record
    Msg: Cardinal;
    CharCode: Word;
    Unused: Word;
    KeyData: Longint;
    Result: Longint;
  end;

===============================================================================
⊙ TWinControl.WMPaint
===============================================================================
上面在討論 TWinControl.WndProc 時提到,TControl 類控件的鼠標和重繪消息是從 Parent TWinControl 中產生的。但咱們只發現了鼠標消息的產生,那麼重繪消息是從哪裏產生出來的呢?答案是TWinControl.WMPaint:

    procedure TWinControl.WMPaint(var Message: TWMPaint); message WM_PAINT;

在 TWinControl.WMPaint 中創建了雙緩衝重繪機制,但咱們目前不關心這個,只看最關鍵的代碼:

    if not (csCustomPaint in ControlState) and (ControlCount = 0) then
      inherited                 // 注意 inherited 的實現
    else
      PaintHandler(Message);    

這段代碼的意思是,若是控件不支持自繪製而且不包含 TControl 就調用 inheritedinherited 是什麼呢?因爲 TWinControl.WMPaint 的父類 TControl 沒有實現這個消息句柄,Delphi 生成的彙編代碼居然是:call Self.DefaultHandler。(TWinControl.DefaultHandler 只是簡單地調用 TWinControl.FDefWndProc。)

若是條件爲否,那麼將調用 TWinControl.PaintHandler(不是虛函數)。PaintHandler 調用 BeginPaint API 得到窗口設備環境,再使用該設備環境句柄爲參數調用 TWinControl.PaintWindow。在 TWinControl 中 PaintWindow 只是簡單地把消息傳遞給 DefaultHandler。PaintWindow 是個虛函數,能夠在繼承類中被改寫,以實現本身須要的繪製內容。PaintHandler 還調用了 TWinControl.PaintControls 方法。PaintControls 使用 Perform 發送 WM_PAINT 消息給 TWinControl 控件包含的全部 TControl 控件。

這樣,TControl 控件纔得到了重繪的消息。

讓咱們設計一個 TWinControl 的繼承類做爲練習:

TMyWinControl = class(TWinControl)
  protected
    procedure PaintWindow(DC: HDC); override;
  public
    constructor Create(AOwner: TComponent); override;
  end;

constructor TMyWinControl.Create(AOwner: TComponent);
begin
  inherited Create(AOwner);
  ControlState := ControlState + [csCustomPaint];
  // 必須通知 WMPaint 須要畫本身
end;

procedure TMyWinControl.PaintWindow(DC: HDC);
var
  Rect: TRect;
begin
  Windows.GetClientRect(Handle, Rect);
  FillRect(DC, Rect, COLOR_BTNSHADOW + 1);
  SetBkMode(DC, TRANSPARENT);
  DrawText(DC, 'Hello, TMyWinControl', -1, Rect, DT_SINGLELINE or DT_VCENTER
    or DT_CENTER);
end;

上面實現的 TMyWinControl 簡單地重載 PaintWindow 消息,它能夠包含 TControl 對象,並能正確地把它們畫出來。若是你肯定該控件不須要包含 TControl 對象,你也能夠直接重載 WMPaint 消息,這就像用 C 語言寫普通的 WM_PAINT 處理函數同樣。

===============================================================================
⊙ 以 TWinControl 爲例描述消息傳遞的路徑
===============================================================================
下圖描述一條消息到達後消息處理函數的調用路徑,每一層表示函數被上層函數調用。

TWinControl.FObjectInstance
 |-TWinControl.MainWndProc
      |-TWinControl.WindowProc
          |-TWinControl.WndProc
              |-TControl.WndProc
                  |-TObject.Dispatch
                      |-Call DMT messages
                      |-TWinControl.DefaultHandler
                          |-TControl.DefaultHandler

注:
如前文所述,上圖中的 WindowProc 是個指針,因此它在編譯器級實際上等於 WndProc,而不是調用 WndProc,圖中爲了防止與消息分枝混淆特地區分紅兩層。
TObject.Dispatch 有兩條通路,若是當前控件以 message 關鍵字實現了消息處理函數,則呼叫該函數,不然調用 DefaultHandler。
有些消息處理函數可能在中途就已經返回了,有些消息處理函數可能會被遞歸調用。

===============================================================================
結束語
VCL 的消息機制就討論到這裏。但願咱們經過本文的討論理清了 VCL 處理消息的框架,從此咱們將使用這些最基礎的知識開始探索 Delphi 程序設計的旅程。
===============================================================================
相關文章
相關標籤/搜索