ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Qt宏录制回放Demo:全局钩子、SendInput与时间轴调度

Qt宏录制回放Demo:全局钩子、SendInput与时间轴调度 1. 需求分析为什么我会在Qt里做一个宏录制/回放 Demo1.1 重复性操作一切需求的起点事情要从我手上那个Qt数据采集项目说起。每天最耗时间的不是写代码而是那一套重复到让人麻木的界面操作打开配置窗口、填参数、点运行、等待采集结果、导出报表然后再改参数、再运行、再导出。一开始我用人工操作一天下来手酸眼累还容易在某一步点错导致整组数据作废。我当时的想法很直接能不能有一个工具像录像机一样把我的操作“录制”下来下一次直接“回放”这就是这个Qt宏录制/回放Demo的出发点。它做的事情很简单在Windows系统级捕获鼠标移动、鼠标点击、键盘按键将它们按时间顺序保存为事件序列需要重复时再按同样的时间节奏把事件重新注入系统。听起来不算复杂但真正拆解下去会发现它牵涉到全局钩子、跨线程通信、事件注入、时间轴调度、焦点管理等一系列问题。这篇文章会把我的设计思路、关键代码、踩过的坑都写出来给同样想做桌面端自动化工具的朋友一个参考。1.2 三种技术路线的对比与选型初学者听到“宏录制/回放”第一反应可能是Qt自带的事件机制。确实Qt的QTest命名空间可以模拟鼠标键盘事件比如QTest::mouseClick、QTest::keyClick但它们只能向当前Qt应用内部的事件循环发送事件目标是“给Qt控件喂事件”而不是“模拟系统层面的真实输入”。也就是说QTest无法录制你在其他程序里的操作也无法把事件回放到记事本、浏览器这些非Qt窗口上。所以要做真正的全局宏录制/回放必须走系统API。我整理了一下常见的几条路线方案全局捕获全局注入优点主要限制QTest不支持不支持与Qt深度集成简单只能作用于当前进程内的Qt窗口Win32 APISetWindowsHookEx SendInput支持支持Demo最直接稳定资料多仅限Windows平台X11/XTest XRecord支持支持Linux X11环境下可用Wayland受限XRecord扩展需开启CGEventTap CGEventPost支持支持macOS方案需要辅助功能权限调用AutoHotkey、xdotool等外部程序部分支持功能强大无需自研依赖外部进程很难和Qt界面无缝集成我的目标环境是Windows最终选了SetWindowsHookEx(WH_MOUSE_LL / WH_KEYBOARD_LL)做全局捕获用SendInput配合SetCursorPos做全局注入。Qt在项目里扮演的角色是承载界面、管理宏文件、控制录制和回放的状态机。这个分工很明确能交给系统的交给系统能用Qt解决的问题用Qt解决。1.3 整体架构与模块划分这个Demo虽然小但我还是按模块拆开写不然后续根本没法维护。整体分四层捕获层安装低级鼠标键盘钩子在系统回调里拿到原始输入转换成MacroEvent对象。存储层维护一个事件列表支持录制时追加、回放时读取并能序列化到JSON文件。回放层根据事件的时间戳按节奏把事件逐个注入回系统。界面层提供录制/停止/回放/保存/加载等入口显示当前状态。四层之间用信号槽解耦捕获层和回放层都不直接操作界面控件。界面层只关心状态切换存储层只关心数据这样的好处是调试的时候能单独验证某一部分比如先不开界面单独跑一个命令行入口录制几秒看日志里有没有事件进来。2. 捕获层实现把全局鼠标键盘操作翻译成可存储的数据流2.1 统一事件结构把输入抽象成“一条铭文”宏的本质是一串带时间戳的输入动作。如果不统一结构录制鼠标用一套逻辑录制键盘又用另一套逻辑回放时就要写两套分支后面再加滚轮事件更麻烦。所以我先定义了一个统一的MacroEvent结构体enum MacroEventType { ME_MouseDown 0, ME_MouseUp, ME_MouseMove, ME_MouseWheel, ME_KeyDown, ME_KeyUp, ME_Unknown }; struct MacroEvent { MacroEventType type ME_Unknown; quint64 timestampMs 0; // 相对录制开始的时间 int mouseX 0; int mouseY 0; int wheelDelta 0; int keyCode 0; // Windows虚拟键码 bool ctrl false; bool shift false; bool alt false; QString text; };几个设计点说明一下timestampMs用的是相对录制起始点的毫秒数这样回放时只需要拿当前已流逝时间跟它比较不用关心绝对日期时间。鼠标坐标记录的是屏幕全局坐标不是某个窗口内的相对坐标。因为宏要回放到不同场景只有全局坐标才是稳定的参照系。键盘事件记录Windows虚拟键码Virtual-Key Code比Qt::Key更直接。后续如果要在不同平台复用可以再抽象一层但Windows下用虚拟键码接入SendInput最顺畅。ctrl/shift/alt三个修饰键单独存一份状态回放时用来合成组合键避免用键盘钩子里的瞬时状态否则容易漏。2.2 低级钩子接入Qt的两种姿势Windows下全局捕获鼠标键盘最常用的是安装低级钩子HHOOK g_mouseHook; LRESULT CALLBACK LowLevelMouseProc(int nCode, WPARAM wParam, LPARAM lParam) { if (nCode HC_ACTION) { auto* ms reinterpret_castMSLLHOOKSTRUCT*(lParam); MacroEvent ev; ev.timestampMs GetTickCount64(); ev.mouseX ms-pt.x; ev.mouseY ms-pt.y; switch (wParam) { case WM_LBUTTONDOWN: ev.type ME_MouseDown; break; case WM_LBUTTONUP: ev.type ME_MouseUp; break; case WM_MOUSEMOVE: ev.type ME_MouseMove; break; case WM_MOUSEWHEEL: ev.type ME_MouseWheel; ev.wheelDelta GET_WHEEL_DELTA_WPARAM(ms-mouseData); break; } if (ev.type ! ME_Unknown) { // 这里不能直接访问Qt容器下面会说 QMetaObject::invokeMethod(g_manager, onCapturedEvent, Qt::QueuedConnection, Q_ARG(MacroEvent, ev)); } } return CallNextHookEx(nullptr, nCode, wParam, lParam); } void startMouseHook() { g_mouseHook SetWindowsHookEx(WH_MOUSE_LL, LowLevelMouseProc, GetModuleHandle(nullptr), 0); }低级钩子跟传统WH_MOUSE钩子最大的不同是它不需要单独放一个DLL里回调就在我们进程内只要进程消息循环在跑就能收到事件。Qt的QApplication默认有一个Windows消息循环所以安装后不用额外做太多事情。不过这里有个隐蔽的坑低级钩子回调运行在系统派发消息的线程上不是Qt主线程。如果你在回调里直接往QVector追加数据大概率会随机崩溃。我在第一个版本就这么干过Qt的容器并不是跨线程安全的。正确做法是通过QMetaObject::invokeMethod把事件抛回主线程处理或者自己做一个环形缓冲区由Qt定时器定时批量消费。Demo的消息频率不算高用Qt::QueuedConnection最简单。键盘钩子几乎是一模一样的套路只是把WH_KEYBOARD_LL和KBDLLHOOKSTRUCT换过来。有一点要注意录键盘事件时不要只关注WM_KEYDOWN还要记录WM_KEYUP否则回放时按键会一直保持按下状态导致字符重复输入。2.3 录制时的状态判断与移动轨迹抽稀一开始我以为只要把钩子收到的所有事件都存下来就行结果录了一个20秒的操作生成了将近一万条移动事件。鼠标稍微动一下WM_MOUSEMOVE就会连续触发大部分移动对回放并没有意义真正有价值的只有点击前后的位置偶尔有一段路径移动会影响操作可见性。所以我在录制端做了两层抽稀空间抽稀当前事件与上一条记录的移动事件之间距离小于3个像素忽略。时间抽稀当前事件与上一条记录的移动事件相隔小于16毫秒也忽略。这样下来同样20秒操作事件数量能压到七八百条回放流畅度一点没损失。其实更好的一种做法是只记录鼠标按下和弹起两个点中间移动路径在回放时用线性插值补出来但在Demo阶段没必要做这么精细抽稀已经足够。3. 回放引擎设计如何按照录制时的节奏精确重放每一个动作3.1 时间戳对齐回放的灵魂回放最核心的问题不是“怎么发送事件”而是“在什么时刻发送”。如果只是把事件列表丢到一个循环里连续发送一秒钟就把全部操作执行完了按钮还没出现点击已经过去了。录制时每个事件都带了一个相对于录制开始的timestampMs回放时只要对齐这个时间戳就行。最简单的办法是启动回放时记录一个起始时刻然后用一个QTimer周期性轮询每次检查“当前已流逝时间 当前事件的timestampMs”如果到了就发送事件并前进索引。void ReplayEngine::tick() { if (m_index m_events.size()) { if (m_loopCount 1) { --m_loopCount; m_index 0; m_startTimer.restart(); return; } stop(); return; } qint64 elapsed m_startTimer.elapsed(); const MacroEvent ev m_events.at(m_index); if (elapsed ev.timestampMs * m_speedFactor) { injectEvent(ev); m_index; } }这里m_speedFactor是回放倍速小于1表示加速大于1表示慢放。为什么用轮询而不是QTimer::singleShot为每个事件单独订时因为事件多的时候定时器数量会爆炸而且轮询方案天然支持循环、暂停、停止还能随时在界面上刷新进度。3.2 SendInput与SetCursorPos注入事件的方式选择回放层的另一个重点是注入API的选择。我一开始图方便鼠标移动和点击都用了SetCursorPos加mouse_event后来发现mouse_event已经过时而且和SendInput混用容易出现奇怪的坐标偏移。最后统一改用SendInput。这里有一个非常容易踩的坑SendInput的鼠标坐标有两种表示方式。如果不带MOUSEEVENTF_ABSOLUTEdx/dy会被当成相对位移如果带了绝对坐标标志dx/dy必须是一个归一化到0到65535范围的值而不是屏幕像素坐标。我整理了两种用法// 直接在指定位置移动并点击推荐 SetCursorPos(ev.mouseX, ev.mouseY); INPUT input {}; input.type INPUT_MOUSE; input.mi.dx 0; input.mi.dy 0; input.mi.dwFlags (ev.type ME_MouseDown ? MOUSEEVENTF_LEFTDOWN : MOUSEEVENTF_LEFTUP); SendInput(1, input, sizeof(INPUT));为什么推荐SetCursorPos加SendInput配合因为SetCursorPos用的是真实的物理像素坐标不用做归一化逻辑简单。移动和点击分开两步执行中间的间隔只有几毫秒用户几乎无感知。如果追求更真实的“连续移动”效果也可以把所有路径点一次性塞进INPUT数组SendInput内部会依次处理但这需要额外管理路径点数组Demo阶段我用一个移动事件一个移动事件地发已经足够。键盘注入类似但要注意修饰键的顺序。录制时如果记录了一个CtrlC组合键回放时必须先按下Ctrl再按下C然后先弹起C最后弹起Ctrl。我的做法是在遇到带修饰键的事件时先注入修饰键的KEYDOWN再注入主键的KEYDOWN最后按相反顺序注入KEYUP。如果直接用SendInput一次性送一个KEYDOWN给C而没有先按住Ctrl快捷键是不生效的。3.3 回放过程中的用户打断与状态反馈回放一旦跑起来必须允许用户随时喊停。我在Demo里用了一个std::atomicbool m_stopRequested在全局热键回调里置位回放轮询的每个tick都会检查一旦发现就立刻停止。这个标志必须是原子的因为热键回调可能跑在非主线程。界面上我放了一个QProgressBar显示回放进度但每tick都更新会占用太多UI时间反而让轮询变慢。后来改成每100毫秒左右刷一次也就是回放引擎每累计elapsed - lastUpdate 100才发一次进度信号。这样界面既不卡顿进度看起来也很平滑。4. Demo界面与全局热键让录制工具不打扰正常操作4.1 简五界面三个按钮、一个列表、一个状态栏这个Demo的界面我做得很克制因为核心功能是录制回放不是设计一个复杂的剪辑工具。主窗口就一个QToolBar上面放了“开始录制”“停止录制”“开始回放”“停止回放”“保存”“加载”几个按钮中间一个QListWidget显示录制到的事件数量最底部QStatusBar显示当前状态。录制时窗口其实很碍事因为用户要操作的目标程序可能就在后面。所以我允许用户录制时把窗口最小化靠全局热键控制启停。窗口本身用普通QWidget就可以不需要特别装饰。状态管理用了一个简单的枚举enum RecorderState { Idle, Recording, Playing };每次切换状态时统一更新按钮的可用性。比如录制中禁掉“开始录制”播放中禁掉“开始回放”这样能避免用户误操作导致状态混乱。状态机的代码量很小但这是整个工具不容易出错的基础。4.2 全局热键让录制/停止键在后台仍能响应如果录制过程中窗口没有焦点用户怎么停止录制正确答案是全局热键。Windows下的RegisterHotKey可以把一个组合键注册到系统级即使当前焦点在别的程序也会收到WM_HOTKEY消息。和Qt集成时我继承了QAbstractNativeEventFilter在实例方法里监听WM_HOTKEYclass NativeHotkeyFilter : public QAbstractNativeEventFilter { public: bool nativeEventFilter(const QByteArray eventType, void* message, qintptr* result) override { auto* winMsg static_castMSG*(message); if (winMsg-message WM_HOTKEY) { int hotkeyId static_castint(winMsg-wParam); if (hotkeyId HOTKEY_TOGGLE_RECORD) { emit toggleRecording(); } return true; } return false; } };注册热键本身很简单RegisterHotKey(reinterpret_castHWND(winId()), HOTKEY_TOGGLE_RECORD, MOD_CONTROL | MOD_ALT, VK_F9);这里有一个我踩过的坑注册热键的组合键本身也会被键盘钩子捕获。也就是说你按CtrlAltF9停止录制时这个按键动作可能已经被录进宏里了。回放时按下这个热键就可能误触发停止逻辑还会让宏“平白无故”多出几个无效按键。我的处理是在键盘钩子回调里判断当前按下的组合键是否等于注册热键如果是就返回1吞掉事件这样热键既不会进入正常程序也不会被录制。配合一个热键按下时的系统提示音用户仍然能感知到状态切换。4.3 宏文件的保存与加载录制完成后应该能把事件列表保存下来下次重新打开直接回放。我用JSON作为文件格式主要原因是调试方便录完后打开一个文本编辑器就能看到每条事件长什么样。QJsonDocument转数组每一条事件存成一个对象。[ {t: 0, type: keyDown, key: 162, ctrl: 1}, {t: 320, type: mouseMove, x: 100, y: 200}, {t: 480, type: mouseDown, x: 100, y: 200}, {t: 520, type: mouseUp, x: 100, y: 200} ]加载时按type字段转成MacroEvent。我建议在文件里放一个version字段比如1方便以后格式升级时做兼容。二进制格式在文件体积上会小很多但Demo阶段JSON完全够用真到了宏文件需要频繁交换再考虑压缩也不迟。5. 踩坑实录跨线程数据竞争、注入事件自触发与焦点丢失5.1 跨线程容器访问引发的随机崩溃这是我遇到的第一根刺。第一个版本里我在低级鼠标钩子回调中直接向一个全局QVectorMacroEvent里append数据。偶尔能跑几十秒偶尔一启动就崩而且崩溃位置每次都不同典型的内存越界或堆损坏。问题的根源很简单低级钩子回调所在线程和Qt主线程是不同的执行上下文QVector不是线程安全的两个线程同时读改写同一块内存谁先谁后完全不可控。解决办法是让回调只做一件事构造一个MacroEvent然后通过QMetaObject::invokeMethod投递到主线程。主线程里有一个槽函数负责往事件列表里追加。QMetaObject::invokeMethod(g_manager, onCapturedEvent, Qt::QueuedConnection, Q_ARG(MacroEvent, ev));如果追求更高的性能可以做一个简单的无锁环形缓冲区让钩子只往里写Qt定时器每5毫秒批量取走。但对Demo来说invokeMethod的跨线程投递开销完全可接受而且代码可读性高。5.2 注入事件“自触发”导致的宏膨胀我最初测试回放时发现一个怪现象回放完一个录制好的宏这个宏的体积反而变大了一圈。打开文件一看里面多了一堆和回放内容一模一样的鼠标键盘事件。原因很好理解SendInput注入的事件对操作系统来说就是真实用户输入钩子会照单全收于是回放过程又变成了“录制过程”新事件被追加到了原宏的末尾。解决办法是在回放开始前设置一个std::atomicbool g_replaying true在钩子回调的入口检查这个标志如果正在回放就直接丢弃捕获到的事件。回放结束时再置回false。注意这个标志必须用原子类型因为钩子线程和主线程都在访问它。5.3 焦点难缠回放时点不是录制时那个窗口这个坑在真实使用中比跨线程崩溃更让人抓狂。我录制的时候目标是记事本回放的时候如果前台窗口是浏览器宏会照着录制的坐标往浏览器上点。如果浏览器窗口在同一个位置可能只是点错更常见的是目标程序窗口被遮挡或移动了鼠标就在桌面空点半天。最粗浅的解决办法是录制时把目标窗口的标题和类名一起存下来回放前先尝试激活它。代码如下HWND target FindWindowW(lpClassName, lpWindowName); if (target) { SetForegroundWindow(target); BringWindowToTop(target); }但因为Windows对后台激活有限制SetForegroundWindow不一定每次都成功。一个常用的技巧是先用AttachThreadInput把自己线程和前台线程绑在一起再调用SetForegroundWindow操作完再解除。更彻底的方案是把目标窗口的最小化状态恢复并调用ShowWindow(SW_RESTORE)。即使这样也还是建议在宏录制时预留1秒以上的“窗口切换缓冲时间”让目标程序有足够时间重新绘制。5.4 数据量暴增与轨迹抽稀的取舍这一条更像是工程体会而不是技术坑。录制高DPI或高分屏下的鼠标操作时WM_MOUSEMOVE的触发频率非常高如果完整保存宏文件会出现大量冗余坐标点。抽稀虽然会丢掉一部分移动轨迹但对于大多数“点按钮、填表单”的场景完全够用。我做抽稀时把阈值调成位移小于3像素且时间差小于16毫秒就忽略。这样录制下来的移动事件在回放时依然很连贯肉眼几乎看不出和平滑移动的区别。如果将来要做“绘图回放”这类精细操作可以再调整阈值甚至改成记录贝塞尔曲线拟合结果这是后话。6. 从Demo走向实用工具能加哪些增强功能6.1 录制层增强过滤器与变量占位一个真正能日常使用的宏录制工具不应该把用户所有的输入都存进去。比如录制过程中用快捷键切到了别的程序这段操作对宏来说是噪音。合理的做法是设置一个“白名单窗口”过滤器只在目标窗口标题匹配时记录事件其他窗口输入直接忽略。变量占位则更贴近办公场景。批量填表时每次启动宏都要输入当天的日期或者工号。与其把这个过程录死不如在录制时把特定输入替换成${date}、${username}这样的占位符回放前让用户填一次变量表宏就能复用。6.2 回放层增强图像识别与条件分支位置回放的局限性在于窗口一旦移动或改版宏就失效。要提升鲁棒性可以在关键点击前加一步“截图识别”用QScreen::grabWindow(0)抓取当前屏幕再拿模板图片做匹配匹配成功后把鼠标移动到匹配点的相对偏移位置而不是直接使用录制坐标。这本质上是把宏从“机械手臂”升级成了“带眼睛的手臂”。条件分支也不难想象比如检测到某个弹窗是否存在决定走确认路径还是跳过路径。加上这些之后这个宏工具就已经接近一类轻量级RPA机器人流程自动化工具了。Qt做这种事情的优势在于它本身就有非常完整的截图、图像处理、界面开发能力不需要再拼一堆脚本。6.3 跨平台移植的注意点如果你打算把这套思路迁到别的系统要提前想好后端差异。Linux下X11可以用XRecord扩展录制XTest扩展注入但很多Linux发行版默认不启用XRecordWayland更是直接限制了全局输入模拟往往需要特殊协议或者自定义桌面扩展才能实现。macOS则需要申请“辅助功能”权限使用CGEventTap和CGEventPost完成同样的捕获与注入。架构上比较好的做法是把捕获和回放分别抽象成接口比如class InputCapturer { public: virtual bool start() 0; virtual void stop() 0; virtual void setFilter(...) 0; }; class InputReplayer { public: virtual bool play(const QVectorMacroEvent events) 0; virtual void stop() 0; };Windows、Linux、macOS各写一个实现上层只依赖接口。这样至少界面层和存储层是完全跨平台复用的。不过真要说投入产出比的话如果只是内部工具先专注于一个平台把体验做扎实远好过草草地铺开所有平台。最后分享一点我的实际体会写这个Demo的过程让我彻底理解了“系统级输入”和“应用级事件”之间的差异。很多人以为Qt里能模拟个点击就完事了但真正要做到全局宏录制还是得老老实实和操作系统底层打交道。如果你也计划做类似工具建议一开始先用最简版本跑通录制和回放循环确认钩子、注入、时间轴这三条链路没问题再加界面和花哨功能。这样即使后面踩坑定位范围也会小很多。这个Demo我还在持续完善下一步准备加入图像识别和循环条件让它能在自动化测试里真正替我顶班。
返回列表