
简介这份面向 C# 开发者的 Windows 输入模拟代码包在 Windows Input Simulator 基础上补充了鼠标模拟事件可用于桌面自动化、辅助工具、测试脚本等场景中模拟键盘按键以及鼠标移动、点击等操作。压缩包共 30 个文件约 93KB包含 12 个 cs 源码、4 个 dll 程序集、4 个 pdb 调试符号以及 txt 说明、xml 文档和 csproj 工程文件等目录按源码、二进制输出与工程配置区分。核心代码围绕输入模拟器主体、输入结构、虚拟键码、鼠标与键盘标志等模块组织对键盘事件、鼠标按键与坐标传递进行了封装便于直接集成到项目中或作为二次开发的基础也可据此梳理输入事件从封装到调用的组织方式。已有 1182 人学习适合需要快速实现键鼠事件模拟、了解 Windows 输入注入实现思路的开发者参考。代码遵循 Ms-PL 许可可结合说明文档了解使用方式与授权范围。1. 键鼠模拟并不只是炫技从一次“点不准”的翻车说起去年帮一个做仓储的团队收拾过一个烂脚本老的管理后台没有接口只能靠界面导表他们用屏幕坐标加模拟点击硬怼换到一台 4K 屏的机器上鼠标永远偏十几像素日志里清一色“点了但没反应”。问题不在业务逻辑而在输入模拟这一层没打牢。Windows Input Simulator 就是用来解决这类问题的 C# 库它对 Win32 的SendInput做了一层门面封装对外只暴露IKeyboardSimulator和IMouseSimulator两个接口单键、组合键、整段文本、鼠标移动点击、滚轮滚动都能一行调用完成INPUT结构体、虚拟键码这些底层操作也都封装好了。适合给桌面客户端写自动化脚本、做上位机联动测试、需要批量录入数据的 C# 开发者。下面按“怎么分层、怎么跑起来、核心代码长什么样、哪里会翻车、怎么改成自己的服务”这条线拆。2. 架构拆解门面、模拟器与 SendInput 消息派发这个库的体量很小但分层比大多数人手写的keybd_event调用干净得多。理解这三层后面改代码基本不会迷路。2.1 三层结构谁负责什么最上层是InputSimulator一个纯粹的入口同时实现IInputSimulator暴露Keyboard和Mouse两个属性。中间层是KeyboardSimulator与MouseSimulator负责把“按下 A”“移动鼠标到某点”这类业务语义翻译成INPUT数组。最底层是一个极薄的消息派发器IInputMessageDispatcher默认实现叫WindowsInputMessageDispatcher它干的唯一一件事就是调 P/Invoke 的SendInput并把返回值传回去。这个拆分最有价值的地方是InputSimulator的构造函数接受一个IInputMessageDispatcher参数默认塞进去的是 Windows 实现。也就是说你在单元测试里可以塞一个假派发器收集所有INPUT结构体做断言完全不需要真的去按键。写自动化脚本的人很少用到这个口子但要把它接进 CI 做回归这就是唯一的入口。// 极简还原门面 派发器的依赖注入结构 public class InputSimulator : IInputSimulator { private readonly IInputMessageDispatcher _dispatcher; public InputSimulator() : this(new WindowsInputMessageDispatcher()) { } // 测试时传入假的 dispatcher就能拦截所有 INPUT public InputSimulator(IInputMessageDispatcher dispatcher) { _dispatcher dispatcher; Keyboard new KeyboardSimulator(this); Mouse new MouseSimulator(this); } public IKeyboardSimulator Keyboard { get; } public IMouseSimulator Mouse { get; } // 上层构造好的 INPUT 数组最终都落到这里 internal uint Dispatch(params INPUT[] inputs) _dispatcher.DispatchInput(inputs); }参数说明inputs是变长数组因为一次SendInput可以批量投递多个事件键盘按下和抬起通常成对塞进去中间不插入其他系统事件时序比连续调用两次更稳。返回值是实际插入到输入流的事件条数正常情况等于数组长度。2.2 目录与命名空间地图源码通常分成两个命名空间这个边界要记清楚出问题时能少绕弯命名空间主要内容你是否需要直接引用WindowsInputIInputSimulator、IKeyboardSimulator、IMouseSimulator、KeyboardSimulator、MouseSimulator、扩展方法TextEntry等是业务代码只跟这一层打交道WindowsInput.NativeVirtualKeyCode枚举、INPUT/KEYBDINPUT/MOUSEINPUT结构体、NativeMethods.SendInput、鼠标事件常量是只要用键码就绕不开目录层级一般是这样WindowsInput/下放接口和模拟器实现WindowsInput/Native/下放VirtualKeyCode.cs、INPUT.cs、NativeMethods.cs。如果你打算把源码内嵌进项目后面会讲为什么推荐这么做建议保持这个结构别打散升级和对比 diff 会方便很多。提示VirtualKeyCode是一个几百项的枚举VK_A到VK_Z、VK_0到VK_9、RETURN、TAB、ESCAPE、F1到F24都在里面用Ctrl这类修饰键时注意区分CONTROL、LCONTROL、RCONTROL用错按键能生效但某些程序识别不了。2.3 一次KeyPress的完整调用链拿最常用的sim.Keyboard.KeyPress(VirtualKeyCode.VK_A)举例调用链是KeyboardSimulator.KeyPress→ 内部先KeyDown再KeyUp→ 每次调InputSimulator.SimulateKeyDown/Up→ 组装一个KEYBDINPUTwVk填虚拟键码、dwFlags填 0 或KEYEVENTF_KEYUP→ 包进INPUT→ 交给 dispatcher →SendInput。这里有个细节值得单独说按下和抬起是两次独立的SendInput调用中间没有任何延迟。绝大多数场景没问题但对某些老式 Win32 程序尤其用PeekMessage轮询键盘状态而不是处理消息的两次调用间隔太短会被合并掉表现就是“少按了一个键”。需要的话自己在中间补Thread.Sleep(20)别指望库帮你处理它不做任何节流。3. 跑起来从空目录到一个能自动打字的控制台程序理论讲完先把它跑起来。下面这套步骤我在三台机器上验证过按顺序来基本不会卡住。3.1 环境与工程准备需要一个 Windows 环境SendInput是 Windows APILinux 和 macOS 上跑不了别浪费时间SDK 用 .NET 6 以上的任意版本都行编辑器随意。核心是一个控制台工程加一个包引用# 新建工程 dotnet new console -n InputDemo cd InputDemo # 引入 InputSimulator 包 dotnet add package InputSimulator # 注意这是一个 net35 时代的老包在 net8.0 工程里会报 NU1701 兼容性警告 # 通常能正常工作如果编译期报类型找不到改用 3.3 节的源码内嵌方式 dotnet build包引用加完之后WindowsInput和WindowsInput.Native两个命名空间就可以直接using了。如果dotnet add package提示找不到包检查一下 NuGet 源配置有些公司内网会把公共源镜像掉那种情况下建议直接走源码内嵌不折腾。另外还有一点容易忽略如果你要做鼠标绝对定位工程最好带 Windows 桌面相关的目标框架比如net8.0-windows因为在 3.2 的演示里用到了System.Drawing.Point做坐标参数。纯net8.0也能跑但会多几个警告。Project SdkMicrosoft.NET.Sdk PropertyGroup !-- 带 -windows 后缀才能用 System.Drawing.Point 这类桌面类型 -- OutputTypeExe/OutputType TargetFrameworknet8.0-windows/TargetFramework Nullableenable/Nullable !-- 老包的兼容警告确认能跑就可以压掉 -- NoWarn$(NoWarn);NU1701/NoWarn /PropertyGroup /Project版本号交给dotnet add package自己写进去不要手抄不同源上能拉到的版本不一定一致。3.2 第一个可运行 Demo倒计时后自动输入新手最容易犯的错是程序一启动就发按键结果全打到了控制台或者编辑器里。所以写演示脚本必须留一个“切换窗口”的时间窗我一般给 5 秒倒计时。using System; using System.Drawing; using System.Threading; using WindowsInput; using WindowsInput.Native; class Program { static void Main() { Console.WriteLine(5 秒后开始模拟请把光标聚焦到记事本窗口...); for (int i 5; i 0; i--) { Console.WriteLine(i); Thread.Sleep(1000); } var sim new InputSimulator(); // 1. 单键敲一个 H sim.Keyboard.KeyPress(VirtualKeyCode.VK_H); // 2. 组合键 CtrlA内部会自动按下并释放修饰键 sim.Keyboard.ModifiedKeyStroke(VirtualKeyCode.CONTROL, VirtualKeyCode.VK_A); // 3. 文本输入走 Unicode 通道中文和符号都能进 sim.Keyboard.TextEntry(hello 键盘鼠标模拟 123); // 4. 鼠标绝对移动到屏幕 (600, 400) 再左键单击 sim.Mouse.MoveTo(new Point(600, 400)); sim.Mouse.LeftButtonClick(); // 5. 滚轮正数向上滚负数向下滚单位是“行” sim.Mouse.VerticalScroll(2); Console.WriteLine(模拟结束); } }运行方式就是dotnet run看到倒计时的时候立刻 AltTab 切到记事本。预期结果是记事本里先出现一个H然后被CtrlA全选接着整段中文替换掉选区同时鼠标跳到屏幕坐标 (600, 400) 位置单击一次。几个参数需要说清楚ModifiedKeyStroke的第一个参数是修饰键可以传单个VirtualKeyCode也有接受数组的重载做CtrlShiftS这种三键组合时用数组版本别自己手动KeyDown两遍再KeyUp很容易漏掉释放。VerticalScroll(int lines)里的一“行”是库自己换算的映射到MOUSEEVENTF_WHEEL的mouseData是lines * 120所以传小数没意义传 1 就是一格标准滚轮。3.3 源码引入还是 NuGet 引用这是实际项目里必须做的第一个决策两种方式我都用过说下判断标准。用 NuGet 的优点是干净一个PackageReference搞定缺点是这是个老包目标框架是 .NET Framework 3.5在 .NET Core 系工程里会挂在兼容层上跑某些环境下SendInput的 P/Invoke 签名会因为IntPtr宽度处理不当出问题而且你改不了它。用源码内嵌的优点是全透明INPUT结构体的字段偏移、dwExtraInfo的类型、绝对坐标的归一化算法都能自己审一遍出了问题能直接下断点缺点是要多维护一份代码。我的习惯是只要这玩意儿会进生产环境、会被别人接手就走源码内嵌。做法很简单把WindowsInput整个目录拖进项目然后在 csproj 里显式包含ItemGroup !-- 把库源码直接编进当前程序集避免跨程序集的兼容层问题 -- Compile IncludeWindowsInput\**\*.cs / /ItemGroup注意排除掉原库自带的Properties/AssemblyInfo.cs否则会和 SDK 风格工程的自动生成属性打架报重复的TargetFramework特性。这是我自己唯一遇到的坑删掉那个文件就没事了。4. 核心代码拆解键盘、鼠标、文本三条链路这一章是重点。三条链路各自有各自的细节尤其是文本输入这条很多人第一次写中文模拟都会卡在这。4.1 键盘链路虚拟键码、扫描码与扩展键键盘事件的本质是往系统输入流里塞一个KEYBDINPUT结构。核心字段就三个wVk虚拟键码、wScan扫描码、dwFlags标志位。库默认只填wVkwScan留 0这在绝大多数应用程序里完全够用因为 Windows 会把虚拟键码翻译成字符消息。// 按下与抬起必须成对出现中间不要插入别的键否则组合语义会变 var sim new InputSimulator(); sim.Keyboard.KeyDown(VirtualKeyCode.LWIN); // 按下 Win 键 sim.Keyboard.KeyPress(VirtualKeyCode.VK_R); // 敲 R触发“运行”窗口 sim.Keyboard.KeyUp(VirtualKeyCode.LWIN); // 必须抬起否则 Win 键会一直处于按下状态不填wScan的代价在两类场景下会暴露第一类是游戏很多游戏读的是 DirectInput 的原始扫描码SendInput塞进去的虚拟键码压根不被识别第二类是某些带键盘钩子的安全软件它会检查扫描码是否合法wScan 0会被判定为“软件模拟”从而丢弃。真遇到这种情况得自己补齐扫描码并加KEYEVENTF_SCANCODE标志用MapVirtualKey从虚拟键码反查。还有扩展键的问题。方向键、Insert、Delete、Home、End、PageUp、PageDown、右边的Ctrl和Alt、小键盘的/和Enter这些属于“扩展键”必须带上KEYEVENTF_EXTENDEDKEY标志否则系统会把它们当成小键盘上的对应键。库里的VirtualKeyCode枚举对这些键没有做特殊标记也就是说你用KeyPress(VirtualKeyCode.UP)的时候扩展标志是不带的多数程序能容忍少数不能。遇到方向键失灵的玄学问题先怀疑这里。4.2 鼠标链路绝对坐标归一化与滚轮鼠标的坑比键盘多主要在坐标系上。SendInput的绝对移动不接收像素坐标它要的是把屏幕映射到 0 到 65535 区间的归一化值而且要配合MOUSEEVENTF_ABSOLUTE标志使用。库里的MoveTo帮你做了这层换算但它用的是主屏尺寸多显示器且分辨率不一致的环境下会算错。// 自己的归一化实现以“虚拟屏幕”而非主屏为基准多屏才准 private const int SM_XVIRTUALSCREEN 76; private const int SM_YVIRTUALSCREEN 77; private const int SM_CXVIRTUALSCREEN 78; private const int SM_CYVIRTUALSCREEN 79; [DllImport(user32.dll)] private static extern int GetSystemMetrics(int nIndex); private static (int dx, int dy) ToAbsolute(int x, int y) { int vx GetSystemMetrics(SM_XVIRTUALSCREEN); // 虚拟屏幕左上角 X副屏在主屏左侧时为负 int vy GetSystemMetrics(SM_YVIRTUALSCREEN); int vw GetSystemMetrics(SM_CXVIRTUALSCREEN); // 所有屏幕拼起来的宽 int vh GetSystemMetrics(SM_CYVIRTUALSCREEN); // 65535 而不是 65536因为区间是闭区间 int dx (int)Math.Round((x - vx) * 65535.0 / (vw - 1)); int dy (int)Math.Round((y - vy) * 65535.0 / (vh - 1)); return (dx, dy); }参数说明vw - 1这个减一是必须的否则最右边一列像素会溢出到 65536被系统截断到边界表现是在副屏最右侧点不准。Math.Round用默认的银行家舍入也行误差不超过半个像素。滚轮方向也容易搞反。MOUSEEVENTF_WHEEL的mouseData是带符号的正数向上、负数向下一格是 120这是 WHEEL_DELTA 常量。横向滚轮走MOUSEEVENTF_HWHEEL同样一格 120正数向右。注意横向滚轮在 Windows 上支持得比较晚老系统上不一定有反应这是系统限制不是代码问题。4.3 文本输入链路KEYEVENTF_UNICODE才是正解很多人的第一反应是“遍历字符串每个字符映射到VirtualKeyCode然后KeyPress”。这条路只能处理英文和数字遇到中文、日文、全角符号直接失效因为虚拟键码表里根本没有这些字符——它们得靠输入法生成。正确做法是走KEYEVENTF_UNICODEwVk填 0wScan填字符的 UTF-16 码元dwFlags带上KEYEVENTF_UNICODE这样系统会跳过键盘布局和输入法直接把字符注入到当前焦点窗口的字符消息里。库里的TextEntry走的就是这条路但如果你内嵌的是老版本源码建议自己确认一下实现我见过某个分支里TextEntry还是按字符查键码的老实现。private const uint INPUT_KEYBOARD 1; private const uint KEYEVENTF_KEYUP 0x0002; private const uint KEYEVENTF_UNICODE 0x0004; // 逐字符构造 Unicode 键盘事件输入法开着也不影响 private static void SendUnicodeString(string text) { var inputs new INPUT[text.Length * 2]; for (int i 0; i text.Length; i) { inputs[i * 2] MakeUnicodeInput(text[i], 0); // 按下 inputs[i * 2 1] MakeUnicodeInput(text[i], KEYEVENTF_KEYUP); // 抬起 } // 返回实际插入的事件数小于 inputs.Length 说明被拦截了 uint sent NativeMethods.SendInput((uint)inputs.Length, inputs, Marshal.SizeOf(typeof(INPUT))); if (sent ! inputs.Length) throw new InvalidOperationException($只有 {sent}/{inputs.Length} 个事件被接受检查目标窗口权限); } private static INPUT MakeUnicodeInput(char ch, uint flags) new INPUT { type INPUT_KEYBOARD, U new InputUnion { ki new KEYBDINPUT { wVk 0, wScan ch, dwFlags KEYEVENTF_UNICODE | flags, time 0, dwExtraInfo IntPtr.Zero } } };参数说明wVk必须是 0填了虚拟键码系统会走键盘布局翻译而不是 Unicode 通道。wScan是ushort只能放一个 UTF-16 码元所以 emoji 这种代理对字符会被拆成两次发送大部分现代程序能正确拼回来少数老程序会显示成两个问号这是没法绕过的限制。dwExtraInfo设成 0 表示不携带额外信息如果你要区分自己注入的事件和真实用户输入可以在这里塞一个魔法值钩子里比对。批量的规模也要注意SendInput单次调用能投递的事件数有上限MAX_INPUT_SIZE一般是几十到上百库在TextEntry里会做分片。你要是自己写批量逻辑一千字的文本一次塞进去会失败安全起见按 50 个字符一片切开。4.4 焦点、前台窗口与权限边界模拟输入有一个绕不过去的前提事件是投递给“当前前台窗口”的而不是你指定的窗口。SendInput没有任何参数能指定目标这是 API 设计决定的。所以任何靠谱的自动化脚本都必须先做前台校验用GetForegroundWindow拿到句柄和预期窗口的句柄比对不一致就先SetForegroundWindow再Thread.Sleep(200)等焦点稳定。这 200 毫秒不是凑数窗口从后台切到前台有动画和消息队列排空立刻发按键经常会丢第一批。权限边界更硬。Windows 的 UIPI 机制规定低完整性级别的进程不能向高完整性级别的进程发送输入。翻译成人话就是——你的程序是普通权限目标程序是管理员权限跑的SendInput会静默失败不报错就是没反应。反过来你的程序是管理员目标程序是普通权限则万事大吉。所以自动化脚本的部署建议永远是“以管理员身份运行”尤其是要操作那些自带提权的老客户端时。注意SendInput返回 0 就意味着一个事件都没进去。这时候别急着改代码先Marshal.GetLastWin32Error()看错误码5 是拒绝访问基本可以确认是 UIPI 拦截提权重跑即可。5. 排查手册模拟键鼠在真实环境里的五类翻车现场下面这五条都是我在实际项目里真踩过的每条按“现象、原因、解决”写遇到问题可以当检查表过一遍。5.1 现象高分屏上鼠标永远点偏现象很典型笔记本外接了一块 4K 显示器脚本在笔记本屏幕上点得准拖到外接屏就往左上偏固定比例偏多少跟缩放比例有关150% 缩放偏得比 125% 多。原因是进程默认是 DPI 不感知的DPI Unaware系统会把GetSystemMetrics返回的坐标按缩放比例虚拟化再喂给你。你的代码以为屏幕是 1920 宽实际是 3840归一化一算全错。解决方式是在程序启动最早期声明 DPI 感知越早越好最好在Main的第一行// 必须在创建任何窗口或读取屏幕尺寸之前调用 [DllImport(user32.dll)] private static extern bool SetProcessDPIAware(); static void Main() { SetProcessDPIAware(); // 让进程拿到物理像素 // ... 后续逻辑 }WinForms 工程更省事的做法是在app.manifest里声明PerMonitorV2这样多屏不同缩放也能各自正确。改完之后记得重新核对一遍所有硬编码的坐标之前按虚拟坐标调出来的值全得重来。5.2 现象按键发出去了但目标程序毫无反应现象是SendInput返回值正常事件确实进了系统输入流可是目标窗口里什么都没发生甚至连光标都没动。原因通常是焦点不在你以为的那个窗口上。SendInput投递给前台窗口而“前台窗口”和“你看到的最上层窗口”不是一回事某些程序的弹出面板、托盘图标菜单、甚至是隐藏的辅助窗口会抢走前台身份。另一个常见原因是目标程序用Raw Input或者键盘钩子过滤了注入事件。解决方式是先加校验再发按键并且把校验结果打到日志里[DllImport(user32.dll)] private static extern IntPtr GetForegroundWindow(); [DllImport(user32.dll)] private static extern bool SetForegroundWindow(IntPtr hWnd); // 发按键之前统一走这个守卫 static void EnsureForeground(IntPtr target) { for (int i 0; i 5; i) { if (GetForegroundWindow() target) return; SetForegroundWindow(target); Thread.Sleep(200); // 等焦点切换稳定 } throw new InvalidOperationException(目标窗口抢不到前台可能被系统限制); }如果校验通过还是没反应就在目标程序里装一个简单的键盘钩子做对照确认事件到没到。到了但被过滤说明是程序层的问题SendInput这一层再调也没用得换思路比如走 UI Automation 或者窗口消息。5.3 现象英文能打进去中文变成一串问号现象是TextEntry(abc)正常TextEntry(你好)出来一堆乱码或者干脆空白。原因基本是两个要么用的是逐字符查虚拟键码的老实现要么工程文件编码和源码字符串编码不一致string在编译期就已经损坏了。解决方式是先确认走的是 Unicode 通道4.3 节那套然后检查源文件是不是 UTF-8 带 BOM。我用TextEntry打中文反正是十次有八次是源文件被某个编辑器存成了 GBK 导致的字符损坏剩下两次才是实现问题。定位手段很简单把字符串的每个char的码点打出来Console.WriteLine((int)你)应该是 20320不是这个数就说明代码里的字符串本身就是坏的。还有一层如果目标程序开了输入法且处于中文状态Unicode 通道依然有效因为它是绕过输入法的。但如果你混用了KeyPress和TextEntry输入法的候选框可能弹出来挡住后续操作记得在脚本里先KeyPress(VirtualKeyCode.ESCAPE)关掉候选。5.4 现象程序跑完Ctrl 键一直处于按下状态现象非常直观脚本执行完之后你在任何地方点一下鼠标都变成多选滚动滚轮变成缩放得手动按一下 Ctrl 才恢复。原因是修饰键的按下和抬起没有配对。常见于三种写法抛异常提前退出、KeyDown之后忘记KeyUp、以及用ModifiedKeyStroke的时候传了错误的修饰键参数导致抬起的是另一个键。解决办法是把所有涉及修饰键的操作包进try/finally这是血泪经验别嫌丑var sim new InputSimulator(); sim.Keyboard.KeyDown(VirtualKeyCode.CONTROL); try { sim.Keyboard.KeyPress(VirtualKeyCode.VK_S); // 中间任何可能抛异常的活儿都放这里 } finally { // 无论怎样都要把修饰键放掉 sim.Keyboard.KeyUp(VirtualKeyCode.CONTROL); }更彻底的兜底是在Main外面套一层全局异常处理退出前统一把Ctrl、Shift、Alt、Win四个键各KeyUp一次。多按一次抬起是无害的少按一次就得让用户手动救场。5.5 现象本地跑得好好的一上服务器全失效现象是把脚本扔到一台无人值守的机器上白天手动跑没问题晚上定时任务一跑全是失败日志里SendInput返回 0。原因有三个层面。第一锁屏状态或者 RDP 断开的会话里没有活跃的输入桌面SendInput无处投递。第二定时任务如果配置成“不登录也运行”跑在 Session 0那是一个隔离的会话压根没有桌面。第三UAC 提权弹窗、登录界面这些属于“安全桌面”普通进程的输入事件会被直接丢弃。解决办法没有银弹只能是工程层面规避定时任务必须配置成“用户登录时运行”并保持会话不锁屏操作提权程序时脚本本身也要提权遇到 UAC 弹窗的场景改用服务化的方案或者干脆改成非界面路径去实现。远程桌面断开这种情况我一般是在机器上装一个虚拟显示器或者禁用锁屏策略比在代码里死磕靠谱。6. 二次开发把 InputSimulator 包装成自己的输入服务层直接裸用InputSimulator写业务脚本写到第三个流程就会开始重复。我的做法是在它外面再包一层自己的服务把四个东西收拢进去焦点守卫、坐标转换、事件节流、失败重试。二次开发点原始用法封装后的做法焦点管理手写SetForegroundWindow统一EnsureForeground(hwnd)带重试与超时坐标直接传像素给MoveTo业务层只用像素服务层做虚拟屏幕归一化节流自己在两行之间塞Sleep服务内部固定 30 到 50 毫秒的事件间隔错误处理忽略返回值检查SendInput返回值失败记日志并抛特定异常接口我一般定义得很窄只留业务真正需要的几个动作避免把整个IKeyboardSimulator暴露出去导致到处乱调public interface IInputService { void TypeText(string text); // 打一段文本自动分片 void Hotkey(VirtualKeyCode modifier, VirtualKeyCode key); // 组合键 void ClickAt(int screenX, int screenY); // 像素坐标点击 void Scroll(int lines); // 滚轮 }实现里的关键就是失败重试和前台校验我把它抽成一个私有方法所有动作都走它private void Execute(IntPtr targetWindow, Action action, int retry 3) { for (int i 0; i retry; i) { EnsureForeground(targetWindow); // 每次重试都重新抢焦点 try { action(); Thread.Sleep(50); // 固定节流给目标程序处理时间 return; } catch (InvalidOperationException ex) when (i retry - 1) { _logger.Warn($第 {i 1} 次输入失败重试{ex.Message}); Thread.Sleep(300); // 退避之后再试 } } }验证这套东西别靠肉眼看记事本。我一般会写一个极简的 WinForms 靶子程序界面上放一个多行文本框再加一个“记录收到的按键序列”的钩子。脚本跑完之后比对两边的序列长度和顺序都对上才算过。这个靶子程序顺带解决了回归测试的问题CI 上跑一遍就知道有没有把键盘链路改坏。还有一个我踩过的坑值得提醒不要把InputSimulator的实例做成全局单例到处共享。它本身没什么状态正常情况这么干没错但一旦你开始做批量输入多个业务线程同时往里塞INPUT顺序就乱了组合键的语义会崩。我现在的习惯是每个业务流程创建自己的实例或者干脆在服务层加一把锁把输入串行化——毕竟真实的键盘鼠标本来就是串行的模拟输入也没必要并发。从那以后我每次接新的桌面自动化需求都强制先写那个靶子程序和前台守卫再动业务逻辑宁可多花半天也不想再经历一次深夜对着“点了但没反应”的日志抓头。希望帮到你。本文还有配套的精品资源点击获取