Skip to content

YukiNative 踩坑记——Windows 的消息队列

Published: at 01:17

最近在实现 YukiNative,也算是顺风顺水虽然一直在现学 C#。对于 YUKI 中大部分平台依赖的代码都解决地差不多了。但是到了我自己加的某一个功能时,却出了大麻烦……

ToC

简述

功能本身说起来很简单:让 YUKI 跟随游戏窗口同步最大化/最小化。但仔细想想就会发现这其实是一个很平台依赖的功能需求。在 Node 中,我是这么实现的:

1
import * as ffi from 'ffi'
2
3
const user32 = ffi.Library('user32.dll', {
4
SetWinEventHook: ['uint32', ['uint32', 'uint32', 'uint32', 'pointer', 'uint32', 'uint32', 'uint32']],
5
UnhookWinEvent: ['bool', ['uint32']]
6
})
7
8
const EVENT_SYSTEM_MINIMIZESTART = 0x0016
9
const EVENT_SYSTEM_MINIMIZEEND = 0x0017
10
11
export function registerWindowMinimizeStartCallback(
12
handle: number,
13
callback: () => void
14
): boolean {
15
return doRegisterEventHook(EVENT_SYSTEM_MINIMIZESTART, handle, callback)
16
}
17
18
export function registerWindowMinimizeEndCallback(
19
handle: number,
20
callback: () => void
21
): boolean {
22
return doRegisterEventHook(EVENT_SYSTEM_MINIMIZEEND, handle, callback)
23
}
24
25
function doRegisterEventHook(
26
event: number,
27
handle: number,
28
callback: () => void
29
): boolean {
30
const eventProc = ffi.Callback('void',
31
[ffi.types.ulong, ffi.types.ulong, ffi.types.int32, ffi.types.long, ffi.types.long, ffi.types.ulong, ffi.types.ulong],
32
(hook: number, event: number, hwnd: number, obj: number, child: number, thread: number, time: number) => {
33
callback()
34
})
35
36
const num = user32.SetWinEventHook(event, event, 0, eventProc, handle, 0, 0)
37
process.on('exit', () => {
38
if (num !== 0) {
39
user32.UnhookWinEvent(num);
40
}
41
eventProc;
42
})
43
44
return num !== 0
45
}

其实这里也藏着一个坑,就是 process.on('exit') 的地方。为了防止回调在被调用之前就被 GC,我们需要额外引用一下 callback

言归正传。不难发现,要做到这个功能,其中一种方法就是通过 SetWinEventHook,也就是上文中用到的方法,配合回调函数实现。在 Node 下,这种方法可谓是开箱即用,没有耗费我太多的时间。纵使对 Windows API 一窍不通,也不算特别困难。因此当我移植到这一步时,我满心以为这项工作很快就能结束。

然而事实证明,我错了。完成这项移植,我们需要稍微了解一些偏底层的知识。虽然只是一点,但需要就是需要,逃不过的。

YukiNative 简介

在说明之前首先来简单介绍一下 YukiNative 的存在。[YukiNative](https://github.com/Yesterday17/YukiNative) 是为了替代 YUKI 中平台依赖部分而独立出来的纯 Windows 应用程序,其目标是替代目前 YUKI 中所有和 Windows (偏)底层打交道的部分,包括 DLL 调用、进程(退出)状态监听、以及我加的窗口检测。

YukiNative 使用了 .NET Framework 4.7.2,但理论上也可以在 .NET Core 2.0 下编译。

控制台程序与图形界面应用

众所周知,在 Windows 下,你可以通过钩子捕获/修改很多东西。这里我们常常会忽视一个概念:钩子是针对 GUI 程序而言的。

想象没有窗口用户界面的时代,那时候只有控制台,但是——如果用窗口的概念去理解的话,也可以说是只有一个窗口。并且当时的事件也很少:键盘事件恐怕是唯一常用的存在了。

然而到了现在,窗口的出现带来了大量的新概念,包括窗口状态、窗口位置等,随之而来的也就出现了大量的事件。这些事件通过消息的形式传递出去,并最终落入需要的窗口手中。

发现了吗?其中没有控制台程序的位置

原因说起来也简单,控制台程序是另一种程序形式。它们没有图形界面(或许有字符界面),与我们今天见到的那些应用们格格不入。

同样如此,所以图形界面那一套默认是没有带到控制台应用中去的。

消息队列

为了应对图形界面带来的这么多消息,消息队列就出现了。

消息队列是针对每一个线程而言的(因为一个线程可能就对应着一个窗口),负责存放线程需要处理的消息。

我们可以通过 GetMessagePeekMessage 来获取消息队列中的消息。唯一的区别就在于前者阻塞,而后者非阻塞

消息循环

通常的图形界面应用会通过消息循环Message Loop)的方式读取消息队列中的内容。也就是一个大循环,里面用 GetMessage 或者 PostMessage 读取消息,并且通过 TranslateMessageDispatchMessage 进行处理。

