
简介这份基于 Python 的王者荣耀自动化脚本学习包面向希望理解游戏辅助实现思路、练习图像识别与键鼠模拟的开发者也可作为 Python 小型项目的拆解案例帮助读者从配置、源码到运行依赖完整观察一个自动化脚本的组成。压缩包共 62 个文件约 667KB以 38 个 json 配置、6 个 py 源码和 8 个 pyc 编译文件为核心另含 exe、dll 运行依赖、docx 使用说明及少量 license、md 等辅助文件体量轻便类型覆盖配置、代码与可执行环境。内容围绕屏幕截图识别、事件监听响应、游戏逻辑控制、多线程并发和异常处理等环节展开目录中可见主入口、公共模块、调试截图、adb 工具及多分辨率适配配置等结构线索便于按模块阅读与二次研究。已有 23957 人学习或下载适合具备一定 Python 基础、想借鉴自动化项目组织方式、图像处理流程和排错思路的读者参考。1. 从截屏到点击一套 Python 手游自动化脚本到底解决了什么很多人第一次拆 Python 手游自动化脚本包会把注意力全放在识别算法上实际跑起来最拖后腿的往往是截屏链路ADB 一次screencap抖动两三百毫秒模板匹配再准也会被拖成慢动作。这个资源包把采集、识别、决策、执行拆成四层核心用 ADB 抓屏、OpenCV 做图像识别、Python 状态机调度点击适合拿来做自动化测试练习、UI 遍历和 CV 入门不适合直接扔进线上竞技环境。下面按能复现的顺序拆先讲设备链路再讲识别阈值最后把状态机和避坑现场摊开。2. 环境与设备链路把 ADB 截屏延迟压到可接受范围2.1 依赖选型为什么用 ADB OpenCV 而不是纯坐标脚本纯坐标脚本的写法很诱人记下几个屏幕坐标input tap一调流程就跑起来了。问题在于坐标脚本把“界面状态”硬编码进了代码里分辨率一变、状态栏高度一变、弹窗位置一变整套坐标全废。我见过最典型的翻车现场是换了一台同品牌但系统版本不同的设备状态栏多了 12 像素点击全部落在按钮边缘脚本看起来在跑实际一直在空点。坐标脚本不是不能用它适合固定设备、固定分辨率、固定 UI 的回归测试不适合多设备、多分辨率的手游自动化场景。ADB 的价值在于把“设备控制”抽象成命令行截屏、点击、滑动、按键、安装应用都能走同一条链路模拟器和真机都能接。OpenCV 的价值在于把“界面判断”从坐标变成图像证据按钮长什么样、弹窗有没有出现、结算页有没有加载完都可以用模板匹配给出一个可量化的分数。Python 在这两层之间做胶水subprocess调 ADBnumpy接图像cv2做匹配状态机负责调度。脚本包一般会拆成采集层、识别层、决策层、执行层四个目录采集层只负责拿图识别层只负责返回坐标和置信度决策层判断下一步执行层封装点击和滑动。这样拆的好处是排错时能快速定位图不对查采集分数低查识别点不动查执行循环卡死查决策。不选深度学习的原因也简单这类脚本的识别目标通常是固定按钮、固定图标、固定弹窗模板匹配足够用部署成本低阈值可解释。换成一个目标检测模型标注、训练、推理环境、模型体积都会把脚本包变重新手拿到手第一关就卡在环境上。模板匹配的边界也要提前说清楚它对旋转、缩放、半透明遮罩、动态特效比较敏感所以后面要补多尺度匹配和 ROI 裁剪不能指望一个阈值打天下。2.2 从零跑通设备连接与截屏先把设备链路跑通再谈识别。模拟器启动后adb devices必须看到device状态offline和unauthorized是两种不同的问题offline多半是 adb 服务或模拟器端口没接好unauthorized是设备端没有授权调试。多设备环境要显式带-s指定序列号否则 adb 会随机抢设备脚本跑着跑着点到另一台机器上这种问题查日志都很难看出来。下面这组命令先把设备信息、分辨率、密度和单帧截屏走一遍。# 查看设备在线状态多设备时记下序列号 adb devices -l # 确认系统版本、屏幕分辨率和密度后面坐标缩放要用 adb shell getprop ro.build.version.release adb shell wm size adb shell wm density # 单帧截屏到本地先验证链路是否通 adb exec-out screencap -p ./assets/screen.png逻辑说明adb devices -l输出里的device才是可用状态offline先重启 adb 服务unauthorized去设备端确认调试授权。wm size给的是物理分辨率wm density影响 UI 缩放这两个值要记进配置文件后面模板目录按分辨率分组。exec-out screencap -p直接把 PNG 二进制写到标准输出比adb shell screencap再adb pull少一次落盘截屏延迟通常能低一截。参数说明-s emulator-5554用于多设备指定序列号-p表示 PNG 格式不加会得到 raw 帧Python 侧还要自己拼图像头。截屏文件先落到./assets/screen.png只是为了验证正式脚本里不落盘直接在内存里解码减少磁盘 IO。注意adb exec-out在部分旧版本 adb 上会把二进制里的换行符转掉导致 PNG 解码失败遇到imdecode返回None先升级 adb 或改用screencap管道写法。Python 侧把截屏封装成一个函数返回值是 OpenCV 的 BGR 图像。这里用subprocess.check_output拿二进制np.frombuffer转uint8再cv2.imdecode解成三通道图。timeout3是保护设备卡死时不要让脚本无限等。解码失败要立刻抛异常不能返回空图继续往下走否则后面模板匹配会报一个更难懂的 shape 错误。import subprocess import cv2 import numpy as np ADB [adb, -s, emulator-5554] # 多设备时显式指定避免抢设备 def grab_screen() - np.ndarray: # exec-out 直接拿 PNG 二进制减少一次 pull 落盘 raw subprocess.check_output( ADB [exec-out, screencap, -p], timeout3 ) img cv2.imdecode(np.frombuffer(raw, dtypenp.uint8), cv2.IMREAD_COLOR) if img is None: raise RuntimeError(截屏解码失败先检查 PNG 头和 adb 版本) return img逻辑说明ADB列表把 adb 路径和序列号固定下来后面所有命令复用避免每个函数里重复拼字符串。check_output会在 adb 返回非零时抛CalledProcessError比run更早暴露设备断开。imdecode用IMREAD_COLOR保留 BGR 三通道模板匹配时屏幕图和模板图通道数一致减少颜色空间转换的坑。参数说明timeout3是单帧截屏上限模拟器冷启动时第一帧可能超过这个值正式跑之前先手动截一帧热身。emulator-5554只是示例序列号真机换成adb devices里的实际值。np.frombuffer不复制内存imdecode会生成新图像所以raw生命周期结束后图像仍然有效。如果设备是 720×1280 而模板按 1080×1920 裁的后面匹配分数会很低不要先怀疑算法先核对wm size。2.3 截屏链路的耗时测量与优化截屏链路不测速后面所有“识别慢”的讨论都是拍脑袋。我一般会连续采 20 帧统计中位数和 P95中位数看常态P95 看卡顿。adb exec-out和adb shell screencap pull的差距在模拟器上尤其明显后者多一次文件落盘和进程通信P95 很容易翻倍。下面这段代码只做一件事把截屏耗时打出来不改业务逻辑。import time from statistics import median def measure_grab(n: int 20) - None: costs [] for _ in range(n): t0 time.perf_counter() _ grab_screen() costs.append((time.perf_counter() - t0) * 1000) costs.sort() p95 costs[int(len(costs) * 0.95) - 1] print(fmedian{median(costs):.1f}ms p95{p95:.1f}ms max{costs[-1]:.1f}ms)逻辑说明perf_counter比time.time适合测短间隔不受系统时间调整影响。采样 20 次足够看趋势采样太少会被第一帧冷启动拉偏采样太多会让调试变慢。p95取排序后 95% 位置反映最慢的那一小撮帧脚本卡死往往就是这些帧把状态机拖超时。参数说明n20是采样次数设备慢可以降到 10设备快可以加到 50。输出单位是毫秒median低于 120ms 算比较顺P95 超过 300ms 就要看是不是模拟器渲染模式或 adb 版本问题。优化顺序建议先换exec-out再降分辨率再裁 ROI最后才考虑异步截屏。降分辨率会改变模板尺寸改完必须重截模板不能拿旧模板硬匹配。提示adb shell wm size 720x1280可以临时改分辨率但部分模拟器改完要重启应用才生效改之前把原始分辨率记下来调试完用wm size reset还原。3. 模板匹配与多分辨率适配把识别准确率从“玄学”拉到可量化3.1 模板匹配原理与阈值选择模板匹配做的事情很朴素拿一张小图在屏幕大图上滑动逐个位置算相似度取最高分的位置。OpenCV 里常用TM_CCOEFF_NORMED它对亮度线性变化有一定容忍度返回分数在 -1 到 1 之间越接近 1 越像。阈值不是越高越好设到 0.95界面稍微加一点半透明遮罩就匹配不上设到 0.6背景里相似的图标会疯狂误报。我一般从 0.86 起步按实际截图看分数分布再调。识别这块最容易变成玄学原因是很多人只看一张图调阈值没有把成功帧和失败帧的分数都打出来。阈值选择要配合 ROI。全屏匹配 1080×1920 的图模板 80×80计算量不小而且背景里相似元素多误匹配概率高。把匹配区域裁到按钮可能出现的下半屏或弹窗区域分数分布会干净很多。ROI 的坐标也要按分辨率缩放不能写死。灰度匹配能进一步降计算量但如果按钮靠颜色区分比如红色确认、灰色取消灰度会把颜色信息丢掉这种场景保留彩色更稳。模板匹配对旋转很敏感手游 UI 一般不会旋转按钮但动画缩放、刘海屏遮罩、圆角裁切都会影响边缘裁模板时尽量避开半透明边缘和动态特效。模板匹配的另一个边界是“同图不同态”。同一个按钮有可点击、灰色不可点击、按下态三种外观只做一个模板就会出现“明明看到了却匹配不上”。常见做法是每个状态一个模板命名带状态后缀识别层返回最高分模板的名字决策层根据名字判断能不能点。这样比调低阈值硬匹配更可控也不会把灰色按钮误判成可点击。3.2 制作模板与目录规范模板必须从实际设备截屏里裁不能用官方宣传图或设计稿。宣传图经过压缩、缩放、加滤镜像素分布和真实截屏差很多拿它做模板阈值调到最后会怀疑人生。裁模板时把按钮完整包进去边缘留 2 到 4 像素背景不要裁到按钮边缘刚好切齐切齐后缩放一次就匹配不上。模板目录按分辨率分组比如assets/templates/720x1280/和assets/templates/1080x1920/文件名带业务含义和状态比如btn_confirm_normal.png、btn_confirm_disabled.png。这样换设备时先选目录再谈阈值排错路径清楚。下面这个函数做单模板匹配返回中心坐标和分数。max_loc是匹配区域左上角加半个模板宽高才是点击中心。分数低于阈值返回None调用方必须处理None不能直接拆包否则脚本会在一次误识别后崩掉。def match_template(screen, template_path, threshold0.86): template cv2.imread(template_path, cv2.IMREAD_COLOR) if template is None: raise FileNotFoundError(template_path) # 模板不能比屏幕大否则 matchTemplate 直接报错 if template.shape[0] screen.shape[0] or template.shape[1] screen.shape[1]: return None res cv2.matchTemplate(screen, template, cv2.TM_CCOEFF_NORMED) _, max_val, _, max_loc cv2.minMaxLoc(res) if max_val threshold: return None h, w template.shape[:2] return max_loc[0] w // 2, max_loc[1] h // 2, float(max_val)逻辑说明imread用彩色读入和截屏保持一致matchTemplate返回分数矩阵minMaxLoc取最大值位置。max_loc是左上角点击要的是中心所以加w//2和h//2。返回None比返回(-1, -1, 0)更好排查调用方用if pos is None就能分流到重试或恢复状态。参数说明threshold0.86是起点不是固定值按钮带半透明遮罩可以降到 0.82背景复杂可以升到 0.9。template_path建议用绝对路径或基于脚本根目录的路径不要用相对当前工作目录的路径否则换个启动目录就找不到模板。TM_CCOEFF_NORMED对亮度变化比TM_SQDIFF稳但对纯色块不敏感纯色按钮要加边缘特征或换TM_CCORR_NORMED对比。3.3 多分辨率适配缩放、ROI 与失败重试多分辨率适配最省事的做法是“每个分辨率一套模板”最通用的做法是“一套模板多尺度匹配”。前者准确率高但维护量大后者代码复杂一点但换设备不用重截。实际脚本包里常见的是混合策略主分辨率用原尺度模板其他分辨率用多尺度匹配兜底分数低于阈值再切到对应分辨率模板目录。多尺度不要开太密0.8到1.2步长0.1已经够用步长太密计算量翻倍收益很小。def match_multiscale(screen, template, scales(0.8, 0.9, 1.0, 1.1, 1.2), threshold0.86): best None for s in scales: t cv2.resize(template, None, fxs, fys, interpolationcv2.INTER_AREA) if t.shape[0] screen.shape[0] or t.shape[1] screen.shape[1]: continue res cv2.matchTemplate(screen, t, cv2.TM_CCOEFF_NORMED) _, max_val, _, max_loc cv2.minMaxLoc(res) if best is None or max_val best[2]: h, w t.shape[:2] best (max_loc[0] w // 2, max_loc[1] h // 2, float(max_val)) return best if best and best[2] threshold else None逻辑说明每个尺度都做一次匹配记录最高分和对应中心点。INTER_AREA适合缩小边缘更干净放大可以用INTER_LINEAR但手游模板一般只缩小不放大。best保存的是缩放后模板的中心不是原模板中心点击坐标要按当前尺度算不能拿原模板宽高去加。参数说明scales覆盖 0.8 到 1.2对应 20% 的尺寸波动超过这个范围建议换模板目录。threshold在多尺度下可以比单尺度低 0.02 到 0.04因为缩放会损失细节但不要低到 0.8 以下否则误报会明显增加。ROI 裁剪时把screen先切片再传入返回坐标要加回 ROI 偏移这一步很容易漏漏了就会点偏。参数建议值说明threshold0.840.90单尺度取高多尺度取低 0.020.04scales0.81.2步长 0.1步长太密计算量翻倍收益很小roi按业务裁下半屏或弹窗区必须按分辨率缩放不能写死重试次数23 次每次重试前重新截屏不要复用旧图注意多尺度匹配返回的分数只适合同一张模板内部比较不同模板之间的分数不能直接横比判断“哪个按钮更像”要用同阈值下的业务优先级不要只看分数。4. 状态机与点击执行让脚本从“能点”变成“会等”4.1 状态机设计IDLE、SEARCH、CLICK、WAIT、RECOVER脚本能点不等于会等。很多新手脚本的写法是“截屏、匹配、点击、再截屏”中间没有等待条件界面动画还没结束就点下一帧点击全部落在过渡态上。状态机的作用是把“现在该干什么”和“下一步什么时候干”分开。常见状态有IDLE初始化、SEARCH找目标、CLICK执行点击、WAIT等界面变化、RECOVER异常恢复。每个状态有超时超时进RECOVER恢复失败回IDLE不能无限循环。from enum import Enum import time class State(Enum): IDLE 1 SEARCH 2 CLICK 3 WAIT 4 RECOVER 5 def run_once(ctx, max_wait3.0): state State.SEARCH deadline time.time() max_wait while time.time() deadline: if state State.SEARCH: pos ctx.find_target() state State.CLICK if pos else State.WAIT elif state State.CLICK: ctx.tap_target() state State.WAIT deadline time.time() max_wait elif state State.WAIT: if ctx.page_changed(): return True state State.SEARCH time.sleep(0.15) return False逻辑说明SEARCH只负责找找到进CLICK找不到进WAITCLICK执行后重置等待截止时间给界面动画留时间WAIT检测页面变化变化了返回成功没变化回SEARCH。sleep(0.15)是轮询间隔太短会疯狂截屏太长会错过短动画。参数说明max_wait3.0是单次流程上限弹窗多的页面可以放到 5 秒sleep(0.15)在模拟器上够用真机可以降到 0.1。page_changed不要用整图哈希动画和时钟会让哈希一直变用关键区域均值或模板分数变化更稳。状态机里不要写业务点击细节点击封装在ctx.tap_target里换设备只改执行层。4.2 点击动作封装tap、swipe、长按与随机化边界点击封装要处理三件事坐标缩放、动作时长、失败重试。坐标缩放按wm size的比例算模板匹配返回的是当前截图坐标如果截图被降分辨率点击坐标也要按比例放大回物理坐标。input tap没有时长参数长按要用input swipe x y x y durationduration单位毫秒。滑动用input swipe x1 y1 x2 y2 durationduration太短会被识别成快速划动太长会变成慢拖。随机化只建议做 1 到 2 像素的抖动用来模拟人手点击的微小误差不要做大范围随机否则点击会漂到按钮外面。import random import subprocess def tap(x: int, y: int, jitter: int 2, timeout: float 2.0) - None: # 抖动控制在 2px 内只抵消触控采样误差不改变目标区域 x random.randint(-jitter, jitter) y random.randint(-jitter, jitter) subprocess.run( ADB [shell, input, tap, str(x), str(y)], checkTrue, timeouttimeout ) def long_press(x: int, y: int, duration_ms: int 600) - None: subprocess.run( ADB [shell, input, swipe, str(x), str(y), str(x), str(y), str(duration_ms)], checkTrue, timeout2.0 )逻辑说明tap先加抖动再拼命令checkTrue让 adb 返回非零时抛异常交给上层重试。long_press用起点终点相同的swipe实现长按duration_ms控制按住时间。两个函数都不做重试重试放在装饰器或状态机里避免一次点击失败后连续补点。参数说明jitter2是像素级抖动1080 屏上很小720 屏上可以降到 1timeout2.0是 adb 命令超时设备卡顿时不要设太小。duration_ms600适合大多数长按部分游戏需要 800 到 1000按实际 UI 反应调。点击前最好确认设备在线adb devices里没有设备时input tap会直接报错不会静默失败。4.3 日志、重试与异常恢复脚本跑崩不可怕可怕的是崩了不知道崩在哪。日志要记录时间、状态、截图文件名、匹配分数、点击坐标、异常堆栈。失败时自动存一张截图到logs/fail_时间戳.png后面复现全靠它。重试装饰器只包幂等动作比如截屏、匹配、查询状态点击这种有副作用的操作要谨慎重试点一次没反应先截屏看界面不要盲目补点。下面这个装饰器做最基础的重试times3、delay0.5异常最后一次抛出。import time import logging def retry(times: int 3, delay: float 0.5): def deco(fn): def wrapper(*args, **kwargs): last None for i in range(times): try: return fn(*args, **kwargs) except Exception as e: last e logging.warning(retry %s/%s: %s, i 1, times, e) time.sleep(delay) raise last return wrapper return deco逻辑说明每次异常记一条 warning带上当前重试次数和异常信息最后一次仍然失败就把异常抛出去让状态机进RECOVER。delay给设备一点恢复时间截屏失败可能是 adb 瞬时卡顿连续快速重试只会更卡。参数说明times3适合大多数读操作点击类操作建议单独写重试策略先检测界面再决定要不要补点。delay0.5秒是经验值模拟器可以短一点真机可以长一点。日志级别用warning而不是error因为重试成功不算错误但要在日志里留下痕迹。失败截图命名带时间戳和状态名比如fail_SEARCH_20250101_120000.png后面按状态筛图很快。提示日志里不要只写“点击失败”要写“在 SEARCH 状态匹配 btn_confirm_normal 分数 0.81 低于阈值 0.86截图 xxx.png”否则复盘时只能靠猜。5. 避坑与排查六个把脚本跑崩的真实现场5.1 设备链路类现象adb devices里设备显示offline脚本一启动就抛CalledProcessError。原因通常是 adb 服务端和模拟器端口没接好或者上一次 adb 进程残留占着端口。解决顺序是先adb kill-server再adb start-server然后重新插拔或重启模拟器多设备环境还要确认没有两个 adb 版本抢服务。血泪经验是不要把adb路径写死到某个 IDE 自带目录系统 PATH 里换一个版本行为就可能不一样。adb kill-server adb start-server adb devices -l逻辑说明kill-server清掉旧服务start-server按当前 adb 版本重新起devices -l看序列号和状态。参数说明-l会多打印设备型号和传输信息排查多设备时有用如果仍然offline换 USB 口或模拟器 adb 端口不要继续在脚本里加重试。现象截屏成功但图片全黑或者只有状态栏。原因多半是模拟器 GPU 渲染模式导致screencap抓不到画面真机上则可能是应用做了安全遮罩。模拟器先切软件渲染或兼容渲染模式试一次确认不是脚本问题如果只有某个页面黑屏先手动截一张对比排除应用层限制。解决方式不是改匹配阈值而是换截屏方式或换渲染模式黑图做模板匹配只会得到随机分数。现象exec-out截屏偶尔解码失败imdecode返回None。原因是部分 adb 版本会把二进制里的\n转成\r\nPNG 头被破坏。解决方式是升级 adb或者改用adb shell screencap -p /sdcard/s.png再adb pull代价是多一次落盘。脚本里要保留解码失败分支失败时存原始二进制到logs/raw_fail.bin拿十六进制看一眼文件头是不是89 50 4E 47比盲猜快。5.2 识别与点击类现象模板匹配分数一直在 0.6 到 0.7 之间阈值降到 0.6 后误报满天飞。原因通常不是阈值而是模板来源不对模板从另一台分辨率截的或者从宣传图裁的像素分布和当前屏幕差太多。解决方式是回到当前设备截一张干净图按实际按钮重新裁模板模板边缘留 2 到 4 像素背景裁完先用单张截图离线跑一遍看分数能不能到 0.9 以上再进在线流程。多分辨率场景先选对模板目录再开多尺度顺序反了会越调越乱。现象识别到了按钮点击也执行了但坐标偏了十几像素偶尔点到隔壁按钮。原因一般是截图被降分辨率点击坐标没有按wm size比例放大回物理坐标或者 ROI 裁剪后返回的坐标没有加回 ROI 偏移。解决方式是统一坐标空间识别层返回的坐标永远基于原图ROI 偏移在识别层内部加回去执行层只做物理坐标缩放。点击前打一条日志把截图尺寸、ROI 偏移、最终点击坐标都打出来偏一次就能对上。def to_physical(x, y, shot_w, shot_h, real_w, real_h): # 截图被缩放时点击坐标要按比例还原到物理分辨率 return int(x * real_w / shot_w), int(y * real_h / shot_h)逻辑说明四个尺寸参数分别来自截图和wm size比例算完取整。参数说明如果截图和物理分辨率一致比例是 1函数不会改变坐标如果只缩放了宽度高度比例也要单独算不要用一个比例套两个方向。点击偏移排查先看这条日志不要先改模板。5.3 状态与日志类现象脚本在某个页面来回点日志里 SEARCH 和 WAIT 反复切换永远不结束。原因是等待条件写成了“页面没变化就继续找”但页面本身有倒计时或动画永远在变或者点击后没有重置超时状态机在旧截止时间里打转。解决方式是给每个状态单独超时WAIT用关键区域变化而不是整图哈希连续 N 次没变化就进RECOVERRECOVER里做返回键、关闭弹窗、重启应用三档恢复恢复失败直接退出并保留截图。不要用while True包住整个流程外层必须有最大轮次。现象脚本跑了一晚上第二天只看到“点击失败”没有截图、没有分数、没有状态。原因是日志只记了异常消息没记上下文。解决方式是把日志格式固定成“时间 状态 动作 目标 分数 坐标 截图”异常时自动存失败帧正常帧每 50 帧存一张关键帧。日志文件按天切分别让一个文件涨到几百兆。复盘时先看失败帧再看分数分布最后才改代码没有失败帧的排查基本靠猜后悔药都来不及。import logging logging.basicConfig( filenamelogs/run.log, levellogging.INFO, format%(asctime)s %(levelname)s %(message)s ) logging.info(stateSEARCH targetbtn_confirm score0.91 pos(540,1200))逻辑说明basicConfig固定格式和文件业务里只传结构化字段。参数说明levelINFO保留正常流程调试期可以开DEBUG打分数矩阵尺寸日志文件不要和截图混在一个目录按logs/run/和logs/fail/分开清理时不容易误删。6. 进阶验证用回放与指标表判断脚本是否真的稳定脚本能不能用不看它第一次跑通看它连续跑 30 次后的失败分布。我一般会攒一个最小回放集从成功和失败流程里各截 10 到 15 帧存到assets/replay/每帧配一个期望结果目标模板名或None。改阈值、改 ROI、换模板之前先跑回放识别结果和期望对不上就不进真机。回放代码很短核心是把离线帧当屏幕图喂给同一个匹配函数保证线上线下走同一套逻辑。def replay(cases, matcher): ok 0 for path, expect in cases: img cv2.imread(path, cv2.IMREAD_COLOR) pos matcher(img) got hit if pos else miss if got expect: ok 1 else: print(mismatch, path, expect, expect, got, got) print(freplay accuracy{ok / len(cases):.2%})逻辑说明cases是(图片路径, 期望)列表matcher复用线上匹配函数避免回放和线上两套代码。参数说明期望值用hit/miss比用具体坐标稳坐标会随分辨率变命中与否是业务底线。回放准确率低于 90% 先别上真机先把失败帧挑出来看是模板问题还是 ROI 问题。真机验证看四个指标识别成功率、点击后状态变化率、单步耗时中位数、P95 耗时。下面这张表可以直接抄到自己的记录里连续跑 30 轮填一次比“感觉挺稳”靠谱。指标计算方式观察阈值识别成功率命中帧 / 总帧低于 90% 查模板和 ROI状态变化率点击后页面变化次数 / 点击次数低于 80% 查点击坐标和等待时间单步耗时中位数截屏 匹配 点击的中位数超过 500ms 查截屏链路P95 耗时95% 分位耗时超过 1.5s 查设备卡顿和重试我后来养成一个习惯每次改阈值前先跑一遍回放把失败帧截图存下来再决定是改模板、改 ROI 还是改等待时间。这个习惯比加更多点击逻辑更省时间也能避免“改一个阈值崩三个页面”的连锁翻车。原始脚本包里的模板目录和日志模块先别急着替换按第 2 章的链路自检跑通再动识别参数需要原始包的话按文末说明获取。希望帮到你。本文还有配套的精品资源点击获取