本系列走到第五篇,前四篇把接口、单测、Web端UI的路数都捋过一遍,这一篇终于轮到移动端。先摊牌讲:把Web端那套Prompt玩法直接搬到App上,大概率要翻车。原因很直接——移动端没有一个稳定得像DOM一样的结构可以依赖,同一个按钮今天是“登录”,明天版本一换就变成一个不带文字的Icon,脚本里写死的路径往往比业务代码还脆弱。ARTEMIS是我在某次专项测试里搭出来的一套LLM移动端自动化测试框架,核心是它的双模式架构:一条链路让LLM先把自然语言意图离线转换成完整的执行计划,再由设备端按计划执行;另一条链路让LLM在测试过程中实时读取屏幕状态、边看边做决策。两条链路对应的是两类截然不同的目标——回归稳定性和探索覆盖率。这篇文章就把双模式为什么拆、怎么拆、踩了什么坑一次说清,正在做移动端自动化智能化改造的同学,应该能从中找到几条可以照搬的实践。
1. 移动端自动化为什么要换思路
1.1 传统脚本的“稳定性悖论”越走越窄
移动端的UI自动化,过去几年主流方案其实没有本质变化:要么靠Accessibility ID/text定位,要么靠层级XPath硬写。这套思路在页面结构稳定的前提下没问题,但只要App进入快速迭代节奏,问题立刻暴露。最常见的一幕是:测试脚本里某个XPath写的是//android.widget.Button[contains(@text,'登')],结果产品把“登录”文案改成了“登 录”,或者干脆换成一个纯英文Sign In按钮,脚本直接红一片。
问题不在于定位手段不够多,而在于传统脚本把“人的意图”和“页面的呈现”死死绑在了一起。脚本作者脑子里想的是“输账号、输密码、点登录”,落到代码里却全是坐标和属性,中间隔着一层极容易碎裂的映射。每次UI改动,碎的先是这层映射,然后才是测试脚本。
1.2 LLM真正改变的不是“写脚本”,而是“拆耦合”
LLM出现以后,很多人第一反应是让它直接生成脚本,省掉维护成本。实测下来这条路能走通,但不是最值钱的部分。LLM真正带来的变化是把“意图—页面—动作”之间的强耦合拆松了。
人对一个测试任务的描述永远是语义化的:我要验证登录成功后能看到首页。而设备端能执行的永远是具体动作:在哪个坐标框输入什么文本、点哪个控件。语义和动作之间,正好需要一个中间层来承接。这个中间层如果交给LLM去做,就等于把“UI变化导致的碎映射”问题,从代码层上移到了语义层。
语义层的容错能力比代码层强得多。页面文案从“登录”改成“Sign In”,对LLM来说只是同一个概念的两种表达,它能基于上下文判断出来。而传统脚本看到文字变了就真看不懂了。
1.3 双模式的本质:把“理解”和“执行”拆到不同时机
移动端自动化测试的需求从来不是单一的,至少可以分成两类:
一类是回归测试、冒烟测试。这类场景要求稳定、可复现、快,最好在版本发布前批量跑完。它希望你提前把所有动作都规划好,执行时不要有任何意外。
另一类是探索性测试、新功能尝鲜。这类场景没有固定预期,反而希望AI能像人一样边看边点,发现脚本写不出来的异常路径。它要求实时理解当前屏幕,实时做决策。
这两类需求对“LLM介入时机”的要求完全相反:一个希望理解发生在执行前,一个希望理解穿插在执行中。
如果只做其中一种,都会有很大的局限。只做离线生成,遇到动态变化的新页面就抓瞎;只做实时决策,回归场景的成本和稳定性根本扛不住。所以ARTEMIS一开始就按双模式来设计,两条链路各管一段。
2. ARTEMIS架构全景:四层结构一张图说清
2.1 描述层、规划层、执行层、验证层
整个ARTEMIS可以抽象成四个层级,层与层之间靠结构化JSON传递数据。
描述层负责接收测试意图,比如“验证错误密码时能提示失败信息,且不会进入首页”。这一层的输入可以是测试人员手写的自然语言,也可以是从需求文档里抽取出来的验收标准。
规划层的职责是把描述层的自然语言转成结构化的执行计划。执行计划不是代码,而是一张动作图。LLM在这层做的事本质上是一个“翻译+编排”的过程:它要知道用户意图里有哪几个关键操作,也知道这些操作在设备上大概对应什么类型的行为。
执行层是真正和设备打交道的部分。它读入动作图,逐条执行,遇到异常时回调自愈服务,执行完一条就记录一条轨迹。这一层不关心“用户到底要测什么”,只关心“动作原语是否被执行成功”。
验证层负责回答“这一步到底算不算通过”。它会把预期状态和当前实际状态做一个语义比对,输出结构化判断结果。
2.2 动作原语:先定义“能干什么”,再谈“怎么理解”
在架构落地时,第一步要做的不是写Prompt,而是先定一套动作原语(Action Primitive)。这是整个系统能运转的前提。
| API | 说明 | 关键参数 |
|---|---|---|
| wait_for | 等待某个条件成立 | target、timeout |
| input_text | 在目标控件输入文本 | target、text |
| tap | 点击目标控件 | target |
| swipe | 滑动操作 | start_target、end_target、direction |
| assert_state | 断言当前页面状态符合预期 | expect_state |
| capture_state | 主动抓取当前状态快照 | scene_type |
| switch_app | 切换前后台或唤起特定页面 | app_id |
这里有一个设计上容易忽视的点:所有原语的参数必须语义化,不能直接给坐标。
比如tap的参数是login_button,不是(240, 560)。具体坐标由执行层根据当前页面状态去解析。这样一来,动作图里承载的是“操作意图”,而不是“某一次执行时的页面布局”。页面布局变了,动作图不需要变。
2.3 运行时状态快照:给LLM一个“可读的世界”
不管是离线规划还是实时决策,LLM都需要知道当前设备上发生了什么。所以ARTEMIS定义了一个标准的状态快照结构:
- 页面树:把原生控件和WebView控件统一转换成结构化文本清单
- 截图:可选,动态模式下必须,静态模式下用于验证层
- 系统状态:当前App包名、Activity/ViewController、网络状态、前后台标记
页面树的Token化是成本控制的关键。一个中等复杂度的页面,原始View层级可能有上百个节点,直接全量丢给LLM会瞬间撑爆上下文。实际做法是过滤掉不可见的、无id无文本的、纯布局容器节点,再按“可见性+交互性”排序截断。压缩后一个页面大概能控制在两千到四千个Token以内,够用且不会太贵。
3. 模式A与模式B的内部链路拆解
3.1 模式A:计划先行,适合回归与冒烟
模式A的核心思路是“先想清楚再做”。LLM在测试执行之前拿到测试意图和一份页面结构预估,离线生成一张完整的动作图,然后设备端照着图执行。
流程分成五步:
- 测试人员输入意图
- 规划服务调用LLM,输出动作图JSON
- 动作图经过校验,存入测试计划库
- 测试执行时,Runner读取计划库里的动作图逐条执行
- 执行结果和验证结果一并写回报告
模式A在Prompt设计上有一条实测有效的经验:要求LLM严格按Json Schema输出,不要让它自己发挥格式。Schema里明确每个节点的action必须取自原语枚举、params.target必须语义化、edges必须能构成完整路径。
温度参数在这个模式下要压到极低,我用的值是0.2。原因是动作图的生成属于强约束任务,不需要创造性,越低越稳。把few-shot示例放两到三组进去,一组购物下单的、一组登录注册的、一组列表滚动的,模型对输出格式的理解立刻上一个台阶。
3.2 模式B:实时决策,适合探索与新功能
模式B的起点没有提前生成好的动作图,只有一个任务描述和一条执行上限。LLM在每一步都观察当前状态快照,然后决定下一个动作原语,执行完再次观察,形成“感知—决策—执行”的循环。
这个循环里最核心的控制参数是步数上限。探索性测试如果没有边界,LLM会在一个页面里反复横跳,或者陷入某种无意义循环。ARTEMIS的做法是默认给15步上限,超过后强制停止并输出已探索路径。实测下来,15步已经足够覆盖绝大多数“登录—浏览—操作—退出”类路径。
模式B必须使用多模态模型,因为它要处理截图。温度可比模式A略高,我设的是0.4,稍微给一点探索多样性,但超过0.6后动作质量会明显下降,会出现大量swipe乱滑。
还有一点很关键:模式B的每一步决策都要带上wait_for原语作为“观察接口”。LLM需要被明确告知,执行完一个动作后不能立刻继续,必须等待页面响应稳定后重新抓取状态快照。否则很多决策都基于半加载完成的状态,后续断言全乱。
3.3 两种模式最终落进同一个Runner
很多双模式设计最后会做成两套互不相干的系统,这是大忌。ARTEMIS的做法是两种模式共享同一个执行引擎Runner。
Runner对外只接收一张动作图。模式A的动作图由LLM先生成再传送给Runner;模式B同样也是生成动作图,只不过这张图不是一次性生成完,而是每次决策后追加一个节点,逐步拼出来的。
这样的好处在于下游所有能力只需要实现一遍:元素定位、异常上报、轨迹记录、截图归档,两套模式天然复用。以后想改定位策略,只改Runner一端就够,不需要动任何一层的LLM逻辑。
4. 关键实现:元素定位、断言验证与自愈
4.1 从View层级到语义索引的四级定位
元素定位是整个移动端自动化里最敏感的地带,也是AREMIS踩坑最深的地方。最终沉淀下来的策略是四级定位,按优先级依次尝试,成功就停:
- 语义ID定位:优先查找开发埋的testID和Accessibility ID
- 文本定位:根据可见文本和占位符匹配目标控件
- 结构路径定位:从页面树里寻找“底部Tab栏第2项”“列表第3行”这类语义路径
- 视觉定位:交给多模态模型识别坐标,在原语执行前做边界校验
前三级在页面树Token化之后执行,速度快、成本低。第四级只在动态模式下作为兜底。实际运行中,前三级的成功率大概在85%左右,剩下15%要靠视觉兜底。
一个重要的工程细节:执行层在拿到LLM给出的目标描述之后,必须先做“锁定”,锁定的意思是把当前页面上匹配到的控件实例缓存起来。否则从动作图生成到真正执行之间有几秒延迟,期间的页面可能已经变化了。
4.2 视觉定位的坐标校正与上下文裁剪
多模态模型在截图里点位置,看起来灵光,真到落地全是细节。
第一个问题是坐标误差。模型给的是一个大致区域,不是精确落点。所以ARTEMIS拿到坐标后要做两步处理:先取目标最小外接矩形的中心点,再检查中心点是否落在可点击控件上。不满足就按步长搜索最近的可点击元素,搜索半径一般控制在二三十个像素内。
第二个问题是截图成本。原始截图直接送进多模态模型又贵又慢。实测下来,把截图长边缩到768、JPEG质量压到70%,对定位准确率的影响几乎可以忽略,但Token和延迟能降一半以上。同时不要一次性把整条截图序列都发给模型,每条请求只带当前最新帧和历史最近两帧,足够维持上下文延续性。
4.3 断言验证:让LLM比较“语义状态”,而不是数像素
断言是双模式里最容易设计失败的一环。一开始的直觉是让LLM看一眼截图判断“登录是否成功”,看起来很AI,实际上不可靠。截图里偶尔弹出个活动弹窗,LLM就可能犹豫。
后来我把断言改成了结构化流程:
- 预期状态转成可核验的语义条件,比如“首页导航栏出现、用户头像控件可见、登录入口消失”
- 把当前页面树文本和截图同时交给LLM
- 要求LLM输出一个严格的结构化结果:
{"result": "pass/fail", "evidence": ["navbar_visible", "avatar_visible"], "reason": "..."}
关键是evidence必须引用页面树里的具体控件ID或文本,而不是模糊地说“看起来没问题”。这能有效逼着模型去对照真实状态,而不是凭常识脑补。实测中启用这个约束之后,断言误判率下降了约一半。
4.4 自愈机制必须“带锁前提”
自愈机制是运行时最危险的地方。最开始我做得比较暴力:定位失败就无限重试,换定位方式再试。结果某次测试中发现一个诡异现象——点击“登录按钮”失败后,自愈逻辑换了一个坐标去点,结果点到了旁边的“忘记密码”,测试继续往下走,竟然把“找回密码流程”当成“登录成功”后续路径跑下去了。
这实际上是LLM在自愈时产生了“错误的创新”:它不是恢复了原定意图,而是为了继续执行而扭曲了步骤。
后来给自愈加了三条硬约束:
- 降级定位只允许在同目标语义下进行,不允许更换目标
- 每次降级前必须重新抓取状态快照,确认目标控件确实改变了位置而不是误判
- 重试超过2次就放弃本次节点,标记异常,不允许绕过执行后续动作
自愈可以灵活,但不能无边界地灵活。灵活的前提是整套动作图仍然成立,一旦动作图被扭曲,整条测试路径就失去了意义。
5. 部署落地时的参数与控制经验
5.1 模型选型与几个容易被忽略的参数
ARTEMIS在模型选型上走的是“分层混用”路线:静态模式的动作图生成、断言判断这类偏语义推理的任务,用中小型模型的强逻辑版本就能胜任;动态模式的视觉定位和实时决策,必须上多模态大模型。
选型之外,有三个参数直接决定成败:
第一个是max_tokens。动作图动辄输出几百行JSON,token给不够就会截断成非法JSON。我一般至少给到2000,断言输出短一些,800到1000足够。
第二个是response_format。只要模型支持强制JSON输出,就一定开启。宁可让模型说“我无法判断”,也不要让它输出一段带散文性质的文本。
第三个是stop序列。在动作图生成场景里,把“```json”加进停止序列可以防止模型输出包裹代码块的Markdown格式,省一道解析逻辑。
5.2 Token预算从哪里省:状态快照优化
成本控制的核心不在模型价格,而在状态快照。快照吃掉的Token远比你想象的多。
一个中等复杂度的App页面,完整View层级打印出来可能有两三百行。ARTEMIS的过滤策略按三步走:
- 去掉
visibility=gone/invisible的节点 - 去掉宽高为0的纯容器
- 去掉没有id、没有文本、没有描述的纯布局View
做过这三步,一个页面通常能压掉六成节点。再配合截图降采样,单次状态快照的成本大约是纯量输入的3到5倍,但已经完全可接受。
实测数据:跑一轮包含40个case的回归,平均每个case触发6次状态快照,模型输入Token消耗大约在15万到20万之间。这个量级在预算上是可以接受的。
5.3 并发、超时与重试的合理边界
LLM服务化和设备集群之间有一个天然的瓶颈:设备能并行跑,但LLM接口的并发是有限制的。ARTEMIS的做法是给LLM调用加了一层队列,保证设备侧请求不会直接打爆模型服务。
单步决策在动态模式下设置8秒超时,超过就返回“决策失败”。不要设置无限等待,因为模型偶尔会陷入“思考僵局”,等多久都不会有结果。
重试预算要区分场景。模式A每个case最多重试2次,重试前必须重新生成动作图;模式B单步最多重试1次,重试不改动作目标只换定位路径。这个边界定下来以后,自动化的整体稳定性反而上升了——因为很多“重试”本质上是在重复一个本身就有问题的决策,重试越多,坏轨迹越长。
6. 我踩过的四个坑
6.1 页面还没加载完,LLM就开始断言
这是最常见、也最隐蔽的坑。App页面切换时有一小段过渡动画,此时页面树是一个半空状态。模式B的LLM看到这个半空状态,直接下了个“当前页面为空”的结论,然后顺着这个结论做了一堆无意义操作。
解决办法是在动作图里强制要求执行层“稳定等待”。我现在会在每次页面切换后自动调用一次wait_for,条件是“页面树非空且最近0.5秒内无新增节点”,然后才允许继续。判断“页面是否稳定”不能靠固定sleep,要靠观察节点变化速率。
6.2 坐标定位在原生页和WebView之间反复横跳
App里混合了原生页面和WebView时,同一个“登录”控件,一会儿出现在原生层,一会儿以Web元素的身份出现。四层定位里,前三种都识别不到WebView内部元素,只能走视觉兜底。
后来发现最有效的办法是先识别当前页面的“类型标记”:页面树里是否存在WebView节点。如果存在,就把定位策略切换成WebView专用的元素提取方案,统一走一次内容注入。这个前置判断比在定位结果里反复尝试高效得多。
6.3 自愈机制产生了“伪创新路径”
这节前面提过。最典型的例子是,LLM为了把一个失败步骤“救回来”,故意把目标控件改成另一个功能相似的控件。表面上看测试继续跑通了,实际上根本测错了东西。
应对方法是给自愈日志加标记。每次走到自愈分支,在执行轨迹里写入"recovered": true,并记录原始目标和新目标。后续分析报告里,凡是recovered: true的步骤都要人工过一遍。同时上面提到的“不允许更换目标”这条规则,是真正解决这个问题的根。
6.4 回归测试的断言开始“撒谎”
用LLM做断言,最怕的不是它判断失败,而是它“判断通过但理由错误”。某次测试中,登录失败时弹了一个toast,页面实际还留在登录页,但LLM看到页面上有一张品牌宣传图,就认为“页面内容丰富,可能进入首页成功”,给出了pass。
这之后我强制要求断言输出必须列出evidence,而且要引用页面树里的实际节点。凡是没有evidence支撑的pass,一律视为fail。同时定期从断言日志里抽一批“通过样本”反向验证,看看LLM到底是怎么“骗过自己”的。
这个方法很笨,但确实管用。我现在每次跑完批量回归,第一件事不是看通过率,而是先翻一遍自愈标记和evidence清单。凡是标记数量异常增多的区块,基本就是页面改动或者用例设计出了问题,比盯着红绿报表有效得多。