1. 从窗口过程到消息循环:Windows消息机制的核心骨架
很多人第一次接触Windows消息机制,都是从那个经典的GetMessage/TranslateMessage/DispatchMessage三件套开始的。但真正在项目里踩过坑的人都知道,光会写这个循环远远不够。消息从哪来、到哪去、中间经过了哪些处理、为什么有时候消息会"丢失"、为什么PostMessage和SendMessage行为差异那么大——这些问题不搞清楚,写出来的界面程序迟早要出问题。
这篇文章是"Windows消息机制详解"系列的第六篇,前面几篇分别聊了消息队列的基本结构、窗口类的注册、窗口过程的编写、消息的分类以及消息的同步与异步投递。这一篇我们把焦点收窄,专门讲窗口过程(WndProc)内部的完整处理链路,以及消息在到达窗口过程之前和之后,系统到底做了哪些事情。换句话说,前面几篇是"消息怎么来",这一篇是"消息来了之后怎么处理"。
适合阅读的人群:已经能写出基本Win32窗口程序、但对消息处理细节一知半解的开发者;从MFC或WinForms转过来、想搞清楚底层到底发生了什么的工程师;以及在做界面性能优化、需要精确控制消息处理时机的老手。文章会涉及具体的代码片段、参数含义、返回值约定,以及我在实际项目中遇到过的几个典型问题。
2. 窗口过程到底在什么时候被调用
2.1 消息循环与窗口过程的关系
先把这个链路理清楚。一个标准的消息循环长这样:
MSG msg; while (GetMessage(&msg, nullptr, 0, 0)) { TranslateMessage(&msg); DispatchMessage(&msg); }GetMessage从当前线程的消息队列里取一条消息,填到MSG结构里。TranslateMessage负责把虚拟键消息转成字符消息(比如WM_KEYDOWN转成WM_CHAR),这个后面会细说。DispatchMessage才是真正把消息送到对应窗口过程的那个调用。
这里有个关键点很多人没注意:DispatchMessage并不是直接调用你写的WndProc函数。它内部会根据MSG结构里的hwnd找到对应的窗口对象,然后调用该窗口注册时指定的窗口过程。如果这个窗口是某个父窗口的子窗口,消息不会自动传给父窗口——除非你在子窗口的WndProc里显式调用DefWindowProc或者手动转发。
我在一个多窗口嵌套的项目里就吃过这个亏。当时做了一个自定义的控件容器,子控件的鼠标消息死活传不到父容器。后来才明白,Windows的消息分发是"精确投递"的,DispatchMessage只认hwnd对应的那个窗口过程,不会做冒泡。想要冒泡效果,得自己在子窗口过程里处理完后调用父窗口的过程,或者用SendMessage转发。
2.2 窗口过程的调用时机与重入问题
窗口过程不是只在DispatchMessage的时候被调用。以下几种情况都会触发WndProc:
DispatchMessage分发队列消息时- 其他线程调用
SendMessage发送非队列消息时(此时目标窗口过程会在调用线程的上下文中执行) - 系统在某些操作中直接调用窗口过程,比如窗口创建时的
WM_CREATE、WM_NCCREATE DefWindowProc内部在某些情况下会回调窗口过程
这就引出了重入问题。窗口过程是可重入的,也就是说,在WndProc执行到一半的时候,可能因为调用了某个API(比如MessageBox、SendMessage)导致WndProc被再次进入。如果你在WndProc里用了静态变量或者全局变量来保存临时状态,重入就会导致状态错乱。
我遇到过一个典型案例:在一个WM_PAINT处理里调用了一个会触发SendMessage的函数,结果WM_PAINT被重入,绘图上下文被破坏,界面出现花屏。解决办法是把状态保存在栈上,或者用窗口的GWLP_USERDATA来存每个窗口独立的数据,避免跨调用共享可变状态。
注意:窗口过程的重入是正常行为,不是bug。设计时要假设任何一次API调用都可能导致重入,尤其是
SendMessage、MessageBox、DialogBox这类会跑消息循环的调用。
2.3 返回值约定与消息处理结果
窗口过程的返回值含义取决于具体的消息。对于WM_CREATE,返回0表示继续创建,返回-1表示创建失败、窗口会被销毁。对于WM_CLOSE,返回0表示已处理,DefWindowProc会调用DestroyWindow。对于大多数消息,返回0表示"我处理了",返回非零的具体含义要看消息定义。
这里有个容易混淆的地方:WM_ERASEBKGND返回非零表示"我已经擦除背景了",返回0表示"还没擦,系统你帮我擦"。如果你在处理这个消