手机端 GUI Agent 的感知层,是决定它到底"能用"还是"智障"的分水岭。我前后折腾过三套方案,最后稳定下来的架构就一句话:无障碍树为主,投屏像素兜底。这套双通道感知不是拍脑袋想出来的,是被真实场景逼出来的——纯无障碍树在自绘控件、游戏、Flutter 页面上直接抓瞎,纯投屏像素又慢又贵还容易点错。下面把我踩过的坑、选型的逻辑、以及最终落地的代码骨架,完整摊开讲一遍。
1. 为什么单通道感知一定会翻车
1.1 纯无障碍树的天花板在哪
无障碍树(AccessibilityNodeInfo 树)是 Android 系统给辅助功能开放的一套结构化 UI 描述。它的好处太明显了:节点带text、contentDescription、className、boundsInScreen、clickable、scrollable这些语义字段,Agent 拿到之后几乎不用做视觉理解,直接就能定位"那个写着'登录'的按钮在屏幕 (540, 1820) 到 (720, 1900) 这个矩形里"。延迟低、精度高、还省电。
但它有三个硬伤,我在实际项目里全撞过:
- 自绘控件直接隐身。很多 App 用 Canvas 自己画 UI,无障碍树里只有一个空壳 View,
text是空的,boundsInScreen覆盖整屏。你让 Agent 去点"确认支付",它根本找不到这个节点。 - Flutter / React Native 的语义树不完整。Flutter 默认只把部分 widget 暴露给无障碍,很多可点击区域压根没注册语义节点。我测过一个 Flutter 电商 App,商品卡片的"加入购物车"按钮在无障碍树里完全不存在。
- 游戏和视频播放器基本是黑盒。SurfaceView、GLSurfaceView 渲染的内容,无障碍树里就是一个大矩形,没有任何子节点。
所以纯无障碍树方案,在工具类、系统设置类 App 上跑得飞起,一进电商、游戏、Flutter 页面就废。
1.2 纯投屏像素的代价
那干脆全用投屏像素(MediaProjection 截屏 + 视觉模型识别)行不行?我试过,问题更大:
- 延迟高。截一帧 1080P 的图,编码、传输、模型推理,端到端轻松 300ms 起步。Agent 点一下等半秒,交互体验直接崩。
- Token 成本爆炸。每帧图都要喂给视觉模型,一个任务跑下来几十上百帧,成本是纯文本方案的几十倍。
- 坐标精度受分辨率影响。投屏出来的图经过缩放,模型给的坐标映射回真实屏幕时会有偏差,点偏几个像素在密集列表里就是点错行。
- 耗电和发热。持续截屏对手机是重负担,跑十分钟机身就烫手。
结论很清楚:无障碍树是"廉价高精度的结构化信息",投屏像素是"昂贵但万能的视觉信息"。正确的做法不是二选一,而是让它们各司其职——能用树解决的绝不截图,树解决不了的才动用像素通道。
1.3 双通道的分工原则
我最终定下的分工原则是这样的:
| 场景 | 主通道 | 兜底通道 | 理由 |
|---|---|---|---|
| 标准原生控件 | 无障碍树 | 不需要 | 树里有完整语义和坐标 |
| 自绘/Canvas 控件 | 无障碍树(拿容器) | 投屏像素 | 树只能给容器范围,具体元素靠视觉 |
| Flutter/RN 页面 | 无障碍树(尽力) | 投屏像素 | 语义不全,需视觉补全 |
| 游戏/视频 | 投屏像素 | 无 | 树完全无效 |
| 验证码/图形 | 投屏像素 | 无 | 必须视觉理解 |
这个表格是我踩了无数坑之后总结的,核心思想是:先问树"你知道这是什么吗",树说知道就直接用,树说不知道或者说不全,再截图让视觉模型补。这样 80% 的操作走廉价通道,20% 的疑难杂症走昂贵通道,整体成本和延迟都能接受。
2. 无障碍树通道的采集与清洗
2.1 AccessibilityService 的初始化陷阱
无障碍通道的入口是AccessibilityService。这里第一个坑就是配置文件的accessibilityFlags,很多人抄个模板就完事,结果后面拿不到想要的事件。
<accessibility-service android:accessibilityEventTypes="typeWindowStateChanged|typeWindowContentChanged|typeViewClicked|typeViewFocused" android:accessibilityFeedbackType="feedbackGeneric" android:accessibilityFlags="flagReportViewIds|flagRetrieveInteractiveWindows|flagIncludeNotImportantViews|flagRequestFilterKeyEvents" android:canRetrieveWindowContent="true" android:notificationTimeout="100" />几个关键 flag 的作用,我逐个解释:
flagReportViewIds:让节点带上viewIdResourceName,也就是com.xxx:id/btn_login这种资源 ID。这个极其重要,因为资源 ID 比 text 稳定得多,App 改文案不会影响 ID。做元素定位时优先用 ID。flagRetrieveInteractiveWindows:允许获取所有可交互窗口,包括悬浮窗、输入法窗口。不加这个,弹窗和输入法里的按钮你抓不到。flagIncludeNotImportantViews:把importantForAccessibility="no"的节点也包含进来。有些 App 故意把关键控件标记成不重要来防抓取,加上这个能多捞一些。notificationTimeout:事件去抖,设 100ms 避免内容变化时疯狂回调。
注意:
flagRequestFilterKeyEvents会拦截按键事件,如果你不需要监听物理按键,别加,加了可能影响用户正常输入。
2.2 节点树的遍历与扁平化
拿到AccessibilityNodeInfo根节点后,不能直接把整棵树丢给 Agent,节点太多、太深、太多噪音。我的做法是遍历 + 扁平化 + 过滤,产出一个"可操作元素列表"。
data class UiElement( val id: String?, // viewIdResourceName val text: String?, // text 或 contentDescription val className: String?, val bounds: Rect, // boundsInScreen val clickable: Boolean, val scrollable: Boolean, val editable: Boolean, val depth: Int, val path: String // 从根到该节点的路径,用于稳定引用 ) fun flatten(root: AccessibilityNodeInfo): List<UiElement> { val result = mutableListOf<UiElement>() val queue = ArrayDeque<Pair<AccessibilityNodeInfo, Int>>() queue.add(root to 0) while (queue.isNotEmpty()) { val (node, depth) = queue.removeFirst() if (isWorthKeeping(node)) { result.add(node.toUiElement(depth)) } for (i in 0 until node.childCount) { node.getChild(i)?.let { queue.add(it to depth + 1) } } } return result }isWorthKeeping是过滤逻辑,判断标准:
- 节点可见(
isVisibleToUser) - 节点有实际尺寸(bounds 宽高都大于 0)
- 节点要么可点击、要么可滚动、要么可编辑、要么有非空 text/contentDescription
- 排除纯布局容器(
className是FrameLayout、LinearLayout且无 text 无 clickable 的)
这样过滤下来,一个复杂页面通常从几百个节点降到 20-50 个有效元素,Agent 处理起来轻松很多。
2.3 坐标归一化:别用绝对像素
这里有个血泪教训。我第一版直接把boundsInScreen的绝对像素坐标给 Agent,结果换一台分辨率不同的手机,所有坐标全错。后来改成归一化坐标(0-1 之间的比例),问题解决。
fun Rect.normalize(screenW: Int, screenH: Int): FloatArray { return floatArrayOf( left.toFloat() / screenW, top.toFloat() / screenH, right.toFloat() / screenW, bottom.toFloat() / screenH ) }Agent 输出动作时也用归一化坐标,执行前再乘回当前屏幕尺寸。这样一套策略跨设备通用。中心点坐标就是((left+right)/2/screenW, (top+bottom)/2/screenH)。
提示:屏幕尺寸要用
WindowManager.currentWindowMetrics拿实时值,别用Display.getSize的缓存值,横竖屏切换时会不一致。
2.4 树的稳定性问题与 path 引用
无障碍树有个烦人的特性:每次内容变化,节点对象都会重建。你上一秒拿到的AccessibilityNodeInfo引用,下一秒可能就失效了,直接操作会抛IllegalStateException。
我的解法是不持有节点引用,只持有"路径 + 特征"。执行动作时重新遍历一次树,用特征匹配找到目标节点:
fun findNode(root: AccessibilityNodeInfo, target: UiElement): AccessibilityNodeInfo? { // 优先用 viewId + bounds 匹配 // 退化到 text + className + bounds 匹配 // 再退化到纯 bounds 重叠度匹配 }匹配优先级:viewId完全相同 >text+className相同 >bounds重叠度 > 0.8。这套匹配逻辑实测下来,在动态列表里也能稳定命中。
3. 投屏像素通道的接入与降本
3.1 MediaProjection 的授权与生命周期
投屏通道用MediaProjection+VirtualDisplay+ImageReader组合。授权流程必须走MediaProjectionManager.createScreenCaptureIntent(),用户手动确认一次。
val mpm = getSystemService(MediaProjectionManager::class.java) startActivityForResult(mpm.createScreenCaptureIntent(), REQ_CODE) // onActivityResult 里 val projection = mpm.getMediaProjection(resultCode, data) val imageReader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2) val virtualDisplay = projection.createVirtualDisplay( "agent-capture", width, height, densityDpi, DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR, imageReader.surface, null, null )几个坑:
- 授权不能复用。每次 App 重启都要重新申请,
MediaProjection对象不能跨进程持久化。所以 Agent 启动时要引导用户授权一次。 - ImageReader 的 maxImages 别设太大。设 2 就够了,设大了内存暴涨。每次
acquireLatestImage()之后必须close(),否则缓冲区耗尽会卡死。 - VirtualDisplay 的分辨率别照搬屏幕。1080P 屏幕截 1080P 图,一帧就是 8MB。我一般缩到 720P 甚至 540P,视觉模型识别 UI 元素完全够用,传输和推理都快很多。
3.2 按需截屏:什么时候才动用像素通道
这是双通道架构的核心省钱逻辑。绝不主动持续截屏,只在以下情况触发一次截屏:
- 无障碍树返回的有效元素列表为空,或元素数量少于阈值(比如 3 个)
- Agent 要执行的动作在树里找不到匹配节点
- 当前页面被判定为"视觉主导"(游戏、视频、Canvas 页面)
- 需要读取图形内容(验证码、图表、图片里的文字)
判断"视觉主导"页面有个简单启发式:如果树里有效元素少于 5 个,但屏幕明显有内容(可以通过一次低分辨率截屏的像素方差判断),就标记为视觉主导。
fun shouldUseVision(tree: List<UiElement>, lastFrameVariance: Float): Boolean { if (tree.size < 5) return true if (tree.all { it.className?.contains("Surface") == true }) return true return false }3.3 截屏帧的压缩与传输
截屏拿到的Image是 RGBA 原始数据,直接传太大。我的处理链路是:
val image = imageReader.acquireLatestImage() val planes = image.planes val buffer = planes[0].buffer val pixelStride = planes[0].pixelStride val rowStride = planes[0].rowStride val rowPadding = rowStride - pixelStride * width val bitmap = Bitmap.createBitmap( width + rowPadding / pixelStride, height, Bitmap.Config.ARGB_8888 ) bitmap.copyPixelsFromBuffer(buffer) image.close() // 裁剪掉 padding,再压缩 val cropped = Bitmap.createBitmap(bitmap, 0, 0, width, height) val stream = ByteArrayOutputStream() cropped.compress(Bitmap.CompressFormat.JPEG, 70, stream) val jpegBytes = stream.toByteArray()JPEG 质量设 70 是实测的甜点:再低文字边缘会糊,影响 OCR;再高体积涨得快收益小。720P 的 UI 截图压完通常 80-150KB,传给视觉模型很轻松。
注意:
rowPadding处理是必须的,很多设备rowStride不等于width * pixelStride,不处理会出现图像错位(经典的"斜条纹"bug)。
3.4 视觉模型输出的坐标映射
视觉模型返回的坐标通常是相对于它看到的图片的像素坐标。映射回真实屏幕要经过两步:
// 模型坐标 -> 截屏图坐标(如果模型内部又缩放了一次) // 截屏图坐标 -> 真实屏幕坐标 fun mapToScreen(modelX: Int, modelY: Int, captureW: Int, captureH: Int, screenW: Int, screenH: Int): Pair<Int, Int> { val scaleX = screenW.toFloat() / captureW val scaleY = screenH.toFloat() / captureH return (modelX * scaleX).toInt() to (modelY * scaleY).toInt() }这里最容易错的是忘记截屏图本身可能已经被缩放过。我建议在截屏时就记录下captureW/captureH和screenW/screenH的比例,映射时统一处理,别在多个地方各算各的。
4. 双通道的融合与仲裁策略
4.1 融合的时机:先树后像素
融合不是把两个通道的数据混在一起喂给模型,而是串行降级。流程是这样的:
- Agent 收到任务,先请求无障碍树快照
- 树快照经过扁平化,得到候选元素列表
- Agent 基于候选列表做决策,输出动作
- 如果动作无法在树里定位目标,触发一次截屏
- 视觉模型分析截图,输出目标坐标
- 用坐标执行动作
这个串行流程的好处是:大部分动作在第 3 步就完成了,根本不会走到截屏。实测在标准 App 上,截屏触发率不到 15%。
4.2 冲突仲裁:树和像素说法不一致怎么办
有时候树和像素会给出矛盾信息。比如树说"登录按钮在 (540, 1820)",但视觉模型说"登录按钮在 (560, 1850)"。差 20 像素,点哪个?
我的仲裁规则:
- 优先信树。树的坐标是系统给的精确值,视觉模型有误差。除非树里压根没这个元素。
- 如果树元素不可点击但视觉识别为可点击,信视觉。这种情况常见于自绘控件,树里是个不可点击的容器,但视觉上它就是个按钮。
- 如果两者坐标偏差超过元素尺寸的 50%,说明可能识别错了,触发一次重新截屏确认。
fun arbitrate(treeElem: UiElement?, visionElem: VisionElement?): Point { if (treeElem != null && treeElem.clickable) return treeElem.center() if (visionElem != null) return visionElem.center() if (treeElem != null) return treeElem.center() throw IllegalStateException("both channels failed") }4.3 缓存策略:别重复截屏
同一个页面状态下,截屏结果可以缓存。我用一个简单的"页面指纹"做 key:把当前树的所有元素 text + bounds 拼起来算个 hash,hash 相同就复用上次的截屏结果。
val pageFingerprint = tree.joinToString("|") { "${it.text}@${it.bounds}" }.hashCode() val cachedFrame = frameCache.get(pageFingerprint)缓存有效期设 2 秒,或者页面指纹变化时立即失效。这样在同一个页面连续操作多个元素时,只截一次屏。
4.4 失败重试与通道切换
动作执行失败(比如点击后页面没变化)时,要有重试和通道切换逻辑:
- 第一次失败:重新获取树,用更宽松的匹配规则再试一次
- 第二次失败:强制走像素通道,截屏重新定位
- 第三次失败:上报任务失败,让上层决策
这个重试链路我实测能把成功率从 70% 拉到 92% 左右。剩下的 8% 基本是网络加载慢、动画未结束这类时序问题,靠等待 + 重试也能救回一部分。
5. 实测中的性能与稳定性数据
5.1 延迟对比
我在一台中端机上跑了 100 次"打开设置-找到 WLAN-点击"的任务,对比三种方案:
| 方案 | 平均单步延迟 | 任务总延迟 | 成功率 |
|---|---|---|---|
| 纯无障碍树 | 45ms | 1.2s | 68% |
| 纯投屏像素 | 380ms | 8.5s | 85% |
| 双通道 | 90ms | 2.1s | 94% |
双通道的延迟只比纯树高一点点(因为大部分步骤走树),但成功率接近纯像素方案,甚至更高(因为树提供了像素方案缺失的语义信息)。
5.2 截屏触发率
在 20 个不同类型的 App 上各跑 50 个任务,统计截屏触发率:
| App 类型 | 截屏触发率 |
|---|---|
| 系统设置 | 3% |
| 工具类 | 8% |
| 电商 | 22% |
| 社交 | 18% |
| Flutter 应用 | 45% |
| 游戏 | 100% |
可以看到,标准原生 App 截屏率很低,Flutter 和游戏才是像素通道的主战场。这也验证了双通道设计的必要性——如果全用像素,那 97% 的简单场景都在浪费算力。
5.3 内存与耗电
持续跑 30 分钟任务,双通道方案的内存占用稳定在 180MB 左右,纯像素方案会飙到 400MB+(因为要缓存多帧)。耗电方面,双通道比纯树高约 15%,但比纯像素低 40%。这个代价换来 26 个百分点的成功率提升,我认为非常值。
6. 几个容易忽略的工程细节
6.1 无障碍服务的保活
AccessibilityService会被系统在某些情况下杀掉(比如内存紧张、用户手动关闭)。Agent 要能检测服务是否存活,挂了就提示用户重新开启。检测方法:
fun isServiceRunning(context: Context): Boolean { val expected = ComponentName(context, MyAccessibilityService::class.java) val enabled = Settings.Secure.getString( context.contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES ) ?: return false return enabled.contains(expected.flattenToString()) }6.2 输入法窗口的处理
输入框聚焦后,输入法会弹出一个新窗口。这个窗口在无障碍树里是独立的,需要单独遍历。而且输入法的候选词、键盘按键都是可点击节点,Agent 可以直接点候选词,比模拟输入快得多。
提示:输入法窗口的
packageName通常是com.android.inputmethod或厂商自己的输入法包名,遍历时要注意区分。
6.3 动画和加载的等待
页面切换有动画,内容加载有延迟。Agent 动作后不能立即读树,要等页面稳定。我的做法是监听TYPE_WINDOW_CONTENT_CHANGED事件,静默 300ms 后才认为页面稳定。
private var lastChangeTime = 0L override fun onAccessibilityEvent(event: AccessibilityEvent) { if (event.eventType == AccessibilityEvent.TYPE_WINDOW_CONTENT_CHANGED) { lastChangeTime = SystemClock.uptimeMillis() } } fun waitForStable(timeout: Long = 3000): Boolean { val start = SystemClock.uptimeMillis() while (SystemClock.uptimeMillis() - start < timeout) { if (SystemClock.uptimeMillis() - lastChangeTime > 300) return true Thread.sleep(50) } return false }6.4 敏感操作的二次确认
涉及支付、删除、授权这类敏感操作,Agent 不能直接执行,要弹出确认让用户点。这是产品层面的安全底线,也是我踩过坑之后加的——早期版本 Agent 误点了一次"确认删除",把测试数据全清了。
7. 双通道感知的边界与后续演进
这套架构不是银弹,有几个明确的边界:
- 跨 App 任务:每个 App 的无障碍树结构不同,Agent 需要针对性地适配。通用策略能覆盖 70%,剩下的靠 App 特定的规则。
- 动态内容:无限滚动的列表、实时更新的数据,树和像素都在变,需要更复杂的时序处理。
- 反抓取:有些 App 会检测无障碍服务并禁用关键功能,这时候只能纯靠像素,成功率会下降。
后续我打算在几个方向继续优化:一是引入轻量的端侧视觉模型,把截屏推理放到本地,进一步降延迟;二是做元素级的跨帧追踪,减少重复识别;三是把树和像素的融合从串行改成并行预取,树快照和低分辨率截屏同时进行,进一步压缩延迟。
这套双通道方案我从去年跑到现在,在十几个真实 App 上验证过,稳定性已经能满足日常自动化需求。核心经验就一条:别迷信单一通道,让便宜的先上,贵的兜底,把成本花在刀刃上。无障碍树和投屏像素不是竞争关系,是互补关系,把它们的分工边界划清楚,GUI Agent 的感知层就稳了。