ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

AI操作手机三层架构:感知决策执行闭环与工程实践

AI操作手机三层架构:感知决策执行闭环与工程实践

1. 先想清楚一件事:让模型“看屏幕点按钮”到底难在哪

第一次接触 AI 操作手机这个命题,很多人脑子里冒出来的画面是:截个图丢给多模态大模型,模型吐出一组坐标,代码照着点一下,完事。我最早也是这么想的,写了个几十行的脚本,在测试机上点开了设置页,然后又点了一次,再一次,接着就卡在一个系统弹窗上原地转圈。那次之后我才明白,AI 操作手机这件事的难点根本不在“模型能不能看懂截图”,而在于屏幕这个东西本身极不稳定,而你要在这块每秒钟都在变的画布上,做一条能收敛到目标的控制回路。

iEasyRun AI 这类方案之所以要拆成三层架构,不是因为分层听起来高级,而是因为它把两类完全不同的不确定性隔开了:一类是“我看到的到底是什么”(语义问题),另一类是“我这一下点到哪去了”(执行问题)。中间那层负责把前者的模糊输出翻译成后者的精确指令。这三层任何一层偷懒,整套东西就跑不起来。

1.1 手机界面不是稳定的 API,而是一块随时变化的画布

桌面端自动化曾经有过一段舒服日子,浏览器有 DOM,有 CSS 选择器,有一整套稳定的元素寻址方式。手机上这套东西基本不存在。你能拿到的只有两样:像素和控件树。像素对模型友好,但坐标系一旦错位,点击就落空;控件树对定位友好,但很多界面(自绘控件、游戏引擎渲染、部分跨平台框架的页面)压根不吐节点,或者吐出来的节点层级乱成一团。

更麻烦的是界面状态的数量级。一个中等复杂度的应用,页面组合加上各种弹窗、浮层、加载态、权限请求,状态空间轻松上到几十万。你不可能穷举,只能在运行时一步步试探。而且试探的成本不低——每走一步都要截一次图,跑一次推理,注入一次手势,单步耗时几百毫秒到几秒。一个十步的任务,稍微走岔两次,用户体验就从“AI 帮我干活”变成“看 AI 表演”。

我在做这类方案的时候有个朴素判断:如果单步时延压不到 2 秒以内,用户就会开始怀疑它卡死了;如果十步里有超过两次走错,用户就会关掉它。这两个数字基本决定了架构的所有取舍。

1.2 三层拆分的真正动机:把“不确定”关进两层之间

把架构划成感知、决策、执行三层,核心收益是每一层的不确定性都被下一个明确接口兜住了。

感知层的输出不是“屏幕上有个登录按钮”,而是一份带标识的结构化描述:每个可交互元素的归一化边界、文本、控件类型、是否可点击、可点击祖先节点在哪。决策层不需要知道这些信息是从像素里抠出来的还是从控件树里读出来的,它只面对一份标准清单。决策层的输出也不是“点那个按钮”,而是一条动作指令:动作类型、目标标识、参数。执行层不关心为什么要这么做,它只负责把这条指令变成系统里一次真实的触摸事件,并回传执行结果。

这条链路上最关键的契约是目标标识。如果用坐标做标识,分辨率一变全线崩;如果用模型描述的文本做标识(“蓝色的确认按钮”),歧义太多。所以实际方案里一定会有一个中间层:把元素的多个属性组合成一个稳定的指纹,模型只对这个指纹说话。

动作指令示例(决策层 -> 执行层) { "action": "tap", "target_id": "e7a3f2c1", "fallback_bounds": [512, 1830, 780, 1912], "reason": "当前页面需要先关闭升级提示", "expect": "弹窗消失,回到首页" }

expect这个字段很多人会省掉,我觉得不该省。它是后面做动作校验和失败恢复的唯一依据,没有它,执行层交回来的只有“点了”两个字,决策层根本不知道自己走对没走对。


2. 感知层:截图和控件树两条腿走路,缺一条都会摔

