消息机制

我们经常进行图形界面开发的都知道,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,wParamlParam是消息的两个自定义参数,不同的消息中可能有不同的含义,time是消息产生的时间,pt是消息产生时的鼠标位置,如上若干字段就组成了一条消息。当系统需要通知窗口的处理函数进行一些操作时,就会生成一条消息分派给窗口处理函数。

此外我们还需要知道,Windows中消息分为系统消息和用户消息。

系统消息:系统消息由操作系统发送,通常与底层系统事件和操作相关,例如窗口的创建、销毁、大小调整等操作都会生成系统消息。系统消息的消息码通常以WM_(Window Message)为前缀进行定义,如WM_CREATEWM_DESTROYWM_SIZE等,系统消息的消息ID范围是0-0x03FF。

用户消息:用户消息是应用程序自定义发送的消息,用户消息的消息ID范围是0x0400-0x7FFF,具体使用时一个最佳实践是使用类似WM_USER + 1的写法来定义,其中WM_USER的值为0x0400。此外还有一个WM_APP也是常用的基准值,它的值是0x8000,具体使用哪一个基准值没有一个明确的规定,我们根据自己的个人喜好或项目约定选取即可。

从编码角度看,我们使用系统消息时有一个特点,我们要么响应系统消息做一些操作,要么发送系统消息给操作系统。而对于自定义消息,我们通常既要自己发送消息,也要自己处理消息。

Windows消息队列

消息是如何投递的呢?实际上,大部分消息会投递到消息队列中,GetMessage函数实际上是从消息队列中取出消息。

Windows的消息队列分为两部分:系统消息队列应用消息队列。所有的消息首先会投递到系统消息队列中,然后由Windows操作系统根据窗口句柄找到具体的应用消息队列并投递到应用消息队列中,不会出现消息直接投递到应用消息队列的情况。

不过也并非所有消息都会通过消息队列投递,以投递自定义消息为例,投递消息分为PostMessageSendMessage两个方法,前者投递消息后直接返回,而后者将阻塞等待消息处理函数处理完成后才会返回。实际上,PostMessage确实会将消息写入系统消息队列,但SendMessage不会这样做,它比较特殊,它在底层会直接以阻塞方式调用窗口的消息处理函数,而不会投递消息到队列。

除了SendMessage外,有一些特殊的系统消息也不会进入消息队列,这些消息通常是底层的系统消息,而不是与用户界面交互有关的常规消息,例如WM_TIMERWM_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,wParamlParam是两个消息参数,它们的具体取值和消息类型有关,消息处理函数的返回值为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。

获取消息

GetMessage

从消息队列中获取一条消息,如果没有消息,就阻塞等待。获取后,该条消息将被从消息队列中删除。如果收到WM_QUIT消息,会返回0

BOOL GetMessage(
  LPMSG lpMsg,
  HWND  hWnd,
  UINT  wMsgFilterMin,
  UINT  wMsgFilterMax
);
  • lpMsg:获取到的消息的存储指针
  • hWnd:它实际上是起一个过滤功能,用于指定只获取对应句柄窗口的消息
  • wMsgFilterMinwMsgFilterMax:也是一个过滤功能,通过消息定义的值大小过滤,很少用到,一般都传0

PeekMessage

从消息队列中查看当前的消息,如果没有消息返回0。和GetMessage的区别就是PeekMessage是非阻塞的。

BOOL PeekMessage(
  LPMSG lpMsg,
  HWND  hWnd,
  UINT  wMsgFilterMin,
  UINT  wMsgFilterMax,
  UINT  wRemoveMsg
);

前四个参数和GetMessage相同,wRemoveMsg用于指定是否在查看到消息时,是否将其从消息队列中移除。

发送消息

SendMessage

对指定窗口发送消息,直接调用其消息处理函数(而非将消息放入消息队列),阻塞等待,直到该消息被处理。

