首页 / 资讯中心 / 文章详情

手机GUI Agent双通道感知:无障碍树+投屏像素兜底方案

手机GUI Agent双通道感知:无障碍树+投屏像素兜底方案 ★ FEATURED ARTICLE
做手机端 GUI Agent 的时候最尴尬的事情往往不是模型能力不够而是感知层跟不上。屏幕明明就在眼前Agent 却拿不到足够干净的结构化信息无障碍树漏了关键文字光靠截图又定位不准。后来我搭了一套双通道感知方案以无障碍树为主投屏像素兜底把“看屏幕”这件事从一门玄学变成了一条相对可靠的流水线。我做的是通用型手机 GUI 自动化智能体方向的探索这套方案解决的问题很具体让 Agent 既懂界面结构又能在结构失效的时候靠图像信息兜住。如果你也在做端侧自动化、GUI Agent、手机辅助功能这类方向这篇文章应该能帮你少走不少弯路。1. 需求拆解为什么单通道感知不够用1.1 GUI Agent 的感知到底要解决什么问题一个 GUI Agent 的完整链路是感知、规划、执行、验证。感知是所有后续动作的地基地基偏一寸后面规划执行就偏一丈。具体来说感知层要回答四个问题屏幕上现在有什么元素每个元素在哪个坐标元素处于什么状态可点击、已勾选、可滚动操作之后屏幕变成了什么样。这四个问题任何一个答不准Agent 后续的动作就大概率会翻车。举个很常见的例子用户想在某购物应用里下单Agent 需要先识别出“立即购买”按钮。如果感知层只是拿到了这张截图没有结构化信息模型哪怕再聪明也要靠猜。要么用 OCR 去识别文字要么用视觉模型去检测按钮位置还得担心识别出来的坐标准不准。如果感知层能直接拿到无障碍树里的“立即购买”节点包括它的精确坐标和可点击属性规划层的负担就小很多执行层也敢直接把坐标点下去。所以我把感知层的目标定得很明确输出一份结构化的界面描述里面包含元素类型、文本、坐标、状态最好还能把“这一屏发生了什么变化”一并告诉 Agent。这份描述要足够干净能直接喂给模型要足够准确能直接用来点击操作还要足够快不能影响整个 Agent 的响应速度。基于这个目标再回头看各种感知手段就会发现单靠任何一路都走不通。1.2 无障碍树和投屏像素各自的短板先看无障碍树这条路。安卓系统里的无障碍服务也就是 AccessibilityService能用一套标准化的方式把当前窗口的 UI 层级树给出来。它的优点非常明显结构清晰每个节点有类型、有文本、有坐标、有交互状态而且不需要跑任何模型速度极快几乎零成本。普通原生页面在大多数情况下用树就能拿到足够完整的界面信息了。但它的问题也不少。第一WebView 内部的细粒度节点拿不到很多混合应用的商品详情页在树里就是一片空白。第二大量自绘控件和游戏界面根本没有语义节点树里只能看到一个孤零零的 SurfaceView。第三很多图标按钮没有 contentDescription树的节点存在但没有意义。第四系统安全输入框这类场景辅助功能服务会被系统策略挡住拿不到具体内容。再来看投屏像素这条路。它的思路很直观把屏幕画面采集下来让 Agent 直接看图。优点也显而易见所见即所得不管是什么类型的界面像素数据都在那里不存在拿不到的问题。但缺点同样致命。像素本身没有结构要从中提取“这里有个按钮”这个事实需要 OCR、图标识别、目标检测这一大串模型串联计算量大得惊人跑一会儿手机就开始发热掉帧。而且视觉识别出来的边界容易抖动同一个按钮这一帧识别成 100 到 200 像素的范围下一帧可能就成了 98 到 205坐标不稳定就直接导致点击偏移。另外像素只能告诉你这块区域长得像什么没法告诉你它的具体状态属性比如一个开关当前是开还是关从像素上区分困难得多。1.3 双通道组合的设计逻辑两条路单独用都有明显的死穴那把它们合起来呢我最终的设计思路是树负责主干像素负责兜底两侧共享同一套坐标系对外输出统一的结构化感知结果。用一句话概括就是能走树就走树树拿不到的就用像素补两者不是竞争关系而是主备关系。树能告诉 Agent 这个页面有哪些语义元素它们的坐标和状态是什么这些信息稳定、准确、低成本像素能告诉 Agent 屏幕上真实绘制出来的内容是什么样的当树的视野盲区出现时像素仍然在那里等着兜底。设计成“主 备”而不是“树 像素融合”还有一个现实考虑融合难做主备简单可靠。如果强行要求每一步都把树和像素做细粒度对齐融合工程复杂度会迅速爆表而且两边信息冲突的时候反而不知道听谁的。主备模式下规则简单清晰绝大多数页面以树为准例外场景切到像素快、稳、好调试。整个感知模块对外暴露的接口也收敛成一套结构化元素列表加证据图片规划层不需要关心内部走了哪条路。2. 无障碍树通道怎么把系统 UI 层级变成 Agent 能读的数据2.1 无障碍服务拿树的基本流程先明确一个前提我这里只讲安卓平台老版本的 iOS 在无障碍信息的开放程度上完全是另一套玩法后面有机会再单独展开。安卓上拿无障碍树的核心入口是一个服务组件继承 AccessibilityService在系统设置里开启对应的辅助功能服务之后系统就会在窗口状态变化、内容变化、滚动等关键时刻回调事件。打开服务之后拿到当前窗口根节点的代码非常直接class TreeDumpService : AccessibilityService() { override fun onAccessibilityEvent(event: AccessibilityEvent?) { when (event?.eventType) { AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED, AccessibilityEvent.TYPE_WINDOW_STATE_CHANGED, AccessibilityEvent.TYPE_VIEW_SCROLLED - captureWindow() } } private fun captureWindow() { val root rootInActiveWindow ?: return val nodes mutableListOfUiNode() traverse(root, nodes, 0) perceptionCache.update(nodes) } private fun traverse(node: AccessibilityNodeInfo, out: MutableListUiNode, depth: Int) { val uiNode UiNode( id generateStableId(node), className node.className?.toString(), text node.text?.toString(), contentDesc node.contentDescription?.toString(), bounds Rect(node.getBoundsInScreen().let { Rect(it.left, it.top, it.right, it.bottom) }), clickable node.isClickable, scrollable node.isScrollable, checked node.isChecked, enabled node.isEnabled, depth depth ) if (uiNode.isMeaningful()) out.add(uiNode) for (i in 0 until node.childCount) { traverse(node.getChild(i), out, depth 1) ?: continue } } }这段代码里最关键的是 getBoundsInScreen。无障碍树给的是屏幕物理像素坐标系下的真实坐标和投屏像素的坐标天然对齐这是双通道能够协同的基础。遍历的时候我顺手做了过滤只有有语义的节点才加入输出列表。所谓有语义就是至少满足下面条件之一有非空文本有 contentDescription可点击可滚动勾选状态有意义。我用了一个稳定 ID 生成策略用节点类型加路径索引组合出 ID比如“Buttonroot(0)-list(3)-item(2)”。这个设计很有用后续 Agent 发操作指令时直接用 ID 指代目标不会因为页面微动导致坐标漂移。2.2 节点清洗与结构化把系统的无障碍树拿下来只是第一步实际拿到的原始数据是非常脏的根本不能直接喂给模型。真实页面里存在大量没有文本没有语义的纯容器节点父节点和子节点在视觉上就是同一个东西甚至会出现一个 LinearLayout 里套着一个 LinearLayout 再套一个 FrameLayout 的情况。如果把这些原样交给规划层模型分分钟被无效信息淹没。我的清洗流程是三步折叠纯容器、合并冗余文本、删除无关节点。折叠纯容器是把那些没有文本、没有描述、不可点击、不可滚动的布局节点直接跳过不加入输出列表。合并冗余文本是把层级里父节点和子节点文本重复的情况去掉一个保留更具体的那个。删除无关节点则是把一些系统辅助节点、不可见节点、零大小节点直接过滤掉这些节点只会在树里制造噪音。清洗之后还需要做一步重要的处理给节点打标签。为了给规划层一个更友好的界面概貌我让清洗器顺便把节点类型分成 Button、Input、List、CheckBox、Switch、Text、Image 等几类。分类依据主要看 className 的关键词辅以可点击、可勾选等属性。比如一个 className 是 android.widget.TextView但 clickable 为 true 的节点就会被打上“可点击文本”的标签而不是单纯按系统类型归类。到这里一个原始节点就会被转换成一个 Agent 友好的条目{ id: Buttonroot(0)-container(2)-action_row(1)-buy_btn(0), type: Button, text: 立即购买, bounds: [120, 880, 960, 1010], state: { clickable: true, enabled: true, checked: false } }2.3 层级剪枝与文本截断树的深度有时候会到二十多层一个普通的商品详情页原始节点可能上千个即使清洗过后也还有上百个有效节点。文本全量保留更不现实有的描述型文本动辄几百个字一组列表页全部展开就能爆掉上下文窗口。我的处理策略是给树做一个容量预算单次感知输出的节点总数不超过 60 个总文本量不超过 2000 字符。在这个预算下优先级排序是可见且可交互的节点最优先其次是有明确文本的视觉重点最后才是其余有描述但没交互能力的节点。超预算时就从低优先级开始裁。这个容量预算是拍脑袋定的吗不是我是根据实际模型的表现调试出来的。节点太少覆盖不全关键操作节点太多模型开始抓不住重点60 个节点加上 2000 字符在绝大多数模型上都能稳定保持准确率。当然不同模型有差异这个数字可以根据自己用的模型上下调。上限对了之后上下文占用比原始树少了百分之八十以上Agent 的响应速度也明显上来了。3. 投屏像素通道树不管用的场景靠什么兜底3.1 像素数据的获取与坐标对齐像素通道的首要任务是在树失效的场景里兜底所以第一步要有一个稳定可靠的屏幕帧获取方式。我这边用的是系统屏幕采集能力通过媒体投影的方式拿帧然后按需压缩成 JPEG 图像数据接到本机的感知服务里。整个链路走的是本地共享内存或者 socket不经过外部网络延迟和带宽都可控。这里有个容易被绕晕的点就是坐标对齐。树给的坐标是设备屏幕的原生物理像素坐标像素通道如果直接使用同一设备的屏幕帧那么两边坐标天然就在同一个坐标系里不需要任何转换。只有在投屏到外部显示设备、或者画面被缩放过的情况下才需要按缩放比例把坐标换算回去。我建议所有内部处理都统一使用设备原生分辨率坐标展示和推理阶段再按需缩放这样能避免把坐标换算错误带进点击操作里。抓到帧之后像素并不会直接进入 Agent 的上下文而是先经过一个可选的预处理阶段。比如把图像缩放到适合视觉模型输入的尺寸裁剪掉不必要的状态栏区域按 80% 的 JPEG 质量压缩。这一步的目标只有一个在尽量保留关键信息的前提下把图像体积压缩下来让整个感知流程延迟可控。3.2 文本兜底OCR 补上缺失的语义像素通道里最常用的兜底手段就是 OCR。典型场景是 WebView 里的商品描述树拿不到文字但屏幕上清清楚楚写着一行价格或者是游戏里自绘的按钮树里什么都没有画面上却是一个孤零零的“开始游戏”。这时候整张图或者局部区域截图交给 OCR 引擎结果按坐标聚合就能补上树的空缺。具体的处理流程是这样的拿到一张截图后OCR 引擎先识别出所有文本块和它们的坐标框接着做一次聚合。聚合的规则是按行的y 坐标比较接近的文本块视为同一行x 方向距离近的合并成一个整体距离远的拆成多个元素。比如“加入购物车”和“立即购买”这两个按钮在同一水平线上OCR 大概率会识别成两个文本块聚合时根据 x 坐标间隔把它们拆开。最后每个聚合结果生成一条结构化的候选元素里面带上文本、坐标框和置信度格式和树输出的节点保持一致。实际经验是整图 OCR 不是好习惯优先对局部区域做识别。比如树里明确了某个区域可能存在动态文本那就只裁剪那一块区域交给 OCR速度和准确率都更好。全屏 OCR 不仅慢还会把背景里的无关文字都识别进来给后续规划层制造噪音。3.3 局部视觉验证与界面变化识别OCR 解决的是“看懂文字”的问题但像素通道还有一个更基础的能力判断画面是不是变了。这个能力在验证环节特别有用。典型场景是 Agent 执行了一个滚动操作树告诉你列表滚动到了新的位置但树有时候会因为节点复用而表现得变化不大这时候就需要看像素来确认画面是否真的变了。我的做法是给每个局部区域算一个感知哈希或者直方图特征连续抓帧对比特征差异超过阈值就认为该区域发生了内容变化。不用跑模型纯计算几十毫秒内就能出结果。另外一类场景是动态内容比如视频播放、直播画面、动画效果树在这些场景下基本帮不上忙判别画面状态只能靠像素。这类场景下我不会去做精确的语义识别只是给 Agent 一个“该区域在持续变化”的信号让规划层知道不要在这个区域做过于频繁的点击猜测。像素通道在这里的作用不是精确识别而是作为树通道的语义补充和状态判断来源职责边界要清晰。4. 双通道协同分诊、切换与状态管理4.1 两个通道怎么分工最合理双通道协同的第一步是明确各自的主责。我的分诊规则很简单常规树能解释的页面百分之百走树树节点缺失、树为空、或者界面上有大量无描述元素的时候切到像素通道补充。实际操作中还遇到过一类情况树给出的节点坐标和像素画面上真实按钮位置明显对不上这种情况我会判定树坐标质量差强制切像素。为了说清楚分工边界我整理过一张场景对照表场景主通道兜底逻辑普通原生列表页无障碍树树空的节点用局部 OCR 补文本WebView 内容像素 OCR树只用于拿 Activity 和整体状态游戏 / 直播 / 视频像素树用于识别系统层弹窗和返回键弹窗与悬浮窗无障碍树像素用于校验实际渲染效果手势拖动后反馈像素 diff树用于判断结构是否刷新这样分工的核心思想是把树的语义能力和像素的覆盖能力放在各自最擅长的领域而不是让某一路单独扛下所有场景。规划层拿到的结果永远是同一种格式不需要内部知道数据来源是谁。4.2 触发式更新与缓存失效策略感知模块最怕的就是“时时刻刻都在感知”不仅耗电耗性能还会把一些中间态当成稳定态传给规划层。我这边采用的是事件驱动的触发策略只有以下情况才去刷新感知结果窗口状态变化事件触发时界面内容变化事件触发时滚动事件触发时Agent 刚执行完一个操作需要观察反馈时。事件触发后还有一个细节就是高频事件合并。很多界面在加载时会连续触发十几次内容变化事件如果每次都刷新一次感知性能直接被拖垮。我设置了一个固定节流窗口一百毫秒内的多次事件只合并成一次刷新并且在界面稳定后追加一次强制刷新。这样就保证了 Agent 拿到的永远是页面的稳定状态而不是某个中途过渡帧。缓存也不是一劳永逸的执行完点击之后我会把上一次感知结果直接置为失效并强制触发新一轮感知。原因很简单任何一次操作都可能改变界面的结构旧的缓存必须作废。失效的粒度可以按页面维度也可以按区域维度前者简单粗暴后者更高效。我的实现里做了两档普通场景整页失效复杂长列表场景仅失效列表所在区域。4.3 降级机制与稳定性指标双通道的价值不只体现在单次识别更全面更体现在稳定性。我把降级规则定义成了三层第一层是树退化。如果树获取超时超过 300 毫秒或者拿到的有效节点少于 5 个就认为树不可用整体切换到像素通道。第二层是树定位退化。如果树节点的坐标和像素画面里的对应文本区域偏差超过一定阈值比如中点偏离超过 20 像素就判定树坐标不可信转而用 OCR 的坐标。第三层是故障退化。如果两个通道都异常就直接返回感知失败Agent 在规划层做重试或放弃处理。越早暴露故障越好不要让 Agent 在错误感知结果上盲目操作。这套机制跑下来之后我统计的一组核心指标是感知完整率指的是屏幕关键可交互元素被成功识别的比例单树模式下大概在七成多双通道稳定后能到九成五以上感知延迟单次从事件到输出结构化结果的耗时平均一百五十毫秒左右其中树模式更快像素模式因为要过 OCR 会慢一些故障率感知失败导致的 Agent 操作错误比例明显降低所有故障事件都会以感知模式和失败原因双维度记录日志。5. 实际调试中遇到的典型问题与排查方法5.1 无障碍树拿不到根节点这个问题在真实环境里出现频率极高场景也很多样。最常见的是回调时机太早无障碍服务刚启动时系统还没把窗口信息准备好这时候 rootInActiveWindow 直接返回 null。对策是等窗口事件真正到来后再触发刷新而不是在服务连接的瞬间就去抓树。另一个情况是焦点不在应用窗口上。比如通知栏下拉、系统弹窗弹出、或者手机处于 AOD 模式这个时候窗口树可能是系统界面而不是目标应用界面。我会在当前窗口树里检查当前包的包名如果不是目标包就直接跳过这一次感知而不是拿到一棵错误的树。还有一种是安全输入场景输入密码、支付验证码这类界面系统的保护策略就是让辅助功能拿不到内容。碰到这种情况直接切像素通道处理并且要明确这是平台的安全设计不要试图绕过系统的保护策略。5.2 WebView 与自绘控件的内容缺失WebView 是个常年踩坑的点树里不是完全没有而是我能拿到的只有最外层容器内部的 HTML 内容根本不会暴露出来。要识别 WebView 里的文字只能靠像素通道的 OCR 去补。自绘控件则更麻烦一些。很多应用的高阶自定义 View 是直接在 Canvas 上画出来的树里就是一个无文本、无描述、无任何语义信息的空节点。某些场景下应用开发者会主动给自定义 View 设置无障碍代理这时候树就能拿到信息了但大多数第三方应用不会做这件事。所以我这边的通用策略还是快速判定树拿不到语义就看像素像素 OCR 识别不出有效文本就只给一个粗粒度的“未知可点击区域”避免瞎猜语义。5.3 坐标映射漂移坐标漂移是双通道最容易出问题的地方现象是树返回的元素坐标和像素画面上实际按钮的位置对不上。我第一次碰到的时候排查了很久最后发现是系统字体大小被人调到了超大模式整个界面布局发生了缩放树的 bounds 也变了但我的感知模块里还缓存着旧版本的坐标。解决思路就是所有内部坐标统一以设备原生像素存储不缓存跨配置的布局信息。凡是系统配置发生变化比如字体缩放、分辨率切换、横竖屏切换都要置空感知缓存强制从当前窗口重建。投屏到别的显示设备时视频流的快门和内部坐标之间需要一个明确的换算比例我之前采用的做法是在视频帧的首个关键帧里带一个分辨率字段接收端自行计算缩放比。5.4 性能热点与发热问题手机端做实时感知性能问题绕不开。我实际调优过程中发现三个热点第一个是全屏 OCR 的 CPU 开销第二个是事件触发太频繁导致树遍历过多第三个是屏幕帧编码码率设置过高导致 IO 压力。针对第一个热点我的做法是坚决不做全屏 OCR优先对局部区域识别并且给 OCR 引擎设置了最长执行时间。针对第二个热点就是前面说的节流合并方案一百毫秒内的多次事件只处理一次跟着对低价值节点层级的遍历也做了深度限制。针对第三个热点把 JPEG 压缩质量控制在一个合理的区间在区域内有大段动态变化时才提高抓帧频率。优化之后双通道感知对手机的平均 CPU 占用降到了个位数百分比日常使用流畅度基本没有可感知的影响。6. 这个方案后续还可以怎么扩展双通道感知落地到现在我最大的体会是不要把两条路都做到极致而是把“什么时候该信任哪条通道”这个判断做对。树的语义信息和像素的真实画面本质上是一个互补关系树告诉你应该看到什么像素告诉你实际看到了什么两者叠加之后感知才稳定可靠。后面还有几个方向我觉得值得继续做。第一个是给像素通道接入更强力的视觉语言模型用多帧截图直接理解界面动态变化树继续保持结构化锚点的角色这样对复杂界面的理解能力能再上一个台阶。第二个是做感知日志的离线回放把每次感知的树快照、像素截图和操作记录都打上时间戳存下来调试问题的时候像看录像一样回放问题定位效率会提升很多。最后再分享一个小经验双通道方案的调试成本大头不在代码实现而在调分诊阈值和缓存时机。我建议第一版就把感知模式的切分记录到日志里每个感知结果都带上数据来源标记这样真实环境下哪条路拖了后腿一眼就能看出来。树为主、像素兜底这条路至少在当前的手机 GUI Agent 落地场景里是性价比最高也最稳的组合方式。
阅读完成 · 觉得有帮助?
咨询建站