​​开始学习 Delphi VCL 的消息处理机制。自从我写下《Delphi的对象机制浅探》,至今正好一个星期,我也基本上把 Delphi VCL 的消息处理框架读完了。我的学习方法就是阅读源代码,一开始比较艰苦,后来线索逐渐清晰起来。​​  


​我在分析 VCL 消息机制的过程中,基本上只考查了三个类 TObject、TControl 和 TWinControl。​​    


​推荐阅读:​


​《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 ​




​=============================================================================== ​​  


​正  文​


​===============================================================================​


​⊙ 一个 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​​​​,消息循环才告结束,通常应用程序在这时清理资源后也结束运行。​


 


​   首先,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 创建窗口的函数。 ​


 


​  上面的讨论可以总结为 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 变量,会造成编译时的二义性。​