線上訂房服務-台灣趴趴狗聯合訂房中心
發文 回覆 瀏覽次數:2908
推到 Plurk!
推到 Facebook!

Delphi 的消息機制淺探

 
Rain
資深會員


發表:31
回覆:236
積分:268
註冊:2003-02-17

發送簡訊給我
#1 引用回覆 回覆 發表時間:2004-01-12 12:11:51 IP:220.160.xxx.xxx 未訂閱
Delphi 的消息機制淺探 作者:savetime 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 ===================
系統時間:2024-03-29 20:49:47
聯絡我們 | Delphi K.Top討論版
本站聲明
1. 本論壇為無營利行為之開放平台,所有文章都是由網友自行張貼,如牽涉到法律糾紛一切與本站無關。
2. 假如網友發表之內容涉及侵權,而損及您的利益,請立即通知版主刪除。
3. 請勿批評中華民國元首及政府或批評各政黨,是藍是綠本站無權干涉,但這裡不是政治性論壇!