这两年我一直泡在中重度手游项目里,负责把游戏UI自动化测试从零搭到能稳定跑。最开始团队对它的预期是“Appium套上去、写几个坐标就能点”,结果第一次全量跑完,失败率超过80%。折腾了大半年后,我才敢说,游戏UI自动化和普通App自动化几乎不是一回事:不能拿控件树、要应付动画、要处理随机弹窗、要接受每帧都在变化的画面。这篇文章就把我趟过的特殊挑战和最终突破思路完整拆开讲。
如果你是从Web或原生App测试转到游戏领域,或者正在尝试在测试环境里拉起一场自动对局,这篇会给你一套可复制的打法。我不会只讲理论,更多是实际项目里踩过坑之后沉淀下来的选型判断、框架设计和排错方法。
1. 游戏UI自动化,和App/Web自动化根本不是同一物种
1.1 游戏界面是“画”出来的,不是“布局”出来的
做过Web和Android测试的人都知道,我们定位一个按钮,可以用DOM id或content-desc,因为渲染层之上有一套语义化结构。游戏不一样。主流引擎(Unity、Unreal、Cocos)在运行时把UI当成多边形网格、贴图和着色器交给GPU,最终你在屏幕上看到的是GPU绘制后的画面,不是一个“控件树”。在Android系统眼里,游戏窗口通常只是一个SurfaceView或TextureView,里面没有任何原生View节点。
这就导致Appium/UiAutomator在游戏页面上扫出来的,要么是空,要么只有一整个SurfaceView。我有一次跑Appium Inspector,能看到的所有元素就只有“android.widget.FrameLayout”,按钮、血条、背包图标全是画出来的。所以做游戏UI自动化,第一课就是忘掉“取控件”,接受“要么从引擎内部拿节点,要么从画面里找特征”。
对刚转来做游戏测试的同学,我建议先建立一个认知:游戏UI自动化本质上是在和“渲染结果”打交道,而不是和“界面结构”打交道。哪怕你用了Poco这类引擎SDK,也只是把引擎内部的UI层次暴露出来,和Web DOM不是一个概念。
1.2 坐标定位的脆弱性
既然拿不到控件,很多人第一反应是:那我直接点击坐标。一开始我也这么干,写死touch((540, 1200))。结果项目组拿了一台刘海屏回来,按钮直接偏移了40像素;又拿了一台平板,偏得更厉害。原因在于游戏UI要做自适应。拿Unity来说,Canvas Scaler会根据设备宽高比缩放整个UI,同一个“开始战斗”按钮,在不同分辨率下的物理像素位置完全不同。如果代码里写死物理坐标,等于把用例绑死在一款设备上。
我的建议是,能用引擎坐标就别用物理像素。Poco的get_position()返回的是归一化坐标,即按钮在屏幕上的相对位置(0到1),点击的时候再根据当前设备宽高换算回像素。这样一套用例可以在不同分辨率上跑。若只能纯图像识别,也要把识别出来的中心点转成归一化坐标再点击,而不是存像素坐标。
一个细节:归一化坐标要区分横屏和竖屏。游戏旋转时,宽高定义会变。建议框架内部用屏幕方向做一次坐标系统一,否则横屏用例在竖屏设备上会全偏。这个坑我遇到过,一台手机正常,换一台手机后所有坐标都反了。
1.3 游戏里的等待逻辑不能照搬Web
Web自动化有显式等待:WebDriverWait,轮询一个DOM节点是否存在。游戏里也存在类似的Poco节点,但问题更大:节点存在不等于渲染完成,渲染完成不等于动画播完,动画播完不等于可以点击。我见过很多用例失败在“点击了‘下一关’,但画面还停在结算动画,导致点击被吞掉或误触了别的按钮”。
所以游戏中等待至少分三层:等待节点存在、等待节点可交互(主要看按钮状态或灰置属性)、等待画面稳定(连续几帧画面不再变化)。后面我会专门讲怎么用画面哈希做“静帧检测”。核心思想是:别用固定sleep;sleep只是兜底,不是等待策略。
你如果把Web自动化那套“等到元素可见就点击”直接搬过来,大概率会得到一堆偶发失败。游戏里的“可见”和“可交互”之间,隔着一整个动画层。
2. 技术路线怎么选:Poco、纯图像识别、还是混合方案?
2.1 三条路线对比
| 方案 | 原理 | 可维护性 | 稳定性 | 接入成本 | 适用场景 |
|---|---|---|---|---|---|
| Poco(Unity/UE/Cocos接入SDK) | 在引擎内挂载UI抽象节点,通过RPC暴露给外部 | 高,定位稳定 | 高 | 中,需要研发配合 | 自己项目组可控、有客户端权限 |
| 纯图像识别(Airtest/SikuliX/OpenCV) | 对屏幕截图做模板匹配或特征匹配 | 低,换UI就换图 | 中低,受特效和分辨率影响 | 低 | 渠道包、黑盒测试、研发不配合 |
| 混合方案 | Poco做主定位,图像识别兜底,接口信息辅助判断 | 中高 | 高 | 较高 | 中重度手游、长期自动化体系 |
表格列完,我对不同团队的建议是:只要游戏包是你自己研发团队出的,就走Poco或混合方案;只有研发完全不给权限、只能拿外部渠道包时,才考虑纯图像识别。纯图像识别不是不能做,而是后续每换一版UI,都要重新录模板,用例维护成本高到让人崩溃。我在一个渠道包项目里体验过,一个按钮三套语言三套分辨率,光维护模板就占掉了大半测试时间。
2.2 Poco原理与适用边界
Poco的核心思路其实和Appium一致:把引擎内部UI树变成可查询的层级。你需要在游戏工程里集成poco-sdk,Unity通过代码扫描UGUI的节点关系,把每个UI元素的名称、位置、尺寸、可见性、附带属性通过Socket发到测试端。测试端用UnityPoco()连接后,就能像操作DOM一样查节点。
所以它能解决“界面是画出来的”这个核心问题。poco("MainUI/StartBtn").click(),脚本拿到的不是坐标,而是逻辑节点,适配问题自然消失。但它又有新边界:一是要处理SDK对游戏性能的影响,Poco会为每帧扫描UI树,建议只扫关键节点或降低扫描频率;二是部分自绘UI(比如用Mesh或自定义Shader画的角色头像)在Poco树里可能只是一个空节点,这时候就必须配合图像识别。
另外,Poco对网络环境也有要求。测试机和游戏设备需要在同一局域网,或者通过USB转发端口,否则连接会断。我们后期把Poco连接封装成了服务,脚本启动时自动检测端口连通性,失败就重连,稳定很多。
2.3 没有引擎SDK权限时的图像识别路线
如果是发行渠道包或者SDK不能随便接的包,Poco这条路走不通,只能走图像识别。Airtest自带Template匹配,把一张按钮截图作为模板,在屏幕上找最相似位置。做法简单,但限制也多:同屏多按钮相似、UI有高光特效、夜晚场景和白天场景按钮颜色变了、不同语言字体不同等,很容易匹配错。我的做法是做多套模板按场景分组,再用归一化坐标二次校验:识别出的坐标若不在预期区域就直接判失败,而不是盲目点击。
SikuliX的思路也类似,它把视觉识别和脚本绑定在一起,但遇到中文游戏界面、复杂特效时同样要手工调Similarity和Region。坦白说,纯图像识别更适合做“冒烟检查”,比如确认闪屏出现、确认主城加载完成,不适合做大量操作型用例。因为操作型用例一旦定位偏一点,后面的流程全都会错。
2.4 AI图像识别在游戏UI定位里的应用思路
最近两年大家都在谈AI自动化测试,我在实际项目中也尝试了一条务实的路线:把AI用在“图像识别后端”,而不是替代整个测试框架。传统模板匹配对像素级变化敏感,但只要UI做了一点渐变或换皮,模板就失效了。我拿历史截图和一些线上测试截图做标注,训练了一个YOLOv8小模型,专门识别“开始战斗”“背包”“商城”等高频按钮。推理部署在本地GPU机器上,脚本通过HTTP调用返回归一化坐标。
实测下来,模型对截图光影变化、按钮小幅度改动的容忍度比模板匹配高很多,但也不是零成本:样本标注、训练周期、每季度模型更新都要投入。我的真实建议是,普通团队不要一开始就铺AI定位服务,先上Poco加模板匹配,把流程跑起来。等你有几百条UI用例、每周都被UI改版打崩的时候,再考虑把高频按钮的识别交给模型,这才是“AI自动化测试实施落地”的合理节奏。
3. 动手搭一套可复用的游戏UI自动化框架
3.1 环境准备里最容易被忽略的一件事
框架以Airtest+Poco+Pytest为例。环境准备网上很多教程,设备连接、adb、安装airtest和pocoui库这些都不再多说。我要强调最容易被忽略的一件事:统一设备的屏幕方向与系统设置。
游戏项目最怕“用例在A机上跑90分,在B机上跑40分”。很多时候不是代码逻辑问题,而是设备差异:A机有虚拟按键,B机是全面屏,C机开了护眼模式导致颜色偏黄,D机系统语言是英文但UI是中英文混杂。我们在设备池里做了一套基线检查:分辨率锁成同一档、关闭自动亮度、关闭护眼、关闭通知权限弹窗、禁用系统自动更新、统一输入法。设备上线前跑一个基准冒烟用例,通过才允许进入自动化夜跑池。这一步能挡掉一半的非稳定失败。
另外,建议测试客户端切到测试服务器,避免线上玩家干扰;如果游戏有账号弹窗,提前在脚本里处理。还有个细节:USB线接触不良导致的adb断连,在长时间夜跑里特别常见,建议用带供电的USB Hub,并在脚本里加adb重连兜底。
3.2 封装Poco连接与通用操作
Poco连接本身不复杂,复杂的是连接之后怎么保证每个操作都安全。我写了类似这样的封装:
from airtest.core.api import connect_device from poco.drivers.unity3d import UnityPoco class GameUI: def __init__(self): connect_device("Android:///") self.poco = UnityPoco() def wait_ui(self, node_name, timeout=30): self.poco.wait_for(node_name, timeout=timeout) def click_ui(self, node_name, timeout=10): node = self.poco(node_name) if not node.exists(): raise AssertionError(f"{node_name} not found") node.wait_for_appearance(timeout=timeout) # 有的按钮灰置时有 disabled 属性,可结合引擎具体字段判断 disabled = node.attr("disabled") if disabled: self.wait_until(lambda: not node.attr("disabled"), timeout=timeout) node.click() def wait_until(self, condition, timeout, interval=0.5): import time deadline = time.time() + timeout while time.time() < deadline: if condition(): return True time.sleep(interval) return False注意node.click()内部会拿节点中心坐标去点击,比图像识别稳定得多。但还是那句话,节点存在不见得能点击,所以我加了disabled属性检查。不同游戏引擎抛出来的属性不一样,Unity常见的有visible、text、enabled,接入时要先跑一个脚本把所有节点的属性dump出来看看。
3.3 用归一化坐标解决设备适配问题
如果定位逻辑里混入了图像识别结果,就要统一坐标体系。Airtest的touch(Template)在内部会调用模板匹配,返回的是当前屏幕的绝对像素坐标。为了让用例在不同分辨率下不重写,我会让图像服务返回归一化坐标:
def image_match_to_normalized(img_path, screen_w, screen_h): from airtest.aircv import imread, find_template # 简化示例,实际用当前屏幕截图做匹配 result = find_template(screen_img, imread(img_path), threshold=0.8) if result is None: return None (x, y) = result['result'] return (x / screen_w, y / screen_h) def touch_normalized(nx, ny): screen_w, screen_h = G.DEVICE.winsize touch((nx * screen_w, ny * screen_h))这样用例里只存归一化坐标,跟具体设备无关。如果同一局内UI有不同画布缩放,Airtest的touch内部其实已经考虑了Android的DisplayMetrics,但没有考虑游戏引擎的Canvas缩放,所以最好从引擎侧拿坐标换算关系。Poco的方案天然规避了这层问题,这也是我推荐混合方案的原因之一。
3.4 游戏状态的断言:该断言“属性”而不是“画面”
游戏UI断言比普通App难,因为很多关键信息不是文本,而是数值条、血条、冷却转圈。新手最容易写assert_exists(Image(...))去截图对比,但截图对比非常脆弱。更稳的是用Poco读取UI属性:
# 断言角色名 assert self.poco("MainUI/PlayerName").get_text() == "ui_tester" # 断言体力值文本 text = self.poco("MainUI/Stamina").get_text() assert int(text.split("/")[0]) > 0 # 断言背包里有物品 item = self.poco("MainUI/BagList/Item[1]") assert item.exists()如果拿不到属性,只能看画面,那就尽量断言“画面中的稳定特征”而不是全屏截图。比如只截取按钮区域、等级数字区域做模板匹配,阈值不要拉满。还可以接入OCR识别美术字,但要注意游戏字体可能不在系统语言环境里,tesseract或PaddleOCR需要专门训练字形,成本不低。我的原则是:能拿属性就不看画面,必须要看画面就只框小区域。
4. 稳定性专项:动画、随机弹窗与AI辅助的“稳定等待”
4.1 时序爆炸:动画、加载和网络抖动
游戏UI自动化跑不稳,80%以上是时序问题。比如点击“战斗”后,客户端先播一段开场动画,然后才发起网络请求,等服务器返回后进入战斗界面。如果脚本在poco("BattleUI").wait_for_appearance(10)后立刻断言,大概率失败,因为节点虽然挂上了,但很多子节点还在加载中。
我的做法是给每个业务步骤定义一个“稳定条件”,而不是只等主节点。比如进入战斗后,等待条件设为:BattleUI/StartBtn出现,且顶部倒计时文本不再是空串,且BattleUI/Energy数值大于0。三个条件都用轮询验证。稳定条件写得越接近真实业务,用例越稳。
这个思路也可以推广到所有游戏界面切换:不追求“最快点击”,追求“满足所有业务前置条件后再点击”。虽然单条用例时间变长了一点,但整体稳定性提升巨大。
4.2 随机弹窗与状态机建模
更大的坑是随机弹窗。首充引导、签到、限时活动、服务器维护公告、断线重连,它们会在任意时刻冒出来。如果用例只管自己主线流程,就会被弹窗挡住。
我们做了一个close_common_popups兜底函数:在每个操作前,先尝试关闭一组已知弹窗。
COMMON_POPUPS = [ ("Popups/Notice/Close", 0.3), ("Popups/Activity/Close", 0.5), ("Popups/Gift/Close", 0.5), ] def close_common_popups(poco): for path, max_wait in COMMON_POPUPS: if poco(path).exists(): try: poco(path).click() time.sleep(0.3) return True except: pass return False注意:不要循环点关闭太久,否则会误关正常页面里的关闭按钮。我们限制每个弹窗最多尝试1到3次,并且close_common_popups返回后,还要等当前真实目标界面重新出现。本质上,你需要把“游戏当前可能处于的状态”建模成一个状态机,每个操作步骤都声明前置状态和后置状态,脚本才能真正稳定。
4.3 画面稳定检测:让用例学会“等动画播完”
动画导致的问题,用Poco往往很难感知,因为动画期间UI树节点一直都在。为了处理“动画未播完”这类问题,我写了一个图像层面的静帧检测:连续几帧截图的感知哈希差异低于阈值,视为画面稳定。
def wait_for_static(duration=0.8, interval=0.05, threshold=0.05): prev_hash = None deadline = time.time() + duration stable_time = 0 while time.time() < deadline: img = G.DEVICE.snapshot() h = perceptual_hash(img) if prev_hash is not None: diff = hamming_distance(h, prev_hash) if diff <= threshold: stable_time += interval else: stable_time = 0 prev_hash = h time.sleep(interval) if stable_time >= 0.4: return True return False这里的perceptual_hash用OpenCV的resize、灰度、DCT变换实现即可,不用很复杂。实际使用中,我会先等节点条件,再调wait_for_static(0.6),接着再执行点击。配合4.1的稳定条件,很多“偶发失败”会变成“基本不失败”。但不要对所有步骤都做静帧检测,开销很大,只用在过场加载、结算动画、大招特写等关键节点前后。
4.4 弱网与服务器状态:把接口信息接进用例
另外不要忘了,游戏很多UI状态由服务器决定。你看到的按钮可点或灰置,可能不是本地逻辑,而是后端下发的状态。纯UI黑盒很难判断“是因为服务器还没返回所以灰置,还是网络断了”。我们后来在测试环境接了一组接口埋点:用例执行中,定时调用测试服务端的/test/status接口,拿到当前角色所在场景、体力、任务进度等状态,再决定是否继续执行。这样能把一部分“UI等待”变成“业务状态等待”,稳定性提升非常明显。
如果你们不方便动服务端,还有一个土办法:在脚本里读取游戏日志中的关键字段,判断当前是否处于弱网或断线重连状态。总之,游戏自动化永远不能只盯着UI层,要多从服务端和日志侧拿证据。
5. 真实排查案例:一次“必现”失败背后的三层原因
5.1 现象描述:按钮点击后没有任何反应
有一段时间,我们的“开始战斗”用例在夜跑里频繁失败。日志显示Poco已找到ButtonStart,也执行了click(),但截图仍然停留在主城界面,没有进入战斗。到第二天白天手工复测,又一切正常。第一次遇到这种问题,我一度怀疑是Poco的点击不稳定。
后来我把频率跑高,连续执行20次,发现成功率只有20%,而且失败主要发生在主城有庆典特效动画的时候。这个规律很关键:不是随机失败,而是和画面状态强相关。
5.2 排查链路:从点击坐标一路挖到特效层
排查过程分三步走。第一步,检查Poco日志和截图:点击的坐标是从get_position()拿到的,坐标确实在按钮中心附近,但截图里按钮上方有一层透明的节日飘雪特效。第二步,用adb shell getevent抓系统触摸事件:点击事件确实下发了,但游戏UI没有响应。这说明事件到了引擎层,但被特效层遮挡。第三步,和客户端开发一起看Unity UI的Raycast结果,发现飘雪特效的RaycastTarget是打开的,它把触摸事件吸收掉了,按钮根本收不到。
所以根因不是脚本定位错,而是透明特效层在动画期间抢占了触摸事件。这种情况用“点击后校验状态加失败重试”能缓解,但不能根治:如果特效一直在,重试也会一直失败。最终解决方案是让开发在特效播放期间关闭RaycastTarget,或者把EventSystem的首个子节点设置为按钮所在的Canvas。测试侧也补