感知层要解决的是“此刻屏幕上有什么、我能操作什么”。这里最常见的误区是二选一——要么纯视觉,要么纯控件树。我实测下来,两条路单独走都有硬伤,必须融合。

2.1 视觉通道解决“看得懂”,结构通道解决“点得准”

纯视觉路线的好处是通用性拉满:截一张图,任何界面都能处理,包括游戏、H5、自绘列表。但它的短板同样明显。多模态模型输出坐标的精度大概在屏幕宽度的 1% 到 3% 之间,在 1080 宽的屏幕上就是 10 到 30 像素的误差。按钮够大没事,一旦碰上密集的图标栏、小的关闭叉号、间距很窄的列表项,这个误差直接导致点错。

纯控件树路线刚好相反。节点给的bounds是精确到像素的,点哪是哪。但它对节点的覆盖率是真的不稳定——同一个应用,主页有节点,进了某个自绘的详情页,整个树就剩一个根节点加一个 WebView 壳子,什么信息都没有。

所以实际做法是:先用控件树把能拿到的东西全部结构化,再把截图和这份结构做交叉验证。举个具体操作,控件树里拿到一个节点bounds=[60, 420, 1020, 512],同时截图在同一个矩形内做一次文本检测,两边文字对得上,这个元素的置信度就很高。对不上的时候通常意味着:界面正在做动画、截图的坐标系包含状态栏而节点坐标不包含、或者这个位置被一个透明浮层盖住了——三种情况都指向“现在不适合点击”,应该等下一帧。

通道强项硬伤适用判断
视觉(截图 + 多模态模型)通用,无节点也能理解坐标精度低,推理成本高兜底、理解复杂布局、识别图标语义
结构(无障碍节点树)坐标精确,带控件类型和可点击性覆盖率不稳,自绘界面缺失主力定位,优先使用
文本(OCR 或节点 text)定位准,可用于校验图像型文字识别慢,易受字体干扰交叉验证、列表项识别

2.2 归一化坐标系:所有坐标漂移问题的总根源

我见过最多的线上问题就是坐标漂移,而且它特别隐蔽——在你自己的测试机上一切正常,换台设备就全乱。根因几乎都出在坐标系没统一。

屏幕坐标这件事至少有四个版本同时存在:物理像素(截图上的坐标)、dp 或逻辑像素(控件树通常给这个)、应用可用区域(扣掉状态栏和导航栏)、全屏区域(含状态栏)。

一个具体的坑:截图如果用AccessibilityService.takeScreenshot(),在部分系统版本上拿到的是不含状态栏的裁剪图,高度比屏幕物理高度小一截;而节点树给的bounds是基于全屏的。两边直接混用,纵向偏移几十像素,点击就会稳定偏上或偏下。这种偏移在小按钮上就是完全点不中。

我的处理方式是:所有内部流转的坐标一律用归一化值,范围[0, 1000],原点固定在屏幕左上角,参照尺寸是包含状态栏和导航栏的完整屏幕。

def to_norm(x_px, y_px, screen_w, screen_h): # 统一到 0~1000 的相对坐标系 return round(x_px / screen_w * 1000), round(y_px / screen_h * 1000) def to_px(x_norm, y_norm, screen_w, screen_h): # 还原时用四舍五入,避免累积误差 return round(x_norm / 1000 * screen_w), round(y_norm / 1000 * screen_h)

用归一化的另一个好处是喂给模型的坐标也统一了。如果不做归一化,模型在 1080x2400 的图上训练出来的坐标习惯,换到 1440x3200 的折叠屏上就完全失效。归一化之后,模型学的其实是“屏幕相对位置”,跨设备迁移会稳很多。

提醒一句:归一化不能解决宽高比差异。同样是(500, 300),在 9:16 的屏幕上和在 4:3 的平板上落在完全不同的视觉位置。如果你的目标设备跨度大,喂给模型时最好同时带上宽高比这个标量,让模型有个参照。

2.3 元素指纹与页面状态哈希:让模型有稳定的指代物

