做 Windows 桌面自动化的时候,"把鼠标操作录下来再重放一遍"这个需求,会隔三差五找上门。我做测试工具时接到过好几个类似case:比如让脚本反复演示某个操作流程,比如把一段复杂的操作录制成回放脚本,再比如给客户演示时让界面按照固定轨迹自动跑。老实说,最开始的方案都是拿按键精灵、AutoHotkey凑合,但到了 C# 项目里,这套东西总归不是原生方案,嵌入、定制、数据序列化都很别扭。后来我干脆用 C# 自己写了一套鼠标轨迹录制与回放工具,把钩子监听、事件记录、SendInput 回放这几个核心环节全部打通。这篇文章就把完整实现思路和源码细节分享出来,项目不大,但踩坑不少,适合正在做桌面自动化、UI回归测试、软件演示录制的朋友参考。
1. 需求拆解与方案选型
1.1 这个工具到底要解决什么问题
先聊聊需求本身。很多人一听到"鼠标轨迹录制回放",第一反应是"这不就是个连点器吗"。其实两者差别很大。连点器是固定位置、固定间隔地反复点鼠标,不关心屏幕内容;而轨迹录制回放要解决的是"把操作员在界面上的一整段操作流程(包含移动、点击、滚轮、停顿)真实还原出来"。
举个例子:一个客户来访登记系统,录入一条新客户信息需要经历“选择客户类型 -> 切换日期控件 -> 输入名称 -> 下拉选择渠道 -> 点击保存”这么一串操作。人工演示一遍没问题,但要让测试脚本反复走这条路,每次都要重新写一堆定位逻辑,成本很高。如果能把手工操作录制成轨迹文件,回放时按键、坐标、时间间隔都一模一样,这在演示、回归测试、培训场景下就非常实用。
我做的这个小工具定位是“通用型”而不是“垂直型”:不确定用户会在什么软件上录制,所以不依赖具体窗口、控件或图像识别,只做最底层的鼠标行为捕获和还原。架构上它天然适配任意桌面程序。
1.2 为什么选 C# 而不是其他方案
选型这件事我认真纠结过。当时备选方案有 AutoHotkey、Python pyautogui、C++ 原生 Hook、C# + WinForms 四种,最后选了 C#。
- AutoHotkey 做这种工具确实快,但脚本语言在数据管理上很弱,录制出来的轨迹想存 SQLite、转 JSON、接测试框架,都很费劲。
- Python pyautogui 优点是上手快,但关键问题在于 pyautogui 的回放是“模拟坐标”,没有系统级输入注入,部分高权限窗口或游戏窗口会直接忽略它。
- C++ 原生 Hook 性能最好,但 UI 界面、异常处理、文件序列化这些开发效率太低,做个小工具不至于动用重武器。
- C# 有原生 WinForms/WPF 支持,还能通过 P/Invoke 直接调用 user32.dll 的底层 API,既能做界面,又能拿到系统级鼠标钩子,加上 .NET 的 JSON 序列化和线程模型都成熟,整个项目可以在一个晚上跑通。
1.3 录制方案的取舍:轮询、全局钩子还是 Raw Input
录制的核心是“想办法拿到鼠标的坐标和按键事件”。我梳理了三种方案:
| 方案 | 原理 | 精度 | 点击事件捕获 | 实现复杂度 |
|---|---|---|---|---|
| 定时轮询 | Timer + GetCursorPos | 中(受采样频率影响) | 无法直接捕获 | 低 |
| 全局低级鼠标钩子 | SetWindowsHookEx(WH_MOUSE_LL) | 高(事件驱动) | 可以完整捕获 | 中 |
| Raw Input | RegisterRawInputDevices | 高 | 可以完整捕获 | 高 |
定时轮询最简单,但最大的缺陷是它只能拿到“位置”,拿不到“按下/抬起”事件。而且就算位置也多多少少有丢帧问题——高频移动时,每两次采样之间的路径是断的。Raw Input 是最底层的方案,通过 WM_INPUT 接收原始输入数据,精度极高,但注册方式和消息解析繁琐,杀鸡用牛刀。
我最终选择的是全局低级鼠标钩子 WH_MOUSE_LL。这个方案不需要注入 DLL 到目标进程,只在自己的进程里设置一个回调,Windows 会把鼠标事件以消息形式传回来,精度足够,实现也直观。它唯一的缺点是必须有一个消息循环在跑——但 WinForms 主窗体天然满足这个条件。
2. 录制的核心原理与实现
2.1 低级鼠标钩子的工作机制
WH_MOUSE_LL 是系统级鼠标钩子,它允许应用程序安装一个回调函数,监听整个桌面所有进程的鼠标输入事件。钩子回调由操作系统调用,在回调里我们能拿到鼠标事件的详细结构体 MSLLHOOKSTRUCT,里面包含鼠标的屏幕坐标、事件类型(移动、按键、滚轮)、附加信息、时间戳。
代码层面核心就三步:安装钩子、处理回调、卸载钩子。DllImport 导入 user32.dll 中的三个函数即可。
[DllImport("user32.dll", SetLastError = true)] private static extern IntPtr SetWindowsHookEx(int idHook, HookProc lpfn, IntPtr hMod, uint dwThreadId); [DllImport("user32.dll", SetLastError = true)] private static extern bool UnhookWindowsHookEx(IntPtr hhk); [DllImport("user32.dll")] private static extern IntPtr CallNextHookEx(IntPtr hhk, int nCode, IntPtr wParam, IntPtr lParam);idHook 传入 WH_MOUSE_LL(值为14),dwThreadId 传 0 表示监听全局。这里有个关键的坑:lpfn 委托必须保存到类字段,防止被垃圾回收器回收。我刚写第一版时把委托直接内联传递,运行几分钟后钩子就静默失效,排查了很久才发现是委托被 GC 回收了。
2.2 录制事件的数据结构设计
录制不是简单地存坐标,而是要把“坐标 + 操作类型 + 相对时间”完整地结构化保存。我设计了这样一个事件模型:
public enum MouseEventType { Move, // 鼠标移动 LeftDown, // 左键按下 LeftUp, // 左键抬起 RightDown, // 右键按下 RightUp, // 右键抬起 Wheel, // 滚轮滚动 } public class MouseEventInfo { public int X { get; set; } // 屏幕坐标 X public int Y { get; set; } // 屏幕坐标 Y public MouseEventType EventType { get; set; } public int Delta { get; set; } // 滚轮delta值 public int Timestamp { get; set; } // 相对起始时间的毫秒数 }Timestamp 我用的是相对时间而不是绝对时间,这样回放时可以自由控制速度(1倍速、0.5倍速、2倍速),也可以做成循环播放。时间基准在点击“开始录制”时用 Environment.TickCount 锁定,后面每个事件的时间戳都是“当前Tick - 起始Tick”,干净利落。
2.3 录制器完整实现
录制器核心代码大致如下。要注意,在钩子回调里不要做耗时操作,比如写文件、序列化、UI 刷新,否则会拖慢系统鼠标响应速度,建议先把事件放进 ConCurrentQueue,再丢给后台线程批量落盘。
public class MouseRecorder : IDisposable { private const int WH_MOUSE_LL = 14; private const int WM_LBUTTONDOWN = 0x0201; private const int WM_LBUTTONUP = 0x0202; private const int WM_RBUTTONDOWN = 0x0204; private const int WM_RBUTTONUP = 0x0205; private const int WM_MOUSEMOVE = 0x0200; private const int WM_MOUSEWHEEL = 0x020A; private delegate IntPtr HookProc(int nCode, IntPtr wParam, IntPtr lParam); private HookProc _hookProc; // 必须持有引用,防止GC回收 private IntPtr _hookId = IntPtr.Zero; private int _startTick; private List<MouseEventInfo> _events; private readonly object _lockObj = new object(); public event Action<MouseEventInfo> EventRecorded; public void Start() { _events = new List<MouseEventInfo>(); _startTick = Environment.TickCount; _hookProc = HookCallback; using (var curProcess = Process.GetCurrentProcess()) using (var curModule = curProcess.MainModule) { _hookId = SetWindowsHookEx(WH_MOUSE_LL, _hookProc, GetModuleHandle(curModule.ModuleName), 0); } if (_hookId == IntPtr.Zero) throw new Win32Exception(Marshal.GetLastWin32Error()); } private IntPtr HookCallback(int nCode, IntPtr wParam, IntPtr lParam) { if (nCode >= 0) { var hookStruct = Marshal.PtrToStructure<MSLLHOOKSTRUCT>(lParam); var msg = wParam.ToInt32(); var evtType = GetEventType(msg); if (evtType.HasValue) { var evt = new MouseEventInfo { X = hookStruct.pt.x, Y = hookStruct.pt.y, EventType = evtType.Value, Delta = (short)((hookStruct.mouseData >> 16) & 0xFFFF), Timestamp = Environment.TickCount - _startTick }; lock (_lockObj) { _events.Add(evt); } EventRecorded?.Invoke(evt); } } return CallNextHookEx(_hookId, nCode, wParam, lParam); } private MouseEventType? GetEventType(int msg) { switch (msg) { case WM_MOUSEMOVE: return MouseEventType.Move; case WM_LBUTTONDOWN: return MouseEventType.LeftDown; case WM_LBUTTONUP: return MouseEventType.LeftUp; case WM_RBUTTONDOWN: return MouseEventType.RightDown; case WM_RBUTTONUP: return MouseEventType.RightUp; case WM_MOUSEWHEEL: return MouseEventType.Wheel; default: return null; } } public List<MouseEventInfo> Stop() { if (_hookId != IntPtr.Zero) { UnhookWindowsHookEx(_hookId); _hookId = IntPtr.Zero; } lock (_lockObj) { return _events.ToList(); } } public void Dispose() => Stop(); }MSLLHOOKSTRUCT 结构体需要自己声明,它包含 POINT 坐标、mouseData 滚轮信息、flags、time 等字段。滚轮数据的解析比较隐晦:mouseData 的高位是滚轮增量,需要右移16位再取低位。
3. 回放引擎的设计与实现
3.1 SendInput 为什么比 SetCursorPos 可靠
回放端最基本的操作是“把鼠标移到某个坐标再点击”。常见的做法有两种:SetCursorPos + mouse_event,或者 SendInput。我最终选择了 SendInput。
SetCursorPos 是直接设置光标位置,mouse_event 是模拟按键,这套组合很多老工具都在用,但问题是 mouse_event 已经被标记为过时,它在 UIPI(用户界面特权隔离)层面有时会被高权限窗口拦截。SendInput 会把输入事件注入到系统的输入流中,行为和真实硬件设备更接近,兼容性更好。
SendInput 的 P/Invoke 声明稍复杂,因为它要接收一个 INPUT 结构体数组,而鼠标和键盘事件的内部结构是联合体。标准声明如下:
[StructLayout(LayoutKind.Sequential)] public struct INPUT { public uint type; // 0 表示鼠标事件 public InputUnion U; } [StructLayout(LayoutKind.Explicit)] public struct InputUnion { [FieldOffset(0)] public MOUSEINPUT mi; [FieldOffset(0)] public KEYBDINPUT ki; [FieldOffset(0)] public HARDWAREINPUT hi; } [StructLayout(LayoutKind.Sequential)] public struct MOUSEINPUT { public int dx; public int dy; public int mouseData; public uint dwFlags; public uint time; public IntPtr dwExtraInfo; }回放鼠标移动时,dwFlags 传 MOUSEEVENTF_MOVE(0x0001),坐标用绝对定位,所以同时要加上 MOUSEEVENTF_ABSOLUTE(0x8000)。这里有个大坑:绝对定位时 dx、dy 的范围不是屏幕像素,而是 0 到 65535 的虚拟坐标。如果直接把鼠标的屏幕坐标塞进去,回放会跑偏到屏幕左上角。需要换算:
// 将屏幕坐标映射为 SendInput 的绝对坐标 int absoluteX = (int)(screenX * 65535.0 / (Screen.PrimaryScreen.Bounds.Width - 1)); int absoluteY = (int)(screenY * 65535.0 / (Screen.PrimaryScreen.Bounds.Height - 1));3.2 时间间隔控制:精确重放的关键
录制时记录的是每个事件距起始点的时间戳,回放时就要精确再现这个间隔。用 Thread.Sleep 做简单延时其实并不可靠——Sleep 的精度有十几毫秒的误差,多次累积后轨迹会越来越飘,和原录制的节奏明显脱节。
我用的方案是“主循环 + 精确校准”:回放线程记录一个 Stopwatch,每轮循环计算当前应该执行哪个事件,如果还没到时间就 Sleep 一个短时间,到了就立即执行。Stopwatch 底层走的是 QueryPerformanceCounter,精度能达到微秒级,完全满足鼠标回放需求。
public async Task PlayAsync(List<MouseEventInfo> events, float speed, CancellationToken token) { var sw = Stopwatch.StartNew(); var firstTime = events[0].Timestamp; int index = 0; while (index < events.Count) { token.ThrowIfCancellationRequested(); long targetTime = (long)((events[index].Timestamp - firstTime) / speed); long elapsed = sw.ElapsedMilliseconds; if (elapsed < targetTime) { int waitMs = (int)(targetTime - elapsed); // 避免频繁空转,剩余时间较多时适当 Sleep await Task.Delay(Math.Min(waitMs, 5), token); continue; } ExecuteEvent(events[index]); index++; } }速度参数 speed 大于1表示加速,小于1表示减速。除以 speed 后,时间轴被压缩或拉长,播放速度就变了。
3.3 移动轨迹的平滑处理
直接逐点回放录制的 Move 事件,理论上能还原轨迹,但有个现实问题:钩子回调在高频鼠标移动时,每秒会产生几百甚至上千个 Move 事件,全部回放会导致 SendInput 调用过于频繁,系统鼠标反而会一卡一卡的。
我的做法是回放时做一次“轨迹抽稀”:相邻两个 Move 事件如果间距小于 2 像素就直接合并,大于 2 像素就保留;点与点之间用 SendInput 快速推进时,如果间距较大,就做简单的线性插值,分多步移动过去。这样既保证了轨迹形态,又大幅减少了 SendInput 调用次数。
回放移动事件的完整代码:
private void ExecuteMove(int targetX, int targetY) { var currentX = Cursor.Position.X; var currentY = Cursor.Position.Y; int dx = targetX - currentX; int dy = targetY - currentY; double distance = Math.Sqrt(dx * dx + dy * dy); // 距离小于阈值就直跳,大于阈值就分步移动 if (distance < 5) { SendMouseMove(targetX, targetY); } else { int steps = Math.Max(1, (int)(distance / 5)); for (int i = 1; i <= steps; i++) { int x = currentX + (int)(dx * i / (double)steps); int y = currentY + (int)(dy * i / (double)steps); SendMouseMove(x, y); Thread.Sleep(1); } } }分步移动的步长控制在 5 像素左右,每步间隔 1 毫秒,实际效果已经非常接近人手移动的轨迹感。要更细腻的贝塞尔曲线肯定可以做,但对一个自动化工具来说没必要——快速稳定才是第一诉求。
3.4 点击与滚轮的回放
点击和滚轮的处理按事件类型分发。左键按下和抬起要严格先 Down 后 Up,中间可以加一个极短的延时,否则某些控件收不到完整的点击序列。
private void ExecuteEvent(MouseEventInfo evt) { switch (evt.EventType) { case MouseEventType.Move: ExecuteMove(evt.X, evt.Y); break; case MouseEventType.LeftDown: SendMouseButton(0x0002, 0); // MOUSEEVENTF_LEFTDOWN break; case MouseEventType.LeftUp: SendMouseButton(0x0004, 0); // MOUSEEVENTF_LEFTUP break; case MouseEventType.RightDown: SendMouseButton(0x0008, 0); // MOUSEEVENTF_RIGHTDOWN break; case MouseEventType.RightUp: SendMouseButton(0x0010, 0); // MOUSEEVENTF_RIGHTUP break; case MouseEventType.Wheel: SendMouseWheel(evt.Delta); break; } }鼠标滚轮的 Delta 通常为 120 的正负倍数,代表向上或向下滚动三行或更多行。回放时把录制的 Delta 值原样传给 mouseData 参数即可。
4. 完整项目实战:从零构建桌面工具
4.1 项目结构与界面布局
整个项目我用的是 .NET Framework 4.8 + WinForms,单窗口就能满足需求。项目结构如下:
MouseRecorder/ ├── Model/ │ └── MouseEventInfo.cs // 事件模型 ├── Core/ │ ├── MouseRecorder.cs // 录制器 │ ├── MousePlayer.cs // 回放器 │ └── NativeMethods.cs // P/Invoke 声明 ├── MainForm.cs // 主界面 └── Program.cs // 程序入口主界面我设计得非常朴素:上面一排按钮(开始录制、停止录制、开始回放、停止回放、保存轨迹、加载轨迹),中间一个 ListView 实时显示录制到的鼠标事件(类型、坐标、时间),底部一个轨迹速度滑块和循环回放复选框。如果需要监听热键,可以再挂一个 RegisterHotKey。
4.2 录制到文件的序列化设计
录制结束后数据保存在内存的 List 中,点击“保存轨迹”时序列化成 JSON 文件。选择 JSON 而不是二进制或 XML 的原因有三个:可读性强、能和测试脚本无缝对接、C# 里 Newtonsoft.Json 或 System.Text.Json 都内置了成熟支持。
private void BtnSave_Click(object sender, EventArgs e) { if (_recordedEvents == null || _recordedEvents.Count == 0) return; using (var dialog = new SaveFileDialog { Filter = "轨迹文件|*.json" }) { if (dialog.ShowDialog() == DialogResult.OK) { string json = JsonConvert.SerializeObject(_recordedEvents, Formatting.Indented); File.WriteAllText(dialog.FileName, json); } } }加载轨迹反过来,反序列化成 List 后交给回放器即可。这里有个细节:加载后的轨迹文件我建议复制一份到内存,而不是持有文件引用,这样回放时即使文件被占用也不影响。
4.3 回放线程与 UI 刷新
回放过程不能卡死主窗体,所以 PlayAsync 做成异步方法,后台执行事件循环,通过回调或者事件把当前回放进度抛回 UI 线程。
WinForms 的 UI 刷新要注意跨线程问题。我用的方式是在 MainForm 里订阅回放进度事件,事件回调里用 BeginInvoke 更新 UI 控件:
_player.ProgressChanged += (pos, total) => { if (InvokeRequired) { BeginInvoke(new Action(() => { progressBar.Maximum = total; progressBar.Value = pos; })); } };有一个隐藏的线程安全问题:回放事件循环里调用了 SendInput,而 SendInput 本身是同步阻塞的吗?实测下来 SendInput 会等待输入系统处理完才返回,所以不用担心事件乱序。但如果速度过快,比如循环回放且每次循环间隔很短,可能造成系统输入队列积压,稳妥做法是在循环回放之间加一个 200 毫秒左右的间隔。
4.4 完整的程序入口与全局初始化
Program.cs 里除了标准启动逻辑,还需要增加一个 DPI 感知声明。不声明 DPI 感知的话,在显示缩放不为 100% 的机器上,系统会把窗口虚拟化,导致 GetCursorPos 拿到的坐标和 SendInput 使用的坐标不在同一个坐标系,回放出来的位置会整体偏移。
[STAThread] static void Main() { SetProcessDPIAware(); // 关键:让进程感知 DPI 缩放 Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); Application.Run(new MainForm()); } [DllImport("user32.dll")] private static extern bool SetProcessDPIAware();把 SetProcessDPIAware 放在 Application.Run 之前调用,确保整个进程从启动就以真实的物理像素工作。经过这行处理后,坐标录制和回放就统一了。
5. 常见问题与排查技巧实录
5.1 钩子安装失败或回调无效
这是最高频的问题,新手经常遇到。钩子装不上,通常有三个原因:
- 进程位数不匹配:如果你编译的是 AnyCPU 但系统是 64 位,而目标进程是 32 位,钩子行为会不稳定。建议编译目标直接固定为 x64(或 x86 视环境而定),强制对齐位数。
- 委托被 GC 回收:钩子回调是托管委托,如果不保存引用会被垃圾回收。安装钩子时一定要把委托存到类成员变量。
- 杀毒软件拦截低级钩子:很多安全软件对 WH_MOUSE_LL 非常敏感,会静默阻止。实测中 Windows Defender 一般不拦自定义写的钩子,但第三方杀软(尤其国产全家桶)可能报毒或拦截,只能是加白名单处理。
5.2 回放坐标偏移:DPI 与多显示器问题
坐标偏移我前前后后踩了两个坑。第一个就是 DPI 缩放,上面已经提到了,解决方法是 SetProcessDPIAware。第二个是多显示器:GetCursorPos 返回的是整个虚拟桌面坐标系,主屏左上角是 (0,0),副屏在左侧时坐标会出现负值。SendInput 的绝对坐标映射是按主显示器计算的,如果轨迹录自副屏,回放时副屏坐标映射到主屏会错位。
我的解决方案是录制时保存一份屏幕分辨率上下文,回放时如果检测到当前分辨率与录制时不同,就对坐标做等比缩放。虽然对多显示器不同布局的场景仍有局限,但至少保证了单显示器环境下的稳定性。
5.3 回放点击没生效的几种情况
点击没生效,不一定是你代码错了。排查顺序我通常这样走:
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
| 左键单击目标按钮无效 | 光标在移动后尚未稳定就触发 Down | 在 Down 之前追加 10ms 稳定延时 |
| 某些控件只能响应鼠标按下 | 按钮在 MouseDown 里触发,回放 Up 太快 | 适当延长 Down 和 Up 之间的间隔 |
| 高权限窗口无响应 | 媒体播放器、任务管理器等受 UIPI 保护 | 需要以管理员权限运行程序 |
| 后台窗口无响应 | 鼠标事件发送到前台窗口 | 回放前先用 SetForegroundWindow 激活目标窗口 |
这里最难排查的是第一种。录制时人手操作有个自然的“悬停稳定”过程,回放时 SendInput 快速连续移动和点击,目标控件可能还没完全响应光标进入就接到了 Down 事件,导致事件丢失。我的办法是:每次 ExecuteMove 结束、执行 Down 之前,固定加一个 15 到 30 毫秒的延时,这个值是通过多台机器实测出来的经验值。
5.4 录制数据量过大与轨迹文件膨胀
录制 10 分钟操作,移动事件可能轻松上万条,JSON 文件可能十几兆。为了控制体积,我在保存前会做一次事件压缩:相邻的 Move 事件如果坐标变化小于 2 像素,就只保留后一个;完全不动的 Move 事件(TimeStamp 增长但坐标没变)也直接跳过。
压缩后文件体积能减少 70% 以上,回放效率也明显提升。加载时再做一次索引预处理,回放循环直接按顺序读取,不额外增加开销。
5.5 回放抖动与卡顿的优化技巧
如果回放过程出现鼠标抖动或明显卡顿,可以检查以下几个方面:
- 回放事件循环中是否做了 UI 刷新:每执行一个事件都刷新一次 ListView,肯定卡。把刷新频率降到每秒 10 次左右就够了。
- SendInput 是否调用过于频繁:移动轨迹分步时步长太小,每秒可能上千次调用。步长从 1 像素调整到 3 到 5 像素后,流畅度大幅提升。
- 是否有其他程序抢占 CPU:低级钩子所在进程回放时尽量保持前台,避免被系统挂起。
6. 应用场景扩展与后续优化思路
6.1 在自动化测试中的落地用法
这套工具在自动化测试里最直接的价值,是把手工操作快速转成回归用例。具体做法是:测试人员手工走一遍流程,录制轨迹文件,然后在 CI 环境中定时回放,比对操作前后的界面状态或数据库记录。
我现在实际的工作流是:录制轨迹文件 + 关键节点截图,回放时在特定时间点触发截图比对。轨迹文件只负责“操作驱动”,断言逻辑由外部的测试框架负责。这样分层清晰,录制回放工具本身保持通用,不绑定任何被测系统。
6.2 坐标归一化:摆脱分辨率依赖
前面提到分辨率变化会导致坐标偏移,更彻底的方案是坐标归一化。录制时不保存绝对坐标,而是保存相对坐标:相对于当前活动窗口或主屏的百分比位置。回放时根据当前屏幕或窗口的实际尺寸反算绝对坐标。
真正落地时,窗口会移动、改变大小,简单归一化并不完全可靠。我实测过的最佳组合是“窗口标题匹配 + 窗口相对坐标 + 偏移修正”:先通过标题找到目标窗口,再把录制坐标转换为窗口客户区坐标,最后回放时映射回当前客户区。这种方式在面对窗口移动和尺寸变化时,稳定性远高于纯屏幕坐标。
6.3 加入图像识别锚点的高级玩法
轨迹回放的天然短板是“死板”——界面按钮位置变了,轨迹就废了。解决思路是给关键步骤加上“视觉锚点”。具体做法是:录制时在特定操作前的截图上做特征提取,保存小图像模板;回放时用 OpenCV 或模板匹配在当前屏幕中找到对应元素的新位置,用新坐标替换录制时的旧坐标。
这个方向我在第二版里做过实验,选中一个保存按钮,截取按钮的小图作为模板,回放前用 EMGU.CV 执行 TemplateMatch,把匹配中心点换算成屏幕坐标后再移动过去。实验效果不错,即使按钮在窗口中移动了位置,回放照样能点中。代价是每次回放增加了 50 到 100 毫秒的匹配耗时,对大多数场景完全可接受。
6.4 工具架构的进一步扩展
如果要做成长期维护的工具,我建议把模块拆得更开:录制器、回放器、轨迹文件、播放策略四层完全解耦。轨迹文件不只有鼠标事件,还应该有键盘事件、剪贴板内容、甚至窗口切换动作;播放策略支持线性播放、条件跳转、循环播放和错误重试。这样它就不再是一个录放工具,而是一个轻量级的桌面自动化引擎了。
我目前这个版本还停留在鼠标事件的录制回放,但架构上保留了扩展点——事件模型可以用继承扩展键盘事件和自定义事件,播放器用策略模式解耦播放逻辑。后面需要加能力时,不用推翻重来,这也是写工具时值得坚持的原则。
我个人在实际操作中的体会是,鼠标轨迹录制回放这类工具,技术门槛其实不算高,真正花时间的都在工程细节里:时间精度、坐标映射、DPI 处理、事件丢失、线程安全,每一个都能让人掉一层头发。如果你是从零开始写,建议先跑通录制和回放的最小闭环,再把那些边界情况一个个补上。等把这套逻辑吃透了,后面做 AnyDesk 远程鼠标控制、RPA 流程自动化,底层思路都是相通的。源码版本我放到自己的本地仓库里了,关键实现文中都贴出来了,照着搭一个完全没问题。最后再分享一个小技巧:录制回放调试的时候,建议先在记事本或者画图里练手,回放完看一眼鼠标轨迹和点击位置是否符合预期,比一上来就在生产系统上试要安全得多。