📄 delphi消息机制.txt
字号:
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 只是效率问题。
⌨️ 快捷键说明
复制代码
Ctrl + C
搜索代码
Ctrl + F
全屏模式
F11
切换主题
Ctrl + Shift + D
显示快捷键
?
增大字号
Ctrl + =
减小字号
Ctrl + -