没事造轮子没事造轮子

没事造轮子

如何创建消息循环?

  • W_Z_C
  • 阅读约 7 分钟
如何创建消息循环?

本节内容主要介绍 Windows 窗口创建 的第四步,如何创建消息循环。消息循环可以说是 Windows 程序的心脏,而消息可以称之为程序的血液,现代 Windows 操作系统上几乎全部事件处理都是基于消息的。

一个现代的 GUI 应用程序必须可以对操作系统和用户触发的事件做出响应。

  • 用户的事件来自于一切可以与应用程序交互的方式。包括鼠标点击、键盘响应、触屏手势等等。
  • 操作系统的事件来自一切可以影响程序的外部事件。包括用户添加新硬件、或者操作系统进入节能状态(休眠或者睡眠状态)等等。

事件可能在程序运行期间的任何时间触发,并且不同的事件之间几乎没有什么顺序关系。如何在这种情况下构建一个稳定可用的程序在早期是一个挑战,不过现在已经出现了成熟的解决方案。

Windows 通过消息推送模型(message-passing model)解决了这个问题,也就是在 Windows 编程中经常提到的消息机制。

操作系统通过该模型向应用程序传递消息。消息本质上就是一串数字,被用来标记一个特定的事件。例如如果用户按下鼠标左键,操作系统就会接收到这个消息,该消息的代码如下:

#define WM_LBUTTONDOWN    0x0201

有些消息还需要提供一些与其相关的附加数据。例如,WM_LBUTTONDOWN 左键按下消息就包含一个 x,y 坐标的鼠标位置数据。

操作系统最终会调用窗口类注册时期指定的窗口过程函数,将消息和附加数据传递到指定的应用程序中。

1. 消息循环

应用程序在运行期间会接收到成百上千条的消息通知(如每次键盘或者鼠标的响应都会触发消息)。除此之外,每个应用程序都可能不止一个窗口,每个窗口都有自己的窗口过程函数,都会有自己的处理逻辑,如何来接收处理这些消息,并确保每个消息都能正确的送达到对应的窗口?

为了解决这个问题,应用程序首先需要一个循环来不断的接收操作系统发来的各种消息,然后在循环的处理逻辑中还要确保每个消息都可以正确的派发到对应的窗口。

对于每个创建窗口的线程( GUI 线程),操作系统都会为其创建一个消息队列。该线程创建的所有窗口接收的消息都会保存在这个队列中,对于开发者来说这个队列是不可见的,你不能直接操作其内部的数据成员。但是可以使用 GetMessage 函数从该队列中获取这些消息。

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

这个函数会从队列取出消息后会自动将其从队列中移除。如果队列中没有任何消息,这个函数会堵塞一直到有新的消息入队。虽然 GetMessage 函数可能处于堵塞状态,但是它并不会导致程序在操作上卡死不动。

如果消息队列中没有任何消息,程序也就不会执行任何逻辑,假如这时候你需要处理一些其他任务,则可以创建一个新的工作线程,以便在 GetMessage 堵塞等待其他消息的情况下仍然可以继续运行。

GetMessage 函数的第一个参数是消息结构体的指针。如果这个函数返回成功,则该参数会保存取出的消息内容,其中包括消息的目标窗口和消息的类型。另外三个参数主要是为了让你从消息队列中过滤某些消息的时候才会使用,默认情况下填充 0 即可。

虽然消息结构体的内容非常明确,你可以通过其内部成员直接获取消息的相关信息,但是一般不推荐这样做。推荐的做法是直接将消息结构体直接传给下边两个函数:

TranslateMessage(&msg);
DispatchMessage(&msg);

TranslateMessage 函数与键盘输入有关,它可以将键盘事件(按下、释放)转换为字符码。你不需要知道其内部是如何工作的,只要确保该函数在 DispatchMessage 前调用即可。如果你想知道更多的信息,请查看 MSDN。

DispathMessage 函数告诉操作系统调用与该消息关联的窗口过程函数。具体过程简单的说,操作系统首先会通过窗口句柄来确认具体的窗口,然后拿到该窗口在窗口类中注册的窗口过程函数的指针,最后调用该函数。

举个例子,假设用户按下鼠标左键,整个过程如下:

  • 操作系统捕获 WM_LBUTTONDOWN (鼠标左键按下消息),并将其放入消息队列。
  • 应用程序调用 GetMessage 函数。
  • GetMessage 函数从消息队列中获取 WM_LBUTTONDOWN 消息并将其填充到 MSG 结构体。
  • 应用程序调用 TranslateMessage 和 DispathMessage 函数。
  • 在 DispathMessage 函数内部,操作系统会调用应用窗口过程函数。
  • 应用窗口过程函数可以处理或者忽略该消息。

当窗口过程函数返回的时候,DispathMessage 也会随之返回。因为消息会随着程序运行不断的产生,所以程序中需要一个消息处理循环连续不断的处理这些消息。代码如下:

// WARNING: Don't actually write your loop this way.
while (1)
{
    GetMessage(&msg, NULL, 0,  0);
    TranslateMessage(&msg);
    DispatchMessage(&msg);
}

因为是 while(1),所以循环永远不会结束。显然这不是我们想要的,程序需要在想退出的时候随时退出才行。你可以通过判断 GetMessage 的返回值来搞定这件事。正常情况下,GetMessage 的返回值一直都是一个非 0 的值。当你想要退出程序,或者突然想要退出消息循环,可以调用 PostQuitMessage 函数。

PostQuitMessage(0);

该函数内部会将 WM_QUIT 消息插入到消息队列。 WM_QUIT 是一个特别的消息,它会导致 GetMessage 函数返回为 0。所以可以更改消息循环如下:

// Correct.
MSG msg = { };
while (GetMessage(&msg, NULL, 0, 0))
{
    TranslateMessage(&msg);
    DispatchMessage(&msg);
}

按照上边的代码逻辑,窗口处理过程中永远不会接收到 WM_QUIT 消息,因为 GetMessage 函数在调用 DispathMessage 之前已经退出了。

2. PostMessage 和 SendMessage

上个章节讲到消息会保存到消息队列中,但是有的时候,操作系统会跳过队列,直接将消息传递到窗口过程函数中。

有如下两种情况:

  • Posting a message。投递一个消息的含义是将消息放到队列中,然后应用程序会在消息循环中调用 GetMessage 和 DispathMessage 函数获取分发消息。
  • Sending a message。发送一消息的含义是跳过消息队列,操作系统直接将其传递到窗口过程函数。

这两种情况一般在窗口传递消息的时候需要注意,前者对应 API 中的 PostMessage 函数,该函数调用后会立即返回。通过调用它可以确保你将消息投放到消息队列,但是无法保证该消息响应(执行)的时间,可以将其看做是异步的。

后者对应 API 中的 SendMessage,该函数调用后并不会插入队列而是直接传递到窗口过程函数进行处理执行,直到消息处理结束返回,可以将其看做同步。

一般在使用中 SendMessage 会导致线程堵塞,所以在处理耗时的任务时不推荐使用,会导致界面假死,常用的场景是一些同步通知且处理迅速的场景。如果不是很在意消息响应的时间和处理顺序,推荐始终用 PostMessage 替代 SendMessage。

3. GetMessage 和 PeekMessage

前面讲到 GetMessage 会堵塞执行直到消息队列中有新的消息插入。普通的应用程序使用没有任何问题,但是如果是游戏应用就会存在游戏逻辑不能及时更新的情况。

一般游戏中都会存在游戏循环,其会一直调用游戏的处理逻辑,每一帧都会调用,而大多数的游戏循环都是和消息循环合并到一起。如果在游戏循环中调用 GetMessage 的时候正好消息队列为空就会导致下面的游戏逻辑不能及时执行。而游戏程序恰好对实时性要求极高,这就会造成游戏运行时期画面卡顿的现象。既然如此,是否可以不执行 GetMessage 分发消息,直接抛弃消息循环?显然不可行,这会导致键盘鼠标不能及时响应,消息队列中消息积累很多确无法及时处理,整个应用处于卡顿假死状态。

为了解决这个问题,游戏程序中一般使用 PeekMessage 函数替代 GetMessage 函数,二者的功能几乎一致,唯一的差别是 PeekMessage 不管消息队列中有没有消息都会立刻返回,也就解决了刚刚提到的更新不及时和不更新卡顿假死问题。

PeekMessage 函数和 GetMessage 函数的唯一差别是多了一个控制消息处理方式的参数 wRemoveMsg:

BOOL WINAPI PeekMessage(
  _Out_    LPMSG lpMsg,
  _In_opt_ HWND  hWnd,
  _In_     UINT  wMsgFilterMin,
  _In_     UINT  wMsgFilterMax,
  _In_     UINT  wRemoveMsg
);

wRemoveMsg 有三种类型:

  • PM_NOREMOVE,该值会导致 PeekMessage 获取消息后不会将该消息从消息队列中移除。
  • PM_REMOVE,该值会导致调用 PeekMessage 后将消息从消息队列中移除。
  • PM_NOYIELD,该值使系统不释放等待调用程序空闲的线程。可以和前两个值组合使用。

常见的游戏循环逻辑如下:

MSG msg = {0};
while (msg.message != WM_QUIT)
{
    if (PeekMessage(&msg, 0, 0, 0, PM_REMOVE))
    {
        TranslateMessage(&msg);
        DispatchMessage(&msg);
    }
    else
    {
        processInput();
        update();
        render();
    }
}

介绍完基本的消息循环,下面介绍几个常用的消息类型:窗口绘制消息、窗口关闭消息以及如何管理应用程序的状态。