现在的位置: 首页 > 综合 > 正文

Delphi VCL 的消息处理机制(1)

2018年05月27日 ⁄ 综合 ⁄ 共 10901字 ⁄ 字号 评论关闭
  开始学习 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 回调函数(callbackfunction) 也通常被称为窗口过程(windowprocedure),本文随意使用这两个名称,代表同样的意义。
  
应用程序使用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 是如何开始一个消息循环的。
  
programProject1;
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;
这个函数只有一句代码:
  
  ifInitProc
nilthen
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 的代码也只有一行:
  
  ifnot
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 专门写了一个窗口过程和一组函数方便用户界面的设计,其内部运作过程与一个普通窗口无异)。
  
functionTApplication.ProcessMessage(varMsg:
TMsg):
Boolean;
var
  Handled:Boolean;
begin
  Result :=False;
  ifPeekMessage(Msg,
0,0,
0, PM_REMOVE)then 
// 从消息队列获取消息
  begin
    Result :=True;
    ifMsg.Message WM_QUITthen
    begin
      Handled :=False
// Handled 表示 Application.OnMessage 是否已经处理过
                         // 当前消息。
                         // 如果用户设置了Application.OnMessage 事件句柄,
                         // 则先调用 Application.OnMessage
      ifAssigned(FOnMessage)
thenFOnMessage(Msg, Handled);
      ifnot
IsHintMsg(Msg)and
notHandled
andnot
IsMDIMsg(Msg)
and
        notIsKeyMsg(Msg)
andnot
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 是一个只读的窗口句柄:
  
  propertyTWinControl.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 的汇编代码:
functionStdWndProc(Window: HWND; Message, WParam:
Longint;
  LParam:Longint):
Longint; stdcall; assembler;
asm
            push ebp
            mov ebp, esp
        XOR    EAX,EAX
            xoreax, 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(varMessage:
TMessage)
ofobject;
  
这句类型声明的意思是:TWndMethod 是一种过程类型,它指向一个接收 TMessage 类型参数的过程,但它不是一般的静态过程,它是对象相关(objectrelated)的。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 变量,会造成编译时的二义性。
 

抱歉!评论已关闭.