C#使用RawInput实现自由组合快捷键

本文是我在大二初即2015年5月份写的一篇关于C#任意组合快捷键,当初是自己有这方面的需求,在百度搜索上找了两天无果,最后在“外面的世界”找到了相关的介绍,然后自己实现了该需求。

前言

使用C#语言做一些客户端应用时,会较长用到全局快捷键功能,而就关于C#全局快捷键的创建,较常使用的方法不外乎以下两种:

  • 使用user32.dll动态链接库的RegisterHotKeyUnregisterHotKey入口方法实现快捷键的注册和注销,但缺点明显,它会截获系统默认快捷键,和系统快捷键产生冲突后会有一系列麻烦,而且最主要的是,它只能提供注册两个键组合的快捷键,且有组合的限制,这就使得快捷键应用不尽人意。
  • 使用Win32API实现全局钩子,即使用user32.dll动态链接库的SetWindowsHookExUnhookWindowsHookEx入口方法创建和卸载监听按键的键盘全局钩子,通过对按键判断处理即可确认设置的快捷键是否被使用,缺点是处理麻烦,且占用较多系统资源,钩子的安装需要系统 管理员权限。

以上方法虽然可以实现基本快捷键的创建,但仍旧达不到对自由快捷键的要求。一个偶然的机会,我在国外某论坛平台找到一篇介绍RawInput使用的文章,我在该文章中找到了解决办法。

首先,什么是RawInput,据字面意思就是“未经加工的输入”,或者我们可以这样叫它——“原始输入”, 意思也就不难理解了。自Windows XP开始,Windows平台开始支持多人机接口设备(即多个人机交互设备的接入使用),允许通过rawinput API使应用程序能直接处理多个设备的信息输入,而一般的处理事件只会把多个设备看作同一个设备进行处理。也就是说,rawinput API支持应用程序对不同输入设备的信息处理,这使得你可以通过不同的输入设备同时完成不同的工作,比如:使用多键盘协同工作、多人多控制器游戏等等。

对于一个键盘的按键信息,Windows一般会截获该数据并转换为一个按键事件,也就是操作系统会将设备按键的特定数据转换为虚拟按键(virtual keys)数据。然而正常的Windows系统处理不会提供关于按键数据来源设备的任何信息,而是将所有按键捆绑为为一类事件(KeyPressEvent),使得应用程序只能认为它们来自同一个输入设备。通过Rawinput,应用程序就可以直接接收到键盘按键数据,以最小的操作系统介入,保证可以从中得到数据的发生源。然而rawinput API不只局限于键盘,还包括鼠标、控制器、触屏等各种人机设备(HID【Human Interface Device】是Windows最早支持的USB类别),而在这篇文章里,我只应需求,介绍关于键盘设备的数据处理。 关于RawInput API请参考MSDN文档

内容实现

入口方法

实现本次功能,主要用到的入口方法为:

  • RegisterRawInputDevices #允许应用程序注册监听的输入设备
  • GetRawInputData #从输入设备检索数据
  • GetRawInputDeviceList #检索连接到系统的输入设备列表
  • GetRawInputDeviceInfo #检索设备信息

核心步骤

注册设备

起初,应用程序是不能得到输入设备的原始数据的,需要对希望获取原始数据的设备进行注册,将设备与应用程序联系起来就可以接收数据。

1
2
3
4
5
6
7
8
9
/// <summary>
/// 注册监听原始输入设备
/// </summary>
/// <param name="pRawInputDevices">原始输入设备集</param>
/// <param name="uiNumDevices">设备集的元素个数</param>
/// <param name="cbSize">原始输入设备信息占用的字节数</param>
/// <returns>如果执行成功则返回True,否则为False,可通过调用GetLastError方法获取关于失败的更多信息</returns>
[DllImport("User32.dll", SetLastError = true)]
internal static extern bool RegisterRawInputDevices(RawInputDevice[] pRawInputDevices, uint uiNumDevices, uint cbSize);

