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

封面图

一个从零到落地的真机 UI 冒烟测试工作流:录制 → 编排 → 执行 → 报告。全文都是实测数据,包含它什么时候会脆——这部分比成功经验更值钱。


一、起因:冒烟测试为什么让人烦

手游每次出包都要过一遍手工冒烟:进游戏 → 到大厅 → 进要塞 → 开商店 → 关商店 → 回大厅……
单次 2~3 分钟,但每次发版都要做,而且必须有人盯着。更烦的是:

  • 跑完只有"我点过了"这句口头结论,没有留证;
  • 偶发问题复现不了("刚才那次好像有个报错");
  • 唯一的自动化手段是脚本模拟点击,但它不知道什么时候该点。

二、为什么"截图 + 点击"的脚本不够用

我们本来有个最朴素的方案:adb 截图、按固定坐标点、按固定时间等。
它能压测按钮,但做不了流程回归,原因有两个:

  1. 不知道"到位了没有"。点完进要塞要加载 28 秒,脚本只能死等;下次网络慢一点就点空了,后面全错。
  2. 画面会被正常弹窗盖住。隐私政策、热更下载、公告、每日签到、领奖邀请…… 这些窗口都是正常的,但它们一出来,脚本的假设("屏幕上应该是我要的界面")就崩了。

第 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 就是全部编排。

两个关键防呆设计

  1. 参考图锁定:screen 条件的参考图恒等于该步自己的截图,不允许选别的。 —— 我们真踩过"框选的是这张图、匹配的是另一张图",像素对比直接显示必然失败(MAE 23~53)。
  2. 终态校验不可删:否则流程可以"最后一步点完就宣布成功",而实际画面早就跑飞了。

六、执行:匹配怎么算,坐标怎么换算

截图匹配(简单但够用):

参考图与实况图取同一个归一化 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. 它抓到了真实问题

  1. 登录界面「游客登录」文案缺本地化词条([TMPLocalize] localize not found)—— 顺着查到是出包时预制体 id 与词条表版本不一致(报错的 GUID 在仓库和表里都不存在),重新出包即消除。
  2. 顺带审计出主界面预制体有 4 个 TMPLocalize 漏配 (SettingButton/Text、Announcement/Text、CADPA 面板标题与正文), 其余 7 个法规文本(版权号/出版号/健康游戏忠告)是 intentional ignore ✓。

C. 一次完整回归里的干扰处理

干扰处理 8 次:公告 / 领取奖励×2 / 每日签到弹窗×2 / 每日月历签到 / 热更下载×3
每条都留了"命中那一刻"的截图,时间戳串起完整清障链

十、冒烟测试怎么落地

做到这几条,它才算"能用":

  1. 产物集中放:流程编排和参考图放 flows/<流程名>/(json 与 screenshots 同目录), 跟工具一起分发。别放在临时目录——我们有过一次惨痛教训:编排放在一个看起来像"测试输出"的目录里, 被当成垃圾清理掉,最后编排 json 从聊天记录里救回来了,9 张参考图全丢,只能人工重走一遍流程。
  2. 一条命令跑:run-workflow.ps1 -FinalJson flows/xxx/workflow.final.json -Apk <包> -Launch, 失败退出码 2,可直接接 CI。
  3. 报告自证:每步的匹配分(带颜色分级)、条件耗时、失败截图内嵌、 干扰命中留证缩略图、可疑日志、外部进程噪声计数。
  4. 基线写清楚:跑之前应用必须在大厅;登录/实名是人工边界。
  5. 干扰库持续长大:跑挂了就看 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 模拟器上的实际运行记录。

posted @ 2026-09-18 09:56  鑫鑫哥Adam  阅读(15)  评论(0)    收藏  举报