尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

基于Qt与Windows API的宏录制回放工具实现

基于Qt与Windows API的宏录制回放工具实现 前阵子公司做回归测试每天要在一个老旧的桌面客户端上重复几百次同样的点击、输入、提交点得我手酸。抽了一个晚上用 Qt 写了个“宏录制/回放 Demo”把鼠标键盘动作原样录下来存成文件需要时一键回放。这个小工具虽然只是 Demo但把 Windows 输入事件这条链路从头到尾摸了一遍——钩子、消息循环、模拟输入、子线程调度一个没落。这篇就把整个 Demo 的拆解思路、关键技术选型和踩坑记录完整写出来。适合正在做 Qt 桌面自动化、或者想搞清楚全局键盘鼠标钩子如何与 Qt 工程结合的朋友也适合想给自家产品内置一个自动化演示模块的开发者。1. 这 Demo 到底做什么需求拆解与功能边界1.1 哪些场景会用到宏录制/回放宏录制/回放就是一台“输入事件复读机”。它把用户操作鼠标键盘的动作按时间顺序记录下来之后可以在任意时刻原样重放。我这里说的回放是输入事件回放不是音视频回放两者完全是两码事。最常见的场景有三个。第一是软件回归测试尤其是对旧系统、桌面客户端这种没有自动化接口的程序通过宏可以把“点击菜单 A → 填写字段 B → 点击提交 C”这类固定路径反复执行几百遍稳定性测试就靠它。第二是办公自动化比如财务每个月要填一批固定格式的报表字段几乎一样只是位置略有变化宏能省掉大部分重复劳动。第三是产品演示销售要给别人展示操作流程提前录好一段宏演示时一键播放比现场临场点鼠标稳定太多。但宏也有非常明确的边界它不理解界面语义不知道什么是一个“按钮”或一个“输入框”它只知道“在坐标 (x, y) 按下左键”和“按下字母 A”。所以一旦目标窗口位置变了、界面布局改了、分辨率不同了回放就可能点错地方。这个边界从一开始就要想清楚后面很多问题都源于此。1.2 为什么选择 Qt Windows API 这套组合做宏录制/回放语言选型其实不少。纯 Win32 可以写C# 也可以Python 加 pyautogui 也行。但我最终选了 Qt C原因有三条都比较实在。第一Qt 和 Win32 API 的互操作非常顺畅。SetWindowsHookEx、SendInput、GetTickCount64 这些都是标准 C 接口在 Qt 工程里直接 include windows.h 就能调用不需要任何桥接层。第二Qt 把界面、线程、JSON、文件处理都封装好了。宏录制需要界面控制录制/回放、需要子线程去跑回放逻辑、需要把事件序列存成文件这些 Qt 都给现成方案开发效率比纯 Win32 高一个量级。第三后期有跨平台的空间。Windows 上用低级钩子Linux 上可以换成 X11/XTestmacOS 上可以用 CGEvent只要把底层输入后端抽象好上层的 UI 和数据结构完全不用动。跟按键精灵、AutoHotkey 这类现成工具相比Qt 方案最大的优势是可控和可嵌入。你可以把一个宏引擎编译进自己的产品做成“自动化演示模块”或者“脚本录制功能”而第三方工具基本只能在系统层面独立运行没法深度整合。1.3 Demo 功能清单这个 Demo 麻雀虽小五脏俱全核心功能有这些功能对应模块说明开始/停止录制Recorder全局捕获鼠标键盘事件不区分目标程序回放ReplayThread子线程按录制节奏重新注入输入事件停止回放ReplayThread通过原子标志中途停止保存/加载宏JSON 持久化事件序列存成 JSON 文件可跨会话使用循环回放ReplayThread支持设置 1~10 次重复播放日志与状态提示MainWindow输出录制事件数、回放进度、错误信息从技术角度看录制端最重要回放端最容易出坑持久化反而是最简单的一环。接下来按原理到实践的顺序一层一层拆。2. 核心原理输入事件在系统里如何流动2.1 从鼠标按下到窗口处理中间发生什么要理解宏录制得先搞清楚输入事件在 Windows 里是怎么走的。鼠标或键盘产生中断后硬件驱动把数据送进系统输入队列Windows 再根据当前的前台窗口和鼠标位置把事件分发到对应线程的输入队列最终由窗口过程Window Procedure处理。这个过程可以类比成一个邮局系统硬件是寄信人窗口过程是收信人系统输入队列是分拣中心。正常流程下信件会直接投递到收件人手里中间没有人能截留。宏录制要做的事情就是在分拣环节加一台“复印机”把所有经过的输入事件都额外拷贝一份出来。Windows 提供的复印机就是钩子Hook。钩子分很多种其中低级键盘钩子 WH_KEYBOARD_LL 和低级鼠标钩子 WH_MOUSE_LL 恰好能把所有键盘鼠标输入都过一遍。这两个钩子工作在当前安装线程的消息循环里系统每处理一个输入事件就会回调一次我们注册的钩子过程。这比任何应用内的事件过滤器位置都更靠前所以能录下全局输入。2.2 录制方案选型应用内监听还是系统级钩子做 Qt 项目时可能有人第一反应是用 Qt 自带的事件机制比如给 QApplication 安装事件过滤器监听 QMouseEvent 和 QKeyEvent。这个方案不是不行但限制非常明显它只能捕获发到本程序窗口的事件一旦你切到记事本、浏览器、其他桌面软件Qt 就什么都收不到了。宏录制如果只能在自家程序里玩意义会大打折扣。我最终选择的是系统级低级钩子。这里有个技术细节值得多说一句WH_MOUSE_LL 和 WH_KEYBOARD_LL 属于低级钩子它们不需要把你的回调代码注入到目标进程只要在当前进程里给出一个函数指针系统会在线程的消息循环里直接调用它。而传统的 WH_MOUSE、WH_KEYBOARD 全局钩子如果要做成系统级通常要把回调封装到 DLL 里让系统把 DLL 注入到每个目标进程工程复杂度会高出不少。两种录制方案对比如下方案捕获范围实现复杂度适用场景Qt 事件过滤器仅本程序窗口最低应用内自定义快捷键、录制内部操作WH_MOUSE_LL / WH_KEYBOARD_LL 低级钩子全系统较低通用宏录制/回放传统全局钩子 DLL全系统高老牌输入钩子库现已不推荐新用所以对于这个 Demo低级钩子是最合适的选择捕获范围满足需求实现又不需要 DLL 注入是性价比最高的路径。2.3 回放方案选型为什么 SendInput 是正解录下来的事件要重新注入到系统里也有几条路。第一是 keybd_event 和 mouse_event这两个是旧时代的 API虽然还能用但功能不够完整对扩展键、滚轮、触控笔的支持都不理想。第二是 PostMessage它能把消息投递给指定窗口但本质上是“发信给某个收件人”不会让全局光标真正移动也不会改变前台窗口遇到按坐标点击的宏场景基本不可用。真正的正解是 SendInput。它把输入事件直接送进系统输入队列由系统重新走一遍分发流程效果上最接近真实硬件输入。它对鼠标按键、滚轮、绝对移动、键盘按下抬起都支持得比较完整。有一点很关键SendInput 模拟的事件同样会被低级钩子捕获。这意味着如果录制端和回放端同时开着回放时系统会把“模拟回放动作”又当成新事件录进去形成自举式的死循环。这个坑我后面专门讲。3. 工程实现一个可运行的录制回放 Demo3.1 环境与工程配置我用的环境是 Windows 10 专业版 Qt 5.15.2 MSVC2019 64 位 Qt Creator。理论上 Qt 5.12 到 Qt 6 都能跑因为核心还是 Win32 APIQt 只承担界面、线程和持久化。工程的 pro 文件很简单QT core gui widgets CONFIG c17 TARGET MacroRecorderDemo TEMPLATE app win32 { LIBS user32.lib }关键就在最后一行SetWindowsHookEx、SendInput、MapVirtualKey 这些函数都在 user32 库里不加这一行链接阶段会报一堆 unresolved external symbol。如果你用 CMake等价写法是target_link_libraries(MacroRecorderDemo PRIVATE user32)另外建议在公共头文件里定义宏来减少 Windows 头文件的干扰#ifndef WIN32_LEAN_AND_MEAN #define WIN32_LEAN_AND_MEAN #endif #include windows.h这一步能加快编译也能避免一些宏定义和 Qt 类型冲突的问题。3.2 Recorder全局钩子接入 Qt 的完整写法Recorder 类是整个 Demo 的核心它负责安装钩子、接收事件、记录数据。这里有一个很现实的工程问题Windows 的钩子回调函数必须是静态函数或者全局函数没法直接调用 Qt 的成员函数。我的做法是定义一个静态实例指针在 start 时赋值回调里通过这个指针访问成员变量。Recorder 头文件的关键部分#pragma once #include QObject #include QVector #include windows.h enum class InputEventType { MouseMove 1, MouseButtonDown, MouseButtonUp, MouseWheel, KeyDown, KeyUp }; struct InputRecord { int type 0; long x 0; long y 0; int vkCode 0; int scanCode 0; int flags 0; bool keyUp false; int wheelDelta 0; int timeMs 0; }; class Recorder : public QObject { Q_OBJECT public: explicit Recorder(QObject *parent nullptr); ~Recorder(); bool start(); void stop(); bool isRecording() const { return m_recording; } void setPaused(bool paused) { m_paused paused; } void clear(); QVectorInputRecord records() const { return m_records; } signals: void recordCountChanged(int count); private: static LRESULT CALLBACK lowLevelMouseProc(int nCode, WPARAM wParam, LPARAM lParam); static LRESULT CALLBACK lowLevelKeyboardProc(int nCode, WPARAM wParam, LPARAM lParam); void appendRecord(InputRecord rec); HHOOK m_mouseHook nullptr; HHOOK m_keyHook nullptr; ULONGLONG m_lastTickMs 0; bool m_recording false; bool m_paused false; QVectorInputRecord m_records; static Recorder *s_instance; };对应的核心实现Recorder *Recorder::s_instance nullptr; Recorder::Recorder(QObject *parent) : QObject(parent) { s_instance this; } Recorder::~Recorder() { stop(); if (s_instance this) s_instance nullptr; } bool Recorder::start() { if (m_recording) return false; m_records.clear(); m_lastTickMs GetTickCount64(); m_recording true; m_paused false; m_mouseHook SetWindowsHookExA(WH_MOUSE_LL, lowLevelMouseProc, GetModuleHandleA(nullptr), 0); m_keyHook SetWindowsHookExA(WH_KEYBOARD_LL, lowLevelKeyboardProc, GetModuleHandleA(nullptr), 0); if (!m_mouseHook || !m_keyHook) { stop(); return false; } return true; } void Recorder::stop() { m_recording false; if (m_mouseHook) { UnhookWindowsHookEx(m_mouseHook); m_mouseHook nullptr; } if (m_keyHook) { UnhookWindowsHookEx(m_keyHook); m_keyHook nullptr; } }安装钩子时第四个参数 dwThreadId 传 0表示钩子与当前线程的消息队列关联。低级钩子对线程不挑剔但安装线程必须有一个消息循环Qt 主线程天然满足这个条件。所以 Recorder 要放在主线程使用回调里尽量不要做耗时操作。3.3 数据模型与关键细节时间戳、坐标、按键InputRecord 结构体是整个宏文件的核心数据模型。字段看着多其实每个都有用处我来逐个解释。首先是时间戳。我用的是 GetTickCount64 的差值记录的是“距上一条事件过去了多少毫秒”。这个相对时间设计很关键回放时从 0 毫秒开始累计跟录制当时是几点几分完全无关这样才支持“换个时间重放”。如果记录绝对时间反而要额外处理时区、跨天等问题。宏回放本质是用户操作的“节奏”重现相对时间最合适。其次是坐标。低级鼠标钩子传回来的 MSLLHOOKSTRUCT.pt 是屏幕物理像素坐标。这里有个很容易踩的坑Qt 如果启用了高 DPI 缩放QWidget 的坐标是逻辑像素而钩子拿到的坐标是物理像素。所以在界面上显示坐标时可能需要除以设备像素比但回放时不能转直接使用原始物理坐标发给 SetCursorPos 或 SendInput 反而最准确。再次是键盘。光存 vkCode 虚拟键码不够左右 Shift、左右 Ctrl 在虚拟键码层面很难区分所以我把 scanCode 扫描码和 flags 标志一起存下来。回放时用 MapVirtualKey 把虚拟键码转成扫描码遇到扩展键再补 KEYEVENTF_EXTENDEDKEY 标志这样对方向键、小键盘等特殊按键才准。这里顺便列一下字段对照表字段含义录制来源回放用途type事件类型根据 wParam 判断决定走鼠标/键盘分支x, y鼠标坐标或滚轮位置MSLLHOOKSTRUCT.ptSetCursorPos / SendInputvkCode键盘虚拟键码KBDLLHOOKSTRUCT.vkCode按键模拟scanCode键盘扫描码KBDLLHOOKSTRUCT.scanCode区分左右修饰键flags键盘标志位KBDLLHOOKSTRUCT.flags扩展键判断keyUp是否抬起wParam WM_KEYUP组合键状态还原wheelDelta滚轮增量GET_WHEEL_DELTA_WPARAM滚轮模拟timeMs距上一条的毫秒差GetTickCount64 差值回放节奏控制鼠标按下与移开的处理逻辑不复杂但要细心。每次事件到达统一先算时间戳再按 wParam 分支。鼠标移动事件记录频率要高后面我还会讲如何做节流优化。键盘回调和鼠标基本对称唯一要注意的是按下和抬起都要记录。很多新手只录 KeyDown回放时就会出现“按键一直按住”的诡异现象因为系统永远等不到 KeyUp。3.4 Replayer子线程回放的节奏控制系统回放如果直接放在主线程里 Sleep界面会卡死按钮点了没反应用户体验极差。我选择把回放逻辑放到 QThread 子线程里跑。子线程每处理一条事件发一个信号让主线程更新进度条主线程依旧能响应用户点击“停止”按钮。ReplayThread 的核心实现如下#pragma once #include QThread #include QAtomicInt #include Recorder.h class ReplayThread : public QThread { Q_OBJECT public: explicit ReplayThread(QObject *parent nullptr); void setRecords(const QVectorInputRecord records) { m_records records; } void setLoopCount(int count) { m_loopCount count; } void stop() { m_stopFlag.storeRelease(1); } signals: void replayProgress(int done, int total); void replayFinished(); protected: void run() override; private: void replayOnce(); void dispatchInput(const InputRecord rec); QVectorInputRecord m_records; int m_loopCount 1; QAtomicInt m_stopFlag 0; };run 的实现void ReplayThread::run() { emit replayProgress(0, m_records.size()); for (int loop 0; loop m_loopCount; loop) { if (m_stopFlag.loadAcquire()) break; replayOnce(); } emit replayFinished(); } void ReplayThread::replayOnce() { for (int i 0; i m_records.size(); i) { if (m_stopFlag.loadAcquire()) break; const InputRecord rec m_records.at(i); if (rec.timeMs 0) QThread::msleep(rec.timeMs); if (m_stopFlag.loadAcquire()) break; dispatchInput(rec); emit replayProgress(i 1, m_records.size()); } }dispatchInput 是关键函数负责把不同类型的事件转成对应的系统调用void ReplayThread::dispatchInput(const InputRecord rec) { if (rec.type int(InputEventType::MouseMove)) { SetCursorPos(rec.x, rec.y); return; } if (rec.type int(InputEventType::MouseButtonDown) || rec.type int(InputEventType::MouseButtonUp) || rec.type int(InputEventType::MouseWheel)) { INPUT input {}; input.type INPUT_MOUSE; input.mi.dx rec.x; input.mi.dy rec.y; switch (rec.type) { case int(InputEventType::MouseButtonDown): input.mi.dwFlags (rec.vkCode VK_LBUTTON) ? MOUSEEVENTF_LEFTDOWN : (rec.vkCode VK_RBUTTON) ? MOUSEEVENTF_RIGHTDOWN : MOUSEEVENTF_MIDDLEDOWN; break; case int(InputEventType::MouseButtonUp): input.mi.dwFlags (rec.vkCode VK_LBUTTON) ? MOUSEEVENTF_LEFTUP : (rec.vkCode VK_RBUTTON) ? MOUSEEVENTF_RIGHTUP : MOUSEEVENTF_MIDDLEUP; break; case int(InputEventType::MouseWheel): input.mi.dwFlags MOUSEEVENTF_WHEEL; input.mi.mouseData rec.wheelDelta; break; } SendInput(1, input, sizeof(INPUT)); return; } if (rec.type int(InputEventType::KeyDown) || rec.type int(InputEventType::KeyUp)) { INPUT input {}; input.type INPUT_KEYBOARD; input.ki.wVk rec.vkCode; input.ki.wScan MapVirtualKey(rec.vkCode, MAPVK_VK_TO_VSC); input.ki.dwFlags 0; if (rec.keyUp) input.ki.dwFlags | KEYEVENTF_KEYUP; if (rec.flags LLKHF_EXTENDED) input.ki.dwFlags | KEYEVENTF_EXTENDEDKEY; SendInput(1, input, sizeof(INPUT)); } }这里有一个取舍鼠标移动我用 SetCursorPos 而不是 SendInput。因为 SendInput 的鼠标移动如果设置了 MOUSEEVENTF_MOVEdx/dy 是相对增量回放时容易累积误差而 SetCursorPos 是绝对定位一步到位不会越走越偏。代价是它不会产生鼠标“滑过去”的轨迹效果但宏回放大多数场景只关心最终位置不关心移动路径。回放线程和主线程是天然解耦的但要注意子线程里不能直接访问 QPlainTextEdit 等界面控件只能通过信号槽把进度抛回主线程。回放结束也要发信号让主线程恢复按钮状态。3.5 UI 集成与宏文件的保存加载主窗口是一个典型的单窗口控制台布局左侧一堆控制按钮和参数右侧一个日志输出区域。录制中时“开始录制”按钮变成“停止录制”回放中时“回放”按钮不可用取而代之的是“停止回放”按钮可点。保存和加载用 QJsonDocument 序列化 InputRecord 数组非常直观。保存逻辑大致是QJsonArray eventArr; for (const InputRecord r : m_recorder-records()) { QJsonObject obj; obj[type] r.type; obj[x] r.x; obj[y] r.y; obj[vk] r.vkCode; obj[scan] r.scanCode; obj[flags] r.flags; obj[keyUp] r.keyUp; obj[wheel] r.wheelDelta; obj[timeMs] r.timeMs; eventArr.append(obj); } QJsonObject root; root[version] 1; root[screenWidth] GetSystemMetrics(SM_CXSCREEN); root[screenHeight] GetSystemMetrics(SM_CYSCREEN); root[events] eventArr; QFile file(fileName); if (file.open(QIODevice::WriteOnly)) { file.write(QJsonDocument(root).toJson(QJsonDocument::Indented)); }把录制时的屏幕分辨率也存进 JSON好处是加载时能主动提示“当前位置可能不一致”。比如这次录的分辨率是 1920x1080下次换到 1366x768 的机器回放点错位置几乎是必然的。有了这个字段至少能在日志里提前警告。UI 这层的代码不多真正重要的是状态流转逻辑。我的状态机只有三态空闲、录制中、回放中。录制中不允许再点录制回放中不允许再点回放但“停止回放”必须随时可点。这个状态如果不用状态机硬编码后面加功能很容易乱套。4. 实测记录与体验优化4.1 最小闭环录制记事本操作并回放我把这个 Demo 跑通的最小闭环是打开一个记事本在左上角区域输入一行文字点一下“文件”菜单然后停止录制保存宏再打开一个新记事本窗口直接回放。第一轮实测下来回放结果基本符合预期但暴露了一个特别典型的焦点问题回放前如果没有先把新记事本窗口激活录制的按键全都跑到当前前台窗口去了。低级钩子录制时是不管焦点在哪儿的回放时 SendInput 也是直接进系统队列系统把它当成真实输入派发给当前前台窗口。要是你录宏的时候目标窗口在前台回放时目标窗口却在后台那所有输入都会打给别的窗口。这个问题没有完美解法因为宏本身不理解窗口概念。但我总结出一个实用习惯录制宏时第一个动作永远是“点击目标窗口的标题栏”让窗口激活。这样回放时第一步也会先点标题栏激活目标窗口后面的事件才有着落。你也可以在回放前让用户手动点一下目标窗口但这只适合演示不适合自动化。第二轮实测我加入了文件保存和重新加载确认 JSON 序列化没有丢字段。之后又试着把宏换个电脑分辨率环境播放果然坐标全部偏移这验证了前面保存分辨率字段的必要性。4.2 几个值得做的优化细节第一个优化是移动事件节流。鼠标移动事件太密了一秒钟可能有几十上百条录久了文件非常大。我在回调里加了个判断如果上一条也是鼠标移动且间隔小于 30 毫秒就丢弃当前这条。这样文件体积能减少六成以上而回放效果几乎没差别因为点击类操作根本不在乎中间移动路径。你甚至可以加一个选项录制时只记录“按下/抬起/滚轮”完全忽略移动最省空间。第二个优化是录制和回放互斥。我做这个 Demo 时第一次回放就遇到了“自我复制”问题回放线程每模拟一次点击低级钩子就抓到一次模拟事件然后又塞进 m_records 里宏文件越变越长。解决办法是在回放开始前调用 m_recorder-setPaused(true)回放结束后恢复。这个标志位一旦忘了设置后果很迷惑录着录着发现宏自己“长大”了。第三个优化是控制键的处理。我用 F9 作为开始/停止录制快捷键F10 作为回放快捷键。问题来了按 F9 停止录制时F9 这个按键事件本身也会被录进宏里回放时它会再次触发“停止”逻辑导致宏只播到一半就被自己中断。我后来直接在回调里对 F9/F10 做过滤录制状态下这两个键只参与控制逻辑不进入事件列表。但要注意如果你在 Qt 界面上也用 QShortcut 监听 F9而低级钩子却把 F9 吞掉了QShortcut 可能收不到。简单起见Demo 里我优先保证钩子逻辑干净界面同时提供按钮操作两种方式都可以触达。第四个优化是日志刷新节流。开始我每记录一条事件就发一个信号去刷新 QPlainTextEdit录到鼠标移动的时候界面明显发卡。后来改成只更新一个数字标签完整日志用定时器每 300 毫秒批量刷新一次问题就解决了。低级钩子的回调里尽量不要做任何可能阻塞的操作凡是能放到外面的都放到外面。4.3 打包发布时别掉进 Qt 的坑这个 Demo 虽然小但发布流程却是很多新人跨不过去的坎。Qt 程序不能直接把 exe 拷给别人跑需要在编译好的 release 目录下执行 windeployqtset PATHC:\Qt\5.15.2\msvc2019_64\bin;%PATH% windeployqt --release MacroRecorderDemo.exe执行完后exe 旁边会多出一堆 DLL 和文件夹。其中最值得注意的是 platforms 目录里面必须有 qwindows.dll。很多新手双击自己编译出来的 exe弹出一句 “windows no qt platform plugin could be initialized”十有八九就是缺少这个文件。比如把 exe 单独拷走了没带 plugins 目录Qt 找不到平台插件自然起不来。另外要提醒一句全局钩子属于敏感 API杀毒软件可能会报毒或拦截。自己用没问题如果要分发给别人最好用正式签名证书对 exe 做数字签名并在文档里说明程序会安装全局钩子。否则对方杀软弹窗体验会很糟糕。5. 常见问题与排查速查5.1 录制端问题钩子没反应、事件丢失录制端最典型的故障是“点击开始录制后鼠标键盘动来动去日志里事件数一直是 0”。遇到这个先检查 SetWindowsHookEx 的返回值如果返回 NULL用 GetLastError() 拿到错误码再对照系统错误码表定位。常见的失败原因有三个一个是权限不够换管理员身份运行一个是杀毒软件拦截了钩子安装看拦截日志还有一个是代码里把钩子装到了某个不会跑消息循环的线程低级钩子需要线程消息循环驱动Qt 主线程没问题但你如果自己建了个 QThread 往里面装钩子那就要当心了。另一个常见问题是“录到一半事件突然不增加了”。这多半是因为 Qt 主线程被某个耗时操作阻塞了比如在界面线程里做了大文件读写或网络请求。低级钩子的回调依赖消息循环主线程一卡钩子就停止被调用。解决办法是保证主线程空闲耗时任务一律丢给工作线程。还有一个体验问题录的宏文件太大。前面说了鼠标移动事件太密集30 毫秒以内的移动直接丢弃立竿见影。5.2 回放端问题点不中、按不对、越跑越偏回放端的问题通常比录制端更隐蔽。点击不生效先确认 SendInput 的返回值返回 0 说明调用失败马上检查 GetLastError。如果返回值正常但还是点不中那十有八九是焦点问题目标窗口不是当前激活窗口。宏的第一步最好安排成点击目标窗口标题栏强制激活。坐标偏移是宏回放的经典难题。换了分辨率、换了屏幕缩放比例、窗口移动过位置都会导致坐标对不上。我在 JSON 里保存了录制时的屏幕分辨率加载时如果发现和当前分辨率不一致就在日志里醒目提示。真要跨环境使用可以考虑把坐标按分辨率归一化存储回放时再还原但这也只能缓解不能根治因为窗口本身的可变位置没法凭空推断。按键回放出现“卡键”也很常见。如果录制过程中程序崩溃或者用户强制停止导致某个键的 KeyUp 没被记录回放时系统就会一直认为该键处于按下状态。我在 Recorder 里加了一个保护逻辑开始录制时重置所有修饰键状态用 keybd_event 把 Ctrl、Shift、Alt 都设置为抬起防止脏状态带到下一次录制。5.3 控制键与线程安全细节控制键这一环本质是要区分“控制宏的键”和“宏要录的键”。最稳妥的做法是录制状态下把控制键从事件列表里滤掉但这会导致 Qt 界面的 QShortcut 收不到按键事件需要界面改用按钮来控制。如果非要全局快捷键不可可以考虑 RegisterHotKey它走的是另一条热键通道和低级钩子互不干扰但 RegisterHotKey 注册的热键也会被系统级别的逻辑消费依然进不了录制列表。线程安全方面记住三条铁律第一钩子回调里不要直接操作 Qt 界面控件所有 UI 更新通过信号发到主线程第二回放线程访问 m_records 前确保录制线程已经彻底停止再拷贝数据或者在 ReplayThread 构造时传入一份深拷贝第三停止回放用 QAtomicInt 而不是普通 bool避免多线程读写不同步。严格遵守这三条这个 Demo 的稳定性会显著提升。做这个 Demo 的过程里我最大的体会是宏录制真正的难点从来不在“怎么录”而在“回放时怎么应对环境变化”。它是一台忠实的录像机但不是一台聪明的指挥家。与其指望宏能智能识别一切不如在设计阶段就把边界定清楚固定环境、固定目标窗口、固定操作路径宏才能稳定发挥。我建议你先从最简单的“录完能放、按键能到位”这个小闭环跑通再逐步加循环、加脚本编辑、加坐标归一化。地基打牢之后这个引擎往任意方向长都容易。
返回列表