换台电脑脚本就全废?KeymouseGo 怎么把“录制“变成一套可移植的事件流水线
换台电脑脚本就全废?KeymouseGo 怎么把"录制"变成一套可移植的事件流水线
【免费下载链接】KeymouseGo类似按键精灵的鼠标键盘录制和自动化操作 模拟点击和键入 | automate mouse clicks and keyboard input项目地址: https://gitcode.com/gh_mirrors/ke/KeymouseGo
你花一晚上录好的一套鼠标键盘操作脚本,换到同事那台 2K 显示器、150% 缩放的机器上一跑——所有点击全飘了。这个场景几乎每个玩过"录制回放"类工具的人都经历过。KeymouseGo 作为一款开源跨平台鼠标键盘录制与自动化工具(基于 Python + PySide6),把"录下来能用"变成了"录下来能在别处用"。这篇文章不讲它怎么"好用",只讲它是怎么把坐标、事件、时序这三件最容易翻车的工程问题逐一摁住的。
坐标为什么会飘?问题出在 DPI,不在分辨率
先说结论:脚本跨机器失效,九成是坐标系统的问题,而坐标系统的问题里,DPI 缩放比分辨率更阴险。
分辨率差异是显性的——你 1920×1080,对方 2560×1440,像素坐标直接错位,这谁都能想到。但 DPI 缩放是隐性的:Windows 默认给高分屏做 125%/150% 的全局缩放,逻辑坐标和物理像素之间隔了一层。你录的时候拿到的是逻辑坐标,回放时若直接当物理坐标用,GetSystemMetrics返回的屏幕尺寸和你看到的不一致,点击自然偏移。KeymouseGo 的 Windows 事件实现里有一行不起眼的代码是整套方案的基石:
user32.SetProcessDPIAware() SW, SH = user32.GetSystemMetrics(0), user32.GetSystemMetrics(1)读法提示:进程声明自己感知 DPI 之后,拿到的屏幕尺寸才是"真实像素",否则 Windows 会塞给你一套被缩放过的假尺寸,坐标转换从源头就是错的。
再看它对坐标的处理思路,其实是一套"存取分离"的策略:录制时存相对值,回放时算绝对值。三套坐标方案各有取舍,直接对比:
| 方案 | 实现原理 | 跨机器可靠性 | 结论/取舍 |
|---|---|---|---|
| 绝对像素坐标 | 录制时原样存(x, y) | 换分辨率必废 | 只能同一台机器自录自放,放弃可移植性 |
| 相对百分比坐标 | 存(x/宽, y/高) | 分辨率变化可适应 | 简单可靠,是 Linux/macOS 与单屏 Windows 的默认方案 |
| 归一化坐标(0~65535) | 回放时用mouse_event的绝对模式映射 | 兼容 DPI 缩放的 Win10 环境 | Windows 下的增强方案,代价是代码分支变多 |
KeymouseGo 的取舍很务实:跨平台实现(UniversalEvents.py)走相对百分比路线,Windows 实现则优先用归一化坐标打通MOUSEEVENTF_ABSOLUTE。这套"录制存比例、回放做换算"的思路,正是它敢说脚本可移植的底气。
别小看那个 0~65535:一条被压扁又拉开的坐标轴
如果你点开 Windows 事件类看执行逻辑,会发现它没有简单调用SetCursorPos,而是把坐标塞进了mouse_event:
if isinstance(x, int) and isinstance(y, int): if numofmonitors > 1: win32api.SetCursorPos([x, y]) else: nx = int(x * 65535 / SW) ny = int(y * 65535 / SH) win32api.mouse_event(win32con.MOUSEEVENTF_ABSOLUTE | win32con.MOUSEEVENTF_MOVE, nx, ny, 0, 0)读法提示:MOUSEEVENTF_ABSOLUTE要求坐标落在 0~65535 的归一化区间内,Windows 内部再按当前缩放比例把它映射回真实屏幕——这相当于把"DPI 适配"这件事甩给了系统层,而不是自己在像素层面硬算。
多显示器时退回SetCursorPos也很有意思:MOUSEEVENTF_ABSOLUTE在多屏下映射的是主屏坐标,副屏坐标会算错,所以这里按numofmonitors > 1做分支。代码里处处是这种"按环境切策略"的痕迹,而不是假设一个完美世界。
跨平台的真相:不是适配系统,是抹平系统
说到跨平台,多数人会想到"分别调用 Win32 / Quartz / X11 的 API"。KeymouseGo 的做法更彻底:在事件层把平台差异整个抹掉,上层只认一种"事件"。
三种平台的输入机制差异巨大:
| 平台 | 底层事件来源 | 录制捕获方式 | 回放注入方式 | 结论/取舍 |
|---|---|---|---|---|
| Windows | Win32 消息循环 | 低层鼠标钩子 WH_MOUSE_LL | mouse_event/keybd_event | 精度高、可捕获侧键,但代码平台耦合深 |
| macOS | Quartz Event Services | pynput 监听 | pyautogui 注入 | 简洁统一,牺牲部分底层控制力 |
| Linux | X11 / Wayland | pynput 监听 | pyautogui 注入 | 与 macOS 共用一套实现,覆盖面广 |
所有事件在Event.py里被抽象成一个统一对象——delay、event_type、action_type、action四个字段,外加一个execute()方法:
class Event(metaclass=ABCMeta): def __init__(self, content: Dict[str, Any]): for key in ['delay', 'event_type', 'action_type', 'action']: setattr(self, key, content[key]) @abstractmethod def execute(self, thd=None): pass读法提示:这个抽象类把"事件长什么样"和"事件怎么执行"彻底分开,录制端只负责拼装字典,回放端只负责调用execute(),平台差异全部下沉到子类。
真正的分发逻辑更简单粗暴——Event/__init__.py里根据platform.system()一行import决定用哪个子类,把WindowsEvent或UniversalEvent统一暴露成ScriptEvent。录制器、执行器、插件全都只认ScriptEvent这个名字,谁也不需要关心底下是 Win32 还是 pynput。这个模式的价值在于:以后要支持新平台,只写一套事件子类和一套录制器,其余代码零改动。
时序精度不是"sleep 得准",而是"睡得能被叫醒"
录制回放工具最容易被低估的难点是时序。录制时每个事件记录"距上个事件的间隔",回放时原样复现。听起来简单,但有两个坑:一是录制端鼠标移动事件太密,全录下来脚本体积爆炸、回放卡顿;二是回放端如果只用time.sleep,用户点"暂停/停止"后线程无法立刻响应,只能干等剩余延时。
录制端的降噪逻辑在WindowsRecorder.py里一目了然:
delay = globalv.current_ts() - globalv.latest_time mouse_move_interval_ms = globalv.mouse_interval_ms or 999999 if action_type == 'mouse move' and delay < mouse_move_interval_ms: return True读法提示:鼠标移动事件被设置了"节流阈值"——间隔小于阈值的移动直接丢弃,只保留关键轨迹点,录制出的脚本体积和回放负载都能降一个量级,且不会影响最终点击位置。
回放端的精髓则在RunScriptMeta里——它把sleep换成了条件变量等待:
def sleep(self, msecs: int): mutex.lock() cond.wait(mutex, QDeadlineTimer(int(msecs))) mutex.unlock() def resume(self): mutex.lock() cond.wakeAll() mutex.unlock()读法提示:cond.wait带超时就是"睡到点自动醒",而wakeAll能让暂停/停止信号立刻中断睡眠——所以暂停响应是毫秒级的,而不是等当前延时走完。这是把"死等"变成"可打断的等",工程上差一个量级的体验。
脚本不是"事件数组",是一张跳转图
普通录制工具把脚本存成一串数组,回放时从头扫到尾,遇到循环就傻眼。KeymouseGo 的Parser.py把 JSON5 脚本在加载时倒序"编织"成一张链表式的控制流图:next_object指向下一条,next_object_if_false留给条件分支,goto通过 label 索引直接跳转。
elif object_type == 'if': current_object.next_object = \ ScriptParser.link_objects(content['do'], target_object, label_maps, pending_dict) current_object.next_object_if_false = \ ScriptParser.link_objects(content['else'], target_object, label_maps, pending_dict)读法提示:if分支被拆成两条链分别递归构建,回放时执行器只需沿next_object或next_object_if_false走,判断逻辑天然落位——脚本文件里没有一行控制流代码,控制流完全由链表结构表达。
老版本脚本(纯数组格式)由LegacyParser兜底解析,同样倒序建成链表。这意味着"新引擎 + 旧脚本"能共存,升级工具不报废存量资产,这是很多同类工具做不到的向后兼容。
插件化:把"改源码"降级成"丢个文件夹"
坐标漂移这种问题,靠内置策略永远堵不干净——总有人有怪异的显示器组合或缩放需求。KeymouseGo 的答案不是把代码写得更全,而是开放插口:PluginManager启动时扫描plugins目录,读取每个子目录里的manifest.json5,用SourceFileLoader动态加载插件类,并把插件注册的函数收进一张全局字典:
loader = SourceFileLoader(manifest['plugin_class'], os.path.join(entry.path, manifest["entry"])) plugin_module = loader.load_module() plugin_class = getattr(plugin_module, manifest['plugin_class']) PluginManager.plugins.append(plugin_class(manifest))读法提示:manifest声明了入口文件和类名,加载器按图索骥完成实例化——插件与主程序之间没有任何编译期耦合,新增一个插件本质就是"丢一个带清单的文件夹"。
脚本侧通过call字段按名字调用插件函数,执行器遇到if类型的节点时,直接把判断逻辑交给插件返回布尔值。这套设计把"坐标自适应""图像识别""条件循环"这些高级玩法全部挡在核心之外,核心引擎保持最小,扩展性交给生态——典型的微内核取向。
回到开头:那个换电脑的脚本,现在能活了吗
回看开头那个"换台电脑全飘"的场景,KeymouseGo 给出的答案是一整套组合拳:录制时存相对坐标(多显示器下除外),Windows 回放时用归一化坐标让系统层消化 DPI 缩放,Linux/macOS 靠屏幕比例换算兜底,再配合可打断的时序控制和插件化的坐标兜底方案。
所以实操建议也顺理成章:录脚本时保持单显示器、100% 缩放环境,让录制端产生干净的比例坐标;如果必须在多屏环境录制,优先选能记录屏幕尺寸信息的方案;遇到极端环境,写个坐标转换插件远比改核心代码现实。
工具的价值不在于"能把操作录下来",而在于"录下来的东西经得起换环境考验"。KeymouseGo 用事件抽象、归一化坐标、可打断延时和插件机制,把桌面自动化从"一次性宏"做成了"可移植的事件流水线"。这份工程取舍,比任何"自动化效率翻倍"的宣传都更值得开发者细读。
【免费下载链接】KeymouseGo类似按键精灵的鼠标键盘录制和自动化操作 模拟点击和键入 | automate mouse clicks and keyboard input项目地址: https://gitcode.com/gh_mirrors/ke/KeymouseGo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考