手机GUI Agent双通道感知:无障碍树为主,投屏像素兜底 搞手机 GUI Agent 这事儿我一开始以为最难的是让模型学会“点击”和“滑动”真上手做了才发现最难的是让 Agent“看明白”眼前这块屏幕到底有什么。我最早试过纯无障碍树方案省 token 是真省但经常被某些 App 的“无文本按钮”和 WebView 页面搞到两眼一抹黑后来我又试过纯截图方案信息是全了可每次操作都要把整张高清图喂给模型推理成本和延迟直接起飞。折腾了好几轮之后我最终落地了一套“双通道感知”方案以无障碍树为主信息通道投屏像素作为兜底。这篇文章就把我怎么设计、怎么实现、踩了哪些坑全部分享出来。这套方案做出来之后我自己在自动化测试和“一句话完成跨 App 操作”的场景里实测了好几个月稳定性和性价比都达到了可用的水平。如果你是做端侧 Agent、自动化测试框架或者对手机 UI 理解和 LLM 结合感兴趣这篇文章应该能帮你少走不少弯路。1. 为什么手机 Agent 的“感知”这么难1.1 不是“能截图”就等于“看得懂”很多刚开始做手机 Agent 的人会有一个直觉既然大模型视觉能力这么强那我直接把屏幕截图丢给它不就完事了这个想法没错但落地的时候会碰上一堆现实问题。首先是成本问题。现在的旗舰手机分辨率都在 1080P 以上一张截图扔给百川、千问、GPT-4V 这类多模态模型做识别输入 token 消耗比纯文本高出一个量级。如果 Agent 一次任务要操作二十步每一步都要看图那这个任务的成本就会变得非常难看。其次是可靠性问题。截图是“所见即所得”但模型从像素里提取 UI 结构、判断按钮位置、理解层级关系这一步非常容易出错尤其是按钮密集、颜色接近的复杂页面。截图认错一个按钮后面的整个任务流程就全乱了。所以我后来才想明白感知通道的第一目标不是“信息全”而是“信息准且便宜”。截图是最后的手段不能作为每一次决策的默认输入。1.2 无障碍树结构化但残缺那如果不用截图手机屏幕上还有什么信息源是结构化的答案就是无障碍树Accessibility Tree。Android 系统为了让盲人用户使用读屏软件会把当前窗口里的控件以树形结构暴露出来每个节点带着 class、text、resource-id、clickable、bounds 这些关键属性。无障碍树的好处特别明显结构清晰、直接能拿到文本、坐标也是精确的像素坐标而且传给 LLM 的时候就是一小段 texttoken 开销低得感人。模型不需要“看”就知道这个页面有哪些按钮、哪些输入框、文本是什么。但无障碍树的缺点同样致命它依赖开发者做无障碍适配。国内很多主流 App 并没有认真做这块大量图标按钮没有 content-desc一些自定义 Canvas 绘制的界面在无障碍树里直接是一片空白WebView 里的内容更是经常拿不到内部结构。我实测过一个电商首页无障碍树能拿到的节点不到视觉上一半的内容底部的商品推荐区对读屏软件来说就是一个“黑盒”。1.3 “树为主、像素兜底”的设计动机既然两条路都有硬伤那思路就变成了不要选边站而是把两者组合起来。我最终的架构是双通道感知默认干活用无障碍树树的成本低、信息准、结构化程度高能覆盖大多数操作场景。只有当树的信息明显不够用的时候才调用投屏像素通道做一次视觉辅助识别。这个方案的核心动机可以用一句话概括用廉价通道覆盖 80% 的常规场景用昂贵通道兜住 20% 的长尾场景。这样的组合既保证了 Agent 的应对能力上限又不会让 token 消耗失控。2. 双通道感知的具体设计与数据形态2.1 无障碍树通道数据怎么拿、怎么处理安卓那边拿无障碍树的标准方式是自己实现一个 AccessibilityService。这个服务启动后系统会通过回调把界面变化事件推给你事件类型包括窗口状态变化、节点新增、焦点变化、点击等。你需要做的核心工作是在 onAccessibilityEvent 里拿到 rootNode然后遍历整棵树。遍历出来的原始树不能直接扔给 LLM因为节点太多、属性太杂直接塞进 prompt 里会撑爆上下文。我做的处理是两件事过滤和序列化。过滤指的是只保留对 Agent 决策有意义的节点属性——text、content-desc、resource-id、clickable、checkable、bounds其余全部丢掉。序列化则是把这些节点拉平成一棵 JSON 树并对每个节点分配一个全局 ID这样 LLM 在决策的时候可以直接说“我要点击节点 #12”而不是去描述坐标。这里有一个我自己反复优化过的细节长文本节点一定要截断。有些新闻类 App 的列表项会把整条新闻预览作为 text 暴露出来一个节点几十字甚至上百字十几个列表项就能把 token 吃光。我最后统一做了按长度截断超过 50 个字的 text 只保留前 30 个字符加省略号。2.2 投屏像素通道怎么低成本地拿到“兜底画面”像素通道的职责不是提供完整 UI 感知而是在树不可用的时候补一眼“现场画面”。所以这个通道最重要的指标不是清晰度而是延迟和可用性。我选择的是通过投屏协议拿到屏幕流再在端侧抽帧而不是直接调 MediaProjection 录屏。两者的区别在于MediaProjection 录屏会持续产生大量数据而且需要用户授权投屏协议虽然同样需要 adb 授权但它的数据链路更适合长时间保持连接抽帧也更灵活。具体到实现我是在电脑上跑一个投屏的解码端Agent 需要“看一眼”的时候就从解码后的画面里截一帧出来。截帧的时机也很讲究。最傻的做法是每一步都截那样像素通道的成本优势就没了。我的策略是“事件驱动关键节点兜底”当无障碍树通道发现当前窗口里存在大量无文本节点时或者出现明显的结构缺失比如树的根节点只剩两三个子节点但视觉明显不是空白页就触发一次像素抽帧把图发给视觉模型。2.3 通道融合与坐标对齐双通道设计里最麻烦的不是各拿各的数据而是把两个通道的数据对齐到同一个坐标系下。无障碍树节点上的 bounds 是设备物理像素坐标而投屏抽帧出来的图片像素尺寸取决于投屏分辨率设置。这两个如果不做换算就会出现“树说按钮在 (500, 800)图里按钮实际在 (400, 650)”的错位。我的解法是统一归一化。拿到投屏分辨率后把树节点 bounds 按比例缩放到和图片一致。这里要特别注意一个坑设备状态栏和导航栏。无障碍树里的 bounds 一般是包含状态栏在内的全屏幕坐标而投屏画面对应的区域如果从内容区开始y 轴就会出现固定偏移。我的处理方式是在无障碍服务里先减掉状态栏高度再做归一化这样两个通道就对齐了。对齐之后数据的注入方式就有了明确分工主通道的树信息以文本 JSON 形式放进 LLM 的上下文像素通道的截图只在必要时以图片形式追加输入。模型看到的是“结构化描述 可选视觉参考”决策路径清晰可控。这样做有一个很明显的好处当树信息完整时模型完全不需要看图片省掉了昂贵的视觉 token只有真正遇到树解决不了的问题视觉通道才会被激活。3. 实操过程与核心环节实现3.1 无障碍树采集实现与事件驱动刷新我用的工程语言是 Kotlin核心组件是一个继承自 AccessibilityService 的类。在无障碍服务的配置文件里我声明了要监听的事件类型和 package 范围这里需要注意canPerformGestures的权限必须开启否则后续想做捏合、滑动这类复杂手势会受限。配置大致如下accessibility-service android:accessibilityEventTypestypeWindowStateChanged|typeWindowContentChanged|typeViewsScrolled android:accessibilityFeedbackTypefeedbackGeneric android:accessibilityFlagsflagDefault|flagRetrieveInteractiveWindows|flagRequestFilterKeyEvents android:canPerformGesturestrue android:canRetrieveWindowContenttrue /在服务内部我把每次事件回调里取到的根节点转换成自己的轻量数据模型。转换逻辑里最核心的是一段递归遍历代码对每个节点提取关键字段并递归到子节点fun buildNode(node: AccessibilityNodeInfo): UiNode { val bounds Rect() node.getBoundsInScreen(bounds) return UiNode( id nextId(), resourceId node.viewIdResourceName ?: , text node.text?.toString()?.take(MAX_TEXT_LEN) ?: , desc node.contentDescription?.toString()?.take(MAX_TEXT_LEN) ?: , className node.className?.toString() ?: , clickable node.isClickable, checked node.isChecked, bounds listOf(bounds.left, bounds.top, bounds.right, bounds.bottom), children (0 until node.childCount).mapNotNull { i - node.getChild(i)?.let { buildNode(it) } } ) }这段代码跑完之后当前窗口的界面结构就变成了一棵 JSON 树。我在 Agent 的上下文里展示它的格式大概是这样{id:1,class:FrameLayout,bounds:[0,0,1080,2280],children:[ {id:2,class:TextView,text:搜索栏,bounds:[30,140,1050,240],clickable:true}, {id:3,class:View,resourceId:home_icon,bounds:[30,280,150,400],clickable:true} ]}事件驱动刷新是整个感知层能“动”起来的关键。Android 的 AccessibilityService 事件非常频繁如果每次都全量遍历一次树性能会有压力。我做的优化是加一层节流同一窗口内容三秒内不重复回调只在关键的 TYPE_WINDOW_CONTENT_CHANGED 事件里做增量判断——对比新旧树结构只有结构发生了实质变化才更新 Agent 的上下文。3.2 投屏像素抽帧与兜底触发逻辑投屏像素通道我放在一个独立的线程里跑。投屏解码拿到的是 YUV 帧需要转成 Bitmap 才能给视觉模型用转之前我会先按需缩放到一个预设的宽度比如 720 像素宽避免原图太大导致上传和识别都慢。抽帧不是定时触发而是被“兜底判定模块”按需唤醒。我把判定逻辑做成了一组简单但有效的规则规则一无描述节点占比过高。统计当前树里没有 text 也没有 content-desc 的叶子节点数量。如果这个数量超过总叶子节点的 30%说明这个界面大量信息在树里不可见需要视觉补一眼。规则二树节点总量异常偏低。同一个页面前后两次全量采集中树节点数量突然下降超过 50%大概率是页面切换到了树覆盖不了的 WebView 或视频层。规则三模型主动请求。如果 LLM 在决策时输出的置信度低我在 prompt 里要求模型在不确定时输出NEED_VISIONAgent 就会触发一次像素抽帧把截图作为辅助信息再问一次。这套规则跑了一段时间后我发现一个现象规则一和规则二覆盖了现实中九成需要视觉兜底的场景模型主动请求的情况反而很少。因为大部分 UI 问题在树结构上就有明显迹象不用等模型“觉得不对劲”。3.3 双通道融合实测一个真实案例我拿一个典型的“跨 App 订机票”任务来做测试从携程里搜航班然后切到航司 App 值机选座。这个任务前半段在携程的搜索列表页后半段切到航司 App 的选座页面。在携程的搜索列表页无障碍树的数据是很完整的每个航班卡片都能拿到价格、时间、航空公司文本树文本总共只有 800 多字符LLM 可以轻松基于文本完成“选最早一班”的决策。这个场景下像素通道完全不用工作。到了航司 App 的选座页问题来了。这是个 Flutter 渲染的页面无障碍树给出的节点要么是 FlutterView 这个壳要么是一个没有任何文本描述的抽象节点。此时规则一触发——无描述节点占比超过一半Agent 主动截了一次屏把座位图发给视觉模型。视觉模型根据图上的座位分布和可选座位标识完成“选靠窗位”的判断并把选中座位区的中心像素坐标返回给 AgentAgent 再映射回虚拟触摸坐标完成点击。整个过程里像素通道只被调用了这一次却解决了纯树方案完全无法处理的环节。这就是双通道的价值日常操作几乎不花视觉成本但遇到关键盲区时一针见血。4. 常见问题、踩坑与排查实录4.1 无障碍树拿不到的隐蔽场景Adaptive 和 Flutter 页面是树通道最典型的盲区这已经在上面说过了。但还有一些场景是你在开发自测时完全注意不到等到换了一批测试机才突然冒出来的。其中一个是部分机型在开启“省电模式”后会主动杀后台或无障服务的链接。表现为树的数据能拿但刷新频率变得极低Agent 点击了页面但树迟迟不更新。排查方法是在无障碍事件回调里加一个 watchdog超过 10 秒没有收到任何窗口内容变化事件就主动重连一次服务。另一个隐蔽场景是系统级弹窗。比如低电量提示、权限请求对话框这类窗口会覆盖在当前 App 之上但标准的rootInActiveWindow拿到的可能是弹窗的树而且很多系统弹窗的按钮文本是不暴露的。这种情况下我直接放弃树固定走像素兜底——截图发给视觉模型让模型识别弹窗文本并判断是否需要点掉它。4.2 坐标换算里的密度与偏移问题坐标对齐在开发机上一切正常一到真机就翻车这种事我经历过不止一次。罪魁祸首通常有两个。第一个是设备逻辑像素和物理像素不一致。有些平板或高端机的 density 不是标准值getBoundsInScreen返回的坐标单位是物理像素而投屏流返回的图像分辨率可能做了缩放两者不在一个坐标系里。处理方法在上文已经提过就是在服务侧维护一个缩放映射表用设备实际分辨率除以投屏分辨率得到一个scaleX/scaleY所有坐标换算都走这两个系数。第二个是虚拟导航栏的偏移。我在小米和华为的部分机型上遇到过无障碍树里的 bounds 包含了底部手势区域而投屏画面没把导航栏区域算进去导致 y 坐标整体偏大几十像素。这个问题在开发机看不到因为开发机的投屏协议默认裁掉了导航栏。加一个偏移校正参数就能解决但需要你在每台测试机上实际核一次不要想当然。4.3 性能开销与 token 成本控制双通道感知最大的生产成本其实是 token 消耗这个不多说。我的控制策略除了“规则触发像素兜底”之外还有一个很值得分享的细节树文本的 token 预算制度。我给每个页面的树序列化文本设定了一个软上限默认 3000 字符。如果页面节点太多导致文本超出我不会简单截断而是做优先级分级可点击的、带文本的、在屏幕中心区域的节点优先保留纯容器类节点、不可见的 off-screen 节点直接丢弃。这样在复杂列表页里Agent 只看到与操作相关的核心节点上下文占用和决策质量都兼顾到了。在像素端的成本控制靠两招抽帧尺寸和模型选择。抽帧统一缩放到 480p 宽度视觉模型能看清大结构就行细节信息反正有树文本兜底。模型层面我试过先让小模型做一次“结构预描述”把截图里的元素先转成文本再让主模型做最终决策这样视觉 token 就只在中间那一层出现过一次整体成本降得不小但链路复杂度变高了。如果你的目标是快速跑通可以先用一个轻量多模态模型直接看图。4.4 兜底判定的时机与状态验证最后想聊一个容易被忽略的点兜底判定不是越勤越好而是要把握“行动前确认”和“行动后验证”两个关键时机。行动前确认的意思是Agent 准备执行点击、滑动这类动作之前如果树信息可疑就触发一次视觉确认避免盲点。行动后验证则是Agent 执行完动作之后再对比一下前后的树结构和截图确认界面确实发生了预期变化。这一步做得好能及时止损——很多任务失败是因为 Agent 以为点击生效了其实那是个假按钮根本没有响应。我在实现里把行动后验证设计成轻量检查默认只对比树根节点的包名、当前 Activity 类名、关键控件的 text 是否变化不强制每次截图。只有在前两个都不变时才触发一次快速像素兜底看看是不是发生了无树感知的页面跳转。实测下来这个验证机制拦截了不少“无效点击”导致的连环错误算是我自己比较得意的一个细节。最后再分享一点体会双通道感知这套东西听上去就是“树为主、像素兜底”八个字但真要把两套数据流稳定地维护在一个 Agent 里比想象中复杂得多。坐标对齐、事件节流、token 预算、兜底规则每个环节都是一步步调出来的。我的建议是先跑通无障碍树这一条通道把结构文本和动作执行的闭环搭完再去接像素兜底不要一上来就双通道并行否则问题叠着问题排查起来会非常痛苦。希望我这几个月的实战经验能给你一些参考。