最近在实现 YukiNative
,也算是顺风顺水虽然一直在现学 C# 。对于 YUKI
中大部分平台依赖的代码都解决地差不多了。但是到了我自己加的 某一个功能时,却出了大麻烦……
ToC
简述
功能本身说起来很简单:让 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 (
return doRegisterEventHook ( EVENT_SYSTEM_MINIMIZESTART , handle , callback )
export function registerWindowMinimizeEndCallback (
return doRegisterEventHook ( EVENT_SYSTEM_MINIMIZEEND , handle , callback )
function doRegisterEventHook (
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 ) => {
const num = user32 . SetWinEventHook ( event , event , 0 , eventProc , handle , 0 , 0 )
process . on ( 'exit' , () => {
user32 . UnhookWinEvent ( num );
其实这里也藏着一个坑,就是 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
程序而言的。
想象没有窗口用户界面的时代,那时候只有控制台,但是——如果用窗口的概念去理解的话,也可以说是只有一个窗口。并且当时的事件 也很少:键盘事件恐怕是唯一常用的存在了。
然而到了现在,窗口的出现带来了大量的新概念,包括窗口状态、窗口位置等,随之而来的也就出现了大量的事件。这些事件通过消息 的形式传递出去,并最终落入需要的窗口 手中。
发现了吗?其中没有控制台程序的位置 。
原因说起来也简单,控制台程序是另一种程序形式。它们没有图形界面(或许有字符界面),与我们今天见到的那些应用们格格不入。
同样如此,所以图形界面那一套默认是没有带到控制台应用中去的。
消息队列
为了应对图形界面带来的这么多消息,消息队列就出现了。
消息队列是针对每一个线程 而言的(因为一个线程可能就对应着一个窗口),负责存放线程需要处理的消息。
我们可以通过 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 )));
public static void Run () {
if ( PeekMessage ( out var msg , 0 , 0 , 0 , 1 )) {
if ( msg . Message == WmQuit )
TranslateMessage ( ref msg );
DispatchMessage ( ref msg );
else if ( Tasks . Count > 0 ) {
while ( Tasks . Count > 0 ) {
var task = Tasks . Dequeue ();
var hook = SetWinEventHook (
const uint WmQuit = 0x0012 ;
[ StructLayout ( LayoutKind . Sequential )]
[ StructLayout ( LayoutKind . Sequential )]
[ 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 ,
[ 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
的诞生原因了。
在整个从无到有的过程中,下面的这些文章给了我很大帮助,故在文末列出(排名代表顺序)。
鸣谢
https://gist.github.com/fjl/4080259
https://stackoverflow.com/questions/15849564/how-to-use-winapi-setwineventhook-in-python
https://docs.microsoft.com/en-us/windows/win32/learnwin32/window-messages