该方法的首个参数为RawInputDevice数据结构类型的数组,第二个参数为该数组的元素个数,第三个为该结构所占用的字节数。
RawInputDevice结构原本定义在windows.h头文件中,在此处,我们仿照C++对其重新进行定义,可参考前面提到MSDN文档

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/// <summary>
/// 定义原始数据设备信息
/// </summary>
[StructLayout(LayoutKind.Sequential)]
internal struct RawInputDevice
{
/// <summary>
/// 顶级集合用法页,接口设备用法页
/// </summary>
internal HidUsagePage usUsagePage;
/// <summary>
/// 顶级集合用法,即监听设备标识
/// </summary>
internal HidUsage usUsage;
/// <summary>
/// 模式标识,指示如何解释处理由用法页和用法提供的信息
/// 默认为Zero时,操作系统会在已经注册的应用窗口获得焦点时,传送顶级集合指定的设备原始数据
/// </summary>
internal RawInputDeviceFlags dwFlags;
/// <summary>
/// 与监听设备关联的目标窗口句柄,如果为空,则遵循键盘焦点
/// </summary>
internal IntPtr hwndTarget;
}

由于我们只关心键盘设备类型信息,在RawKeyBoardB(原始键盘设备封闭类)的构造方法中只声明一个长度的RawInputDevice结构:

1
2
3
4
5
6
7
8
9
10
11
public RawKeyBoard(IntPtr hwnd, bool captureOnlyInForeground){
var rid = new RawInputDevice[1];
rid[0].usUsagePage = HidUsagePage.GENERIC;
rid[0].usUsage = HidUsage.Keyboard;
rid[0].dwFlags = (captureOnlyInForeground ? RawInputDeviceFlags.UNDEFINE : RawInputDeviceFlags.RIDEV_INPUTSINK) | RawInputDeviceFlags.RIDEV_DEVNOTIFY;
rid[0].hwndTarget = hwnd;
if(!Win32API.RegisterRawInputDevices(rid, (uint)rid.Length, (uint)Marshal.SizeOf(rid[0])))
{
throw new ApplicationException("注册设备失败");
}
}

这里RawInputDeviceFlags.INPUTSINK的取值为0x00000100,意味着应用窗口将一直接收输入信息,即便窗口是不被激活的,关于RawInputDeviceFlags的取值,可参考API文档。

注册设备后,应用程序就可以通过GetRawInputData方法获取处理数据了。

过滤接收

当设备注册后,应用开始准备接收原始数据,一旦注册设备被使用,Windows会生成一个包含来自该设备未处理数据的WM_INPUT消息。

而与该设备关联的窗口会在一个 WM_INPUT消息送达时检查收到的信息,并作进一步处理,在本应用程序中, RawInputManager类会负责过滤和截取WM_INPUT消息,它继承自低级封装窗口,通过重写其 WndProc方法以截取该消息,并通过RawKeyBoard对象的ProcessRawInput方法传递和处理该消息。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
protected override void WndProc(ref Message message)
{
switch (message.Msg)
{
case WinMessage.WM_INPUT:
{
keyBoardDriver.ProcessRawInput(message.LParam);
}
break;
case WinMessage.WM_USB_DEVICECHANGE:
{
keyBoardDriver.EnumerateDevices();
}
break;
}
base.WndProc(ref message);
}

接收处理

现在,通过消息过滤获得了我们想要的信息,然后就可以对其进行数据处理。ProcessRawInput方法用GetRawInputData方法检索WM_INPUT消息内容并转换为有用的信息。首先,导入该方法

1
2
[DllImport("User32.dll", SetLastError = true)]
internal static extern int GetRawInputData(IntPtr hRawInput, RawInputDataCommand command, [Out] IntPtr pData, [In, Out] ref int size, int sizeHeader);

