最近在实现 YukiNative
,也算是顺风顺水虽然一直在现学 C#。对于 YUKI
中大部分平台依赖的代码都解决地差不多了。但是到了我自己加的某一个功能时,却出了大麻烦……
简述
功能本身说起来很简单:让 YUKI
跟随游戏窗口同步最大化/最小化。但仔细想想就会发现这其实是一个很平台依赖的功能需求。在 Node
中,我是这么实现的:
import * as ffi from 'ffi' const user32 = ffi.Library('user32.dll', { SetWinEventHook: ['uint32', ['uint32', 'uint32', 'uint32', 'pointer', 'uint32', 'uint32', 'uint32']], UnhookWinEvent: ['bool', ['uint32']] }) const EVENT_SYSTEM_MINIMIZESTART = 0x0016 const EVENT_SYSTEM_MINIMIZEEND = 0x0017 export function registerWindowMinimizeStartCallback( handle: number, callback: () => void ): boolean { return doRegisterEventHook(EVENT_SYSTEM_MINIMIZESTART, handle, callback) } export function registerWindowMinimizeEndCallback( handle: number, callback: () => void ): boolean { return doRegisterEventHook(EVENT_SYSTEM_MINIMIZEEND, handle, callback) } function doRegisterEventHook( event: number, handle: number, callback: () => void ): boolean { const eventProc = ffi.Callback('void', [ffi.types.ulong, ffi.types.ulong, ffi.types.int32, ffi.types.long, ffi.types.long, ffi.types.ulong, ffi.types.ulong], (hook: number, event: number, hwnd: number, obj: number, child: number, thread: number, time: number) => { callback() }) const num = user32.SetWinEventHook(event, event, 0, eventProc, handle, 0, 0) process.on('exit', () => { if (num !== 0) { user32.UnhookWinEvent(num); } eventProc; }) return num !== 0 }
其实这里也藏着一个坑,就是 process.on('exit')
的地方。为了防止回调在被调用之前就被 GC
,我们需要额外引用一下 callback
。
言归正传。不难发现,要做到这个功能,其中一种方法就是通过 SetWinEventHook
,也就是上文中用到的方法,配合回调函数实现。在 Node
下,这种方法可谓是开箱即用,没有耗费我太多的时间。纵使对 Windows API
一窍不通,也不算特别困难。因此当我移植到这一步时,我满心以为这项工作很快就能结束。
然而事实证明,我错了。完成这项移植,我们需要稍微了解一些偏底层的知识。虽然只是一点,但需要就是需要,逃不过的。
YukiNative
简介
在说明之前首先来简单介绍一下 YukiNative
的存在。YukiNative
是为了替代 YUKI
中平台依赖部分而独立出来的纯 Windows
应用程序,其目标是替代目前 YUKI
中所有和 Windows
(偏)底层打交道的部分,包括 DLL
调用、进程(退出)状态监听、以及我加的窗口检测。
YukiNative
使用了 .NET Framework 4.7.2
,但理论上也可以在 .NET Core 2.0
下编译。
控制台程序与图形界面应用
众所周知,在 Windows
下,你可以通过钩子捕获/修改很多东西。这里我们常常会忽视一个概念:钩子是针对 GUI
程序而言的。
想象没有窗口用户界面的时代,那时候只有控制台,但是——如果用窗口的概念去理解的话,也可以说是只有一个窗口。并且当时的事件也很少:键盘事件恐怕是唯一常用的存在了。
然而到了现在,窗口的出现带来了大量的新概念,包括窗口状态、窗口位置等,随之而来的也就出现了大量的事件。这些事件通过消息的形式传递出去,并最终落入需要的窗口手中。
发现了吗?其中没有控制台程序的位置。
原因说起来也简单,控制台程序是另一种程序形式。它们没有图形界面(或许有字符界面),与我们今天见到的那些应用们格格不入。
同样如此,所以图形界面那一套默认是没有带到控制台应用中去的。
消息队列
为了应对图形界面带来的这么多消息,消息队列就出现了。
消息队列是针对每一个线程而言的(因为一个线程可能就对应着一个窗口),负责存放线程需要处理的消息。
我们可以通过 GetMessage
或 PeekMessage
来获取消息队列中的消息。唯一的区别就在于前者阻塞,而后者非阻塞。
消息循环
通常的图形界面应用会通过消息循环(Message Loop
)的方式读取消息队列中的内容。也就是一个大循环,里面用 GetMessage
或者 PostMessage
读取消息,并且通过 TranslateMessage
和 DispatchMessage
进行处理。
于是……
还记得上面的 SetWinEventHook
吗?我们现在知道了事件的传递是通过消息进行的,那么我们想要的最大/最小化事件也就一定蕴含在了事件之中。SetWinEventHook
帮我们声明了需要的事件,Windows
将事件对应的消息送到了线程的消息队列中,而我们却一直没有去取,导致了没有注册成功的假象。
这种假象是很致命的,和带有垃圾回收的语言结合起来尤甚。你会怀疑出问题的地方究竟在哪里,从而忽视了事情的本质。
解决
成功定位问题之后,解决起来就简单多了。主要部分如下:
public static class MessageLoop { private static readonly Queue<Tuple<Events, uint, Action<int>>> Tasks = new Queue<Tuple<Events, uint, Action<int>>>(); public static TaskCompletionSource<int> AddHook(Events @event, uint pid) { var promise = new TaskCompletionSource<int>(); Tasks.Enqueue(new Tuple<Events, uint, Action<int>>(@event, pid, i => promise.TrySetResult(i))); return promise; } public static void Run() { while (true) { if (PeekMessage(out var msg, 0, 0, 0, 1)) { Console.WriteLine(msg); if (msg.Message == WmQuit) break; TranslateMessage(ref msg); DispatchMessage(ref msg); } else if (Tasks.Count > 0) { while (Tasks.Count > 0) { var task = Tasks.Dequeue(); var hook = SetWinEventHook( task.Item1, task.Item1, IntPtr.Zero, WinEventCallback, task.Item2, 0, 0); task.Item3(hook); } } Thread.Sleep(0); } } const uint WmQuit = 0x0012; [StructLayout(LayoutKind.Sequential)] private struct MSG { IntPtr Hwnd; public uint Message; IntPtr WParam; IntPtr LParam; uint Time; POINT Point; } [StructLayout(LayoutKind.Sequential)] private struct POINT { long x; long y; } [DllImport("user32.dll", CharSet = CharSet.Ansi, CallingConvention = CallingConvention.StdCall)] private static extern bool GetMessage(ref MSG msg, int hWnd, uint wMsgFilterMin, uint wMsgFilterMax); [DllImport("user32.dll")] private static extern bool PeekMessage(out MSG msg, int hWnd, uint wMsgFilterMin, uint wMsgFilterMax, uint wRemoveMsg); [DllImport("user32.dll", CharSet = CharSet.Ansi, CallingConvention = CallingConvention.StdCall)] private static extern bool TranslateMessage(ref MSG msg); [DllImport("user32.dll", CharSet = CharSet.Ansi, CallingConvention = CallingConvention.StdCall)] private static extern IntPtr DispatchMessage(ref MSG msg); [DllImport("user32.dll", SetLastError = true)] private static extern int SetWinEventHook(Events eventMin, Events eventMax, IntPtr hmodWinEventProc, WinEventProc lpfnWinEventProc, uint idProcess, uint idThread, uint dwflags); [DllImport("user32.dll", SetLastError = true)] private static extern int UnhookWinEvent(int hWinEventHook); } }
完整代码位于 https://github.com/Yesterday17/YukiNative/blob/master/YukiNative/services/Win32.cs。
结语
撞坑撞了一晚上的存在吃了没文化的亏。从完全不知道消息队列这个东西到渐渐明白其中的内容,度过了一段非常有意义的时光(笑。
上面反复加粗线程,其实这里有个说得不是特别详细的地方:不同线程的消息队列不同。而由于 C#
的 Task
是 Thread
的语法糖,因此这种类似 Promise
的东西你也是没法用的。你只能乖乖地把所有东西都放到一个线程里,也就是上面代码里 AddHook
的诞生原因了。
在整个从无到有的过程中,下面的这些文章给了我很大帮助,故在文末列出(排名代表顺序)。