于是……

还记得上面的 SetWinEventHook 吗?我们现在知道了事件的传递是通过消息进行的,那么我们想要的最大/最小化事件也就一定蕴含在了事件之中。SetWinEventHook 帮我们声明了需要的事件,Windows 将事件对应的消息送到了线程的消息队列中,而我们却一直没有去取,导致了没有注册成功的假象

这种假象是很致命的,和带有垃圾回收的语言结合起来尤甚。你会怀疑出问题的地方究竟在哪里,从而忽视了事情的本质。

解决

成功定位问题之后,解决起来就简单多了。主要部分如下:

1
public static class MessageLoop {
2
private static readonly Queue<Tuple<Events, uint, Action<int>>> Tasks =
3
new Queue<Tuple<Events, uint, Action<int>>>();
4
5
public static TaskCompletionSource<int> AddHook(Events @event, uint pid) {
6
var promise = new TaskCompletionSource<int>();
7
Tasks.Enqueue(new Tuple<Events, uint, Action<int>>(@event, pid, i => promise.TrySetResult(i)));
8
return promise;
9
}
10
11
public static void Run() {
12
while (true) {
13
if (PeekMessage(out var msg, 0, 0, 0, 1)) {
14
Console.WriteLine(msg);
15
16
if (msg.Message == WmQuit)
17
break;
18
TranslateMessage(ref msg);
19
DispatchMessage(ref msg);
20
}
21
else if (Tasks.Count > 0) {
22
while (Tasks.Count > 0) {
23
var task = Tasks.Dequeue();
24
var hook = SetWinEventHook(
25
task.Item1,
26
task.Item1,
27
IntPtr.Zero,
28
WinEventCallback,
29
task.Item2,
30
0,
31
0);
32
task.Item3(hook);
33
}
34
}
35
36
Thread.Sleep(0);
37
}
38
}
39
40
const uint WmQuit = 0x0012;
41
42
[StructLayout(LayoutKind.Sequential)]
43
private struct MSG {
44
IntPtr Hwnd;
45
public uint Message;
46
IntPtr WParam;
47
IntPtr LParam;
48
uint Time;
49
POINT Point;
50
}
51
52
[StructLayout(LayoutKind.Sequential)]
53
private struct POINT {
54
long x;
55
long y;
56
}
57
58
[DllImport("user32.dll", CharSet = CharSet.Ansi, CallingConvention = CallingConvention.StdCall)]
59
private static extern bool GetMessage(ref MSG msg, int hWnd, uint wMsgFilterMin, uint wMsgFilterMax);
60
61
[DllImport("user32.dll")]
62
private static extern bool PeekMessage(out MSG msg, int hWnd, uint wMsgFilterMin, uint wMsgFilterMax,
63
uint wRemoveMsg);
64
65
[DllImport("user32.dll", CharSet = CharSet.Ansi, CallingConvention = CallingConvention.StdCall)]
66
private static extern bool TranslateMessage(ref MSG msg);
67
68
[DllImport("user32.dll", CharSet = CharSet.Ansi, CallingConvention = CallingConvention.StdCall)]
69
private static extern IntPtr DispatchMessage(ref MSG msg);
70
71
[DllImport("user32.dll", SetLastError = true)]
72
private static extern int SetWinEventHook(Events eventMin, Events eventMax, IntPtr hmodWinEventProc,
73
WinEventProc lpfnWinEventProc, uint idProcess, uint idThread, uint dwflags);
74
75
[DllImport("user32.dll", SetLastError = true)]
76
private static extern int UnhookWinEvent(int hWinEventHook);
77
}
78
}

完整代码位于 https://github.com/Yesterday17/YukiNative/blob/master/YukiNative/services/Win32.cs

结语

撞坑撞了一晚上的存在吃了没文化的亏。从完全不知道消息队列这个东西到渐渐明白其中的内容,度过了一段非常有意义的时光(笑

上面反复加粗线程,其实这里有个说得不是特别详细的地方:不同线程的消息队列不同。而由于 C#TaskThread 的语法糖,因此这种类似 Promise 的东西你也是没法用的。你只能乖乖地把所有东西都放到一个线程里,也就是上面代码里 AddHook 的诞生原因了。

在整个从无到有的过程中,下面的这些文章给了我很大帮助,故在文末列出(排名代表顺序)。

鸣谢

  1. https://gist.github.com/fjl/4080259
  2. https://stackoverflow.com/questions/15849564/how-to-use-winapi-setwineventhook-in-python
  3. https://docs.microsoft.com/en-us/windows/win32/learnwin32/window-messages