最近在调一个手机端 GUI Agent 的环境感知模块,整个项目做到一半我才意识到,单靠截图喂多模态模型这条路,走起来特别别扭。界面稍微复杂一点,模型就会把图标认错、把文字叠在一起读串行,响应速度也撑不住高频操作。后来我把感知层整个重构了一遍,换成了双通道设计:以无障碍树为主干道,投屏像素作为兜底,跑完一轮下来,成功率明显上了一个台阶。这篇就来复盘一下我的完整思路、关键实现细节和一路上踩出来的坑。
先说清楚这套方案是干什么的,适合谁看。GUI Agent 要让大模型在手机屏幕上完成“看界面、做决策、点按钮”这一整条链路,第一步就是让 Agent 能感知当前屏幕状态。我的做法是,用 Android 的无障碍服务把界面结构“读”出来,得到一棵包含控件类型、文本、状态、坐标的语义树;同时通过截屏或者帧缓冲拿到的屏幕像素,作为结构信息缺失时的备用信号。两条通道互相配合,既稳定又可控。适合正在做移动端自动化测试、手机端智能助理、或者类似辅助功能类 Agent 的开发者参考。
1. 为什么要做双通道感知:一个“结构”和“像素”都不能少的场景
1.1 只靠截图的智能体,到底哪里不够用
我刚起步时的实现很简单:用截图工具拿到屏幕位图,转成 Base64 发给多模态大模型,让模型直接输出点击坐标。这种“视觉直出”的方案在简单页面上问题不大,打开一个干净的设置页面,模型能准确找到“飞行模式”那个开关。但真跑起来,要命的问题全冒出来了。
第一个问题是小目标识别不可靠。手机上很多按钮是光秃秃的图标,没有文字,比如右上角那个“+”号,或者一个只有三根横线的菜单按钮。多模态模型看小图标的准确率很不稳定,经常把“更多选项”识别成“返回”。第二个问题是文字密集区域会串行。列表页里挨得很近的几行文字,模型容易把上一行的后半句和下一行的前半句拼在一起。第三个问题更现实:延迟和成本。一张截图进大模型,一次推理一秒钟起步,复杂页面甚至要两三秒。高频操作下,这个延迟会让 Agent 看起来像卡死了一样。
而且还有个我一开始没意识到的隐患:像素画面里的信息密度其实很低。一个屏幕上,几平方厘米的区域内全是边距、留白、背景图,真正影响决策的控件往往只占很小一部分。让模型从海量像素里“大海捞针”式地找关键信息,本质上就是难为它。截图模式适合当“人类看一眼”那样用,但不适合当唯一感知通道高频使用。
1.2 只靠无障碍树,又会踩出哪些坑
既然像素通道这么不省心,那我是不是可以只靠无障碍树?Android 的无障碍服务可以拿到非常干净的结构化数据:哪个是 Button、哪个是 TextView、文本内容是什么、在屏幕上的 bounds 是多少,这些信息喂给大模型,Token 消耗低,语义又准确。
但只用树同样走不通。最典型的情况是 Canvas 绘制页面。很多游戏、地图、甚至部分短视频 App 的界面是直接用 OpenGL 或自绘引擎渲染的,屏幕上有绚丽的画面,但无障碍树里可能就只有一个孤零零的“SurfaceView”节点,什么文本都没有。还有些 WebView 页面在部分系统版本上,无障碍树是能导出,但节点层级乱得像一锅粥,很多关键操作元素没有 contentDescription,模型拿到树也猜不出那个区域画的是什么。
另外还有一个工程上的坑:无障碍树不是时刻都能保持最新的。页面转场动画进行到一半,树节点可能还没更新完;列表快速滑动时,树的新旧节点会交替出现,产生明显的时序错位。如果 Agent 拿到的树和用户看到的画面不是同一个状态,那决策就全错了。
1.3 双通道的定位:主干道、辅路和轮子
所以我在实践中确定的思路是:把无障碍树当作感知主干道,把投屏像素当作兜底辅路。主干道负责提供稳定的结构信息,让 Agent 在绝大多数普通页面里高效决策;辅路不参与日常的“抢活”,只在主通道给出低置信度结果、或者明确检测到结构缺失时才介入。
打个比方。无障碍树就像一个房间的结构图纸,哪面墙在哪、门朝哪儿开都标得清清楚楚;截图像一张实景照片,能看到墙纸花纹、家具颜色,但看不出承重结构。要在一间房子里快速规划路线,看图纸比看照片高效得多。只有在图纸缺页或者标注和实景对不上的时候,照片才有登场价值。这套思路落到工程上,就是两条采集链路并行运行、按需仲裁,既不会让 Agent 变“瞎”,也不会让它被像素推理拖垮速度。
2. 主通道:无障碍树怎么采集、怎么用,才不浪费
2.1 无障碍树的来源与采集方式
无障碍树的采集现在有几个路子,我分别试过,各有优劣。
系统级无障碍服务是最正统的采集方式。在 Android 里注册一个 AccessibilityService,系统会把界面结构变化通过事件推给你。这个方式的优势是实时性最强——界面一有变化,你立刻能拿到最新节点;缺点是必须在系统层常驻服务,权限管理和前台保活都得处理。目前我用的就是这一套,后面所有关于树的处理都是基于它展开的。
ADB 方式也可以抓树。通过手机开启“开发者选项”里的“无障碍功能”,再用 adb shell 相关的调试命令,能把当前界面的节点 dump 成 XML 文件。这个方式适合调试和做离线验证,因为整个过程是“拉取快照”,不具备实时性,不适合做在线循环决策。自动化测试框架里常见的 dump 操作基本就是这个原理。
还有一条路是用厂商的私有接口,某些深度定制的系统可以通过隐藏接口拿到更完整的节点信息,但可移植性太差,我只在有源码的调试机上验证过,不敢放到正式链路里。
采集到手的是 XML 结构,一棵树动辄几百个节点。要是直接把原始节点全部塞给大模型,光是 Token 就够喝一壶。所以我的处理管线里,拿到树之后要先做三件事:清洗、聚类、压缩。
清洗是去掉无用节点。很多系统界面里会有隐藏在屏幕外的节点,bounds 超出屏幕范围;会有不可见的节点,visibleToUser 为 false;会有纯布局容器节点,没有文本也没有可点击属性。这些节点对决策没有贡献,反而会干扰模型理解,直接滤掉。我按照设备屏幕的高宽比对 bounds 做裁剪过滤,只保留屏幕范围内的节点,误判率降了不少。
聚类是把同区域的碎片节点合并。有个经常出现的坑:一个“保存”按钮,在无障碍树里被拆成了外层 ViewGroup、内层 TextView、再内层的另一个 View,三层节点各有部分信息。如果逐条给模型,模型会以为屏幕上有三个按钮。我的策略是:如果某个节点有 clickable 或 focusable 属性,就以它为聚合根,把它的子节点文本合并过来。这样按钮、列表项这类复合控件就变成了一个完整条目。
压缩嘛,就是把节点属性转换成紧凑的文本形式。我用了类似下面的格式描述每个节点:
{"id":3,"type":"Button","text":"保存","bounds":[120,880,360,960],"clickable":true}节点类型用英文枚举,文本为空就省略字段,坐标归一化成百分比。这样一整棵树压缩下来,复杂页面也能控制在几百 Token 以内,喂给大模型绰绰有余。
2.3 坐标对齐:树节点和屏幕坐标系怎么统一
无障碍树里的 bounds 是绝对的像素坐标,这个坐标的基准是整块屏幕。屏幕像素坐标系通常也是以设备原生分辨率为基准。这两个坐标系理论上是同一个,但实际工程里会有偏移问题。
最坑的是横竖屏切换。转屏的时候如果采集链路没来得及重新初始化,树里拿到的坐标会和当前显示方向对不上。我的解决办法是:每次收到屏幕旋转事件,强制清空树缓存,并重新请求一次界面结构,保证下一轮决策用的是新方向的数据。
还有虚拟导航栏的问题。部分设备上虚拟按键区域会占掉屏幕底部一块,而无障碍树的坐标基准是包含或排除导航栏,不同系统版本行为不一致。我统一拿应用可用区域的左上角作为原点,再对树里所有 bounds 做平移校准,避免底部按钮点偏。
3. 兜底通道:投屏像素怎么抓、怎么理解
3.1 投屏画面采集的三条常见路子
像素通道的基础是拿到屏幕位图。我这里说的“投屏像素”,本质就是通过系统截屏机制拿到实时的屏幕画面,而不是真的要把屏幕投影给其他设备看。常见的采集方式有这么几种。
第一种是系统级截屏 API。在 Android 里可以申请屏幕捕获权限,用 MediaProjection 拿一块屏幕图像数据。这个方式能拿到高帧率画面,延迟低,适合实时处理。缺点是 MediaProjection 在部分系统上需要弹授权框,而且授权之后系统顶部会有录制提示,影响画面内容。我在调试机上验证过,授权流程在自动化场景里很碍事,每次重置环境都要重新授权。
第二种是 ADB 截屏。用 adb exec-out screencap -p 可以把当前屏幕导出成 PNG。优点是非常省事,任何能连 ADB 的地方都能用;缺点是速度慢,一手命令下来要几百毫秒,连续调用还会导致画面卡顿。这个方式适合开发调试,不适合实时决策循环。
第三种是系统录屏后分帧。利用 MediaProjection 配合虚拟显示输出到 Surface,后台每隔一定时间取一帧做处理。这一条是三种方式里最符合生产环境的,因为画面数据是流式的,你可以自己控制采样频率,不想处理的时候就不取帧,资源占用可控。我现在正式链路就是用的这一套。
3.2 像素理解:传统视觉和视觉模型怎么分工
拿到像素之后,怎么让 Agent “看懂”这张图也是一个关键决策点。我试过两个极端方案。一个是什么都交给多模态大模型,让模型直接看图输出操作坐标;另一个是什么都自己用 OpenCV 处理,写一堆规则去匹配图标。
两个都不理想。全都交给多模态模型,延迟回到最初的问题;全用传统视觉写规则,覆盖场景太少,换个应用界面就废了。我的折中方案是分三层处理。
第一层是明确了树缺失的时候,先用传统视觉做什么。传统视觉负责找“结构线索”,比如用边缘检测找到卡片区域、用颜色聚类找到高亮选中的 Tab、用模板匹配定位一组重复的图标按钮。这些信息虽然不是语义级别的,但能给大模型一个很明确的“锚点”。比如模型被问到“底部导航有几个标签”,我通过像素层的边缘投影和颜色分布,能直接告诉它“底部有一条高约 80 像素的区域,内有 4 个颜色分裂块”。这个比扔一张原图让模型自己数要准得多。
第二层是 OCR。需要识别文字时,先用本地 OCR 引擎把画面里的文字块连同坐标全部抽出来。这样当树不可用但页面仍有文字时,Agent 依然能知道“屏幕左上方写着‘扫一扫’”。文本内容不需要模型从像素里硬读,准确率直线上升。
第三层才是多模态模型。只有在树缺失、传统视觉找不到明确锚点、OCR 结果也零散不成句的时候,我才把局部画面裁剪成小图,发给多模态模型做目标识别。注意是局部小图,不是整张截图。我做了个默认策略:以树缺失区域为中心,把四周再扩 60 像素裁出来,图片尺寸控制在一个小范围内,推理速度和准确率都有保障。
3.3 兜底触发逻辑:不能等“彻底坏了”才切换
像素通道虽然重要,但也不能一直开着。持续做像素级推理,耗电、发热、延迟都受不了。我在代码里维护了一个感知状态机,有三种状态:树主导态、树不稳定态、像素兜底态。
树主导态下,像素通道只做低成本采集,但不跑推理。树不稳定态是一个中间状态,判定条件是:树节点连续几帧没有变化、或者树里可见节点数量比历史均值少了太多、或者页面存在大面积 SurfaceView。进入这个状态后,像素通道开始做 OCR 和锚点检测,但多模态模型仍不登场。
只有当 OCR 和锚点检测都失败,或者 Agent 对树给出的选项置信度过低时,才进入像素兜底态,这时候局部画面的多模态识别才开始工作。这个三级触发逻辑非常关键,它让像素兜底真正扮演了“最后防线”的角色,而不是一上来就跟树抢戏。
4. 双通道融合:竞态、仲裁与状态管理
4.1 两路信号的时间戳对齐策略
两条通道同时工作,最棘手的问题是两边看到的不一定是同一个瞬间的屏幕。树感知到界面变化通常早于像素采帧,因为无障碍事件是实时推送的;而截屏总是慢半拍,画面里看到的可能还是上一个状态。
这个问题不解决,仲裁就是无源之水。我采用了一个全局单调递增的时间戳机制。每次无障碍树更新,打上当前事件时间;每次像素帧采集成功,也打上采集完成时间。做融合决策时,只比对前后时间差在窗口阈值内的数据。阈值我试过 300 毫秒——小于这个间隔的两路信号,被认为是同一屏幕状态的描述;超过这个间隔,宁可丢弃旧的像素帧重新采样,也不拿错位的旧数据硬凑。
还有一个补充手段:用“用户操作时间点”做对齐锚点。Agent 每次执行完一次点击或滑动后,强制先清空树的缓存,再把这一时刻当成起点,等待新的树事件和像素帧。两路数据都带上“此操作之后”的标签做关联,就不会出现“点的已经是 A 页面,感知到的还是 B 页面旧结构”的乌龙。
4.2 可信度仲裁机制
仲裁要解决的问题是:树说一个按钮在这里,像素说那里有个按钮,到底听谁的?我的仲裁逻辑不是简单的“树优先”,而是给两边都打可信度分,综合出结果。
树的可信度我参考这几个指标:节点密度、文本完整度、事件新鲜度、历史一致性。如果一棵树节点多、文本普遍完整、事件是刚推过来的、结构跟上一次变化不大,那可信度就很高,可能是 0.95。反过来,如果树里大面积是空文本节点,或者历史节点持续几秒没变化,那可信度直接砍半。
像素的可信度则取决于来源。OCR 大段文字配坐标和当前界面强相关的,得分高;传统视觉锚点匹配出现了重复图案的,得分中;多模态局部识别结果孤零零、周围没有其他支撑信息的,得分低。最后的融合规则很简单:树分高于阈值就用树,低于阈值但像素分足够高就用像素,两边都拿不准就把操作请求挂起,让 Agent 重新尝试一次新的感知流程。
这个仲裁机制的灵感来源其实挺朴素:人走路的时候,眼睛看着路,脚底踩着地,大多数时候眼睛是主导;但到了光线不足的地方,脚感就变成主要的判断依据。双通道仲裁的逻辑也是这个调子。
4.3 从仲裁状态到 Agent 行为的映射
仲裁结果不只是给 Agent 一个坐标点。我做成了一套统一的感知输出结构,包含三层信息:屏幕状态描述、候选可交互元素列表、推荐操作项。树通道给出的候选元素天然带着控件类型和文本,像素通道给出的候选元素至少也有坐标和区域分类。Agent 拿到这套结构后,就可以像看一份结构化菜单一样做规划,而不是对着黑箱图片瞎猜。
这里有个细节我要重点提一下:Agent 的操作规划应该只看这份统一输出,而不去看原始数据。不管是树通道的 JSON 还是像素通道的裁剪图,都只是感知层的内部中间产物。上层如果直接拿中间产物做决策,一旦通道切换,策略逻辑就全乱了。我自己早期犯过这个错,后来把接口收敛成感知结果对象,通道切换对上层完全透明,改动后整个系统的稳定性提升了一大截。
5. 实操记录:一个点击“确认按钮”的完整事件流
5.1 整体流程与工程骨架
这一节我拿一个真实场景串一遍:页面上有一个“确认”按钮,它在一个弹窗里,弹窗底部背景有半透明的遮罩层。Agent 的最终任务是点击这个“确认”按钮。
整体流程分五步:采集、结构化、融合仲裁、动作执行、结果验证。采集阶段,无障碍服务监听事件,同时像素采集器每约 1 秒采一帧。结构化阶段,树节点清洗成紧凑列表,像素帧做 OCR 和锚点检测。融合仲裁阶段,两条数据进入仲裁模块,输出统一的感知对象。动作执行阶段,Agent 根据感知对象生成“点击确认”操作。结果验证阶段,等待新的界面变化,判断弹窗是否关闭。
工程骨架我用一个轻量级消息队列来串联这些环节。采集线程只负责把数据丢进队列,不去做任何耗时的结构化处理。仲裁线程是单消费模型,逐条处理队列里的感知请求。这样做的好处是,即使像素采集频繁,也不会把性能瓶颈扩散到 Agent 的决策主线程。
5.2 一次典型决策的逐步拆解
第一步,树通道先来了消息:弹窗的根节点出现在屏幕中央,节点层级显示它包含一个“确认”TextView 和一个“取消”TextView。“确认”节点带 clickable 属性,坐标归一化后大约在屏幕水平中点、竖直 72% 的位置。树可信度评分给出 0.92。
第二步,像素通道同一时间窗口的帧也到了。OCR 在同样的区域检测到“确认”两个字,文字块的矩形轮廓跟树节点坐标有约 6 像素的偏移。传统视觉锚点检测在弹窗区域内发现一个颜色与页面主题色一致的条形区域,宽高比跟按钮特征吻合。像素可信度 0.8。
第三步,仲裁模块比较了两路信息。树给出的坐标是(0.5, 0.72),像素 OCR 给出的文字块中心是(0.503, 0.716),两者在误差允许范围内,直接采用树的坐标,因为树的可信度更高。Agent 拿到候选元素列表:确认按钮(text=确认, 置信度0.92)、取消按钮(text=取消, 置信度0.92),规划出“点击确认”的决策。
第四步,执行点击前我加了一道保险:把点击坐标映射回完整屏幕分辨率,再调用系统注入事件接口。点击完成后不急着做下一个感知,而是等待 500 毫秒的事件静默期,确认界面有变化。
第五步,下一次树事件到达,弹窗节点消失,页面回到原来的列表结构。这一步验证通过,整个操作就算成功闭环。如果弹窗没关,我会让 Agent 进入重试流程,最多重试三次,每次重试前重新做一次感知融合。
5.3 性能与资源控制建议
双通道感知在资源上确实比单通道要重,但如果控制得当,整体开销完全能接受。树通道的处理是毫秒级的,几乎不构成压力。像素通道的采集本身便宜,真正贵的是 OCR 和多模态识别。我做了两个硬性限制:OCR 只在树不稳定态或兜底态下开启,多模态局部识别只在兜底态下开启。日常树主导态下,像素通道只做“采帧但不分析”,CPU 占用率可以压低很多。
内存方面,我不用数组把整帧原始位图长期保存在内存里,而是每一帧这份数据用完就释放。处理结果只保留结构化字段,一张 1080P 截图压缩成 JSON 之后也就几 KB。发热控制的体会是:连续做大规模 OCR 超过几分钟,手机温度上升就会明显,所以中间态判定窗口我设得比较保守,宁可让感知延迟个几百毫秒,也不要让设备热降频。
6. 常见问题排查实录:双通道模式下我踩过的坑
6.1 无障碍树明明有节点,点击却失效
这是我线上遇到最多的一个问题。树里能看到一个 Button 节点,坐标也对,文本也对,但注入点击事件后就是没反应。排查后发现两种典型原因。一种是节点处于 disabled 状态,树里是有这个节点,但是 enable 属性为 false,这时候点击无效是正常的,Agent 应该跳过它。第二种是节点被一个透明的兄弟节点盖住了,点击事件被上层拦截。这种情况树里通常会出现两个 bounds 高度重叠的节点,而且上层节点不一定有点击属性。我的处理办法是:在做点击前,把当前候选节点和屏幕内其他所有节点做一次 bounds 重叠检测,如果有透明节点覆盖,就放弃这个坐标并把候选元素标记为“疑似被遮挡”,让 Agent 换一条路径操作。
6.2 像素兜底模式下坐标偏移半个状态栏
像素兜底出现坐标偏移,大多是系统窗口 insets 问题。树通道的坐标是以应用窗口为基准的,而截屏的坐标系是整个物理屏幕,两者在存在刘海屏、挖孔屏、虚拟三键的设备上会相差一个状态栏高度。我之前在带挖孔屏的手机上调试,点击位置整体比预期偏上了几十像素,排查了好久才发现是坐标系基准不一致。解决办法是在采集像素的同时,把窗口 insets 信息一起带出来,对像素坐标统一做偏移校正。这事要提前做,不要等出现了偏移才补。
6.3 WebView 页面和无障碍树“失联”
WebView 页面是最容易让树通道失效的场景之一。旧版系统上 WebView 的内容压根不出现在无障碍树里;新版系统上虽然能导出,但很多网页元素是自定义标签,没有语义信息,树里全是 Generic 节点。我的兜底策略碰到这种情况正好发挥价值:像素通道优先做整屏 OCR,把所有文字块抽出来,再结合网页按钮常见的样式特征,比如背景色块、边框圆角,用传统视觉把它们位置标出来。这个组合对常见的移动端网页效果不错。但如果遇到极度重度的 Canvas 渲染页面,比如在线游戏大厅,树和像素双通道都会很吃力,这种场景我目前的做法是显式标记为“不可操作页面”,Agent 走提示用户手动处理的路径,而不是硬撑着做无意义的推理。
6.4 性能失控:主线程卡成 PPT
调早期版本的时候,我在主线程里做了树的 JSON 序列化、坐标归一化、还给像素做 OCR,结果整机界面肉眼可见地卡顿。后来我做了三处调整:所有耗时操作全部挪到后台线程;树的变化事件用节流阀,界面快速刷新时只取最近一帧,丢弃中间重复帧;像素通道的采帧和推理拆成两个独立线程,采帧线程一直跑,推理线程按需从队列里取最新帧处理。这样改完之后,主线程几乎不再参与感知工作,Agent 的执行流畅度回到了正常水平。性能问题的排查思路,总结起来就是一句话:把感知链路里每一步的“谁来干、什么时候干、干不完怎么丢弃”都提前想清楚。
6.5 双通道数据打架,仲裁结果反复横跳
这个坑很隐蔽。当树和像素对同一片区域的描述差异很大,且两边的信任值都处于中间水平时,仲裁结果就会在“信树”和“信像素”之间反复横跳,导致 Agent 在一轮操作里变换了两次主意。我给仲裁模块增加了滞回逻辑:一旦选中某个通道作为主信源,就让它保持主导至少两轮操作,除非像素(或树)的可信度出现大幅跳变,否则不切换。这个机制有点类似硬件电路里的施密特触发器,能抑制掉中间状态下的抖动。加了滞回之后,决策路径稳定性好了很多。
我个人在实际操作中最深的体会是:双通道感知并不是简单粗暴地让两个传感器并行输出,然后取个交集就算完事。它真正的核心在于,你要为每一路通道找到最擅长的环节——树的强项是结构、语义、低延迟,像素的强项是兜住那些“结构描述不了”的真实画面,再通过仲裁机制把它们缝合起来。这个项目做完之后,我又在往两个方向扩展:一是把半透明遮罩、弹窗层级这类空间关系的理解做得更细,二是给像素通道加入更多轻量级的目标检测能力,期望在树完全缺席的场景里也能独立撑住多数操作。如果你也在做手机 GUI Agent 的感知层,我建议别一头扎进多模态模型的调参里,先把两条数据通道的底座打扎实了,上层模型的成功率自然就上来了。