做手机 GUI Agent 的时候感知层是最容易被低估的一环。模型选多大、指令怎么调往往都不是瓶颈真正让你在真机上跑不过十分钟的是代理根本拿不到准确可靠的界面信息。我最早只依赖无障碍服务提供的无障碍树来观察页面结果实测下来树的覆盖率远没有想象中高——权限被系统回收、应用不暴露语义节点、WebView 页面一片空白一旦遇到这些情况代理就开始原地打转。后来我把投屏像素作为一路兜底形成了双通道感知正常情况以无障碍树为主树不可用或者节点信息严重缺失时用屏幕像素识别来补位。这样跑了几轮之后动作成功率才真正稳定下来。这篇文章是我自己落地这个方案时的完整复盘包含设计取舍、具体实现、仲裁逻辑和踩坑记录适合正在做手机端 GUI Agent、自动化测试或者 UI 自动化框架的朋友。如果你是刚接触这块的新手我也会把基础概念交代清楚顺着读下来应该能直接上车。1. 双通道感知的整体设计与取舍1.1 为什么不能只信无障碍树无障碍树这个概念用一句话解释就是系统为了让读屏软件能够“看懂”界面给每个可见控件生成的一个结构化节点。每个节点上带了很多字段比如控件类型、文本内容、content-desc、坐标范围、是否可点击等等。对 GUI Agent 来说这棵树是天然的界面 DOM拿过来就能做语义理解和动作规划在原生控件页面里确实效率很高。但理想很丰满现实是这棵树并不是操作系统强制每个应用都要完整上报的。我在真机上遇到的第一个问题是游戏和自绘控件页面它们基本不向无障碍层暴露可靠节点整个屏幕在树里可能就是几个空包裹。第二个问题是某些应用会在特定页面关闭无障碍数据上报或者出于性能原因只暴露很少的节点。第三个问题则来自 WebView 这类混合页面网页内容不是原生控件树的加载速度会明显落后于界面渲染代理已经“看见”了页面树却还是旧状态。一旦树不全整个动作链路的起点就是歪的。你让代理读取当前树它看到的可能只有三个节点而实际屏幕上却有二十几个可操作元素接下来的点击目标规划就全错了。所以单纯相信无障碍树至少在真实场景里是扛不住的。1.2 投屏像素兜的是哪一类底屏幕上的像素是最终的真值。无论控件走的是什么渲染路径只要界面画出来了像素就一定会反映在投屏画面上。投屏像素这套方案就是把手机屏幕实时镜像到抓帧器里拿到一帧一帧的位图再基于这些像素去做页面理解。像素方案能兜住前面提到的几乎所有盲区。游戏界面没有无障碍节点但是像素上有按钮自绘控件没有文本但是像素上有图标和文字WebView 加载慢但像素上能看见加载中的骨架。通过 OCR 可以提取文字信息通过目标检测或者轻量图像定位可以找到可点击区域甚至可以把整张图直接交给视觉模型让模型基于视觉信息生成动作。但像素方案也有明显的代价。全图推理耗时高OCR 和检测模型在复杂背景上容易误识别而且像素给的是“屏幕位置”不是“语义信息”。比如你看到一个圆角方块模型可能识别成按钮但并不知道它点击之后会跳到哪里去。这就是为什么像素方案适合做兜底而不是替代主通道。1.3 主辅关系为什么不是“平级双通道”既然两路数据各有优势那我为什么不把两路都拿来当“平级输入”一把丢给 Agent我一开始确实这么试过效果很差。原因是两路数据天然不同构一个给的是结构化节点一个给的是位图同时丢进去模型要花很大精力去对齐和仲裁反而降低了决策准确性。我最终定的策略是以无障碍树为主投屏像素兜底。这里“兜底”不是“备用”不是树坏了才去启动像素流程而是一套房子里有主干道和应急通道的关系。平时主通道到达了就优先用主通道主通道数据不完整时再启动像素识别并把识别结果作为补充输入。这个关系定下来之后感知层的复杂度才真正可控。用一张表总结两路通道的权衡关系对比项无障碍树为主投屏像素兜底数据结构结构化控件节点位图图像元素定位节点自带 bounds 坐标需要检测/OCR 后计算语义信息页面自带文本和控件类型语义微弱需要模型补全覆盖场景原生控件页面、系统页面游戏、自绘、WebView主要劣势应用不暴露节点、权限被回收耗时、误检、缺少语义适用角色主感知通道兜底感知通道2. 主通道无障碍树的采集、清洗与特征组装2.1 树的采集方式和权限卡点想在手机端拿到无障碍树一般做法是注册一个无障碍服务。系统会在界面发生变化时回调事件我们再通过事件去拿当前窗口的根节点。这个流程看起来简单但有一个权限卡点用户必须手动在系统设置里打开无障碍服务开关。针对测试设备通常需要预先写好引导脚本让自动化流程启动时自动帮用户开启权限或者内置一个开关检查机制检测到服务未开启就先提示。更麻烦的是部分手机厂商的系统省电策略会把后台的无障碍服务杀掉或挂起。你会发现服务进程可能还在但拿到的根节点一直是空。这个坑我在真机上遇到好几次。对策一般是做服务自愈定期探活如果连续几秒拿不到根节点就主动重启服务或者提示用户重新激活权限。采集频率也要控制。无障碍事件本身是异步的高频刷新会带来性能压力。我实际设置的策略是“事件驱动为主轮询兜底”每次收到窗口状态变化或内容更新事件时刷新一次树如果超过 500ms 没有事件再主动拉一次。这样既不会漏掉页面变化也不会让主线程被树抓取拖垮。提示不要每次事件都全量抓树。全量遍历几百个节点的树在主线程上做会明显掉帧一定要放到带限流的异步任务里或者直接对节点做剪枝。2.2 节点清洗与特征组装拿到根节点之后不能直接把整棵树丢给 Agent。原始树里包含大量不可见、无交互能力的节点全都塞给模型不仅浪费 token还会干扰判断。我这边会做三层清洗。第一层过滤不可见节点。有些节点虽然存在于树中但实际不在屏幕上比如被遮挡、宽高为 0、或者处于页面外。判断方法很简单看节点的 bounds 是否与屏幕尺寸相交以及宽高是否为 0。第二层过滤不重要节点。很多布局容器节点、无文本无描述的 FrameLayout 节点对 Agent 没有价值。保留规则是节点必须具备 text、content-desc、可点击、可聚焦、有输入框类型、或者有可见的子节点这几种条件里至少一种。第三层合并同类项。比如一个按钮的文字被拆到两个子 TextView 里我会按坐标邻近度和父子关系合并成一个候选元素。目标是让 Agent 看到的列表尽量接近人眼看到的“可操作控件”列表。清洗完的节点我会组织成统一的数据结构大致是下面这个 JSON{ bounds: [120, 340, 260, 400], class: Button, text: 立即登录, content_desc: null, editable: false, clickable: true, focusable: false, node_id: com.example:id/btn_login }每个字段都对应到动作规划里会用到的信息。clickable 决定要不要把它列为候选可点目标bounds 决定最终的点击坐标text 和 content_desc 则是语义输入。这样组装出来的数据模型读起来非常轻也方便转成表格或者文本序列化输入。2.3 主通道最常失效的三种情况第一应用主动关闭无障碍暴露。部分应用会在 WebView 页面或自定义渲染页面里禁用无障碍导致树里只有少数空白节点。这时候你看到的界面和树完全不匹配必须触发像素兜底。第二权限被系统回收。某些设备强化了省电管理进程挂起后无障碍服务虽然没被杀死但拿不到新的窗口节点。这时候需要服务自愈逻辑以及和系统权限页面的联动。第三事件丢失导致树状态滞后。尤其是页面动画、弹窗出现这类高频变化场景树可能停在上一帧状态。应对方式除了把事件和轮询结合还可以在 Agent 决策后、点击执行前做一次强制刷新比对确认目标节点还在原坐标。3. 兜底通道投屏像素的获取与轻量识别3.1 投屏拉流的方案选型移动端抓屏幕像素最直接的方式是使用系统提供的投屏能力这套方案经常被封装成 MediaProjection。流程大体是申请录屏权限创建虚拟显示再把虚拟显示里的画面送到帧缓冲最终拿到 Bitmap 或图像帧。我这里踩过一个细节虚拟显示的尺寸和屏幕分辨率不一定完全一致。很多设备上如果按原始分辨率创建虚拟显示帧率还行但有些机型在高分辨率下图像帧尺寸会被自动裁剪。所以我建议创建虚拟显示时先明确获取真实屏幕尺寸再设置 ImageReader 的宽高最好要求两者一致减少后面的坐标换算成本。另一个选择是 adb screencap。这个方式简单直接一条命令就能截图但是速度比较慢单次截图耗时在百毫秒级连续操作时体验很差。我更推荐把投屏能力做成一个常驻截帧服务平时以 10 到 15 fps 的低帧率后台拉流一旦感知层判定主通道失效立即抓取最新帧。这样兜底通道的启动延迟能压到几十毫秒。注意投屏拉流需要动态申请系统录屏权限这个权限在每次服务重启后都要重新授权。一定要把“权限是否可用”作为兜底通道自身的健康检查项否则可能出现主通道失效后想切像素结果发现像素通道也没法启动的尴尬。3.2 像素侧的快速元素识别路线拿到像素之后做页面理解有很多路线。我推荐分场景用。第一种是 OCR 路线。图片先经过预处理比如灰度化、对比度增强、去背景然后跑 OCR得到文本块和对应坐标。这套方案适合文本按钮较多的页面比如设置页、登录页、表单页。OCR 的精度和速度在主流开源模型里已经不错配合界面对齐逻辑完全够用。第二种是目标检测路线。训练或者复用一个小型目标检测模型专门检测按钮、图标、输入框、滑动条这些常见控件。每一帧做一次检测输出候选框和类别再把候选框和 OCR 结果合并成“伪无障碍树”。这条路线对游戏和自绘界面比较有用。第三种是视觉大模型路线。直接把整张截图丢给视觉模型让模型输出页面描述和可操作点。这个方式的通用性最强但耗时和成本也最高适合训练样本不足的冷启动阶段不适合做成每步都调用的主执行链路。我实际的做法是用 OCR 加目标检测组合生成一个简化版的候选元素列表。这个列表虽然不如原生无障碍树完整但已经能满足基本感知。比如 OCR 识别出“确定”两个字再结合检测模型发现这个位置有按钮样式我就会生成一个候选节点带 bounds 和 text。3.3 兜底通道的触发条件怎么定我列了四个触发条件命中任何一个就会启动像素兜底主通道拿不到根节点或者根节点为空持续超过 1 秒。树里可交互节点数量非常少而屏幕面积占比又很大的页面明显树和界面不匹配。关键节点属性缺失比如某个按钮连文本和 content-desc 都没有点击语义无法判断。Agent 在规划阶段明确反馈“当前观察信息不足以决策”也就是模型认为画面内容太少、没法判断。这四条里前两条是硬性触发第三、四条是软性触发。为了避免频繁切换导致抖动我加了一个确认机制像素兜底至少要连续稳定两帧才会正式接管感知输出如果下一帧主通道恢复正常就立即切回。4. 双通道结果仲裁与动作执行细节4.1 两路感知结果的对齐策略先把两种感知结果统一成相同格式。无障碍树清洗之后输出“候选元素列表”像素兜底经过 OCR 和检测后也输出“候选元素列表”只是来源标记不同。仲裁层要做的事情就是看这两份列表之间是否对得上。对齐主要靠坐标。固定的原则是先看 bounds 是否高度重合再比较文本和控件类型。如果无障碍树里某个节点的中心和像素检测框的中心距离小于一定阈值同时文本或类型近似就认为两个通道对齐对齐后优先保留无障碍树的字段作为最终观察结果因为它的语义更可靠。如果两个通道的识别结果不一致我不建议直接“少数服从多数”因为这种二选一在单帧上永远是赌博。更好的做法是把冲突项标记成 conflict连同两个通道的原始证据一起交给 Agent让决策层基于更多上下文去判断。比如某处文本显示“同意”像素识别却识别成“允许”两个通道打平这时只有结合页面上下文才能确定。提示对齐阈值不是写死的要根据设备分辨率做归一化。我的经验是取设备宽度乘以一个比例系数比如 0.05 倍屏幕宽作为重叠容差这样在不同尺寸的测试机上表现更稳定。4.2 坐标换算与点击点选择GUI Agent 最终要落回到具体动作最常见的就是点击、长按、输入、滑动。坐标是最容易翻车的环节。进入像素兜底通道时所有识别结果都记录在投屏位图坐标系里触发动作时必须把位图坐标换算回设备真实屏幕坐标。换算公式很简单scale_x device_width / bitmap_width scale_y device_height / bitmap_height real_x bitmap_x * scale_x real_y bitmap_y * scale_y前提是设备屏幕尺寸和位图分辨率都拿到了。我这里有个坑部分设备在媒体投屏模式下图像帧会有裁剪或者留黑边如果直接按比例算会偏移。所以要在截帧服务初始化时连续采样三帧检测边缘区域是否纯黑自动校正坐标原点和缩放系数。点击点选择也有讲究。不管来自哪个通道我都不建议直接点在元素中心。对按钮这类控件中心点大概率没问题但输入框、滑杆这类控件中心点可能超出可交互范围。一般选择规则的 min(bounds) 加上大约 70% 的宽高分量更多靠近控件的有效点击区域比严格中心更稳妥。4.3 动作执行链路与整体闭环感知层对齐之后动作执行层要形成一个闭环不能只点一次就结束。我当前的顺序是解析目标元素、生成点击坐标、执行动作、重新采集感知数据、校验动作是否生效。这个闭环很关键。比如 Agent 判断要点击“登录”按钮点完之后不能马上接着规划下一步而应该先重新拉一次无障碍树看页面是否跳转、登录按钮是否消失、是否有错误提示。如果页面没变化说明点击没有生效就需要走重试逻辑或者重新仲裁目标。动作执行本身有两条路线如果目标元素来自无障碍树可以通过无障碍服务执行点击动作这个方式的优点是系统知道自己在点击哪个控件精度很高如果目标元素来自像素兜底或者目标控件不支持无障碍动作就用手势来模拟触摸。手机端产品里手势模拟是最通用的方案它通过系统级手势接口能够在绝大多数应用中触发点击和滑动。我自己的做法是优先无障碍动作手势作为通用回退这样既保精度也保兼容性。整个闭环跑通之后Agent 才真正具备了“观察、决策、执行、再观察”的能力而不是一个只会出坐标的脚本。5. 实测踩坑记录与常见问题速查5.1 无障碍树侧的几个老坑第一个老坑之前提过系统省电策略杀掉无障碍服务。你在开发者工具里看服务进程还在但拿根节点的接口一直返回 null。表现形式就是 Agent 突然“失明”了。办法是加自愈程序定时探活发现拿不到根节点就调用系统页面重新引导用户打开服务。第二个坑是动态加载控件节点出现晚于渲染。特别典型的是列表滚动加载、长页面懒加载页面已经渲染出一屏内容了树里却只有前几个节点。解决办法是滚动后强制等待 300 到 500ms再重新抓树必要时连续抓两次取并集。第三个坑是弹窗权限页面。首次启动应用会弹出各种授权框这种系统窗口的树和宿主 App 的树是两套。如果不做窗口识别Agent 很可能把后台页面的节点和弹窗节点混在一起。我这里加了一道过滤逻辑只保留当前活跃窗口的节点并在观察数据里带上窗口标识。5.2 像素拉流侧的另几个坑像素拉流第一个坑是截图不及时。虚拟显示如果长时间不消费帧系统会降低供给频率等你需要兜底时切到最新帧可能已经是几百毫秒之前的画面。应对办法是维护一个双缓冲一个缓冲负责持续消费丢弃旧帧另一个缓冲存储最新可用帧查询时直接取最新帧。第二个坑是分辨率不一致。部分设备的真实显示分辨率、投屏流分辨率和系统上报的逻辑分辨率三套数值都不一样导致动作坐标偏差。我的做法是统一以真实设备屏幕分辨率为基准所有输入先转换到设备坐标系再统一执行。第三个坑是 OCR 误识别尤其是有背景图、渐变色的页面。我做过一个优化把文本识别结果和树里的文本做交叉校验像素侧识别出的每个文本都跟树里已有文本做过近似匹配匹配不上的再标记为新增候选这样即使有误识别也不容易把整个观察带偏。5.3 双通道协作时的仲裁坑两个通道协作时最容易犯的错误是让两路结果互相打架。比如无障碍树说某按钮叫“下一步”像素识别出来也叫“下一步”看着一样其实是旧页面的按钮和新页面的按钮坐标不同。如果仲裁逻辑只比对文本就合并就会把两个不同位置的按钮当成同一个元素动作坐标自然就错了。所以仲裁一定要做“文本加坐标”双重校验而且坐标权重应该大于文本。因为两个通道可能因为 OCR 精度不同导致文本略有差异但坐标不一致基本可以判断不是同一个元素。另一个坑是频繁切换。我遇到过主通道偶尔抖一帧像素兜底立刻接管紧接着主通道恢复又切回来结果 Agent 一帧一个观察状态规划全乱。加了“连续两帧确认”之后切换抖动基本消除了。最后整理一个速查表方便排查问题用现象可能原因解决方案无障碍树一直为空服务被系统挂起或权限未开启自愈探活、引导重新授权树里节点很少应用是自绘或 WebView 页面触发像素兜底点击后页面无反应动作执行未走对通道改用系统手势模拟并做闭环校验像素截图偏旧帧消费不及时双缓冲持续消费取最新帧点击坐标偏移投屏分辨率不一致统一换算到设备坐标并校正黑边两通道文本冲突单通道识别误差文本加坐标双重校验带冲突标记上抛这套双通道感知做好之后最让我意外的一点是真正拉高成功率的不是任何一个通道单独有多准而是仲裁逻辑够不够稳。把无障碍树和投屏像素按主次关系组合起来再配上连续确认和坐标双重校验Agent 才从“偶尔能点对”变成“大多数时候能点对”。如果你也正在做手机端 GUI Agent建议先从单通道把链路完整跑通再加入兜底逻辑双通道不是项目一开始就需要的复杂度而是单通道到天花板之后最值得投入的方向。
阅读完成 · 觉得有帮助?