简介:面向C#开发者的Windows输入模拟组件,基于Windows Input Simulator扩展了鼠标事件支持,可在桌面程序中触发键盘按键与鼠标点击、移动、滚轮等操作,适合自动化测试、辅助工具和远程控制等需要程序化输入的场景。压缩包共30个文件,约93KB,包含12个cs源码文件、4个dll程序集、4个pdb调试符号、4个txt说明文档以及xml、csproj等项目文件;源码中可见输入结构体、虚拟键码、鼠标与键盘标志及核心模拟类,便于按模块阅读和二次集成。已有1182人学习下载。整体遵循Microsoft Public License (Ms-PL),读者可参考其封装方式理解Windows输入事件构造流程,并在此基础上裁剪或扩充键鼠模拟能力,较快接入现有C#项目。
1. 给老系统做批量导出时,我靠两行代码解决了最后一个对话框
给一个财务系统做批量导出的时候,流程卡在最后一个确认框上:它没有命令行参数,也没有可调用的接口,只能靠人手工点。我一开始手写SendInput的结构体,对齐INPUT大小、算绝对坐标、处理修饰键,一天下来几乎被各种问题埋住;换成 C# 的 Input Simulator 之后,同样的键盘鼠标模拟逻辑就被简化成sim.Keyboard.KeyPress(...)和sim.Mouse.LeftButtonClick()两行。它的作用很直接:把 Win32 的SendInput包装成一组强类型接口,键盘、鼠标、滚轮、Unicode 文本各有一套方法,链式调用还能顺带控制节奏。
适合写桌面自动化、RPA 小工具、UI 冒烟测试、操作录制回放的开发者。
2. 方案对比与分层架构:SendInput 被包成了什么样子
在动手之前先想清楚一件事:C# 里模拟键鼠至少有三条路,选错了后面全是补丁。
2.1 三种注入方式的能力边界
| 方案 | 能做什么 | 做不到什么 |
|---|---|---|
SendKeys.Send/SendWait | 发字符串、少量特殊键 | 精确的 down/up 时序、鼠标、滚轮;依赖消息循环,控制台或后台线程里经常静默失败 |
keybd_event/mouse_event | 老系统上兼容性还行 | 已被SendInput取代;多次调用之间会被真实输入插队;没有 Unicode 通道 |
SendInput | 键鼠、滚轮、Unicode 文本,支持一次传数组 | 受 UIPI 与会话隔离限制,跨权限、跨会话直接失效 |
真正关键的是那两个差异。第一,SendInput接受一个INPUT[],系统把这批事件当成一个整体插入输入流,中途不会被别的按键插进来,这对Ctrl+Shift+S这种组合键至关重要;keybd_event一条一条发,中间只要用户手抖碰一下键盘,组合就废了。第二,SendInput支持KEYEVENTF_UNICODE,绕开键盘布局直接投递字符,中文、日文、emoji 都能发,而前两种方案只能发当前布局里映射得到的键。
那为什么不直接 P/Invoke?因为INPUT是个带 union 的结构体,x86 下 28 字节、x64 下 40 字节,cbSize传错一个字节,SendInput就返回 0 且什么都不做,这个坑我踩过不止一次。再加上多显示器坐标归一化、双击间隔判定、修饰键释放顺序,这些细节凑起来就是 Input Simulator 存在的理由。
2.2 四层结构:一行调用到底经过了谁
InputSimulator(门面) ├── Keyboard : IKeyboardSimulator └── Mouse : IMouseSimulator ↓ 把方法调用翻译成 INPUT[] InputBuilder ↓ IInputMessageDispatcher → WindowsInputMessageDispatcher → user32!SendInput门面层只负责持有键盘和鼠标两个子模拟器,本身几乎没有逻辑。键盘/鼠标模拟器负责把语义参数(键码、按钮、坐标)转成结构体字段。InputBuilder是真正干活的地方,它把一次ModifiedKeyStroke展开成「按下修饰键 → 按下主键 → 抬起主键 → 逆序抬起修饰键」这样一串INPUT。最底下IInputMessageDispatcher只有一个Dispatch(INPUT[])方法,唯一的实现就是调SendInput。
把出口抽成接口是个很实用的设计决定:写单元测试时换一个假的 dispatcher,就能断言「我按下 Ctrl 时到底构造了哪几条事件」,不用真的让鼠标在屏幕上乱跑。做二次开发时,你也可以在这个位置插一层限流或日志,把每条注入事件记下来,出问题时不用靠猜。
2.3 核心类型与职责
| 类型 | 命名空间 | 职责 |
|---|---|---|
InputSimulator | WindowsInput | 门面,暴露Keyboard/Mouse |
IKeyboardSimulator | WindowsInput | KeyDown、KeyUp、KeyPress、TextEntry、ModifiedKeyStroke、Sleep |
IMouseSimulator | WindowsInput | MoveMouseTo、LeftButtonDown/Up/DoubleClick、VerticalScroll、HorizontalScroll |
VirtualKeyCode | WindowsInput.Native | VK_开头的虚拟键码枚举,从VK_A到VK_F24一应俱全 |
MouseButton | WindowsInput.Native | LeftButton、RightButton、MiddleButton |
INPUT/KEYBDINPUT/MOUSEINPUT | WindowsInput.Native | 与user32逐字段对齐的结构体 |
IInputMessageDispatcher | WindowsInput | 注入出口,可替换 |
有一个容易被忽略的细节:几乎所有方法都返回this。这不是为了好看,是为了让你能把「输入」和「等待」串成一条流水线,sim.Keyboard.KeyPress(VK_TAB).Sleep(50).TextEntry("abc")这种写法比一堆Thread.Sleep可读得多,也更容易统一改节奏。
3. 源码拆解:一次 KeyPress 走到 SendInput 的完整链路
这个库总共不到二十个文件,读一遍花不了半小时,但有几处必须看明白,否则用起来就是黑匣子。
3.1 目录与文件职责
| 路径 | 作用 |
|---|---|
InputSimulator.cs | 门面,构造时把KeyboardSimulator/MouseSimulator装配起来 |
KeyboardSimulator.cs | 键盘全部方法的实现 |
MouseSimulator.cs | 鼠标全部方法的实现,含坐标换算 |
InputBuilder.cs | 把「按下一个键」翻译成INPUT结构体 |
WindowsInputMessageDispatcher.cs | 唯一真正调SendInput的地方 |
Native/VirtualKeyCode.cs | 虚拟键码枚举,几百个常量 |
Native/NativeMethods.cs | SendInput、GetSystemMetrics等 P/Invoke 声明 |
Native/INPUT.cs等 | 结构体定义,用LayoutKind.Explicit做 union |
先看InputBuilder和WindowsInputMessageDispatcher,所有行为都从这两个文件出去,其它都是语法糖。
3.2 键盘:KeyDown / KeyUp 的字段含义
public IKeyboardSimulator KeyDown(VirtualKeyCode keyCode) { var input = new INPUT { Type = INPUT_KEYBOARD }; // 1 = 键盘事件 input.KeyboardEvent.VirtualKeyCode = (ushort)keyCode; input.KeyboardEvent.ScanCode = 0; // 虚拟键模式下填 0 input.KeyboardEvent.Flags = 0; // 0 表示按下 input.KeyboardEvent.Time = 0; // 0 = 由系统填时间戳 input.KeyboardEvent.ExtraInfo = IntPtr.Zero; _messageDispatcher.Dispatch(new[] { input }); return this; } public IKeyboardSimulator KeyUp(VirtualKeyCode keyCode) { var input = new INPUT { Type = INPUT_KEYBOARD }; input.KeyboardEvent.VirtualKeyCode = (ushort)keyCode; input.KeyboardEvent.Flags = KEYEVENTF_KEYUP; // 0x0002 = 抬起 _messageDispatcher.Dispatch(new[] { input }); return this; }Type是事件大类,1是键盘、0是鼠标、2是硬件事件,这三个值是SendInput唯一认的。ScanCode填 0 的意思是「我说的是虚拟键码,你自己按当前键盘布局去翻译成扫描码」,这样同一个VK_A在美式布局上是 A、在法语布局上是 Q,行为跟人按物理按键一致。如果你确实想按物理位置发键(比如做游戏按键映射),才需要填扫描码并把Flags加上KEYEVENTF_SCANCODE。
KeyPress就是KeyDown和KeyUp的组合,中间还夹了个Sleep。默认的那个间隔很小,对普通应用够用,碰上需要防抖的控件就得自己指定。
3.3 ModifiedKeyStroke:修饰键的释放顺序是有讲究的
public IKeyboardSimulator ModifiedKeyStroke( IEnumerable<VirtualKeyCode> modifierKeyCodes, IEnumerable<VirtualKeyCode> keyCodes) { var modifiers = modifierKeyCodes.ToList(); // 1. 依次按下所有修饰键,顺序与调用方传入一致 foreach (var m in modifiers) KeyDown(m); // 2. 按下并抬起主键 foreach (var k in keyCodes) { KeyDown(k); KeyUp(k); } // 3. 逆序释放修饰键,最后按下的最先松开 for (int i = modifiers.Count - 1; i >= 0; i--) KeyUp(modifiers[i]); return this; }逆序释放不是洁癖。Windows 对 Alt 有特殊处理,VK_MENU按下时系统会进入菜单模式,如果Ctrl+Alt+X里先松Ctrl再松Alt,松Alt的那一刻系统可能把后面残留的按键解释成菜单快捷键,表现为「菜单栏莫名其妙高亮」。反过来先松Alt就干净得多。这是一个典型的「代码没错,顺序有毒」的场景。
3.4 鼠标:0 到 65535 的绝对坐标系
public IMouseSimulator MoveMouseTo(double absoluteX, double absoluteY) { var input = new INPUT { Type = INPUT_MOUSE }; input.MouseEvent.Flags = MOUSEEVENTF_ABSOLUTE | MOUSEEVENTF_MOVE; input.MouseEvent.Dx = (int)absoluteX; // 0 ~ 65535,不是像素 input.MouseEvent.Dy = (int)absoluteY; _messageDispatcher.Dispatch(new[] { input }); return this; }MOUSEEVENTF_ABSOLUTE的意思是「Dx/Dy是归一化绝对坐标」,0,0是主屏左上角,65535,65535是主屏右下角。很多人第一次调用MoveMouseTo(100, 200)发现光标贴在屏幕左上角,就是这个原因——100在 65535 的尺度里几乎等于 0。
要多显示器支持,用MoveMouseToPositionOnVirtualDesktop,它内部按虚拟桌面的宽高做换算。MOUSEEVENTF_VIRTUALDESK这个标志加上之后,坐标系才扩展成整个虚拟屏幕。
双击的实现比想象中朴素:
public IMouseSimulator LeftButtonDoubleClick() { LeftButtonDown(); LeftButtonUp(); LeftButtonDown(); LeftButtonUp(); return this; }系统判断双击靠的是两次点击的间隔小于GetDoubleClickTime()(默认 500 毫秒)。正常执行这段代码间隔在几毫秒内,肯定算双击;但如果你为了「稳一点」在中间插了Sleep(600),那就变成两次单击了,目标程序收到的是两个独立的WM_LBUTTONDOWN,行为完全不同。
3.5 手动 P/Invoke,对照一次完整链路
using System; using System.Runtime.InteropServices; [DllImport("user32.dll", SetLastError = true)] static extern uint SendInput(uint nInputs, INPUT[] pInputs, int cbSize); static void Main() { var inputs = new INPUT[2]; inputs[0].Type = 1; // INPUT_KEYBOARD inputs[0].U.KeyboardEvent.VirtualKeyCode = 0x41; // 'A' inputs[0].U.KeyboardEvent.Flags = 0; // 按下 inputs[1] = inputs[0]; inputs[1].U.KeyboardEvent.Flags = 0x0002; // KEYEVENTF_KEYUP uint sent = SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(INPUT))); if (sent != inputs.Length) Console.WriteLine($"注入失败,错误码 {Marshal.GetLastWin32Error()}"); }四个参数分别是事件数量、数组指针、单条结构体大小。第三个参数是纯手工 P/Invoke 最容易错的地方,一定要用Marshal.SizeOf,不要写死28或40。返回值是「成功插入的事件条数」,不等于入参数量就说明有事件被系统拒绝了——但注意SendInput不会告诉你哪一条失败,只返回 0 或部分成功,这也是封装库有价值的地方:它至少能帮你把结构体搞对。
4. 跑起来:从建工程到第一段能看见效果的代码
4.1 三种引用方式,按你的网络环境选
dotnet new console -n InputDemo cd InputDemo dotnet add package InputSimulator dotnet run能连公网的话这是最省事的。内网环境就把源码里那几个.cs文件直接拷进项目,改一下命名空间,库本身只依赖System.Runtime.InteropServices,没有任何第三方依赖,编译目标也不挑。如果你在维护 .NET Framework 4.x 的老项目,走packages.config那条老路引包就行,方法签名一模一样。
提示:拷源码的时候记得把
Native目录整个带上,只拷KeyboardSimulator.cs会编译不过。
4.2 最小可运行示例:往当前焦点窗口打字
using System; using System.Threading; using WindowsInput; using WindowsInput.Native; class Program { static void Main() { Console.WriteLine("3 秒后开始输入,请把光标放进目标输入框..."); Thread.Sleep(3000); // 留出切换窗口的时间 var sim = new InputSimulator(); sim.Keyboard.TextEntry("hello input simulator"); // 走 Unicode 通道,不受布局影响 sim.Keyboard.KeyPress(VirtualKeyCode.RETURN); // 回车 sim.Keyboard .ModifiedKeyStroke(VirtualKeyCode.CONTROL, VirtualKeyCode.VK_A) // 全选 .Sleep(100) .ModifiedKeyStroke(VirtualKeyCode.CONTROL, VirtualKeyCode.VK_C); // 复制 } }Thread.Sleep(3000)不是凑数,控制台程序启动后焦点还在终端上,你必须有时间切到目标窗口。TextEntry逐个字符走 Unicode 通道,中文也能打,这一点比SendKeys强不少。最后的Ctrl+A、Ctrl+C是验证手段:跑完去剪贴板里粘一下,内容对得上就说明整条链路通了。
Sleep放在链式调用中间,返回的还是IKeyboardSimulator,所以能一直串下去。批处理场景里这个很重要,后面会讲。
4.3 自测:搭一个能看见事件顺序的窗体
跨进程测试受焦点、权限影响太大,第一次验证建议在自己进程里做。
private void Form1_Load(object sender, EventArgs e) { textBox1.Multiline = true; textBox1.Focus(); var sim = new InputSimulator(); sim.Keyboard.TextEntry("第一行") .KeyPress(VirtualKeyCode.RETURN) .TextEntry("第二行") .Sleep(50) .ModifiedKeyStroke(VirtualKeyCode.CONTROL, VirtualKeyCode.VK_A); }窗体加载时把焦点给文本框,然后模拟输入,你应该能看到两行文字被写进去并且全选高亮。如果这里都不对,问题一定出在调用参数上;如果这里对、跨进程就不对,那基本就是权限或焦点问题,直接跳到第 5 章。
这个自测还有个隐藏好处:控件会真实收到WM_KEYDOWN消息,你可以挂个KeyDown事件把e.KeyCode打出来,逐个核对事件顺序和键码是否符合预期,比盲猜快得多。
4.4 节奏控制:为什么两次事件之间要 Sleep
目标程序处理键盘消息是在 UI 线程上排队做的。你要是几十毫秒内连发上百个事件,大部分应用扛得住,但有些控件——尤其是带防抖、带自动补全、带输入法联想的——会吞掉一部分,表现为「少打了几个字」或者「焦点跳错」。
sim.Keyboard .TextEntry("张") .Sleep(30) // 等输入法/联想处理完 .KeyPress(VirtualKeyCode.TAB) // 跳到下一个字段 .Sleep(50) .TextEntry("1001") .Sleep(30) .KeyPress(VirtualKeyCode.TAB);Sleep的数值没有标准答案,我一般从 30 毫秒起步,跑十遍观察有没有丢事件,再往下压。压到 0 还能稳定的话当然最好,但别为了快把稳定性赌进去——自动化脚本跑一半错位,排查成本远高于多等那几十毫秒。
5. 避坑与排查:五个高概率失败场景
这一章是血泪经验汇总。下面五种情况,我几乎每个都真实遇到过。
5.1 鼠标一调用就飞到屏幕左上角
现象:调MoveMouseTo(100, 200),光标直接贴到左上角,离你想要的位置十万八千里。
原因:这个方法收的是 0 到 65535 的归一化绝对坐标,不是像素。传100相当于屏幕宽度的一千五百分之一,肉眼看起来就是左上角。
解决:自己封一层像素换算。
// 把屏幕像素坐标换算成 SendInput 需要的 0~65535 绝对坐标 static (int X, int Y) ToAbsolute(int px, int py, int screenWidth, int screenHeight) { return ( (int)Math.Round(px * 65535.0 / (screenWidth - 1)), (int)Math.Round(py * 65535.0 / (screenHeight - 1)) ); }宽度减一是为了让最右一列像素正好映射到 65535,不减的话边缘会差一两个像素。多显示器场景别用主屏宽高,改用MoveMouseToPositionOnVirtualDesktop,它内部按虚拟桌面换算。另外高 DPI 缩放下GetSystemMetrics返回的是缩放后的逻辑像素,程序清单里记得声明 DPI 感知,否则坐标会整体偏一截。
5.2 SendInput 返回 0,事件像石沉大海
现象:调用不抛异常,目标程序毫无反应;加日志发现SendInput返回 0,GetLastError是 87。
原因:错误码 87 是「参数无效」,九成是cbSize传错。INPUT在 x86 下 28 字节、x64 下 40 字节,手写 P/Invoke 写死大小,或者项目是 AnyCPU 又勾了「首选 32 位」,运行时结构和编译期假设对不上,系统直接拒收。
解决:永远用Marshal.SizeOf(typeof(INPUT)),不要硬编码数字。项目属性里把平台目标明确成 x86 或 x64,别留 AnyCPU 让它自己猜。用现成库的话,别反过来「优化」它的结构体定义——那点字节对齐是几十年 Win32 的老规矩,动不得。
5.3 目标窗口是管理员权限,键盘事件全丢
现象:对着普通程序一切正常,对着装机后默认以管理员身份运行的程序,一点反应都没有,SendInput返回值还是正常的。
原因:UIPI(用户界面特权隔离)。低完整性级别的进程不能向高完整性级别的窗口注入输入,系统静默丢弃,不报错。这是安全机制,不是 bug。
解决:让自己的程序也以管理员身份运行,在应用清单里写requestedExecutionLevel level="requireAdministrator"。排查时先打开任务管理器看目标进程有没有「已提升」标记,这一步能省掉一半时间。
注意:不要为了图省事默认全局提权,只在确实需要操作高权限窗口时才提,否则你会给用户凭空造一个安全风险。
顺带说一个同源问题:如果这段代码跑在计划任务或 Windows 服务里,它位于会话 0,压根没有交互式桌面,SendInput注入的事件没有任何窗口能收到。这类需求得换思路,改成在被控端放一个常驻的交互式进程。
5.4 Alt 卡住:菜单莫名其妙高亮
现象:脚本跑完一次,之后点任何窗口标题栏外的菜单都会弹出来,按字母直接触发菜单项,重启程序才好。
原因:KeyDown(VK_MENU)执行了,但后面的KeyUp因为异常、提前return或者条件分支没走到而没执行。系统认为 Alt 一直是按下状态,于是每个后续按键都被当成菜单助记符。
解决:把所有涉及修饰键的代码包进try/finally,收尾时强制释放。
static void ReleaseAllModifiers(InputSimulator sim) { // 通用键码和左右分区键码都要释放,避免漏网 var keys = new[] { VirtualKeyCode.MENU, VirtualKeyCode.LMENU, VirtualKeyCode.RMENU, VirtualKeyCode.CONTROL, VirtualKeyCode.LCONTROL, VirtualKeyCode.RCONTROL, VirtualKeyCode.SHIFT, VirtualKeyCode.LSHIFT, VirtualKeyCode.RSHIFT, VirtualKeyCode.LWIN, VirtualKeyCode.RWIN }; foreach (var k in keys) sim.Keyboard.KeyUp(k); }这个方法我建议挂在所有自动化流程的finally里,成本极低,能省掉一次「用户以为键盘坏了」的售后。
5.5 中文和 emoji 打不出来
现象:TextEntry("你好")输出成问号、乱码,或者干脆什么都没发生。
原因:两种情况。一是走的是虚拟键码通道,非 ASCII 字符在当前键盘布局里翻译不出对应按键;二是 emoji 这类字符在 .NET 里是代理对,占两个char,逐字符发送时被拆成两个孤立的半字符,目标端拼不回来。
解决:确认走的是KEYEVENTF_UNICODE通道(自己拼INPUT时ScanCode填字符本身、Flags加KEYEVENTF_UNICODE)。处理代理对时按char遍历但不要拆分,或者直接用StringInfo按文本元素切分。如果目标程序对 Unicode 注入依然不买账,最稳的办法是绕道剪贴板:把文本塞进剪贴板,发Ctrl+V,再还原剪贴板内容。
var backup = Clipboard.ContainsText() ? Clipboard.GetText() : null; try { Clipboard.SetText("要输入的文本"); sim.Keyboard.ModifiedKeyStroke(VirtualKeyCode.CONTROL, VirtualKeyCode.VK_V); } finally { if (backup != null) Clipboard.SetText(backup); }剪贴板方案还有个副作用要留心:它会覆盖用户当前剪贴板,所以finally里的还原不能省。
6. 二次开发:加一层录制回放,把它变成自己的输入引擎
单次调用解决不了重复劳动,真正有价值的是「录一遍、回放 N 遍」。我的做法是在 Input Simulator 外面套一层薄薄的调度器,三个职责:录制、序列化、定时回放。
录制用SetWindowsHookEx挂WH_KEYBOARD_LL和WH_MOUSE_LL两个低级钩子,回调里只记三样东西:相对起始的毫秒时间戳、事件类型、键码或屏幕坐标。别在钩子回调里做耗时操作,系统对钩子有超时限制,超了会直接把你的钩子摘掉。序列化就用 JSON,一条记录一个对象,回放时按时间差Sleep。
有个技巧能显著提升回放精度:不要用「上一条事件的时间差」做Sleep,那样每条的调度误差会累积,录五分钟的脚本末尾能偏出去好几秒。改用Stopwatch记录回放起点,每次循环算目标时间 - 已耗时,误差不累积。
var sw = Stopwatch.StartNew(); foreach (var ev in script) { var wait = ev.TimeMs - (int)sw.ElapsedMilliseconds; if (wait > 0) Thread.Sleep(wait); Replay(ev, sim); // 内部按类型分派到 KeyDown / MoveMouseTo }扩展点也清晰:想加鼠标中键拖动,就在Replay的类型分派里补一个分支,调MiddleButtonDown/MiddleButtonUp;想加「回放前先把目标窗口拉到前台」,在循环开始前插一段SetForegroundWindow加Sleep。验证方法是固定的:录一段在自测窗体里的输入,回放到同样的控件,逐字符比对结果,再跑十遍看有没有偶发丢事件——有就说明某个Sleep压得太狠了。
从那以后我每次写完带修饰键的自动化脚本,都强制走一遍ReleaseAllModifiers,哪怕逻辑上不可能漏,也不赌这个万一。希望帮到你。
本文还有配套的精品资源,点击获取