简介:这份资源面向需要在 Windows 平台实现键鼠自动化操作的 C# 开发者,基于开源的 Windows Input Simulator 扩展而来,在原有键盘事件模拟的基础上补充了鼠标模拟事件,可用于自动化测试、桌面辅助工具、批量操作脚本等场景,适合具备一定 C# 基础、希望快速集成输入模拟能力的开发者参考使用。压缩包共 30 个文件,约 93KB,以 cs 源码文件为主,配合 dll 程序集、pdb 调试符号、xml 文档及 csproj 工程文件,涵盖键盘与鼠标输入结构定义、虚拟键码枚举、标志位枚举等核心模块,工程结构完整,可直接编译运行。目前已有 1181 人学习下载。通过阅读源码,读者能够理解 Windows 底层输入事件的封装方式,掌握键盘按键、鼠标移动与点击等模拟逻辑的实现思路,并在此基础上按需扩展自定义输入行为,同时遵循 Microsoft Public License (Ms-PL) 协议,便于在合规前提下用于个人或商业项目。
1. 键盘鼠标模拟的 C# 利器:Windows Input Simulator 能帮你做什么
如果你写过自动化测试、远程控制、游戏辅助或者批量操作工具,大概率绕不开一个需求:让程序代替人去敲键盘、点鼠标。用 Win32 的SendInput、keybd_event、mouse_event当然能做,但 P/Invoke 的签名、结构体对齐、扫描码和虚拟键码的转换,写一次错一次,调试起来全是玄学。Windows Input Simulator 就是来解决这个问题的:它把 Win32 底层输入 API 封装成一套干净的 C# 接口,让你用SimulateKeyPress、SimulateTextEntry、SimulateMouseClick这样的方法就能完成键盘鼠标模拟。这份资源适合做桌面自动化、UI 测试、辅助工具、批量录入的开发者,也适合想搞明白 Windows 输入模拟底层原理的人。下面我按「能干什么 → 怎么跑 → 怎么改」的顺序,把这份源码拆开讲一遍。
2. 项目定位与技术选型:为什么不用原生 SendInput
2.1 它到底封装了什么
Windows 的输入模拟最终都落到user32.dll的SendInput上。这个函数接收一个INPUT结构体数组,每个结构体描述一次键盘或鼠标事件。问题在于INPUT是个联合体(union),在 C# 里要用StructLayout(LayoutKind.Explicit)配合FieldOffset手动布局,键盘事件还要区分KEYBDINPUT、MOUSEINPUT、HARDWAREINPUT三种类型。Windows Input Simulator 把这些全部封装在内部,对外只暴露IInputSimulator接口。
它的核心能力包括:
- 键盘:单键按下/释放、组合键(Ctrl+C 这类)、文本直接输入、扫描码模式
- 鼠标:移动、点击(左/右/中)、滚轮、按下/释放分离
- 扩展:支持传入自定义的键盘/鼠标模拟实现,方便做单元测试
技术栈上,它是纯 C# 类库,目标框架覆盖 .NET Framework 和 .NET Standard,没有第三方依赖,直接引用 DLL 或源码即可。这意味着你可以在 WinForms、WPF、控制台程序、甚至 Unity 的桌面构建里用它。
2.2 为什么值得用而不是自己写
自己写 P/Invoke 的坑在于:INPUT结构体在不同位数进程下大小不同(32 位是 28 字节,64 位是 40 字节),SendInput的cbSize参数传错就直接静默失败,返回 0 你都不知道为什么。另外键盘事件里wVk(虚拟键码)和wScan(扫描码)的取舍、KEYEVENTF_UNICODE标志的使用,都是血泪经验堆出来的。这个库帮你把这些细节都处理好了,还提供了KeyCode枚举,把VK_开头的常量变成可读的枚举值。
提示:如果你的目标进程以管理员权限运行,而你的模拟程序不是,
SendInput会失败。这是 UIPI(用户界面特权隔离)机制,不是库的问题。
3. 快速上手:从引用到跑通第一个模拟
3.1 获取与引用
这份资源是源码包,拿到后有两种用法。第一种是直接把WindowsInput项目加入你的解决方案,添加项目引用;第二种是编译成 DLL 后引用。用 .NET CLI 的话,在解决方案目录下执行:
# 进入源码目录,编译类库 dotnet build WindowsInput/WindowsInput.csproj -c Release # 编译产物在 bin/Release 下,按目标框架分目录编译完成后,在你的项目里添加引用。如果是 .NET Core / .NET 5+ 项目,直接引用 csproj 更省事:
<!-- 在你的 csproj 里添加项目引用 --> <ItemGroup> <ProjectReference Include="..\WindowsInput\WindowsInput.csproj" /> </ItemGroup>参数说明:-c Release指定发布配置,产物会做优化;如果你要兼容老项目,注意看WindowsInput.csproj里的TargetFramework,常见做法是同时输出net45和netstandard2.0,前者给 .NET Framework,后者给 .NET Core 系。
3.2 第一个可运行示例
引用好之后,写一个控制台程序,模拟按下 Win+R 打开运行框,输入 notepad 并回车:
using WindowsInput; using WindowsInput.Native; class Program { static void Main() { // 创建输入模拟器实例,这是整个库的入口 var sim = new InputSimulator(); // 按下 Win 键 sim.Keyboard.KeyDown(VirtualKeyCode.LWIN); // 按下 R 键,此时 Win 还按着,形成 Win+R sim.Keyboard.KeyPress(VirtualKeyCode.VK_R); // 释放 Win 键 sim.Keyboard.KeyUp(VirtualKeyCode.LWIN); // 等待运行框弹出,实际项目里建议用更稳的等待方式 System.Threading.Thread.Sleep(500); // 直接输入文本,内部会处理字符到按键的映射 sim.Keyboard.TextEntry("notepad"); // 回车确认 sim.Keyboard.KeyPress(VirtualKeyCode.RETURN); } }逻辑说明:KeyDown和KeyUp是分离的按下与释放,适合组合键场景;KeyPress是按下加释放的原子操作;TextEntry接收字符串,内部逐字符处理,遇到大写字母或符号会自动配合 Shift。参数上,VirtualKeyCode枚举覆盖了绝大多数键,LWIN是左 Win 键,VK_R是字母 R,RETURN是回车。
3.3 鼠标模拟示例
鼠标操作同样直观,下面这段模拟移动鼠标到屏幕坐标 (500, 300) 并左键单击:
using WindowsInput; class MouseDemo { static void Main() { var sim = new InputSimulator(); // 移动到绝对坐标,参数是像素值 sim.Mouse.MoveMouseTo(500, 300); // 左键单击(按下+释放) sim.Mouse.LeftButtonClick(); // 滚轮向下滚动,参数是滚动量,正数向上负数向下 sim.Mouse.Scroll(-120); } }逻辑说明:MoveMouseTo内部会把像素坐标转换成SendInput要求的 0~65535 归一化坐标,这一步如果自己写很容易算错。Scroll的 120 是 Windows 标准滚轮步进单位,传 -120 就是向下滚一格。注意MoveMouseTo是绝对移动,如果你要做相对移动,用MoveMouseBy。
4. 核心模块拆解:一个按键事件从调用到落地的完整链路
4.1 接口层与实现层的分离
这个库的架构是典型的「接口 + 实现」分层。顶层是IInputSimulator,它聚合了IKeyboardSimulator和IMouseSimulator。默认实现是InputSimulator,内部持有IInputDeviceStateAdaptor(用于查询按键状态)和IKeyboardSimulator、IMouseSimulator的具体实例。
这种设计的好处是:你可以替换掉任意一层。比如写单元测试时,注入一个假的IKeyboardSimulator,断言「是否调用了 KeyPress」,而不真的去敲键盘。常见做法是配合依赖注入容器,把IInputSimulator注册为单例。
// 自定义一个用于测试的键盘模拟器,只记录调用不真正发送 public class FakeKeyboardSimulator : IKeyboardSimulator { public List<VirtualKeyCode> PressedKeys { get; } = new(); public IKeyboardSimulator KeyPress(VirtualKeyCode keyCode) { PressedKeys.Add(keyCode); // 只记录,不调用 SendInput return this; } // 其余接口方法省略,按需实现 public IKeyboardSimulator KeyDown(VirtualKeyCode keyCode) => this; public IKeyboardSimulator KeyUp(VirtualKeyCode keyCode) => this; public IKeyboardSimulator TextEntry(string text) => this; // ... }参数说明:IKeyboardSimulator的方法大多返回自身,这是流式调用的设计,可以链式写sim.Keyboard.KeyDown(A).KeyPress(B).KeyUp(A)。实现自定义模拟器时,只要保证返回this就能保持链式风格。
4.2 键盘模拟的内部实现
键盘模拟的核心在KeyboardSimulator类。以TextEntry为例,它不是简单地把字符串拆成字符逐个发送,而是要处理大小写和特殊符号。内部逻辑大致是:遍历每个字符,判断是否需要 Shift,然后发送对应的虚拟键码。
// 简化后的 TextEntry 核心逻辑,帮助理解实现思路 public IKeyboardSimulator TextEntry(string text) { foreach (char c in text) { // 判断字符是否需要配合 Shift 键 bool needShift = char.IsUpper(c) || IsShiftRequiredSymbol(c); VirtualKeyCode key = CharToVirtualKey(c); if (needShift) { // 先按下 Shift,再按键,最后释放 Shift _keyboard.KeyDown(VirtualKeyCode.SHIFT); _keyboard.KeyPress(key); _keyboard.KeyUp(VirtualKeyCode.SHIFT); } else { _keyboard.KeyPress(key); } } return this; }逻辑说明:CharToVirtualKey负责把字符映射到虚拟键码,比如 'a' 映射到VK_A,'1' 映射到VK_1。IsShiftRequiredSymbol判断像!@#$%这类需要 Shift 的符号。这里有个细节:对于中文等非 ASCII 字符,虚拟键码方式行不通,需要用KEYEVENTF_UNICODE标志直接发送 Unicode 扫描码,库内部对这种情况有单独分支处理。
4.3 鼠标模拟的坐标转换
鼠标模拟里最容易翻车的是坐标。SendInput的MOUSEINPUT结构里,如果带MOUSEEVENTF_ABSOLUTE标志,dx和dy是 0~65535 的归一化值,映射到整个虚拟屏幕。库内部的转换逻辑是:
// 像素坐标转归一化坐标的核心计算 private static int ConvertToNormalized(double pixel, double screenSize) { // 65535 是 SendInput 要求的最大值,加 0.5 做四舍五入 return (int)(pixel * 65535 / screenSize + 0.5); } // 调用时传入屏幕宽高 int normalizedX = ConvertToNormalized(x, screenWidth); int normalizedY = ConvertToNormalized(y, screenHeight);参数说明:screenSize要用SystemParameters.PrimaryScreenWidth或虚拟屏幕尺寸,多显示器场景下要用SystemInformation.VirtualScreen的宽高,否则坐标会偏。这是多屏用户最常踩的坑。
4.4 扫描码模式与虚拟键码模式的区别
库支持两种键盘事件发送方式:虚拟键码(wVk)和扫描码(wScan)。默认用虚拟键码,兼容性好,大多数场景够用。但有些游戏或特殊程序只认扫描码,这时需要走扫描码模式。区别在于KEYBDINPUT结构里填哪个字段,以及是否设置KEYEVENTF_SCANCODE标志。
| 模式 | 填充字段 | 标志位 | 适用场景 |
|---|---|---|---|
| 虚拟键码 | wVk | 无 | 普通桌面程序、输入框 |
| 扫描码 | wScan | KEYEVENTF_SCANCODE | 部分游戏、DirectInput 程序 |
| Unicode | wScan | KEYEVENTF_UNICODE | 中文、emoji 等非 ASCII 字符 |
注意:扫描码模式需要先用
MapVirtualKey把虚拟键码转成扫描码,库内部封装了这个转换,但如果你自己扩展,别忘了这一步。
5. 避坑与常见问题排查
5.1 模拟没反应,程序像没执行一样
现象:代码跑完没有任何报错,但目标窗口没有任何输入。原因通常是权限不对等——目标进程以管理员身份运行,而你的程序是普通权限,UIPI 会拦截SendInput。解决:把你的程序也以管理员身份运行,或者在项目清单里加上requireAdministrator。另一个可能是目标窗口没有焦点,SendInput是发给当前前台窗口的,先调用SetForegroundWindow把目标窗口激活。
5.2 中文输入变成乱码或没反应
现象:TextEntry("你好")执行后,输入框里什么都没有或者出现奇怪字符。原因:虚拟键码模式无法表达中文字符,必须走 Unicode 模式。解决:确认库版本支持 Unicode 分支,或者手动构造带KEYEVENTF_UNICODE的INPUT结构。另外,如果目标程序用的是 IME 输入法,直接发 Unicode 可能被输入法拦截,这种情况建议先切换到英文输入法状态。
5.3 多显示器下鼠标点偏了
现象:双屏环境下,MoveMouseTo(100, 100)点到了副屏而不是主屏。原因:坐标归一化时用了主屏尺寸,但SendInput的绝对坐标是相对整个虚拟屏幕的。解决:用SystemInformation.VirtualScreen获取虚拟屏幕的宽高和偏移,计算时把偏移量算进去。如果只是想在主屏操作,确保传入的坐标在主屏范围内。
5.4 组合键偶尔失效
现象:Ctrl+C十次里有一两次不生效。原因:KeyDown(Ctrl)和KeyPress(C)之间如果间隔太短,某些程序来不及响应;或者KeyUp(Ctrl)发得太快,目标程序还没处理完 C。解决:在关键操作之间加短延时,一般 10~50ms 就够。库本身不提供延时,需要自己用Thread.Sleep或异步等待。血泪经验是别用 0 延时,稳定性和速度要权衡。
5.5 编译报错找不到 VirtualKeyCode
现象:引用了库但VirtualKeyCode枚举用不了。原因:VirtualKeyCode在WindowsInput.Native命名空间下,不在根命名空间。解决:文件顶部加using WindowsInput.Native;。如果还是找不到,检查引用的 DLL 版本和源码版本是否一致,老版本可能枚举名不同。
6. 二次开发与改造:从能跑到好用
6.1 封装一层自己的输入服务
直接到处new InputSimulator()不是好习惯,我一般会封装一个IInputService,把常用操作组合成业务方法。比如「点击某个按钮」这种操作,内部包含移动、等待、点击三步,暴露成一个方法给上层调用。这样做的另一个好处是方便加日志和重试。
public interface IInputService { void ClickAt(int x, int y, int delayMs = 50); void TypeText(string text, int charDelayMs = 20); } public class InputService : IInputService { private readonly IInputSimulator _sim; public InputService(IInputSimulator sim) => _sim = sim; public void ClickAt(int x, int y, int delayMs = 50) { _sim.Mouse.MoveMouseTo(x, y); Thread.Sleep(delayMs); // 给目标程序反应时间 _sim.Mouse.LeftButtonClick(); } public void TypeText(string text, int charDelayMs = 20) { foreach (char c in text) { _sim.Keyboard.TextEntry(c.ToString()); Thread.Sleep(charDelayMs); // 逐字符延时,模拟真人输入 } } }参数说明:delayMs是操作间隔,默认 50ms 适合大多数桌面程序;charDelayMs是逐字符延时,20ms 在稳定性和速度之间比较平衡。如果目标程序响应慢,可以调大。
6.2 加入重试与异常兜底
SendInput失败时返回 0,但库不一定抛异常。稳妥的做法是在关键操作后检查返回值,或者用「操作后验证」的方式——比如点击按钮后检查窗口标题是否变化。我习惯给每个关键操作包一层重试:
public static bool Retry(Action action, int maxRetry = 3, int intervalMs = 200) { for (int i = 0; i < maxRetry; i++) { try { action(); return true; } catch { Thread.Sleep(intervalMs); } } return false; }逻辑说明:maxRetry控制最大重试次数,intervalMs是重试间隔。这个模式适合点击、输入这类幂等操作,不适合「切换开关」这种有状态的操作,否则会重复切换。
6.3 扩展扫描码模式支持
如果你的目标程序只认扫描码,需要扩展库的键盘模拟部分。核心是构造KEYBDINPUT时填wScan而不是wVk,并设置KEYEVENTF_SCANCODE。改造思路是继承或替换KeyboardSimulator,重写发送逻辑。验证方法是拿一个 DirectInput 的游戏或工具做测试,看按键是否被识别。
| 改造点 | 原实现 | 改造后 | 验证方式 |
|---|---|---|---|
| 键码字段 | wVk | wScan | 目标程序能收到按键 |
| 标志位 | 无 | KEYEVENTF_SCANCODE | 用 Spy++ 看消息 |
| 转换 | 不需要 | MapVirtualKey | 对比扫描码表 |
6.4 一个我踩过的坑
早期我图省事,在循环里连续调用TextEntry输入几百个字符,中间不加任何延时,结果目标程序丢字符,十次里错两三次。排查了半天以为是库的问题,后来才想明白是目标程序的消息队列处理不过来。从那以后我每次做批量输入,都强制走一遍「逐字符 + 延时」的路径,宁可慢一点也要稳。希望这个经验能帮你少走弯路。
本文还有配套的精品资源,点击获取