该方法的参数如下:

  • hRawInput
    RAWINPUT 数据结构句柄,包含WM_INPUT消息的lParam 数据

  • command
    标识如何从RAWINPUT数据结构检索输入信息或头信息,可能的取值为RID_INPUT (0x10000003) 或 RID_HEADER (0x10000005)

  • pData:
    取决于期望的结果:
    如果pData 赋值为 IntrPtr.Zero, 数据所需的内存区大小将被返回给 size变量。
    否则pData 必须为分配给由WM_INPUT消息产生的RAWINPUT 数据结构的内存区指针。当方法返回时,已分配存储的内容将组成消息的头信息或输入数据,这取决于command的值

  • size
    返回由 pData指示的数据大小

  • sizeHeader
    RAWINPUTHEADER 数据结构大小

具体介绍参考MSND文档。

为了确保能为我们期望的数据分配足够的空间,我们首先在调用该方法时将pData 赋值为IntrPtr.Zero以获取数据大小:

1
2
var dwSize = 0;
Win32.GetRawInputData(hdevice, RawInputDataCommand.RID_INPUT, IntPtr.Zero, ref dwSize, Marshal.SizeOf(typeof(Rawinputheader)));

现在,可以再次调用该方法, 从当前消息中用RAWINPUT 数据结构填充 _rawBuffer变量。 调用成功的话,方法会返回接收数据的大小,以此来检查是否与之前获取的数据大小匹配以判断是否获取到了期望的正确数据。

1
2
3
4
if (dwSize == Win32.GetRawInputData(hdevice, RawInputDataCommand.RID_INPUT, out _rawBuffer, ref dwSize, Marshal.SizeOf(typeof (Rawinputheader))))
{
//dosomething...
}

数据已经获取,现在可以对其进行处理了。WM_INPUT 包含了封装进RAWINPUT数据结构的原始数据。同样的,该数据类型也要被重新定义

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
/// <summary>
/// 包含来自设备的原始输入
/// </summary>
[StructLayout(LayoutKind.Explicit)]
public struct RAWINPUT
{
[FieldOffset(0)]
internal RAWMOUSE mouse;
[FieldOffset(0)]
internal RAWKEYBOARD keyboard;
[FieldOffset(0)]
internal RAWHID hid;
}
[StructLayout(LayoutKind.Sequential)]
internal struct RAWHID
{
public uint dwSizHid;
public uint dwCount;
public byte bRawData;
}
[StructLayout(LayoutKind.Explicit)]
internal struct RAWMOUSE
{
[FieldOffset(0)]
public ushort usFlags;
[FieldOffset(4)]
public uint ulButtons;
[FieldOffset(4)]
public ushort usButtonFlags;
[FieldOffset(6)]
public ushort usButtonData;
[FieldOffset(8)]
public uint ulRawButtons;
[FieldOffset(12)]
public int lLastX;
[FieldOffset(16)]
public int lLastY;
[FieldOffset(20)]
public uint ulExtraInformation;
}
[StructLayout(LayoutKind.Sequential)]
internal struct RAWKEYBOARD
{
public ushort Makecode;
public ushort Flags;
private readonly ushort Reserved;
public ushort VKey;
public uint Message;
public uint ExtraInformation;
}

