窗口消息

一个GUI程序必须对用户以及操作系统产生的事件有所响应。

用户发出的事件包括所有能够与程序交互的所有方式,例如:点击鼠标、敲击键盘、触摸手写屏等等。

操作系统发出的事件指除了用户发出事件外所有能影响程序的事件,例如:用户插入一个u盘等。

这些事件可以在程序运行期间的任意时刻以任意顺序发生,那么怎样去创建一个这样运行顺序不能被提前预测的应用程序呢?

为了解决这个问题,windows使用一个消息传递模型。操作系统通过向应用程序窗口发送消息来与应用程序互动。每个消息都是一个代表指定事件的数字代码。例如用户按下鼠标左键,应用程序窗口将会收到下面这则消息代码:

#define WM_LBUTTONDOWN 0x0201

有些消息与窗口有数据关联。比如:WM_LBUTTONDOWN消息包含了光标所在位置的x、y坐标。

为了把消息传递给窗口,操作系统会调用注册在这个窗口上专门处理消息的窗口过程。

消息循环

一个应用程序在运行时会收到上千条消息。一个程序可以有多个窗口,每个窗口有着自己的窗口过程。那么应用程序怎样正确接收消息又怎样将消息正确地传递给窗口过程呢?应用程序需要一个循环来正确地获取消息和派发消息到正确的窗口。

对创建窗口的每一个线程来说,操作系统为窗口消息创建了一个队列。这个队列保存了该线程创建的所有窗口的消息。队列对于程序来说是不可见的。你不能直接操作队列,但是你可以通过调用GetMessage()函数来从队列中获取一条消息。

1
2
MSG msg;
GetMessage(&msg, NULL, 0, 0);

这个函数从队首移除消息。如果队列为空,这个函数阻塞直到另一个消息进入队列。GetMessage阻塞并不会导致应用程序失去响应。如果没有消息,应用程序不会做任何事情。如果需要在后台运行程序,可以在GetMessage等待其它消息时创建额外的线程来继续运行。

GetMessage函数的第一个参数是MSG结构体地址,如果函数成功运行,它将会利用消息相关信息补齐MSG结构体,包括目标窗口以及消息代码;其它三个参数则可以让你过滤掉从队列中获取的其它消息。大多数情况下你会令这些参数为0。

虽然MSG结构体包含了消息的相关信息,你几乎不可能直接检测这个构造函数,而是将它传递给另外两个函数。

1
2
TranslateMessage(&msg);
DispatchMessage(&msg);

TranslateMessage函数与键盘输入相联系,它把敲击键盘的行为解释为字符串。你并不需要知道这个函数如何运转;只需要记住在DispatchMessage前调用这个函数。

DispatchMessage函数告诉操作系统调用目标消息的窗口的窗口过程。换句话说,操作系统在窗口表中查找窗口句柄,找到与窗口相关的函数指针,然后调用这个函数。

例如:

当用户点击鼠标左键后,会引发一连串事件:

1、操作系统将WM_LBUTTONDOWN消息加入消息队列中。

2、应用程序调用GetMessage函数

3、GetMessage从队列中获取WM_LBUTTONDOWN消息,并填入MSG结构体中

4、应用程序调用TranslateMessage函数和DispatchMessage函数

5、在DispatchMessage函数中,操作系统调用窗口过程

6、窗口过程可以选择响应或者忽略它

当窗口过程返回时,返回给DispatchMessageDispatchMessage返回下一条消息到消息循环中。只要你的程序还在运行,消息会持续到达队列中,因此需要一个循环持续地从队列中提取信息并派发它们。你可以认为循环像下面这个工作:

1
2
3
4
5
6
while (1)
{
GetMessage(&msg, NULL, 0, 0);
TranslateMessage(&msg);
DispatchMessage(&msg);
}

就像上述代码一样,这个循环是一个死循环。一般来说,GetMessage返回一个非零值,当你想退出应用、离开消息循环时,只需要调用PostQuitMessage函数即可。

1
PostQuitMessage(0);

PostQuitMessage函数将WM_QUIT消息加入消息队列中。WM_QUIT是一个特殊的消息,它会导致GetMessage返回0,也就是消息循环终结,下面是修改后的消息循环:

1
2
3
4
5
MSG msg = {};
while(GetMessage(&msg, NULL, 0, 0)) {
TranslateMessage(&msg);
DispatchMessage(&msg);
}

只要GetMessage返回一个非零值,while循环中的表达式便会始终为真。当你调用PostQuitMessge时,表达式变为假,程序退出循环。一个有趣的现象是窗口过程永远不会收到WM_QUIT消息,所以不需要在窗口过程中考虑这种情况。

一些情况下,操作系统会绕过队列,直接调用窗口过程。

区别在于术语PostingSending

Posting意味着消息加入消息队列中,通过消息循环派发

Sending意味着消息跳过队列,操作系统直接调用窗口过程。

暂时来说,这两者的区别并不重要,窗口过程处理所有的消息,但一些消息绕过队列,直接进入窗口过程。
然而如果你的应用程序在窗口间交互,这两者的区别就很大。

将消息写入窗口过程

DispatchMessage函数调用目标消息窗口的窗口过程。窗口过程有如下声明:

1
LRESULT CALLBACK WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam);

这里有四个参数:

hwnd是一个窗口句柄

uMsg是消息代码;比如WM_SIZE表示窗口大小改变。

wParamlParam包含这个消息的其他信息;所指示的意义取决于消息代码。

LRESULT是一个程序返回给windows的整数值,它包含了程序对于特定消息的响应,这个整数值所代表的意义取决于消息代码。

CALLBACK是函数的调用协定。

一个典型的窗口过程是一个检测消息代码的switch语句,形式如下:

1
2
3
4
switch(uMSg) {
case WM_SIZE: //Handle window resizing
//etc
}

消息所包含的多余信息保存在lParamwParam中。这两个参数值的具体含义取决于uMsg,需要在MSDN上查找消息代码并作正确的数据类型转换。通常这个值要么是一个数值,要么是一个指向结构体的指针。当然,有些消息不包含任何多余的数据。

举个栗子,WM_SIZE的文档表述为:

wParam指示窗口是最小化、最大化、还是重新设置大小。

lParam包含了用16位值表示的窗口新宽度、新高度。而16位值被存储为32位或64位形式,需要做一些位的转换操作才能得到原来的值。幸运的是,WinDef.h头文件包含了处理这些任务的宏命令。

一个窗口过程可能会处理很多消息,这就导致它的switch代码很长。一种使代码模块化的方法是把处理不同逻辑的语句放在不同的函数中。在窗口过程中,会把wParamlParam转换为正确的数据类型,并将对应的值传递给函数。例如,为了处理WM_SIZE消息,窗口过程会这么做:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
LRESULT CALLBACK WindowProc(HWND hwnd, UINT uMsg, WPARAM wParam, LPARAM lParam)
{
switch (uMsg)
{
case WM_SIZE:
{
int width = LOWORD(lParam); // Macro to get the low-order word.
int height = HIWORD(lParam); // Macro to get the high-order word.
// Respond to the message:
OnSize(hwnd, (UINT)wParam, width, height);
}
break;
}
}
void OnSize(HWND hwnd, UINT flag, int width, int height);
{
// Handle resizing
}

LOWORDHIWORD宏从lParam中获取16位的宽度和高度。窗口过程提取宽度、高度,然后把值传递给OnSize函数。

默认的消息处理

如果不在窗口过程中处理一条特定的消息,把消息参数直接传递给DefWindowProc函数,这个函数会根据消息类型表现不同的默认消息处理行为。

1
return DefWindowProc(hwnd, uMsg, wParam, lParam);
在窗口过程中避免瓶颈

当窗口过程执行时,它会屏蔽掉该线程创建的其他窗口的所有消息。因此要避免在窗口过程中长时间处理一条消息。举个栗子,程序打开了一个TCP连接,然后无限等待服务器的响应,在这段等待的时间中,窗口将不能处理点击鼠标、敲击键盘、甚至是关闭的事件。

遇到这种瓶颈时需要做的就是把任务交由其他线程来完成,可以用以下几种方式之一来完成:

  • 创建一个新线程

  • 使用一个线程池

  • 使用一个异步的I/O调用

  • 使用异步程序调用