拓冰建站拓冰建站
首页 / 资讯中心 / 正文

游戏UI自动化测试:从画质到稳定性的实战拆解

这两年我一直泡在中重度手游项目里负责把游戏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 游戏里的等待逻辑不能照搬WebWeb自动化有显式等待WebDriverWait轮询一个DOM节点是否存在。游戏里也存在类似的Poco节点但问题更大节点存在不等于渲染完成渲染完成不等于动画播完动画播完不等于可以点击。我见过很多用例失败在“点击了‘下一关’但画面还停在结算动画导致点击被吞掉或误触了别的按钮”。所以游戏中等待至少分三层等待节点存在、等待节点可交互主要看按钮状态或灰置属性、等待画面稳定连续几帧画面不再变化。后面我会专门讲怎么用画面哈希做“静帧检测”。核心思想是别用固定sleepsleep只是兜底不是等待策略。你如果把Web自动化那套“等到元素可见就点击”直接搬过来大概率会得到一堆偶发失败。游戏里的“可见”和“可交互”之间隔着一整个动画层。2. 技术路线怎么选Poco、纯图像识别、还是混合方案2.1 三条路线对比方案原理可维护性稳定性接入成本适用场景PocoUnity/UE/Cocos接入SDK在引擎内挂载UI抽象节点通过RPC暴露给外部高定位稳定高中需要研发配合自己项目组可控、有客户端权限纯图像识别Airtest/SikuliX/OpenCV对屏幕截图做模板匹配或特征匹配低换UI就换图中低受特效和分辨率影响低渠道包、黑盒测试、研发不配合混合方案Poco做主定位图像识别兜底接口信息辅助判断中高高较高中重度手游、长期自动化体系表格列完我对不同团队的建议是只要游戏包是你自己研发团队出的就走Poco或混合方案只有研发完全不给权限、只能拿外部渠道包时才考虑纯图像识别。纯图像识别不是不能做而是后续每换一版UI都要重新录模板用例维护成本高到让人崩溃。我在一个渠道包项目里体验过一个按钮三套语言三套分辨率光维护模板就占掉了大半测试时间。2.2 Poco原理与适用边界Poco的核心思路其实和Appium一致把引擎内部UI树变成可查询的层级。你需要在游戏工程里集成poco-sdkUnity通过代码扫描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 环境准备里最容易被忽略的一件事框架以AirtestPocoPytest为例。环境准备网上很多教程设备连接、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, timeout30): self.poco.wait_for(node_name, timeouttimeout) def click_ui(self, node_name, timeout10): node self.poco(node_name) if not node.exists(): raise AssertionError(f{node_name} not found) node.wait_for_appearance(timeouttimeout) # 有的按钮灰置时有 disabled 属性可结合引擎具体字段判断 disabled node.attr(disabled) if disabled: self.wait_until(lambda: not node.attr(disabled), timeouttimeout) node.click() def wait_until(self, condition, timeout, interval0.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), threshold0.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(duration0.8, interval0.05, threshold0.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。测试侧也补
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门