接下来,就是对数据进行筛选,确定是否有按键被按下。通过驱动相应事件向窗体传递特定按键消息(如设定的快捷键已匹配)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public int ProcessRawInput(IntPtr hdevice)
{
if (deviceList.Count == 0) return 0;
var dwSize = 0;
Win32API.GetRawInputData(hdevice, RawInputDataCommand.RID_INPUT, IntPtr.Zero, ref dwSize, Marshal.SizeOf(typeof(Rawinputheader)));
if (dwSize != Win32API.GetRawInputData(hdevice, RawInputDataCommand.RID_INPUT, out _rawBuffer, ref dwSize, Marshal.SizeOf(typeof(Rawinputheader))))
{
return 1;
}
int virtualKey = _rawBuffer.data.keyboard.VKey;
int makeCode = _rawBuffer.data.keyboard.Makecode;
int flags = _rawBuffer.data.keyboard.Flags;
if (virtualKey == WinMessage.KEYBOARD_OVERRUN_MAKE_CODE) return 0;
var isE0BitSet = ((flags & WinMessage.RI_KEY_E0) != 0);
KeyPressEvent keyPressEvent;
if (deviceList.ContainsKey(_rawBuffer.header.hDevice))
{
lock (padLock)
{
keyPressEvent = deviceList[_rawBuffer.header.hDevice];
}
}
else
{
return 2;
}
var isBreakBitSet = ((flags & WinMessage.RI_KEY_BREAK) != 0);
keyPressEvent.KeyPressState = isBreakBitSet ? "BREAK" : "MAKE";
keyPressEvent.Message = _rawBuffer.data.keyboard.Message;
keyPressEvent.VKeyName = KeyMapper.GetKeyName(VirtualKeyCorrection(virtualKey, isE0BitSet, makeCode)).ToUpper();
keyPressEvent.VKey = virtualKey;
if (KeyPressed != null)
{
hotkeyPressEvent.CheckKey(keyPressEvent);
KeyPressed(this, new RawKeyEventArg(keyPressEvent));
}
if(hotkeyPressEvent.hotActived)
{
OnHotKeyPressed(this, new RawHotKeyEventArg(hotkeyPressEvent));
}
return 0;
}

测试实现

核心功能已经完成,再进行一些完善即可测试使用了。(当然,虽说是核心功能,但仅占到代码量的十分之一左右,这就是我不贴全代码的原因)

新建一个WPF窗体项目,并将该类库进行引用。由于,类库中的RawInputManager继承自System.Windows.Forms下的NativeWindow低级封装窗口,而WPF并不支持,所以要在WPF项目中同样对该命名空间进行引用。

在实例化RawInputManager对象时,需要提供窗体句柄,这个在Winform环境下只需this.Handle即可,但WPF窗体不支持句柄,可以通过引用System.Windows.Interop命名空间,使用IntPtr handle = new WindowInteropHelper(this).Handle 即可取得(但这样并不是经常起作用,在某些时候会出现获取不到的情况,即 handle值为0,此时可尝试另一种非安全的方法,即通过API入口调用非安全代码(不是unsafe代码块)获取窗体句柄(该方法不被官方建议使用):

1
2
3
4
5
6
7
8
9
10
11
12
[System.Runtime.InteropServices.DllImport("user32.dll")]
public static extern IntPtr GetForegroundWindow();
private void Window_Loaded(object sender, RoutedEventArgs e)
{
IntPtr handle= GetForegroundWindow(); //通过非安全代码获取句柄(不建议)
KeyMap[] hotkey = { KeyMap.LWin, KeyMap.LMenu , KeyMap.C };
rawinput = new RawInputManager(handle, false);
rawinput.HotKeys = hotkey;
rawinput.AddMessageFilter(); //消息筛选过滤是为了让按键消息不再被其他应用处理
rawinput.OnKeyPressed += rawinput_OnKeyPressed;
rawinput.OnHotKeyPressed += rawinput_OnHotKeyPressed;
}

运行测试,从上面的代码可以看到,我们设置了快捷键为左Windows徽标键+左ALT键+C的三键组合,而按键信息会显示在窗体上:

  • 启动后依次按下键且不松开,可以看到此窗口并没有被Actived,但依旧能接收到按键信息:
按下键
  • 当设定的快捷按键被按下时,显示消息(或做出反应):
激活快捷键

当然,注意到设备源信息,你可以通过指定过滤设备源来实现不同输入设备区别处理。

终于写完了,这应该是一篇理解很累的文章,具体内容参见源码,有不懂的可以问我或度娘。如果文章有语病或错别字,请谅解,语文课是什么来着已经忘了。


C#使用RawInput实现自由组合快捷键
https://vicasong.github.io/csharp/cs-rawinput-look/
作者
Vica
发布于
2015年5月29日
许可协议