
最近在调一个手机端 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 的感知层我建议别一头扎进多模态模型的调参里先把两条数据通道的底座打扎实了上层模型的成功率自然就上来了。