之前就注意到一个现象就是 windows 的窗口被右键菜单栏弹出菜单后或者按住右键后整个程序似乎会被冻结, 而对于游戏更是直接像停掉了主循环一样. 除此之外左键按住窗口也会有同样的效果, 但是例外是部分游戏会被阻塞 500ms 后恢复, 而有些干脆没受到影响. 不过这个问题看上去重要性不大所以之前一段时间没去在意它.
这一段时间摸 win32 和游戏相关的内容比较多, 发现左键按住默认的行为是直接阻塞住, 那问题就来了, 该如何让程序的行为像某些程序一样只是阻塞 500ms? 目前这是一段简单并简陋的 opengl 程序:
#include <Windows.h>
#include <wingdi.h>
#include <stdio.h>
#include <iostream>
#include <gl/GL.h>
#pragma comment(lib, "opengl32")
#pragma comment(lib, "winmm")
#define CheckLastError() _CheckLastError(__LINE__)
void _CheckLastError(int line)
{
DWORD le = GetLastError();
if (!le) return;
LPVOID buf = nullptr;
FormatMessageW(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM, NULL, le, 0, (LPTSTR)&buf, 0, NULL);
std::wcout << L"ERROR(L" << line << L"): " << (wchar_t*)buf << std::flush;
LocalFree(buf);
}
LRESULT CALLBACK WindowProc(_In_ HWND hwnd, _In_ UINT uMsg, _In_ WPARAM wParam, _In_ LPARAM lParam);
float c = 0.0f;
HDC winHdc;
HGLRC hglrc;
static void tick()
{
glClear(GL_COLOR_BUFFER_BIT);
c += 0.02f;
glColor3f(abs(c), 0.0f, abs(c));
if (c >= 1.0f)
c = -1.0f;
glRectf(0.0f, 0.0f, 0.5f, 0.5f);
SwapBuffers(winHdc);
Sleep(10);
}
int WINAPI WinMain(_In_ HINSTANCE hInstance, _In_opt_ HINSTANCE hPrevInstance, _In_ LPSTR lpCmdLine, _In_ int nCmdShow)
{
timeBeginPeriod(1);
#if _DEBUG
AllocConsole();
FILE* conout, * conin;
freopen_s(&conout, "CONOUT$", "w+t", stdout);
freopen_s(&conin, "CONIN$", "r+t", stdin);
#endif
WNDCLASSW wc{};
wc.lpfnWndProc = WindowProc;
wc.hInstance = hInstance;
wc.hCursor = LoadCursorW(nullptr, IDC_ARROW);
wc.lpszClassName = L"MyWindowClass";
RegisterClassW(&wc);
HWND hwnd = CreateWindowExW(WS_EX_APPWINDOW | WS_EX_CLIENTEDGE | WS_EX_ACCEPTFILES,
L"MyWindowClass", L"一个简单的win32窗口",
WS_OVERLAPPEDWINDOW | WS_CLIPCHILDREN | WS_CLIPSIBLINGS,
CW_USEDEFAULT, CW_USEDEFAULT, 1200, 800, nullptr, nullptr, hInstance, nullptr);
ShowWindow(hwnd, nCmdShow);
UpdateWindow(hwnd);
MSG msg{};
while (true)
{
if (!IsWindow(hwnd))
return 0;
while (PeekMessageW(&msg, hwnd, 0, 0, PM_REMOVE))
{
TranslateMessage(&msg);
DispatchMessageW(&msg);
}
tick();
}
return 0;
}
uint32_t move_timer_id = 0U;
LRESULT CALLBACK WindowProc(_In_ HWND hwnd, _In_ UINT uMsg, _In_ WPARAM wParam, _In_ LPARAM lParam)
{
switch (uMsg)
{
case WM_CREATE:
{
winHdc = GetDC(hwnd);
CheckLastError();
PIXELFORMATDESCRIPTOR ps{
sizeof(PIXELFORMATDESCRIPTOR),
1, // version number
PFD_DRAW_TO_WINDOW | // support window
PFD_SUPPORT_OPENGL | // support OpenGL
PFD_DOUBLEBUFFER, // double buffered
PFD_TYPE_RGBA, // RGBA type
24, // 24-bit color depth
0, 0, 0, 0, 0, 0, // color bits ignored
0, // no alpha buffer
0, // shift bit ignored
0, // no accumulation buffer
0, 0, 0, 0, // accum bits ignored
24, // 24-bit z-buffer
0, // no stencil buffer
0, // no auxiliary buffer
PFD_MAIN_PLANE, // main layer
0, // reserved
0, 0, 0 // layer masks ignored
};
int fmt = ChoosePixelFormat(winHdc, &ps);
if (!fmt) CheckLastError();
SetPixelFormat(winHdc, fmt, &ps); CheckLastError();
hglrc = wglCreateContext(winHdc); CheckLastError();
wglMakeCurrent(winHdc, hglrc);
glViewport(0, 0, 1200, 800);
glClearColor(0.0f, 0.0f, 0.0f, 1.0f);
CheckLastError();
return 0;
}
case WM_DESTROY:
{
PostQuitMessage(0);
CheckLastError();
return 0;
}
case WM_SIZE:
{
CheckLastError();
int width = LOWORD(lParam);
int height = HIWORD(lParam);
glViewport(0, 0, width, height);
PostMessageW(hwnd, WM_PAINT, 0, 0);
CheckLastError();
return 0;
}
case WM_PAINT:
{
CheckLastError();
PAINTSTRUCT ps{};
BeginPaint(hwnd, &ps);
EndPaint(hwnd, &ps);
CheckLastError();
return 0;
}
case WM_ERASEBKGND:
CheckLastError();
return TRUE;
}
return DefWindowProcW(hwnd, uMsg, wParam, lParam);
}
然后我们很容易发现这个程序的行为是按住直接阻塞住, 同时想到到 godot 游戏的行为是按住 500ms 后会停止阻塞, 我们不妨去翻阅 godot 的源码来查找这个问题.
翻阅和调试的过程还算顺利, 最终可以定位到如下几行 godot 源码上(display_server_windows.cpp(L4324): WndProc):
...
case WM_ENTERSIZEMOVE: {
Input::get_singleton()->release_pressed_events();
windows[window_id].move_timer_id = SetTimer(windows[window_id].hWnd, 1, USER_TIMER_MINIMUM, (TIMERPROC) nullptr);
} break;
case WM_EXITSIZEMOVE: {
KillTimer(windows[window_id].hWnd, windows[window_id].move_timer_id);
} break;
case WM_TIMER: {
if (wParam == windows[window_id].move_timer_id) {
_process_key_events();
if (!Main::is_iterating()) {
Main::iteration();
}
} else if (wParam == windows[window_id].focus_timer_id) {
_process_activate_event(window_id, windows[window_id].saved_wparam, windows[window_id].saved_lparam);
KillTimer(windows[window_id].hWnd, wParam);
windows[window_id].focus_timer_id = 0U;
}
} break;
...
似乎有点复杂, 其中重要的是 move_timer_id
这个计时器, 我们可以看到在 WM_ENTERSIZEMOVE
消息发生时, 也就是左键按住窗口后, 该计时器被创建, 其中时长请求了 windows 能提供的最短时间, 不过这不要紧, 毕竟这会消息循环还在阻塞呢. 然后在 500ms(估计是 windows 内部的一个设置)后, 我们会发现此时 WM_TIMER
能够被触发了, 所以我们不妨在这会继续进行一次更新. 最后在左键被松开时, WM_EXITSIZEMOVE
发生后清除这个计时器避免多次触发更新.
那么知道具体如何了, 我们把这部分代码抄到刚才的简陋的 opengl 程序中:
首先把一次渲染的操作提到一个函数里, 比如直接叫 tick
:
static void tick()
{
glClear(GL_COLOR_BUFFER_BIT);
c += 0.02f;
glColor3f(abs(c), 0.0f, abs(c));
if (c >= 1.0f)
c = -1.0f;
glRectf(0.0f, 0.0f, 0.5f, 0.5f);
SwapBuffers(winHdc);
Sleep(10);
}
然后搞个保存计时器 id 的变量:
UINT_PTR move_timer_id = 0U;
最后在消息循环重要的部分加上那三个消息的处理:
case WM_ENTERSIZEMOVE:
{
move_timer_id = SetTimer(hwnd, 1, USER_TIMER_MINIMUM, (TIMERPROC) nullptr);
break;
}
case WM_EXITSIZEMOVE:
{
KillTimer(hwnd, move_timer_id);
break;
}
case WM_TIMER:
{
if (wParam == move_timer_id)
{
tick();
}
break;
}
现在启动程序, 按住程序标题栏, 发现确实是期望的等待 500ms 后阻塞"停止".
最后我们也在 WinForms 框架中尝试这个操作. 已知的是 WinForms 的 Application.Idle
会在处理完一次消息后执行, 也就相当于之前的 tick()
, 同时在按住 WinForms 的窗口时该事件不会被执行.
首先同样构造一些能帮我们识别程序是否阻塞了的标志, 比如这里订阅 Application.Idle
并持续更改一个 textbox 的内容:
namespace WinFormsApp1;
public partial class Form1 : Form
{
private int counter;
public Form1()
{
InitializeComponent();
}
private void Form1_Load(object sender, EventArgs e)
{
Application.Idle += Application_Idle;
}
private void Application_Idle(object? sender, EventArgs e)
{
counter += 1;
textBox1.Text = $"counter: {counter}";
}
}
不过有些小问题,我个人并不是很熟一些 win32 的东西封装进 WinForms 成什么了, 所以暂且暴力解决一下:
using System.Runtime.InteropServices;
namespace WinFormsApp1;
public partial class Form1 : Form
{
private nint moveTimer;
private int counter;
public Form1()
{
InitializeComponent();
}
private void Form1_Load(object sender, EventArgs e)
{
Application.Idle += Application_Idle;
}
private void Application_Idle(object? sender, EventArgs e)
{
Tick();
}
private void Tick()
{
counter += 1;
textBox1.Text = $"counter: {counter}";
}
protected override void WndProc(ref Message m)
{
switch (m.Msg)
{
case 0x0231: // WM_ENTERSIZEMOVE
moveTimer = SetTimer(m.HWnd, 1, 0x0000000A, IntPtr.Zero);
return;
case 0x0232: // WM_EXITSIZEMOVE
KillTimer(m.HWnd, moveTimer);
moveTimer = 0;
return;
case 0x0113: // WM_TIMER
Tick();
return;
}
base.WndProc(ref m);
}
[DllImport("user32.dll")]
private static extern nint SetTimer(IntPtr hWnd, nint nIDEvent, uint uElapse, IntPtr lpTimerFunc);
[DllImport("user32.dll")]
private static extern int KillTimer(IntPtr hWnd, nint uIDEvent);
}
一些题外话:
在研究这个问题的时候发现了一个额外有趣的用法. 在调试这个问题时发现阻塞状态下 DefWndProc
卡在 WM_SYSCOMMAND
这个消息上, 其中 wParam
值为 0xf012
, 不过奇怪的是 查阅 msdn 时并没有找到值为 0xf012
的解释, 经过一番搜索发现这个值其实是 SC_MOVE + HTCAPTION
, 而在 mfc 框架内部也是忽略了这个值的最后一位:
...
void CFrameWnd::OnSysCommand(UINT nID, LONG lParam)
{
CFrameWnd* pFrameWnd = GetTopLevelFrame();
ASSERT_VALID(pFrameWnd);
// set status bar as appropriate
UINT nItemID = (nID & 0xFFF0); // 忽略最后一位
// don't interfere with system commands if not in help mode
if (pFrameWnd->m_bHelpMode)
...
有趣的用法就是, 我们可以手动发送这个消息, 比如通过 SendMessage(hwnd, WM_SYSCOMMAND, SC_MOVE + HTCAPTION, 0)
, 如果在处理这个消息的时候鼠标左键是按下的(任意窗口上的位置), 你会发现此时就像按住了标题栏一样在拖动窗口.