我们经常进行图形界面开发的都知道,GUI系统实现大致就是两种思路:一种就是常用于游戏的帧绘制,也叫IMGUI,另一种就是常用于应用软件的消息驱动绘制。Windows系统的图形界面部分就是采用消息驱动绘制的,我们对窗体进行的各种操作会发出一系列的消息,这些消息最终由消息处理函数来处理。
本章节使用的基础例子代码还是前一章窗体初始化的代码,不过这里我们着重介绍和消息处理相关的部分。
关于消息机制,这里有一些基本概念需要我们理解。
Windows中,消息由结构体MSG
定义,其内容如下。
typedef struct tagMSG {
HWND hwnd;
UINT message;
WPARAM wParam;
LPARAM lParam;
DWORD time;
POINT pt;
DWORD lPrivate;
} MSG, *PMSG, *NPMSG, *LPMSG;
其中,hwnd
是窗口句柄,message
是消息ID,wParam
和lParam
是消息的两个自定义参数,不同的消息中可能有不同的含义,time
是消息产生的时间,pt
是消息产生时的鼠标位置,如上若干字段就组成了一条消息。当系统需要通知窗口的处理函数进行一些操作时,就会生成一条消息分派给窗口处理函数。
此外我们还需要知道,Windows中消息分为系统消息和用户消息。
系统消息:系统消息由操作系统发送,通常与底层系统事件和操作相关,例如窗口的创建、销毁、大小调整等操作都会生成系统消息。系统消息的消息码通常以WM_
(Window Message)为前缀进行定义,如WM_CREATE
、WM_DESTROY
、WM_SIZE
等,系统消息的消息ID范围是0-0x03FF。
用户消息:用户消息是应用程序自定义发送的消息,用户消息的消息ID范围是0x0400-0x7FFF,具体使用时一个最佳实践是使用类似WM_USER + 1
的写法来定义,其中WM_USER
的值为0x0400。此外还有一个WM_APP
也是常用的基准值,它的值是0x8000
,具体使用哪一个基准值没有一个明确的规定,我们根据自己的个人喜好或项目约定选取即可。
从编码角度看,我们使用系统消息时有一个特点,我们要么响应系统消息做一些操作,要么发送系统消息给操作系统。而对于自定义消息,我们通常既要自己发送消息,也要自己处理消息。
消息是如何投递的呢?实际上,大部分消息会投递到消息队列中,GetMessage
函数实际上是从消息队列中取出消息。
Windows的消息队列分为两部分:系统消息队列和应用消息队列。所有的消息首先会投递到系统消息队列中,然后由Windows操作系统根据窗口句柄找到具体的应用消息队列并投递到应用消息队列中,不会出现消息直接投递到应用消息队列的情况。
不过也并非所有消息都会通过消息队列投递,以投递自定义消息为例,投递消息分为PostMessage
和SendMessage
两个方法,前者投递消息后直接返回,而后者将阻塞等待消息处理函数处理完成后才会返回。实际上,PostMessage
确实会将消息写入系统消息队列,但SendMessage
不会这样做,它比较特殊,它在底层会直接以阻塞方式调用窗口的消息处理函数,而不会投递消息到队列。
除了SendMessage
外,有一些特殊的系统消息也不会进入消息队列,这些消息通常是底层的系统消息,而不是与用户界面交互有关的常规消息,例如WM_TIMER
、WM_QUIT
。
通过前面的例子我们知道,注册窗口时需要指定窗口处理函数wndclass.lpfnWndProc
,其本质就是一个处理消息的多分支结构。消息进入消息队列后,我们通过DispatchMessage
就可将其分派给具体窗口进行处理。一个消息处理函数通常如下定义。
LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
{
switch (message)
{
case XXX:
// ... 处理某个消息
return 0;
case WM_DESTROY:
PostQuitMessage(0);
return 0;
default:
return DefWindowProc(hwnd, message, wParam, lParam);
}
}
消息处理函数接收4个参数:hwnd
为窗口句柄,message
为消息ID,wParam
和lParam
是两个消息参数,它们的具体取值和消息类型有关,消息处理函数的返回值为LRESULT
类型,它是一个32位整数值,一般来说,返回0
表示消息已成功处理。对于我们不关心的消息,一定要调用Windows提供的默认处理函数DefWindowProc
对其进行处理,而不要弃置不管,否则我们的窗体会丢失很多默认行为。
WNDCLASS wndclass;
// 设置消息处理函数
wndclass.lpfnWndProc = WndProc;
// ... 设置其它信息
在窗体类中,我们需要指定消息处理函数的函数指针供系统回调。
我们还是观察前一章节的例子,有这样的一段代码,它定义了一个while
循环。
// 开启消息循环
MSG msg; // 窗口接收的消息对象
while (GetMessage(&msg, NULL, 0, 0)) // 循环接收消息,无消息时阻塞等待
{
TranslateMessage(&msg); // 将虚拟键消息转换为字符消息,一般如此固定写法
DispatchMessage(&msg); // 将UI线程的消息分派给窗口
}
while(GetMessage(...))
除非接收到WM_QUIT
,否则会一直将消息循环进行下去。每当接收到消息,都会调用DispatchMessage
,将消息分派给窗口的处理函数。至于WM_QUIT
它是一个十分特殊的消息,GetMessage
遇到WM_QUIT
时会返回0,以此终止循环。WM_QUIT
则实际上是在窗口销毁时我们调用PostQuitMessage
函数手动发出的。
这里我们介绍一些常用的发送和获取消息的API。
从消息队列中获取一条消息,如果没有消息,就阻塞等待。获取后,该条消息将被从消息队列中删除。如果收到WM_QUIT
消息,会返回0
。
BOOL GetMessage(
LPMSG lpMsg,
HWND hWnd,
UINT wMsgFilterMin,
UINT wMsgFilterMax
);
lpMsg
:获取到的消息的存储指针hWnd
:它实际上是起一个过滤功能,用于指定只获取对应句柄窗口的消息wMsgFilterMin
和wMsgFilterMax
:也是一个过滤功能,通过消息定义的值大小过滤,很少用到,一般都传0
从消息队列中查看当前的消息,如果没有消息返回0
。和GetMessage
的区别就是PeekMessage
是非阻塞的。
BOOL PeekMessage(
LPMSG lpMsg,
HWND hWnd,
UINT wMsgFilterMin,
UINT wMsgFilterMax,
UINT wRemoveMsg
);
前四个参数和GetMessage
相同,wRemoveMsg
用于指定是否在查看到消息时,是否将其从消息队列中移除。
对指定窗口发送消息,直接调用其消息处理函数(而非将消息放入消息队列),阻塞等待,直到该消息被处理。
LRESULT SendMessage(
HWND hWnd,
UINT Msg,
WPARAM wParam,
LPARAM lParam
);
hWnd
:接收消息的窗口句柄Msg
:具体消息定义类型wParam
:附加信息lParam
:附加信息对指定窗口发送消息,立即返回,不阻塞等待。
BOOL PostMessage(
HWND hWnd,
UINT Msg,
WPARAM wParam,
LPARAM lParam
);
参数同SendMessage
。如果hWnd
为NULL
,则消息放入UI线程消息队列,任何窗口都可以处理。
对指定UI线程发送消息,将消息放入消息队列即返回,不阻塞等待,和PostMessage
类似,只不过不是指定窗口,而是直接指定线程标识。
BOOL PostThreadMessage(
DWORD idThread,
UINT Msg,
WPARAM wParam,
LPARAM lParam
);
idThread
:线程标识向当前UI线程的消息队列发送一个WM_QUIT
消息,用于结束形如while(GetMessage(...))
的消息循环。
void PostQuitMessage(
int nExitCode
);
nExitCode
:退出返回值,正常退出一般传0
Windows预定义了大量的系统消息,功能涵盖GUI程序的方方面面,下面是一些常见的消息ID,下面表格仅仅是作为一个例子进行说明,实际上日常开发中可能用到的系统消息类型就有数百种,不过它们的用法都是相通的,我们这里仅举一个例子,至于其它消息用到时查阅文档即可。
消息类型 | 说明 |
---|---|
WM_CREATE | 窗口创建 |
WM_DESTOROY | 窗口销毁 |
WM_MOVE | 窗口移动 |
WM_SIZE | 窗口改变大小 |
WM_ACTIVATE | 窗口被激活 |
WM_PAINT | 窗口需要重绘 |
WM_CLOSE | 窗口关闭 |
WM_KEYDOWN | 窗口键盘按下 |
WM_KEYUP | 窗口键盘弹起 |
WM_TIMER | 定时器消息 |
WM_MOUSEMOVE | 窗口内鼠标移动 |
WM_LBUTTONDOWN | 窗口内鼠标左键按下 |
WM_LBUTTONUP | 窗口内鼠标左键弹起 |
WM_LBUTTONDBLCLK | 窗口内鼠标左键双击 |
WM_RBUTTONDOWM | 窗口内鼠标右键按下 |
WM_RBUTTONUP | 窗口内鼠标右键弹起 |
WM_RBUTTONDBLCLK | 窗口内鼠标右键双击 |
下面例子是一个消息处理函数,代码中我们处理了WM_SIZE
消息。根据MSDN文档,WM_SIZE
消息投递时,会同时在lParam
参数位置传递窗体新的大小,不过它传参的方式比较奇葩,其将两个无符号整数压缩到了一个参数中,我们需要用LOWORD
和HIWORD
这两个宏函数取出其内部数据。
LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
{
switch (message)
{
case WM_SIZE:
{
// 读取lParam参数值
UINT width = LOWORD(lParam);
UINT height = HIWORD(lParam);
TCHAR buffer[100];
wsprintf(buffer, TEXT("Size: %d %d\n"), width, height);
WriteLog(buffer);
return 0;
}
case WM_DESTROY:
// 收到WM_DESTROY,即窗体上点击关闭按钮时,向UI线程发送一个WM_QUIT消息,携带返回值'0',使得应用程序从WinMain退出
PostQuitMessage(0);
return 0;
default:
// 其它我们不关心的消息,交给默认消息处理函数
return DefWindowProc(hwnd, message, wParam, lParam);
}
}
代码中,我们将窗体新的大小打印在了控制台上。注:WriteLog
是我们自定义的一个函数,其内部实现了输出信息到终端的逻辑,具体参考第一章有关输出调试信息的部分。
我们先看以下几种消息。
对于以上几种消息,参数WPARAM是按键的VirtualKey值,我们可以通过它判断按下的是哪个按键;LPARAM是一些按键的参数,通常不使用。
下面例子代码中,我们在消息处理函数中判断Enter键被按下了。
LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
{
switch (message)
{
case WM_KEYDOWN:
if (wParam == VK_RETURN)
{
// 判断Enter键按下了
MessageBox(hwnd, TEXT("Enter按下了"), TEXT("提示"), MB_OK);
return 0;
}
else
{
return DefWindowProc(hwnd, message, wParam, lParam);
}
return 0;
case WM_DESTROY:
PostQuitMessage(0);
return 0;
default:
return DefWindowProc(hwnd, message, wParam, lParam);
}
}
除了上面介绍的消息,还有一个比较特殊的WM_CHAR
消息。
触发该消息时,WPARAM是具体的字符ASCII值。我们已经有了WM_KEYDOWN
和WM_KEYUP
了,为什么还需要WM_CHAR
呢?考虑这样一种情况,我们按下了CapsLock键的状态下再按A键,此时WM_KEYDOWN
一定触发,且WPARAM会传递A键的VirtualKey,然而,我们怎么知道用户究竟想要输入的小写a
还是大写A
呢?实际上此时仅使用WM_KEYDOWN
就不能满足我们的需求了,我们可能还需要获取CapsLock和Shift键的状态,不过更好的实现方式是直接使用WM_CHAR
消息。
下面例子代码中,我们输入a
和A
会有不同的效果。
LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
{
switch (message)
{
case WM_CHAR:
{
TCHAR s[256];
wsprintf(s, TEXT("%c\n"), wParam);
MessageBox(hwnd, s, TEXT("提示"), MB_OK);
return 0;
}
case WM_DESTROY:
PostQuitMessage(0);
return 0;
default:
return DefWindowProc(hwnd, message, wParam, lParam);
}
}
鼠标消息常用的包括如下几种类型。
按下和抬起消息一般成对出现,WM_MOUSEMOVE
则在鼠标移动时持续触发。其中WPARAM参数为其它按键状态,如CTRL、SHIFT等;LPARAM参数是鼠标的位置,其中LOWORD为相对于客户区的X坐标,HIWORD为相对于客户区的Y坐标。
LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
{
switch (message)
{
case WM_LBUTTONDOWN:
{
MessageBox(hwnd, TEXT("左键按下了"), TEXT("提示"), MB_OK);
return 0;
}
case WM_DESTROY:
PostQuitMessage(0);
return 0;
default:
return DefWindowProc(hwnd, message, wParam, lParam);
}
}
对于鼠标双击消息,有如下几种。
双击消息的参数和单击相同,但要注意我们必须在窗体类中指定CS_DBLCLKS
样式,只有指定这个样式Windows系统才会识别并发送双击消息给消息处理函数,否则无论鼠标点击多快都只能收到单击消息。此外,双击消息和单击消息的发送通常遵循一定的顺序,以左键双击为例,它发出的消息顺序分别为为WM_LBUTTONDOWN WM_LBUTTONUP WM_LBUTTONDBLCLK WM_LBUTTONUP
。
除了单击和双击,还有一种滚轮消息。
对于滚轮消息,WPARAM的LOWORD是其它按键状态,HIWORD是滚轮偏移量,用正负值表示其滚动方向;LPARAM的LOWORD和HIWORD分别是相对于屏幕坐标系的坐标,注意这里不是客户区坐标系,不过该参数较少使用。
Win32API中提供了一个定时器功能,它能周期性的发送定时器消息,以实现定时处理一些代码逻辑。注意这个定时器具有一定的误差,通常在几毫秒之内,因此不能用于对定时要求较为精确的逻辑,例如类似游戏引擎的帧循环用这个定时器实现就不合适。
下面是一个窗体消息处理函数,我们在窗体创建时设置了定时器,定时器会周期发送WM_TIMER
消息,我们的处理逻辑为弹出一个提示框。
LRESULT CALLBACK WndProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam)
{
switch (message)
{
case WM_CREATE:
// 创建定时器
g_timerID = SetTimer(hwnd, 1, 3000, NULL);
return 0;
case WM_TIMER:
// 处理定时器消息
if (wParam == 1)
{
MessageBox(hwnd, TEXT("定时器触发"), TEXT("提示"), MB_OK);
}
return 0;
case WM_DESTROY:
// 销毁定时器
KillTimer(hwnd, g_timerID);
PostQuitMessage(0);
return 0;
default:
return DefWindowProc(hwnd, message, wParam, lParam);
}
}
对于SetTimer
函数,其参数分别是hwnd
窗体实例句柄,nIDEvent
自定义的定时器标识符,用于标识定时器,触发WM_TIMER
消息时会传递到wParam
参数中,uElapse
定时器间隔时间,单位为毫秒,lpTimerFunc
通常不使用,固定传NULL。通过指定多个不同的nIDEvent
,我们可以标识多个定时器。