模型的输出必须能落到一个具体的控件上,所以每个可交互元素都要有个稳定 ID。我用的是一个组合指纹再做哈希:

def fingerprint(node): parts = [ node.resource_id or "", # 资源 ID,最稳定 node.class_name or "", strip_digits(node.text or ""), # 文本里的数字脱敏,防止时间、价格变动导致指纹漂移 strip_digits(node.content_desc or ""), node.norm_bounds_key(), # 归一化边界,取整到 10 的倍数,容忍 1 像素抖动 ] return md5("|".join(parts).encode()).hexdigest()[:8]

两个细节值得单独说。第一,文本要做数字脱敏。列表里的“3 分钟前”“¥129.00”每隔一会儿就变一次,不脱敏的话同一个按钮每次指纹都不一样,模型上一轮记下来的东西这一轮就找不到了。第二,归一化边界要取整。浮点数的尾数会引入无意义的差异,取整到 10 的倍数能把 1 到 2 像素的渲染抖动吸收掉。

有了元素指纹,就能顺手算页面状态哈希:把当前包名、Activity 名(或页面标题)加上排序后的元素指纹列表拼起来取哈希。这个值用处很多——判断“页面变没变”、判断“是不是又走回了老地方”、判断“滚动有没有生效”。后面讲无限循环排查的时候会用到它。

2.4 采集链路优化:帧差、缩放、分块

感知层的开销是整套系统的大头,尤其是截图。可选通道和各自耗时大致是这样:

采集方式典型耗时权限要求备注
adb exec-out screencap -p200~500 msUSB 或网络调试最通用,但慢,且大图传输开销大
AccessibilityService.takeScreenshot()80~200 ms无障碍服务API 30+,部分版本不含状态栏
MediaProjection + ImageReader30~80 ms需用户授权一次流畅,适合高频循环
控件树实时遍历20~80 ms无障碍服务只需结构时最划算

优化上我用了三招。第一招是帧差:截图后先算一个低分辨率的感知哈希,和上一帧比,差异小于阈值就认为界面没变,直接跳过推理。这在等待加载、等待动画结束的时候能省掉大量无效调用。第二招是缩放:模型侧不需要原图,长边缩到 720 到 1080 已经足够识别界面结构,token 消耗能降一半以上。第三招是分块:只有当需要模型“细看”某个区域时才裁剪局部图,而不是每次都送整屏。

这三招叠起来,一个典型任务的平均单步感知开销能从 400ms 压到 120ms 左右,而且模型调用的次数能少三到四成。


3. 决策层:把“帮我订个外卖”翻译成二十几次点击

决策层是整个方案里最像“智能”的部分,也是最容易写歪的部分。写歪的典型症状是:给模型一句自然语言,让它直接输出一大段操作步骤,然后照着执行。这条路在 demo 里能跑通,在真实环境里第二三步就崩了,因为真实界面不会按你的剧本走。

3.1 任务拆解与 ReAct 循环的真实骨架

我采用的骨架是“先粗排计划,再逐帧决策”。第一步让模型把用户指令拆成若干个子目标,这个子目标列表是粗粒度的,允许粗略、允许不完整。比如“在某本地生活应用里买一杯冰美式”,拆出来大概是:打开应用 → 进入点单页 → 选规格 → 加购 → 结算 → 确认支付。这六步里,后面几步的细节在前面根本看不出来,但那不重要,计划的作用是给循环一个前进方向,避免它在一个页面上反复乱点。

真正跑起来的是观察-思考-行动的主循环:

for step in range(MAX_STEPS): obs = perception.capture() # 截图 + 控件树 if obs.page_hash in visited and last_action_failed: strategy = "回到上一层重新规划" decision = planner.decide( goal=current_subgoal, observation=obs.compact(), # 结构化精简后的描述 history=memory.recent(5), # 最近 5 步动作与结果 banned_actions=memory.blacklist(obs.page_hash), ) result = executor.run(decision.action) ok = validator.check(decision.expect, perception.capture()) memory.record(obs.page_hash, decision.action, ok) if decision.action.type == "done": break

这里有几个设计决定值得展开说。history只传最近 5 步而不是全量,是因为上下文一长,模型的注意力就会漂移,而且成本也顶不住。banned_actions是按页面哈希记录的失败动作黑名单,防止在同一个地方反复踩。validator是独立的校验模块,不依赖模型的自我判断——模型说“我点了”,不代表点对了,必须靠界面变化来验证。

3.2 动作空间设计:宁可动作少,也不要动作模糊

动作空间我压到了八个,多一个都不要:

动作参数说明
taptarget_id或归一化坐标点击元素中心或指定点
long_presstarget_id, 时长长按,默认 600ms
swipe起点、终点、时长滑动,用于翻页和拖拽
input_texttarget_id, 文本向可编辑控件写入文本
back无系统返回
wait毫秒数显式等待,用于加载态
done结果摘要任务完成
ask问题文本需要用户决策时中断

刻意没做的两个:一个是scroll_to(滚动到某个元素),因为“滚几次能滚到”这个信息模型判断不了,拆成 swipe + 观察反而更稳;另一个是open_app这种高层语义动作,因为不同应用内部跳转逻辑差异太大,容易变成模型的一厢情愿,我宁愿让它老老实实回桌面点图标。

动作少的好处是输出格式收敛快。八个动作的 JSON schema 约束在提示词里,模型照着填的成功率远高于开放式的自由文本描述。

3.3 坐标接地:模型给出的是意图,代码要给的是像素

这是三层架构里最容易被忽略的接口细节。模型输出的往往是“点击右上角的设置入口”,而执行层需要的是(978, 136)这样的像素坐标。中间这段翻译就是接地,做不好会出现各种玄学问题。

我的做法是双路径接地,优先走结构,结构不行走视觉。如果模型给的是target_id,直接在感知层的元素表里查边界,取矩形中心;如果查不到(指纹对不上,通常说明界面变了),就退回到模型给的归一化坐标,再做一次“就近吸附”——在这个坐标周围一个有限半径内找最近的可点击元素,把落点修正到它的中心。

def resolve_target(decision, elements, screen_w, screen_h): if decision.target_id: node = elements.get(decision.target_id) if node: cx = (node.bounds[0] + node.bounds[2]) / 2 cy = (node.bounds[1] + node.bounds[3]) / 2 return to_px(*to_norm(cx, cy, screen_w, screen_h), screen_w, screen_h) # 退化路径:用模型坐标,并做就近吸附 raw = to_px(decision.x, decision.y, screen_w, screen_h) near = nearest_clickable(elements, raw, radius=int(screen_w * 0.06)) return near.center if near else raw

半径取屏幕宽度的 6% 是我试出来的经验值。太小吸收不了模型误差,太大又容易吸附到隔壁那个按钮上——尤其是底部那种并排的双按钮(“取消 / 确认”),半径一大就可能吸附错,反而更危险。对这类高危区域,我的处理是:一旦吸附结果和原始坐标分属不同元素,直接不吸附,改用原始坐标并要求二次确认。

3.4 记忆、校验与失败恢复

记忆分三块。短期记忆是最近几步的动作和结果,随上下文滚动丢弃。页面记忆记录走过的页面哈希和在上面成功执行过的动作,下次遇到同样哈希可以直接复用,省一次推理。黑名单记录在某个页面哈希下失败超过两次的动作,后续不再尝试。

校验这块,我定义了几种“成功信号”:目标元素从可访问节点里消失(点了关闭按钮)、页面哈希发生变化(跳转成功)、目标文本出现在新页面上(搜索生效)、出现加载指示器(至少说明动作被接收了)。相反,如果动作执行后页面哈希完全没变,基本可以判定失败。这里要留一个例外:有些动作本来就不改变页面哈希,比如点输入框拉起键盘,键盘是系统窗口,可能不在应用节点树里。这类动作我单独标记为“无状态变化型”,不做哈希校验。

失败恢复的阶梯是这样的:第一次失败,原样重试一次(很多是时序问题);第二次失败,换个方式,比如从target_id点击改成坐标点击;第三次失败,执行back回到上一层重新规划;back连续三次无效(可能回到了桌面),就直接终止任务并回报失败原因。这个阶梯能挡掉绝大多数偶发问题,又不会陷入死循环。

3.5 护栏:哪些动作必须先停下来问一句

这部分很多方案会跳过,但只要真正让 AI 在用户手机上跑过任务,就知道护栏不是可选项。

需要中断确认的动作,我划了这几类:涉及金额的操作(结算、付款、转账)、涉及对外发送的操作(发消息、发帖、发邮件)、涉及删除的操作(清空、卸载、删除聊天记录)、涉及账号的操作(修改密码、退出登录、授权第三方)。判定方式是动作发生前的页面上出现特定语义的按钮文本,或者当前应用在白名单里被标记为“高风险”。

另外还有一个常被忽略的护栏:输入内容不得含敏感信息。用户在任务描述里给出的地址、电话,写进输入框之前要过一遍脱敏检查,并明确记录进日志,方便用户回溯。


4. 执行层:从一次点击到系统里真正的触摸事件

执行层看起来最简单,实际上最容易出“看起来成了其实没成”的问题。它的任务就一句话:把动作指令变成系统里真实的输入事件,并如实回报结果。

4.1 无障碍手势注入:无 root 情况下的主力通道

主力通道是无障碍服务的手势分发,API 24 以上可用,需要在服务配置里声明canPerformGestures。好处是不需要 root、不需要额外授权,走的是系统正规输入通道,绝大多数应用都认。

fun tap(x: Int, y: Int, durationMs: Long = 40L): Boolean { val path = Path().apply { moveTo(x.toFloat(), y.toFloat()) } val stroke = GestureDescription.StrokeDescription(path, 0L, durationMs) val gesture = GestureDescription.Builder().addStroke(stroke).build() return dispatchGesture(gesture, null, null) }

durationMs这个参数有讲究。设成 0 或 1 毫秒,部分机型会识别为异常事件直接丢弃;设成 40 到 60 毫秒最稳,接近真实手指的接触时长。滑动同理,duration建议不低于 300 毫秒,太快会被识别成 fling(快速甩动),滚动距离超出预期,列表直接冲过头。

滑动路径我一般用带中间控制点的三次贝塞尔曲线,而不是直线。直线滑动在部分应用的列表里会触发“惯性加速”判定,滚动距离是曲线的两倍多。加两三个控制点让轨迹稍微弯一点,滚动距离的可预测性会好很多。

4.2 ADB 通道的能力边界与常见误用

ADB 通道我用在三个场景:调试期的高频验证、无障碍服务没起来时的兜底、需要跨应用快速跳转的自动化测试。

# 点击 adb shell input tap 540 1830 # 滑动(起点x 起点y 终点x 终点y 时长ms) adb shell input swipe 540 1800 540 600 400 # 按键 adb shell input keyevent KEYCODE_BACK # 文本(仅 ASCII 可见字符,空格要写成 %s) adb shell input text "hello%sworld"

坑主要在三个地方。一是延迟不可控,input命令本身启动一个 shell 进程再调系统输入,实际耗时 100 到 300 毫秒,做高频循环时它会成为瓶颈。二是文本输入只支持 ASCII,中文直接失败或者变成乱码。三是无线调试连接一旦抖动,命令会静默失败,返回码正常但事件没注入,这种“假成功”最难查。

所以我的策略是:ADB 只做兜底,不进主循环。主循环永远走无障碍通道,无障碍挂了才降级到 ADB,并且降级后立刻打日志标记,方便定位。

4.3 中文输入这块硬骨头

输入中文是这类方案绕不过去的坎,我试过三种路子。

路子一是无障碍的ACTION_SET_TEXT,直接给可编辑节点设置文本,最干净,不弹键盘,不依赖输入法。限制是节点必须isEditable且支持这个动作,WebView 内的输入框和部分自绘输入框经常不支持,调用后文本为空。

路子二是装一个无界面的输入法服务,通过广播把 Base64 编码的文本传过去,输入法解码后提交。这条路通用性最好,中文英文都行,缺点是用户要手动切换输入法并授权,安装成本高。

路子三是坐标点击输入框后用 ADB 发文本,只对英文有效,中文行不通。

我实际的组合是:优先ACTION_SET_TEXT,失败则拉起无界面输入法,两者都不行就中断任务提示用户手动输入。第三条路我基本放弃了,因为一旦涉及中英混排就彻底没法用。

还有一个细节:ACTION_SET_TEXT设置的是完整文本,会覆盖原有内容。如果输入框里已经有默认文字(比如“请输入手机号”这类占位符不算,但预填内容是),需要先发一个全选加删除的组合动作,否则会变成拼接。

4.4 手势节奏、异步等待与重试策略

真实用户的操作节奏是有停顿的,机器操作如果零点几毫秒一个动作连发,很多应用的风控会直接判定异常。我在动作之间加了 150 到 400 毫秒的随机延迟,滑动结束到下一次截图之间至少等 300 毫秒,让渲染和动画有时间跑完。

等待这块我不用固定 sleep,用的是条件轮询加指数退避:每 120 毫秒检查一次目标条件(元素出现、页面哈希变化、加载指示器消失),最多等 6 秒,超过就判定超时。相比固定sleep(2000),这套策略的平均等待时间能降一半以上,而任务成功率不降。

重试策略上有一条铁律:同一个动作在同一个页面状态下最多重试两次。第三次必须换策略,否则就是在给无限循环铺路。


5. 把三层串成一个能跑通的闭环:时延、成本与断点

三层拆开看都不复杂,真正难的是把它们串成一个能在两秒内转一圈的闭环。这一节说说我调这套东西时最耗时间的几件事。

5.1 一次循环的时延预算拆解

一个完整的决策循环,耗时大致这么分布:

环节典型耗时优化手段
截图80~200 msMediaProjection 替代 ADB 截图
控件树遍历20~80 ms限制遍历深度,只取可见区域
观察构建(结构化 + 压缩)10~30 ms元素数量超阈值时按可点击性裁剪
模型推理800~4000 ms小模型分流、局部图替代整屏、结果缓存
动作注入50~150 ms无障碍手势,避免进程启动开销
结果校验80~200 ms复用下一轮截图,不额外采集
合计1.0~4.7 s—

能看出来,模型推理是绝对的瓶颈,占了七成以上。所以优化重点不在别的地方,就在推理。我做了三件事:把高频页面的“页面哈希 → 动作”映射缓存起来,命中就直接跳过推理;把简单判断(比如“这个弹窗是不是有个明显的关闭叉号”)交给规则处理,不惊动模型;把整屏图换成“结构化文本 + 少量局部裁剪图”的组合,token 数量能砍掉六成。

这里有个反直觉的结论:给模型的图不是越多越好。我一度把整屏图全送进去,想着信息越全模型越准,结果发现界面元素一多,模型反而开始“挑花眼”,选的元素经常不是最该点的那个。改成先给结构化清单让模型指认,需要细看时再裁局部图,准确率反而上去了。

5.2 断线、应用被杀、系统弹窗的兜底

真实环境里,任务跑到一半被打断是常态。我遇到的打断源大概有这几类:系统权限弹窗(定位、通知、存储)、应用自身的升级提示和活动弹窗、来电或消息横幅、低电量提示、应用被系统回收。

统一处理思路是在每轮感知后先做一次“意外检查”:如果当前页面的包名不是目标任务的应用,或者页面上出现了已知的弹窗特征(大面积遮罩 + 单个或双个按钮 + 特定关键词),就优先执行关闭动作,最多三次,三次关不掉就回报人工。这个检查放在决策之前,比事后回滚成本低得多。

断线这块主要针对 ADB 通道。我的做法是每次动作后校验返回码,同时保留一个心跳检测,连续三次动作无响应就重连,重连失败则降级或终止。终止时要把现场信息(最后一张截图、最后几个动作、当前页面哈希)落盘,不然线上出问题完全没法复盘。

5.3 任务中断后能不能接着跑

这个能力我觉得很值。完整方案的记忆里存了子目标进度,任务中断后重新启动,可以读回进度接着做,而不是从第一屏重来。实现上不用很复杂——存一份{goal, completed_subgoals, last_page_hash, last_screenshot_path}就够了。重启后先感知一次,如果页面哈希和上次记录的对得上,就接着往下走;对不上就回到最近的稳定页面(通常是应用首页)重新规划。


6. 实测踩过的七个坑(每个都带排查链路)

下面这些坑都是我实际遇到并且花时间查过的,排查过程比结论更有参考价值,所以我尽量把链路写全。

6.1 点击落空:坐标对了但点在输入法下面

现象:任务执行到“输入搜索词”这一步,点击输入框成功,键盘弹出,接着要点搜索按钮,点了三次都没反应,页面哈希一直不变。

排查:先看动作日志,三次点击的坐标完全一样,都在(978, 2180)。再调出那一刻的截图,发现键盘弹起后整个页面被顶了上去,搜索按钮已经移到了(978, 1420)附近,而(978, 2180)现在是键盘的字母区。也就是说,代码点的是旧的坐标。

根因:决策用的是键盘弹起前的感知结果,而执行发生在键盘弹起之后,中间隔了一次界面变化没被感知到。

处理:给动作注入加了一个前置校验——注入前重新读一次目标节点的当前边界,如果和决策时的边界偏差超过 20 像素,就作废这次动作,重新感知后再决策。多花 60 毫秒,能挡掉一大类坐标失效问题。

6.2 UI 树为空:dump 超时与自绘控件的取舍

现象:某个应用的商品详情页,控件树里只有根节点和一个WebView,看不到任何按钮,导致所有基于target_id的动作全部失败。

排查:换成系统命令行 dump,等了两秒才返回,而且结果一样。确认不是 dump 时机问题,是这个页面本身不吐节点。

处理:这类页面直接切纯视觉模式,用截图加模型识别,模型输出的坐标做就近吸附时没有节点可用,就退化成原始坐标。同时把这类页面的特征(包名 + Activity + 页面标题)记进一个“视觉优先”名单,下次进这个页面直接跳过控件树,省一次无用遍历。

6.3 无限循环:模型在原地复读

现象:任务卡在一个列表页,日志里同一个坐标被点了十几次,每次页面哈希都不变。

排查:把最近五步的动作和观察拉出来看,发现模型每次都自信地认为“点击列表第一项可以进入详情页”,但实际点的是一个不可点击的标题区域。失败后模型没收到有效反馈,下一轮看到同样的界面,做出同样的判断。

根因:失败信息没有回流到决策层。执行层只回了“已点击”,没回“无效”。

处理:在校验环节加了“哈希未变即失败”的判定,把失败结果写进黑名单,同一页面哈希下同动作失败两次就禁用。同时给提示词里加了一条硬约束:如果最近三步的动作和观察完全重复,必须换策略或放弃。

6.4 分辨率与折叠屏适配

现象:同一套流程在小屏手机上正常,在折叠屏展开态下点击全部偏移。

排查:对比两个设备的感知数据,发现折叠屏展开后宽高比是 1.1 左右,接近方形,而提示词里的示例坐标都是按竖屏手机写的,模型对“右上角”的理解在方形屏上完全跑偏。

处理:归一化坐标改成按短边缩放,并且在送入模型时显式带上宽高比。更彻底的做法是让模型少输出绝对坐标,多输出“点击第几个元素”,坐标由代码从元素表里查。这条改完之后,跨设备问题的比例降了非常多。

6.5 列表到底了没

现象:任务要“找到列表里某个商品”,模型一直在往下滑,滑到列表底部后继续滑,滑了二十多次才超时结束。

排查:翻日志看到每次滑动后页面哈希都在变——因为滑动本身会让列表的滚动偏移变化,元素边界跟着变,指纹就变了。哈希一直在变,程序就误判成“还有新内容”。

处理:滚动到底的判定不能只看哈希,要同时满足三个条件中的两个:可滚动的列表容器其scrollable属性变成 false、页面出现“没有更多了”之类的文本、连续两次滑动后可见元素指纹集合的重合度超过 80%。第三个条件最关键,我用集合重合度替代哈希,一下就把误判挡住了。

6.6 弹窗与权限请求的干扰

现象:任务中途被系统的定位权限弹窗挡住,模型把这个弹窗当成了目标页面的一部分,开始在上面乱点。

处理:加了一个统一的“中断拦截”前置步骤,维护一份常见弹窗的特征规则(遮罩层 + 一到两个按钮 + 特定关键词 + 非目标包名的窗口)。命中规则就先处理弹窗,再重新感知。规则库要持续补,因为每个应用的活动弹窗长得都不一样,这块只能靠攒。

6.7 回归测试与评估怎么做

自动化这种东西不改没事,一改就可能全线崩,所以得有回归集。我的做法是攒一批固定任务(20 到 50 个,覆盖登录、搜索、下单、设置、跨应用跳转),每次改动后跑一遍,记录任务成功率、平均步数、平均耗时、人工接管率四个指标。成功率是第一优先,但人工接管率同样重要——有些方案成功率不低,是靠大量“卡住了就弹给你手动点”撑起来的,那不算真本事。

跑回归的时候有个细节,模拟器和真机结果差异很大。模拟器上跑通了不能说明问题,真机上至少得覆盖三种分辨率和两个厂商的定制系统。


7. 什么场景下三层架构会扛不住,以及怎么兜

说了这么多好处,也得说清楚边界。这套架构不是万能的,有几类场景我试下来效果就是不好,提前知道能省很多弯路。

强实时交互类,比如游戏、直播间的抢购、需要精确时序的操作。三层闭环单步秒级,扛不住这类需求。这类场景适合写死的坐标脚本加图像匹配,模型只做兜底判断,别指望它进主循环。

强视觉语义类,比如“把这张图里的猫抠出来”或者“找出画面里最显眼的商品”。这类任务的核心是理解图像内容,而不是操作界面,属于另一个问题域,硬塞进操作框架里会很难受。

高安全等级场景,比如涉及身份的验证、金融确认。这类操作要的不是自动化,是无误,人工介入的成本远低于出错的成本。

信息量极大的长流程,比如要在几十个页面里反复比对数据再决策。这类任务不是操作能力的问题,是上下文和推理深度的问题,最好是拆成“抓取”和“分析”两个独立任务,抓取用这套架构,分析另起炉灶。

反过来,这套架构最舒服的场景是:步骤在五到三十步之间、界面结构相对标准、允许秒级延迟、失败后可以安全重来的重复性操作。日常的填表、查询、批量设置、内容发布、跨应用数据搬运,基本都落在这个区间里。

我自己在实际操作中的一个体会是,别急着把模型往主循环里塞。先用规则和控件树把能确定的部分做扎实,把界面状态摸清楚,再把模型放在真正需要判断的地方。我一开始反过来做,让模型处理所有环节,结果调试成本高得离谱,因为一旦出错你根本分不清是感知错了、模型想错了还是执行错了。分层之后,每层的日志独立、可复现,问题定位时间从几小时压到了几分钟。这个顺序上的经验,比任何参数调优都值钱。

再补一个实操小技巧:给每一轮循环存一张截图和一个 JSON 快照,按任务ID/步骤序号归档。跑崩了直接把最后五步的快照翻出来,一眼就能看出问题出在哪一层。这个习惯帮我挡掉了无数次“线上复现不了”的扯皮,做这类方案的人,越早养成越好。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进