关键词: C++, Win32, 阻塞, 卡, DefWindowProc, 标题栏, 移动窗口, 窗口大小
问题简介
在win32
相关编程时,我们经常遇到程序更新受阻。
在大多数平台,不论是原生Win32
还是QT
、SFML
等平台,创建的窗口都会在拖动
和改变大小
时卡住我们自己写的Idle
处理逻辑,如:
MSG msg = { 0 };
while (true) {
if (PeekMessageW(&msg, 0, 0, 0, PM_NOREMOVE)) {
if (GetMessageW(&msg, 0, 0, 0)) {
TranslateMessage(&msg);
DispatchMessageW(&msg);
}
else break;
}
else {
IdleProcess(false);
}
}
其中IdleProcess()
在移动窗口
和缩放窗口
乃至点击标题栏按钮
时不执行,准确的说,上述整个消息循环都不再运行。
这对于一些有渲染任务的窗口,如游戏窗口,是十分不好的表现,因为它会使得下一次更新横跨一个巨大的时间步。当然你也可以手动限制时间步上限,不过联机游戏这样处理又会造成同步问题。
大致原因
最开始探索这个问题时,我认为是移动窗口
或缩放窗口
产生大量WM_MOVING
和WM_SIZING
导致消息队列爆满,从而分支不能到达IdleProceess()
。
但是简单地加入断点调试后,我们就能发现循环实际卡死在DispatchMessage()
中,因为我们只创建了一个窗口,所以很可能是 窗口消息处理函数WndProc()
阻塞了。
在WndProc()
中,唯一不可控的内容就是我们不希望处理消息时调用的DefWindowProc()
。
果然,简单调试后我们就能发现WndProc()
确实停在调用DefWindowProc()
处。
LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam);
在DefWindowProc()
之前输出消息的参数message
、lParam
、wParam
,发现以下消息与阻塞相关,且其实际表现经查询资料后被归结在其后:
WM_SYSCOMMAND
,SC_MOVE
:等待 500 ms 左右 无双击 后,开始移动窗口 并 进入 SIZEMOVE LoopWM_SYSCOMMAND
,SC_SIZE
:开始改变大小 并 立即进入 SIZEMOVE LoopWM_CONTEXTMENU
:打开菜单 并 立即进入 MENU loopWM_NCLBUTTONDOWN
WM_NCRBUTTONDOWN
其中 SIZEMOVE Loop 和 MENU Loop 是DefWindowProc()
中的消息循环。
而WM_NCLBUTTONDOWN
和WM_NCRBUTTONDOWN
比较复杂:
WM_NCLBUTTONDOWN(左键点击非客户区)
- 点击 标题栏 (
HTCAPTION
) 时,触发WM_SYSCOMMAND
,SC_MOVE
- 点击 图标 (
HTSYSMENU
) 时,触发WM_CONTEXTMENU
- 点击 按钮(
HTCLOSE
,HTMAXBUTTON
,HTMINBUTTON
,HTHELP
) 时,在松开鼠标前不会返回,松开时执行相应功能(即发送相应消息)
WM_NCRBUTTONDOWN(右键点击非客户区)
- 在松开鼠标前不会返回,松开鼠标时触发
WM_CONTEXTMENU
解决方法
解决思路有参考:
主要围绕上述几个消息专门处理
对于进入 DefWindowProc() 内部消息循环
上述进入 SIZEMOVE Loop 和 MENU Loop,即进入DefWindowProc()
内部消息循环。
进入之后,其会发布WM_ENTERSIZEMOVE
和WM_ENTERMENULOOP
消息。
需要注意,并不是DefWindowProc()
接收到上述消息才进入循环,而是进入循环后才发布消息来通知我们。
对于这种阻塞,我们不能改变其中的默认消息循环,只能使用SetTimer()
注册计时器,让计时器以一定频率调用我们的IdleProcess()
。
这种方法需要我们对 计时器 和 Windows消息机制 有较好的理解。简要地说,GetMessage()
和PeekMessage
在消息队列为空时会进行检查,注册的计时器就是在此时更新的。于是DefWindowProc()
内部的默认消息循环也会处理我们的计时器,就使我们的IdleProcess()
可以继续运行了。
我们既可以专门写一个回调函数由计时器调用:
void CALLBACK TimerProc(HWND hWnd, UINT message, UINT_PTR nTimerid, DWORD systemTick) {
return IdleProcess();
}
且使用
SetTimer(0, 0, TIMER_ELAPSE, (TIMERPROC)TimerProcess);
也可以不使用回调函数,直接在WndProc()
中处理WM_TIMER
消息:
case WM_TIMER:
IdleProcess();
break;
对于在非客户区按下鼠标右键
我们可以拦截WM_NCRBUTTONDOWN
消息,再在WM_NCRBUTTONUP
中发送消息来打开菜单,基本思想如此:
case WM_NCRBUTTONDOWN:
break;
case WM_NCRBUTTONUP:
PostMessageW(hWnd, WM_CONTEXTMENU, 0, lParam);
break;
当然,我们需要更多逻辑来避免鼠标按下和松开的位置不同时打开菜单。
对于在非客户区按下鼠标左键
如果按下的是按钮,那么可以简单地拦截按下消息然后在松开时发送相应消息:
switch(wParam) {
case HTMAXBUTTON: {
bool wndMaxed = GetWindowLongPtrW(hWnd, GWL_STYLE) & WS_MAXIMIZE;
PostMessageW(hWnd, WM_SYSCOMMAND, wndMaxed ? SC_RESTORE : SC_MAXIMIZE, lParam);
break;
}
case HTMINBUTTON:
PostMessageW(hWnd, WM_SYSCOMMAND, SC_MINIMIZE, lParam);
break;
case HTCLOSE:
PostMessageW(hWnd, WM_SYSCOMMAND, SC_CLOSE, lParam);
break;
case HTHELP:
break;
default:
return DefWindowProcW(hWnd, message, wParam, lParam);
}
而标题栏有两种功能:
一是双击,与最大化按钮一致;
二是拖动,移动窗口。
我们必须拦截WM_NCLBUTTONDOWN
消息,所以我们只能自己处理移动:
static WPARAM l_lastHitNC = HTNOWHERE;
/* ... */
case WM_NCMOUSEMOVE:
if (l_lastHitNC == HTCAPTION) {
l_lastHitNC = HTNOWHERE;
PostMessageW(hWnd, WM_SYSCOMMAND, SC_MOVE | HTCAPTION, lParam);
PostMessageW(hWnd, WM_MOUSEMOVE, MK_LBUTTON, 0);
}
else return DefWindowProcW(hWnd, message, wParam, lParam);
break;
case WM_NCLBUTTONDOWN:
if (wParam == HTCAPTION || wParam == HTCLOSE ||
wParam == HTMAXBUTTON || wParam == HTMINBUTTON || wParam == HTHELP) {
l_lastHitNC = wParam;
PostMessageW(hWnd, WM_ACTIVATE, WA_CLICKACTIVE, 0);
}
else {
l_lastHitNC = HTNOWHERE;
return DefWindowProcW(hWnd, message, wParam, lParam);
}
break;
/* .. */
其中
PostMessageW(hWnd, WM_MOUSEMOVE, MK_LBUTTON, 0);
的作用是让SC_MOVE
触发的移动不再等待 500 ms。
经过测试,这种处理方法:
- 在 Windows 7 SP1 无效,
SC_MOVE
仍可能等待 500 ms - 在 Windows 11 有效,
SC_MOVE
确实不会再等待,而是直接开始移动 - 其他版本未测试
总结起来就得到了全部解决方案,例子如下:
/**
* MIT License
*
* Copyright (c) 2023 Tyler Parret True
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*
* Tyler Parret True (OwlHowlinMornSky) <mysteryworldgod@outlook.com>
*/
#include <stdio.h>
#include <Windows.h>
#define IDLE_DEBUG_OUTPUT
#define DEFAULT_ELAPSE 20
#define TIMER_ELAPSE 50
bool MyRegistClass(HINSTANCE);
LRESULT CALLBACK WndProc(HWND, UINT, WPARAM, LPARAM);
void CALLBACK TimerProc(HWND, UINT, UINT_PTR, DWORD);
#ifdef IDLE_DEBUG_OUTPUT
HANDLE hStdOut = 0;
void IdleProcess(bool state) {
// TODO
static int cnt = 0;
WCHAR tmp[64] = { 0 };
swprintf_s(tmp, 64, L"Idle Process. Message Pump State: %s.", state ? L"In System" : L"Normal");
WriteConsoleW(hStdOut, tmp, static_cast<DWORD>(wcslen(tmp)), 0, 0);
for (int i = 0; i < cnt; ++i)
tmp[i] = L'.';
tmp[cnt] = '\n';
tmp[cnt + 1] = '\0';
cnt = (cnt + 1) % 16;
WriteConsoleW(hStdOut, tmp, static_cast<DWORD>(wcslen(tmp)), 0, 0);
return;
}
#else
void IdleFunc(bool state) {
// TODO
return;
}
#endif // IDLE_DEBUG_OUTPUT
int APIENTRY wWinMain(_In_ HINSTANCE hInstance,
_In_opt_ HINSTANCE hPrevInstance,
_In_ LPWSTR lpCmdLine,
_In_ int nCmdShow) {
UNREFERENCED_PARAMETER(hPrevInstance);
UNREFERENCED_PARAMETER(lpCmdLine);
#ifdef IDLE_DEBUG_OUTPUT
AllocConsole();
hStdOut = GetStdHandle(STD_OUTPUT_HANDLE);
#endif // IDLE_DEBUG_OUTPUT
if (!MyRegistClass(hInstance)) {
MessageBoxW(0, L"Regist Class: Failed!", L"Error", MB_ICONERROR);
return EXIT_FAILURE;
}
// TODO
HWND hWnd0 = CreateWindowW(L"MY_TEST_CLASS_OHMS", L"MY_TEST_WINDOW",
WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN | WS_CLIPSIBLINGS,
CW_USEDEFAULT, CW_USEDEFAULT, 640, 480,
0, 0, hInstance, 0);
if (!hWnd0) {
MessageBoxW(NULL, L"Create Window: Failed!", L"Error", MB_ICONERROR);
return EXIT_FAILURE;
}
ShowWindow(hWnd0, nCmdShow);
UpdateWindow(hWnd0);
// Message Pump
MSG msg = { 0 };
while (true) {
if (PeekMessageW(&msg, 0, 0, 0, PM_NOREMOVE)) {
if (GetMessageW(&msg, 0, 0, 0)) {
TranslateMessage(&msg);
DispatchMessageW(&msg);
}
else break;
}
else {
IdleProcess(false);
Sleep(DEFAULT_ELAPSE);
}
}
DestroyWindow(hWnd0);
// TODO
return EXIT_SUCCESS;
}
bool MyRegistClass(HINSTANCE hInst) {
WNDCLASSEXW wcex = { 0 };
wcex.cbSize = sizeof(WNDCLASSEX);
wcex.hbrBackground = (HBRUSH)(COLOR_WINDOWFRAME);
wcex.hCursor = LoadCursorW(nullptr, IDC_ARROW);
wcex.hInstance = hInst;
wcex.lpfnWndProc = WndProc;
wcex.lpszClassName = L"MY_TEST_CLASS_OHMS";
wcex.style = CS_HREDRAW | CS_VREDRAW;
return (RegisterClassExW(&wcex) != 0);
}
void CALLBACK TimerProc(HWND hWnd, UINT message, UINT_PTR nTimerid, DWORD systemTick) {
IdleProcess(true);
return;
}
LRESULT CALLBACK WndProc(HWND hWnd, UINT message, WPARAM wParam, LPARAM lParam) {
static WPARAM l_lastHitNC = HTNOWHERE;
static UINT_PTR l_timerID = 0;
switch (message) {
case WM_CREATE: {
CreateWindowW(L"BUTTON", L"TEST",
WS_TABSTOP | WS_VISIBLE | WS_CHILD | WS_CLIPSIBLINGS | BS_DEFPUSHBUTTON,
10, 10, 50, 50,
hWnd, 0, 0, 0);
// TODO
break;
}
case WM_DESTROY: {
// TODO
break;
}
case WM_PAINT: {
PAINTSTRUCT ps;
HDC hdc = BeginPaint(hWnd, &ps);
// TODO
EndPaint(hWnd, &ps);
break;
}
case WM_CLOSE:
PostQuitMessage(0);
break;
case WM_NCMOUSEMOVE:
if (l_lastHitNC == HTCAPTION) {
l_lastHitNC = HTNOWHERE;
PostMessageW(hWnd, WM_SYSCOMMAND, SC_MOVE | HTCAPTION, lParam);
PostMessageW(hWnd, WM_MOUSEMOVE, MK_LBUTTON, 0);
}
else return DefWindowProcW(hWnd, message, wParam, lParam);
break;
case WM_NCLBUTTONDOWN:
if (wParam == HTCAPTION || wParam == HTCLOSE ||
wParam == HTMAXBUTTON || wParam == HTMINBUTTON || wParam == HTHELP) {
l_lastHitNC = wParam;
PostMessageW(hWnd, WM_ACTIVATE, WA_CLICKACTIVE, 0);
}
else {
l_lastHitNC = HTNOWHERE;
return DefWindowProcW(hWnd, message, wParam, lParam);
}
break;
case WM_NCLBUTTONUP:
if (l_lastHitNC == wParam) {
l_lastHitNC = HTNOWHERE;
switch (wParam) {
case HTMAXBUTTON: {
bool wndMaxed = GetWindowLongPtrW(hWnd, GWL_STYLE) & WS_MAXIMIZE;
PostMessageW(hWnd, WM_SYSCOMMAND, wndMaxed ? SC_RESTORE : SC_MAXIMIZE, lParam);
break;
}
case HTMINBUTTON:
PostMessageW(hWnd, WM_SYSCOMMAND, SC_MINIMIZE, lParam);
break;
case HTCLOSE:
PostMessageW(hWnd, WM_SYSCOMMAND, SC_CLOSE, lParam);
break;
case HTHELP:
break;
default:
return DefWindowProcW(hWnd, message, wParam, lParam);
}
}
else {
l_lastHitNC = HTNOWHERE;
return DefWindowProcW(hWnd, message, wParam, lParam);
}
break;
case WM_NCRBUTTONDOWN:
if (l_lastHitNC == HTNOWHERE && (wParam == HTCAPTION || wParam == HTCLOSE ||
wParam == HTMAXBUTTON || wParam == HTMINBUTTON || wParam == HTHELP)) {
l_lastHitNC = wParam | 0x8000;
PostMessageW(hWnd, WM_ACTIVATE, WA_CLICKACTIVE, 0);
}
else
l_lastHitNC = HTNOWHERE;
break;
case WM_NCRBUTTONUP:
if (l_lastHitNC & 0x8000) {
l_lastHitNC = HTNOWHERE;
PostMessageW(hWnd, WM_CONTEXTMENU, 0, lParam);
}
else
l_lastHitNC = HTNOWHERE;
break;
case WM_ENTERMENULOOP:
if (l_timerID) {
KillTimer(0, l_timerID);
}
l_timerID = SetTimer(0, 0, TIMER_ELAPSE, (TIMERPROC)TimerProc);
break;
case WM_EXITMENULOOP:
if (l_timerID) {
KillTimer(0, l_timerID);
}
l_timerID = 0;
break;
case WM_ENTERSIZEMOVE:
if (l_timerID) {
KillTimer(0, l_timerID);
}
l_timerID = SetTimer(0, 0, TIMER_ELAPSE, (TIMERPROC)TimerProc);
break;
case WM_EXITSIZEMOVE:
if (l_timerID) {
KillTimer(0, l_timerID);
}
l_timerID = 0;
break;
default:
return DefWindowProcW(hWnd, message, wParam, lParam);
}
return 0;
}