AI-移动端 APP / 游戏 APP UI 自动化断言方案
移动端比 Web 复杂得多:原生控件树(APP)vs Canvas 渲染(游戏),断言方案完全不同。
一、先分清两大场景
| 普通 APP(原生/混合) | 游戏 APP(Unity/Cocos/UE) | |
|---|---|---|
| 渲染方式 | 原生控件 / WebView | Canvas / OpenGL 直接画图 |
| 能否获取控件树 | ✅ 可以(UIAutomator2/XCUITest) | ❌ 不行(没有DOM) |
| 核心断言手段 | 控件属性 + 图像识别 | 图像识别为主 + 引擎Hook |
| 代表工具 | Appium, uiautomator2, Espresso | Airtest, Maate, UnityTest |
二、普通移动 APP 断言方案
方案 1:控件属性断言(UIAutomator2 / Appium)
python
1# uiautomator2(Android 推荐)
2import uiautomator2 as u2
3
4d = u2.connect()
5
6# ① 文本断言
7assert d(text="登录").exists, "登录按钮不存在"
8assert d(text="欢迎回来").get_text() == "欢迎回来", "欢迎语错误"
9
10# ② 状态断言
11assert d(resourceId="com.app:id/checkbox").is_checked(), "复选框未选中"
12assert d(resourceId="com.app:id/btn").is_enabled(), "按钮不可点击"
13
14# ③ 属性断言
15el = d(resourceId="com.app:id/avatar")
16assert el.info['contentDescription'] == "用户头像"
17assert el.info['bounds'] # 位置大小是否合理
18
19# ④ 列表断言
20items = d(resourceId="com.app:id/list").children()
21assert len(items) == 10, f"列表期望10项,实际{len(items)}项"
22assert items[0].get_text() == "第一条数据"
23
24# ⑤ 层级结构断言
25assert d(resourceId="com.app:id/parent").child(text="子按钮").exists
26
方案 2:图像识别断言(Airtest 模板匹配)
当控件无法定位(自定义View、游戏化UI、WebView)时用
python
1from airtest.core.api import *
2
3# ① 模板匹配 - 判断元素是否存在
4# 存在(相似度>0.9) → 通过;不存在 → 抛出TargetNotFoundError
5assert_exists(Template("tpl_login_btn.png", threshold=0.9),
6 "登录按钮未找到")
7
8# ② 模板匹配 - 判断元素状态(按钮变灰/变亮)
9# 灰色按钮模板
10if exists(Template("tpl_btn_disabled.png", threshold=0.8)):
11 assert False, "按钮被禁用了,不应出现"
12
13# ③ 区域截图对比 - 验证整个页面
14assert_snapshot(Template("page_home.png", threshold=0.95),
15 "首页截图不匹配")
16
17# ④ OCR 文字断言
18from airtest.core.api import Ocr
19ocr = Ocr()
20text = ocr.ocr("screen.png")
21assert "订单编号" in text, "页面缺少'订单编号'文字"
22assert "¥128.00" in text, "价格显示错误"
23
方案 3:混合方案(控件 + 图像互补)
python
1# 先用控件定位容器,再用图像验证内部内容
2container = d(resourceId="com.app:id/banner")
3banner_img = container.screenshot()
4assert_exists(Template("tpl_banner_v2.png", threshold=0.9),
5 "Banner图片不是最新版本")
6
7# 控件定位 + OCR 验证文字
8price_el = d(resourceId="com.app:id/price")
9assert Ocr.ocr(price_el.screenshot()) == "¥99.00"
10
三、🎮 移动端游戏 APP 断言方案(重点)
游戏没有控件树!所有断言几乎都依赖图像识别 + 特殊手段
⭐ 方案 1:Airtest 模板匹配(最主流)
python
1from airtest.core.api import *
2
3# ① 基础 - 判断游戏元素是否出现
4assert_exists(Template("tpl_hp_bar.png"), "血条未显示")
5
6# ② 判断数值变化(金币从 100 → 200)
7# 先OCR识别当前金币
8gold_text = Ocr.ocr(Template("tpl_gold_area.png"))
9assert int(gold_text) == 200, f"金币应为200,实际{gold_text}"
10
11# ③ 判断按钮状态(可点击/灰色/红点提示)
12if exists(Template("tpl_btn_with_reddot.png", threshold=0.8)):
13 touch(Template("tpl_btn_with_reddot.png"))
14 assert not exists(Template("tpl_btn_with_reddot.png")), "红点未消失"
15
16# ④ 判断弹窗出现
17wait(Template("tpl_dialog_victory.png"), timeout=10)
18assert_exists(Template("tpl_dialog_victory.png"), "胜利弹窗未出现")
19
20# ⑤ 判断场景切换(通过标志性图片)
21assert_exists(Template("tpl_stage_3_bg.png"), "未进入第3关")
22
⭐ 方案 2:图像特征点匹配(SIFT/SURF — 抗旋转缩放)
python
1# 模板匹配怕旋转/缩放?用特征点匹配
2from airtest.core.api import features_match, ST
3
4# 在游戏画面中找"开始游戏"按钮(可能有旋转/缩放)
5pos = features_match(Template("tpl_start_btn.png"))
6if pos:
7 touch(pos)
8else:
9 assert False, "开始按钮找不到(可能被遮挡/旋转)"
10
⭐ 方案 3:区域颜色/像素断言(轻量但有效)
python
1import cv2
2import numpy as np
3from airtest.core.api import load_image
4
5img = load_image("screen.png")
6
7# ① 判断某区域颜色(比如血条是否变红=低血量)
8hp_region = img[100:120, 200:300] # 裁剪血条区域
9avg_color = np.mean(hp_region, axis=(0,1))
10# 红色通道高 = 血量低
11assert avg_color[2] > 200, "血量应该很低(红色)"
12
13# ② 判断某像素点(比如右上角小地图是否有敌人红点)
14pixel = img[50, 350]
15assert pixel[2] > 200, "小地图应有敌人红点"
16
17# ③ 判断画面是否黑屏/闪退
18assert np.mean(img) > 10, "画面全黑,可能闪退"
19
⭐ 方案 4:游戏引擎 Hook 断言(最精准)
Unity 游戏
python
1# 通过 adb + Unity C# 反射获取内部数据
2# 需要游戏有开启 "Development Build" + "Script Debugging"
3
4# 方案A: adb shell 调用 Unity 内部方法
5import subprocess
6
7def get_unity_player_hp():
8 result = subprocess.run(
9 ["adb", "shell", "am", "broadcast",
10 "-a", "com.game.GET_HP", "--es", "key", "hp"],
11 capture_output=True, text=True
12 )
13 hp = int(result.stdout.strip())
14 assert hp > 0, f"玩家血量异常: {hp}"
15 return hp
16
17# 方案B: 使用 Maate 框架(专为Unity游戏测试)
18from maate.unity import Unity
19unity = Unity()
20hp = unity.get_player_hp()
21assert hp == 100, f"初始血量应为100,实际{hp}"
22
23# 方案C: 读取内存(需要root/越狱)
24def read_game_memory(address):
25 # /proc/pid/maps + /dev/mem 或 frida hook
26 pass
27
Cocos2d-x 游戏
python
1# Cocos 游戏可以通过 JS Bridge(如果内嵌JS)或 hook native层
2# 常用: frida hook Cocos2d-x 的 getChildByTag / getPosition
3
4import frida
5
6script = """
7Java.perform(function() {
8 var Player = Java.use("org.cocos2dx.cpp.Player");
9 Player.getHp.implementation = function() {
10 var hp = this.getHp();
11 console.log("[TEST] HP = " + hp);
12 return hp;
13 };
14});
15"""
16# 注入后读取日志获取HP
17
⭐ 方案 5:ADB 命令辅助断言
python
1import subprocess
2
3# ① 判断页面是否在前台(防止切到后台)
4def is_app_foreground(package):
5 result = subprocess.run(
6 ["adb", "shell", "dumpsys", "window", "windows"],
7 capture_output=True, text=True
8 )
9 assert package in result.stdout, f"{package} 不在前台"
10
11# ② 判断帧率(需要游戏开启性能监控)
12def get_fps():
13 result = subprocess.run(
14 ["adb", "shell", "dumpsys", "gfxinfo", "com.game"],
15 capture_output=True, text=True
16 )
17 # 解析 Janky frames 数量
18 assert "0 janky" in result.stdout, "游戏掉帧了"
19
20# ③ 判断内存
21def get_memory(package):
22 result = subprocess.run(
23 ["adb", "shell", "dumpsys", "meminfo", package],
24 capture_output=True, text=True
25 )
26 assert "512 MB" not in result.stdout, "内存占用过高"
27
28# ④ 判断是否ANR/Crash
29def check_crash():
30 result = subprocess.run(
31 ["adb", "logcat"