给 Unity 手游做一套「会自己躲弹窗」的真机自动化测试

一个从零到落地的真机 UI 冒烟测试工作流:录制 → 编排 → 执行 → 报告。全文都是实测数据,包含它什么时候会脆——这部分比成功经验更值钱。
一、起因:冒烟测试为什么让人烦
手游每次出包都要过一遍手工冒烟:进游戏 → 到大厅 → 进要塞 → 开商店 → 关商店 → 回大厅……
单次 2~3 分钟,但每次发版都要做,而且必须有人盯着。更烦的是:
- 跑完只有"我点过了"这句口头结论,没有留证;
- 偶发问题复现不了("刚才那次好像有个报错");
- 唯一的自动化手段是脚本模拟点击,但它不知道什么时候该点。
二、为什么"截图 + 点击"的脚本不够用
我们本来有个最朴素的方案:adb 截图、按固定坐标点、按固定时间等。
它能压测按钮,但做不了流程回归,原因有两个:
- 不知道"到位了没有"。点完进要塞要加载 28 秒,脚本只能死等;下次网络慢一点就点空了,后面全错。
- 画面会被正常弹窗盖住。隐私政策、热更下载、公告、每日签到、领奖邀请…… 这些窗口都是正常的,但它们一出来,脚本的假设("屏幕上应该是我要的界面")就崩了。
第 2 点后来被证明是整件事的核心难点。我们最初的版本遇到弹窗就超时中止——一轮白跑。
三、整体设计:四段式
① 录制(游戏内) → workflow.raw.json + 逐次点击的截图
② 编排(可视化) → workflow.final.json(每步一条前置校验条件)
③ 执行(adb 驱动) → 等条件 → 点击 → 等稳定,遇弹窗自动清
④ 报告 → report.html(每步匹配分/耗时/留证/可疑日志)
关键决策是第 ② 步:把"点击序列"升级成"带前置校验状态机"。
前置校验模型(整套设计的核心)
第 N 步的条件 = 进入第 N 步之前,屏幕上应该出现什么。
执行器每步干三件事:等本步条件满足 → 点击 → 稳定等待。
| 你的意图 | 挂在哪儿 |
|---|---|
| A→B 固定等 N 毫秒再点 B | A 的 waitAfterMs = N |
| A→B 等某画面出现再点 B | B 的 condition = screen |
| A→B 等某条日志打印再点 B | B 的 condition = log |
⚠️ 方向相反很容易搞错:条件挂在"被进入"的那一步,等待挂在"被离开"的那一步。
(我们早期就写过 off-by-one 的等待时长,导致长加载被算到了错误的一步上。)
四、录制:让程序先学会你点过哪
录制器是游戏内的一个 #if !CS_Release 调试组件,只在非 release 包里存在。
打开面板点「开始录制」(面板会自动收起,避免入镜),手动走一遍流程,三指同时点击结束。
它记录三样东西:
- 每次点击的归一化坐标(不依赖分辨率);
- 每次点击距上一次的间隔 deltaMs;
- 每次点击那一刻的截图(还有起始帧和结束帧)。
截图的语义是这套东西的地基:
step-{id}.png= 第 id 次点击那一刻的画面 = 第 id-1 步的结果(点击的副作用还没生效)。
所以它天然就是"第 id 次点击的前置条件"——这也是为什么编排里一个 ROI 都不用重录就能直接复用。
五、编排:可视化地"框出什么算到位"
编排器是个单文件 HTML(离线、无依赖),打开就能看到录制的每一步:
- 在截图上拖拽 = 框选 ROI(这一小块用来判断"到位了没有");
- 单击 = 放点击标记;
- 点某一行的日志就能生成一条 log 条件;
- 末尾自动带一个不可删除的「终态校验」步骤。
导出的 workflow.final.json 就是全部编排。
两个关键防呆设计
- 参考图锁定:screen 条件的参考图恒等于该步自己的截图,不允许选别的。 —— 我们真踩过"框选的是这张图、匹配的是另一张图",像素对比直接显示必然失败(MAE 23~53)。
- 终态校验不可删:否则流程可以"最后一步点完就宣布成功",而实际画面早就跑飞了。
六、执行:匹配怎么算,坐标怎么换算
截图匹配(简单但够用):
参考图与实况图取同一个归一化 ROI → 各自下采样到 96×96 灰度
匹配分 = 相同像素占比(单像素灰度差 > 30 记为不同)
匹配分 ≥ 阈值(默认 0.8)即通过
实测:同一帧 ≈ 1.0,不同界面 ≈ 0.65 → 0.8 落在空档里,这就是默认阈值 0.8 的来历。
坐标换算(踩过坑的地方):
- 录制存的是 Unity 坐标(下沿为 0),
input tap用显示坐标(上沿为 0):tapY = (1 - pos.y) × 屏高 - 但 ROI 是图像坐标(上沿为 0),不翻转——两套约定混用就是连续点错。
- 屏幕尺寸要取实况截图的尺寸,不能取
wm size:横屏时wm size可能返回未旋转的1080x2340,照着算会整体错位。模拟器上还遇到过wm size报竖屏、截图却是横屏的情况。
七、真正让它跑起来的,是「干扰库」
这是整套东西从"玩具"变成"能用"的分界线。
问题
隐私政策、热更下载、公告、每日签到、领奖邀请……这些是正常的,但会盖住画面,
让前置校验永远不满足。它们是已知的有限集合,不该当成故障来处理。
方案:把干扰当成一等公民
一份全局干扰库 interrupts.json,每条长这样:
{
"name": "每日签到弹窗",
"enabled": true,
"detect": { "type": "screen", "reference": "daily-checkin.png",
"roi": { "x": 0, "y": 0, "w": 1, "h": 1 }, "tolerance": 0.85 },
"handle": { "type": "tap", "pos": { "x": 0.86, "y": 0.12 } },
"settleMs": 1200,
"maxPerRun": 3
}
三类处置方式对应三种干扰性质:
| 干扰 | 处置 |
|---|---|
| 隐私政策、公告、签到、领奖 | tap —— 点掉它(配关闭坐标) |
| 热更下载 / 加载中 | wait —— 点不掉,只能等它自己消失 |
| 某些返回式弹窗 | back |
执行时机(三个点,缺一不可)
每一步:
① 轮询条件时:先查干扰库 → 命中就处置 → 继续轮询
主条件满足 **且** 无干扰命中,才算真的可以动作
② 动作前再确认一次无干扰 ← 防「log 条件已满足、但弹窗正盖着画面」导致点击打在弹窗上
③ 条件超时后不再立即失败:先处置干扰 + 重试条件(-MaxCondRetry,默认 3 轮)
仍失败才 abort,并把"未识别画面"另存到 report/unknown/ 供人工收录
第 ③ 点是从"脆"到"韧"的关键:把"唯一出路是中止"改成"先自愈,再放弃"。
有意思的实测现象:弹窗是「套娃」的
关掉「领取奖励」→ 后面露出「每日月历签到」→ 再关→ 露出「评价邀请」。
所以执行器每轮都重查全部条目,一条一条清;每条各有 maxPerRun 兜底,不会死循环。
怎么录一条干扰(让 agent 帮你截屏)
1) agent 截屏并生成框选页(复用审核器当界面)
2) 你在图上:改 screen → 拖拽框选弹窗的检测区 → 单击关闭按钮位置 → 导出
3) agent 入库
如果弹窗是原生 Android 界面(隐私页、TapTap 登录、权限申请),还能更快:
adb shell uiautomator dump 直接读出元素边界,按钮坐标和检测区都能算出来,连浏览器都不用开。
实测这样生成的检测区区分度 0.16(阈值 0.8),是所有条目里最好的。
两条用血换来的经验
① 框选要框"弹窗自己不透明的内容块",别图省事框整宽横带。
| 框法 | 区分度(对正常界面的最高分,越低越好) |
|---|---|
| 弹窗内容块(中间竖长条) | 0.38 ✓ 余量很大 |
| 整宽顶部横带(含背景) | 0.59 ~ 0.72 ⚠️ 余量薄 |
半透明弹窗尤其危险——实测有一条整宽横带的条目,在转场画面上误命中 0.8357(阈值 0.8),
往游戏里连点了 3 下。误触发比漏检更危险,因为漏检只是不干活,误触发会乱点。
② 阈值按「最差负样本 + 余量」定,不要一律拍 0.9。
我们一开始为了"整齐"把几条都提到 0.9,结果:
- 太松:0.8357 的转场画面骗过了阈值 0.8 的条目 → 误触发;
- 太紧:「公告」在另一台设备上真命中只有 0.8927,被 0.9 的阈值挡掉 → 差 0.007 漏检。
改成 0.85 后两边都对。阈值是数据问题,不是审美问题。
八、它什么时候会脆(重要)
这一节是全文最该看的部分。
1. 参考图丢了,不能靠脚本重建
我们试过两条路,都失败:
- 盲跑重放(按记录的坐标点、等画面稳定再截图): 实机上失败一次,模拟器上又失败一次。模拟器那次的数据很典型:
第 1 下 ✓ 生效 第 2 下 ✗ 几乎没生效 第 3 下 ✓ 生效 第 4~7 下 ✗ 完全没反应(相似度全是 1.0) - 改成"等画面稳定再点" 也不行:加载页本身就是静止的(一个 logo / 黑屏), 看起来稳定却并不接受点击。
根因是循环依赖:判断"到位了没有"的唯一依据就是前置参考图,而那正是要重建的东西。
结论:参考图只能人引导重建(你走一步我抓一张),或者用游戏内录制器重录。
现在这套流程已经写进 skill 文档,并配了一个抓图 + 比对的小工具。
2. 跨分辨率不通用
- 干扰条目往往可以跨:弹窗大而简单,实测另一台 16:9 的机器上「公告」0.89、「热更下载」0.97 都能命中;
- 流程的画面条件不行:大厅在 16:9 下与 19.5:9 的参考图只有 0.732(阈值 0.8)→ 换分辨率必须重录流程。
我们把模拟器的分辨率/密度改到与真机一致后,干扰条目立刻满分命中(1.0),
说明渲染真的逐像素对齐了 —— 这就把"跨分辨率"问题变成了"把分辨率调成一样"的运维问题。
3. 输入型环节永远要人工
登录、实名认证需要输入(账号、验证码、身份证),坐标点击搞不定。
全新安装的首次启动必然经过这一关 —— 所以约定:跑之前应用必须已经在大厅(手动基线)。
已经在大厅时用 -FromStep 2 跳过"等待到达大厅"那一步。
4. 长加载要靠"等条件"而不是"等时间"
录制里的等待时长是当时那一次的耗时。哪次网络慢一点,死等就会点空。
这正是前置校验模型存在的意义:用"画面到了"代替"时间到了"。
另外给启动阶段的热更把超时放大到 80 秒 × 3 轮 = 240 秒,配合 wait 型干扰,才够稳。
5. 录制器只在非 release 包里
录制器是 #if !CS_Release 的调试组件,所以录制流程必须用 dev 包,
而回归验证用 release 包 —— 两边包名要一致,否则执行器的包名/进程过滤都要改。
6. 别的坑(都给后来者)
- PowerShell 变量名不区分大小写:
$W(屏幕宽)和$w(等待毫秒)是同一个变量—— 我们的重放脚本因此把坐标算成了"上一步的等待时长",点得一塌糊涂。 同类事故还发生在$Interrupts(参数)与$script:Interrupts(数组)同名上。 .ps1含中文必须带 UTF-8 BOM,被编辑器/工具改过就可能丢,丢了就是一堆乱码解析错。 我们专门写了个fix-bom.ps1一键补,改脚本后必跑。- adb 常把提示写到 stderr(
monkey、install都是),调用处不临时放宽错误策略就会被当终止错误打死。 - logcat 落文件是追加写:启动前不
rm旧文件,上一轮的报错会冒充这一轮。 - 崩溃日志要按进程过滤:某台机器上每轮都稳定报 6~34 条
FATAL EXCEPTION, 全是 Google Play Services 崩的,跟游戏无关。不按Process:行判归属,真正的问题会被淹没。
九、实机效果(实测数据)
A. 从装包到自动验证,全链路 9/9 通过(release 1.1.1,2340×1080)
| 阶段 | 实测 |
|---|---|
adb install -r |
Success,111 秒,firstInstallTime 未变 → 登录态保留 |
| 启动 → 热更等待 | 「热更下载」命中 3 次 score=1.0,wait 等它下完 |
| 弹窗清障 | 启动阶段清「公告」→ 到达大厅(45 秒);步骤 3 又连清 3 个 |
| 各步匹配分 | 0.9878 / 0.8972 / 0.8944 / 0.9936 / 0.8443 / 0.8569(阈值 0.8) |
| 弹性重试救场 | 步骤 3 连续 2 轮「超时 → 清障 → 重试」后成功;没有它这轮会在步骤 3 中止 |
| 日志 | 外部进程噪声 5 条已忽略,可疑日志 0 条 |
| 总耗时 | 约 2 分 50 秒 |
B. 它抓到了真实问题
- 登录界面「游客登录」文案缺本地化词条(
[TMPLocalize] localize not found)—— 顺着查到是出包时预制体 id 与词条表版本不一致(报错的 GUID 在仓库和表里都不存在),重新出包即消除。 - 顺带审计出主界面预制体有 4 个
TMPLocalize漏配 (SettingButton/Text、Announcement/Text、CADPA 面板标题与正文), 其余 7 个法规文本(版权号/出版号/健康游戏忠告)是intentional ignore✓。
C. 一次完整回归里的干扰处理
干扰处理 8 次:公告 / 领取奖励×2 / 每日签到弹窗×2 / 每日月历签到 / 热更下载×3
每条都留了"命中那一刻"的截图,时间戳串起完整清障链
十、冒烟测试怎么落地
做到这几条,它才算"能用":
- 产物集中放:流程编排和参考图放
flows/<流程名>/(json 与 screenshots 同目录), 跟工具一起分发。别放在临时目录——我们有过一次惨痛教训:编排放在一个看起来像"测试输出"的目录里, 被当成垃圾清理掉,最后编排 json 从聊天记录里救回来了,9 张参考图全丢,只能人工重走一遍流程。 - 一条命令跑:
run-workflow.ps1 -FinalJson flows/xxx/workflow.final.json -Apk <包> -Launch, 失败退出码 2,可直接接 CI。 - 报告自证:每步的匹配分(带颜色分级)、条件耗时、失败截图内嵌、 干扰命中留证缩略图、可疑日志、外部进程噪声计数。
- 基线写清楚:跑之前应用必须在大厅;登录/实名是人工边界。
- 干扰库持续长大:跑挂了就看
report/unknown/里的"未识别画面", 一键录成新的干扰条目 —— 越跑越稳。
十一、这套工具的意义
- 把"人点一遍"变成"可复现的回归":步骤、等待、判定条件都落成数据,能进版本库、能对比、能交接。
- 失败不再只是"失败了":每一步都有匹配分、截图、日志增量、干扰记录 —— 出问题能复盘。
- 它真的抓到了 bug:那 5 条本地化配置问题,是人手点一百遍也不会注意到的。
- 它把"正常的意外"变成了"可管理的清单":弹窗不再是敌人,而是干扰库里的一条数据。
- 它的边界很清楚:这不是通用自动化框架,而是"确定性的、状态无关的、布局稳定的局外 UI 流程" 的复读机 + 状态机。局内随机玩法、需要输入的环节,它明确不管。
一句话总结:它不是一个更聪明的点击器,而是一个把"手工流程"编译成"可校验状态机"的工具;真正让它稳定运行的,是承认"意外是常态"并给它们建了张表。
十二、踩坑速查(给要复刻的人)
| 现象 | 原因 |
|---|---|
| 每步都点空、后面全错 | y 轴翻转 / ROI 不翻转两套约定混用 |
| 坐标整体错位 | 用了 wm size 而不是实况截图尺寸 |
| 干扰在转场画面误触发 | ROI 框得太泛(整宽横带),阈值却不够高 |
| 真命中被自己挡掉 | 阈值拍得太紧(一律 0.9) |
| 长加载被算到错误的步骤 | waitAfterMs off-by-one(应挂"被离开"的那一步) |
| 日志里混进上一轮的报错 | logcat -f 是追加写,启动前没 rm |
| 报告里几万条"可疑日志" | 没做良性白名单 + 没按进程过滤 |
| 脚本一堆乱码解析错 | .ps1 丢了 UTF-8 BOM |
| 脚本莫名其妙算错 | PowerShell 变量名不区分大小写,$W/$w 撞车 |
| 参考图没了想脚本重建 | 死路:判断"到位"要靠参考图本身(循环依赖) |
本文所有数据来自一台 Redmi 9(Android 10)与一台 Android 15 模拟器上的实际运行记录。

浙公网安备 33010602011771号