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 就调用 inherited。 inherited 是什么呢?因为 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 程序设计的旅程。 ===============================================================================