LRESULT SendMessage(
  HWND   hWnd,
  UINT   Msg,
  WPARAM wParam,
  LPARAM lParam
);
  • hWnd:接收消息的窗口句柄
  • Msg:具体消息定义类型
  • wParam:附加信息
  • lParam:附加信息

PostMessage

对指定窗口发送消息,立即返回,不阻塞等待。

BOOL PostMessage(
  HWND   hWnd,
  UINT   Msg,
  WPARAM wParam,
  LPARAM lParam
);

参数同SendMessage。如果hWndNULL,则消息放入UI线程消息队列,任何窗口都可以处理。

PostThreadMessage

对指定UI线程发送消息,将消息放入消息队列即返回,不阻塞等待,和PostMessage类似,只不过不是指定窗口,而是直接指定线程标识。

BOOL PostThreadMessage(
  DWORD  idThread,
  UINT   Msg,
  WPARAM wParam,
  LPARAM lParam
);
  • idThread:线程标识

PostQuitMessage

向当前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参数位置传递窗体新的大小,不过它传参的方式比较奇葩,其将两个无符号整数压缩到了一个参数中,我们需要用LOWORDHIWORD这两个宏函数取出其内部数据。

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是我们自定义的一个函数,其内部实现了输出信息到终端的逻辑,具体参考第一章有关输出调试信息的部分。

键盘消息

我们先看以下几种消息。

  • WM_KEYDOWN:按键按下时产生,一直按下会一直触发
  • WM_KEYUP:按键放开时产生
  • WM_SYSKEYDOWN:按下ALT组合键或F10触发,一直按下会一直触发
  • WM_SYSKEYUP:放开ALT组合键或F10触发

对于以上几种消息,参数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消息。

  • WM_CHAR:对于可见字符产生该消息,表示具体的按键字符

触发该消息时,WPARAM是具体的字符ASCII值。我们已经有了WM_KEYDOWNWM_KEYUP了,为什么还需要WM_CHAR呢?考虑这样一种情况,我们按下了CapsLock键的状态下再按A键,此时WM_KEYDOWN一定触发,且WPARAM会传递A键的VirtualKey,然而,我们怎么知道用户究竟想要输入的小写a还是大写A呢?实际上此时仅使用WM_KEYDOWN就不能满足我们的需求了,我们可能还需要获取CapsLock和Shift键的状态,不过更好的实现方式是直接使用WM_CHAR消息。

下面例子代码中,我们输入aA会有不同的效果。

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_LBUTTONDOWN 左键按下
  • WM_LBUTTONUP 左键抬起
  • WM_RBUTTONDOWN 右键按下
  • WM_RBUTTONUP 右键抬起
  • WM_MOUSEMOVE 鼠标移动

按下和抬起消息一般成对出现,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);
    }
}

对于鼠标双击消息,有如下几种。

  • WM_LBUTTONDBLCLK 左键双击
  • WM_RBUTTONDBLCLK 右键双击

双击消息的参数和单击相同,但要注意我们必须在窗体类中指定CS_DBLCLKS样式,只有指定这个样式Windows系统才会识别并发送双击消息给消息处理函数,否则无论鼠标点击多快都只能收到单击消息。此外,双击消息和单击消息的发送通常遵循一定的顺序,以左键双击为例,它发出的消息顺序分别为为WM_LBUTTONDOWN WM_LBUTTONUP WM_LBUTTONDBLCLK WM_LBUTTONUP

除了单击和双击,还有一种滚轮消息。

  • WM_MOUSEWHEEL 滚轮滚动时触发

对于滚轮消息,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,我们可以标识多个定时器。

作者:Gacfox
版权声明:本网站为非盈利性质,文章如非特殊说明均为原创,版权遵循知识共享协议CC BY-NC-ND 4.0进行授权,转载必须署名,禁止用于商业目的或演绎修改后转载。
Copyright © 2017-2024 Gacfox All Rights Reserved.
Build with NextJS | Sitemap