最近 computer use 在海外挺热,不过绝大多数演示是电脑和网页上的。手机端是另一回事——我在这上面把感知和执行的通道都接了一遍,这篇写两条官方通道各拿什么、什么时候用哪条,以及踩到的三个坑。
代码开源在这里:momo-null/hachifish。
先说结论:手机没有官方的电脑操作接口,只有两条路。桌面有 uiautomator、有 CDP、有各家 SDK,但 Android 出于安全模型,不向第三方应用开放"跨应用界面控制"能力——这是设计的一部分,不是待补齐的缺口。不用 root、不用 hook,全程只调官方接口,能用的就是这两条:
- AccessibilityService(无障碍服务) :拿结构化 UI 树,能读、能点、能输入
- MediaProjection(投屏)+
takeScreenshot:拿像素帧,也就是"屏幕长什么样"
上图是这篇的核心:感知不是一条线,是两条线,中间由"树是否可用"决定走哪条。
TL;DR
- 两条通道不是备份关系,拿的东西根本不同:UI 树是语义(有哪些控件、能不能点),像素是真相(长什么样)
- 架构是「树为主、像素兜底」:树非空就基于节点决策;树不可用才走视觉兜底
- 成本差 3 倍:
observe取树 p50 6ms,screenshotp50 20ms(同机、N=12/原语)
- 三个真机坑:坐标空间不同源(落点偏高 6.7% ,量化过)、转场抖动烧 token 预算、行对齐花屏(经典坑,但在 Agent 里会"污染 VLM 描述")
- 两处还没做好的地方(自认):树上限是静默截断(不告诉模型被裁了)、语义空树 + 视觉失败时静默返回空树
- "树不可用"实测只有两类(转场瞬态、纯自绘);WebView / 游戏只有声明、没跑过
- 同构原语面有硬证据:u2(XML) 与 a11y(JSON) 两后端树级语义一致率 100%
- 边界:单机型(Android 12)、树级口径、原生 App 为主;WebView 与跨机型未系统验证
一、"树为主、像素兜底"长什么样
先看结构,因为后面所有的取舍都由它决定:
怎么读:这张图有两个特点决定了全部设计。① 兜底不是补丁,是分支——"树不可用"不是异常,是可以预期的正常状态,所以视觉从第一天就在架构里,只是默认按需触发。② 两条线在末端汇合到同一组原语——上层决策不关心输入是树还是图,只看到"有什么 + 在哪",所以后端可以整体替换(第五节)。
补一句诚实的边界: "树不可用"我实测覆盖的只有两类(转场瞬态、纯自绘界面),详见第三节末。WebView、游戏这些常被提到的场景,我的仓库里其实没有实测记录——这是实测覆盖的空洞,不假装有。
这段在代码里的形状也是这样,兜底逻辑直接内建在 observe 里:
关键点:视觉兜底没有单独的工具让模型"自己想起来要用",而是焊在 observe() 里。模型只要调感知,兜底就自动发生——因为"树不可用"这件事模型自己看不见,指望它判断"现在该看屏了"是不现实的。
二、两条通道各拿什么
| AccessibilityService | MediaProjection / takeScreenshot | |
|---|---|---|
| 拿到什么 | 结构化 UI 树:节点层级、view_id、文本、contentDescription、可点击/可编辑/可滚动、屏幕坐标 bounds | 像素帧(Bitmap → JPEG → base64) |
| 语义层次 | 「有什么控件、能点什么」 | 「长什么样」 |
| 授权 | 设置页开一次,之后需对抗系统回收 | 一次授权(录屏模式)连续取帧;Android 14+ 每会话需重新确认 |
| 时延 | 低(读树,p50 6ms) | 中(取帧 p50 20ms;喂 VLM 还要降采样 + JPEG + base64 + 一次模型调用) |
| 盲区 | 自绘控件(Canvas/SurfaceView)实测零语义;WebView / 游戏是声明范围、未实测 | 不依赖控件语义,因此不受上述盲区影响 |
| 能否操作 | ✅ 能(performAction / dispatchGesture) | ❌ 只能看,不能点 |
怎么读:这张表的关键不是"谁更好",而是两者维度不同——树给语义、像素给真相。所以不是二选一,而是"树为主、像素兜底"。注意代价是双向的:只看树会被自绘界面骗(树里节点还在,但一个语义都没有),只看像素又贵又慢且不能点。两条都要,但优先级分明。
注意"盲区"那行:我把实测过的(自绘)和只是声明范围、没跑过的(WebView、游戏)分开写了。这个区别对做同方向的人有用——前者有数据,后者目前只是待验证的假设。
一个具体的成本账
跑了一遍八原语的时延基准(单台真机,N=12/原语):
| 原语 | p50 (ms) | 说明 |
|---|---|---|
observe | 6 | 读无障碍树(compact 模式噪声约为全树 1/10) |
tap_xy | 3 | 归一点按 |
gesture | 4 | 手势 |
screenshot | 20 | 官方 takeScreenshot(API 30+) |
tap_by_id | 24 | 控件点击(含聚焦恢复) |
type_text | 42 | 文本输入(含授权弹层 + 焦点恢复) |
怎么读:感知几乎是"免费动作"——读树 6ms。真正贵的是输入(42ms) ,因为它要等授权弹层和焦点恢复。至于"看屏",
screenshot本身 20ms 不算贵,但喂给 VLM 的成本在别处(降采样 + JPEG + base64 + 模型调用),所以它是兜底而不是主力。直接推论:既然
observe只要 6ms,就不必为省时延而减少观察——Agent 该多观察、少动作。这和"一步步猜着点"的直觉是反的。
三、三个真机踩坑
坑 1:坐标空间不同源,落点整体偏高 6.7%
最隐蔽的一个,因为它不报错,只是 "偶尔点不中" 。
无障碍树里的坐标来自 getBoundsInScreen(),落在真实全屏像素空间(含系统导航栏)。而我一开始算归一化坐标用的是 Service 上下文的 resources.displayMetrics——它给的是可用区域(排除了导航栏)。
两者不同源。实测设备真实分辨率 1080×2400,而 Service 的 displayMetrics 只有约 2250——归一化 y 的落点整体偏高约 6.7% 。
后果比"点不中"更麻烦:
- 底部 tab、悬浮按钮系统性打不中(不是偶发)
- 连带产生逻辑误判:清理闹钟的任务里,"点开了闹钟编辑器"被判定成"列表已空",因为点到的位置对不上预期
怎么修:改用 getRealMetrics() 取真实全屏尺寸(并保留 displayMetrics 作为兜底)。
为什么这个坑值得单独写:讨论这个问题的资料里,通常只说到"要用 getRealSize / getRealMetrics"——但很少有人给出量化。6.7% 听起来不大,可它落在 2400px 高的屏幕上就是 160px,足够让底部那排 tab 全部落空。而且它的代价不止是点不中:一个归一化坐标系不统一,会同时制造"操作失败"(点空了)和"逻辑误判"(点错了还以为列表空了)两种看起来毫无关联的故障,排查时很容易往两个方向查。
坑 2:转场抖动被模型放大成成本
对话框刚关闭、App 刚切换的那一瞬间,rootInActiveWindow 会短暂为空——界面还没稳定下来。
这不是 bug,是时序现实。但代价不小:模型拿到"空树"会以为没读到,反复重试,几轮下来把 token 预算烧掉了。原始记录写得很直白:「该噪声让模型反复重试烧穿预算」。
怎么修:observe() 里加轮询——最多等 2 秒,期间每 150ms 重试一次,超时才报错。
怎么读:这个坑的教训不是"加个 wait"这么简单,而是——界面抖动会被 Agent 的循环结构放大。一次空读数在人类看来是"再等一下",在 ReAct 循环里是"再来一轮完整推理"。所以桥接层的稳定性直接影响的是模型成本,不只是成功率。
顺便说一句:我查资料时,讲"UI 树有填充延迟"的不少(普遍说是 200-500ms),但把这件事和 Agent 的循环成本连起来的,我没看到。这可能是 Agent 时代特有的问题——同样一个时序抖动,在传统自动化脚本里只是"慢一点",在会自我重试的 Agent 里是"多烧几轮推理"。
坑 3:ImageReader 的行对齐(rowStride ≠ width×4)
先说清楚:这是个经典坑,不是我的发现——StackOverflow 上有好几个高赞问答,CSDN 也有专文。
但它在 Agent 场景里的表现值得写一笔:从 MediaProjection 取到的帧是 Image,它的平面有行对齐 padding——每行数据按硬件要求补齐,所以 rowStride 往往大于 width × 4。
本设备实测:rowStride = 4352,而 w × 4 = 4320——每行多出 32 字节 padding。如果直接 copyPixelsFromBuffer 整块拷进 Bitmap,图像就会从第一行之后开始斜移花屏。
怎么修:逐行拷贝——按 y * rowStride 定位每行起点,读出 w × 4 字节写进紧凑缓冲,再一次性拷入 Bitmap。
为什么在 Agent 里更麻烦:花屏的图仍然是一张合法的 JPEG——它会一路通过 grab_frame,被喂给 VLM,而 VLM 会一本正经地描述一张扭曲的图。也就是说,这个坑在普通截图 App 里是"图坏了"(人一眼看出),在 Agent 里是"模型给出了错误描述",故障从图形层悄悄转移到了推理层。
附:树序列化的两个上限,和一个"测过必留痕"的判断
walk() 里有硬上限:maxDepth = 40、maxNodes = 500。这里有个值得说的诚实结论:这两个值没有任何实测依据。
我怎么确认的:查了 git 历史——它们在第一个骨架提交(2026-09-26)里就是这个数,之后再没改过;全项目搜索也没有关于取值的讨论或压测记录。而这个项目的习惯是测出来的参数一律带日期注释(比如「噪声约全树 1/10」「2026-09-27 跨品牌兼容改造」「displayMetrics 偏高 6.7% 实录」都是)。没留痕,基本可以断定是骨架期定的经验护栏。
它实际防的是两件事:
| 上限 | 防什么 |
|---|---|
maxDepth = 40 | 病态深树(RecyclerView 多层嵌套)递归爆栈 |
maxNodes = 500 | 全量树撑爆 LLM 上下文(一个节点约 200B JSON,500 个近 100KB) |
这里藏着一个我自己不太满意的设计:超限时
walk是直接 return,不告诉模型树被裁过。也就是说,哪天真撞上上限,模型只会以为"界面就这么点内容"。按项目里"从未有实测记录触发调整"反推,真实任务大概率没撞过——但这是个静默截断,理想做法是回一个truncated: true让模型知道。这一条我留作待办。
四、执行面:为什么原语是这 8 个
操作通道收敛到 8 个原语:
observe / tap_by_id / tap_xy / type_text / gesture / screenshot / launch_app / press
几个值得说的取舍:
① tap_by_id 与 tap_xy 双轨,而且分工是写进契约的。 有 view_id 就用 id(稳,控件位置变了也命中),没有就用归一化坐标(通用,但界面一变就废)。
这个分工不是随手挑的 API,而是架构契约里的一张映射表决定的——每个原语都有自己的"双后端等价物":
| 原语 | 桌面侧(uiautomator2) | 手机侧(Accessibility) |
|---|---|---|
tap_by_id(id) | d(resourceId=…).click() | performAction(ACTION_CLICK) on viewId |
tap_xy(x, y) | d.click(x, y) | dispatchGesture 点按 |
为什么坐标点按不能走 performAction:performAction 的前提是树上存在这个节点——而 tap_xy 存在的理由恰恰是树盲区。纯自绘界面(Canvas/SurfaceView)根本没有节点可点,只能注入真实触摸事件。整个"VLM 给坐标 → tap_xy"的视觉兜底链路,就建立在这个技术前提上。反过来,有节点时也不用 dispatchGesture——节点操作比坐标稳,不用处理滚动出屏和 bounds 偏移。
这个双轨制还解释了一件更重要的事:"动作序列型技能"为什么不可靠——坐标和控件 id 每次运行都可能变,把点击步骤逐字重放的前提根本不成立。(这一点在另一篇讲知识层的实验里有实测:动作序列形态的技能多轮实验一致没有测到收益。)
② type_text 是替换语义,不是追加。 工具描述里专门写明:「替换语义,非光标插入——追加时须传入含原文的完整新文本」。这类 API 语义差异如果不写进工具描述,模型一定会踩。
③ launch_app 的"先索引后跳转"。 早期启动应用走的是「回桌面 → observe → 找图标 → 点按」的视觉搜索慢路径。后来加了本地应用索引:
| 命中情况 | 行为 |
|---|---|
| 唯一命中 | 直接 startActivity,省掉整条视觉搜索链 |
| 多候选 | 返回 candidates(≤5),模型选定后按包名重试 |
| 无命中 | 回退桌面视觉搜索 |
怎么读:这是一个"用索引替代视觉"的典型优化——凡是能用确定性数据(应用名 → 包名映射)解决的,就别用视觉搜索(看屏幕找图标)。后者又慢又容易错,还消耗视觉模型调用。思路可以推广:能用结构化数据的,就别用"看"。
④ 授权开销是可见的。 type_text 和 launch_app 默认是敏感操作,执行前会过一道授权门控。所以这两个原语的 p50(42ms / 24ms)明显高于纯读取动作——门控的成本在指标里跑不掉,这也是"为什么感知便宜、动作贵"的另一半答案。
五、同构原语面:两套后端、一样的语义
内核要同时跑在两条通道上(PC 调试用 uiautomator2 的 XML 树,真机运行时用无障碍的 JSON 树),所以我要求两边语义一致——同一屏幕,两套后端读出来的树要对得上。
对拍结果(Fossify Notes 列表/编辑页,u2=15 节点 / a11y=15 节点):
| 层级 | 口径 | 一致率 |
|---|---|---|
| L1 严格 | bounds + text + id | 100.0% |
| L2 几何 | bounds + text | 100.0% |
| L3 文本集合 | text | 100.0% |
怎么读:这是"内核零改动切换后端"的硬证据——两套完全不同的实现,在同一屏幕上语义对齐。注意口径:这是树级对拍(同一屏幕的树),不是任务级(完整轨迹一致性是另一个口径、另一套用例)。别把这两个混起来说。
补一句:看到有人对比"无障碍 vs uiautomator2"的性能(比如"快 100 倍"),但那些多是厂商自测的卖点。我做的是另一件事——让两个后端在同一屏幕上对拍,验证语义是否等价,这样才敢说"可以零改动换后端"。这个验证方式我没在别处见过,算是自己摸出来的。
六、失败行为与边界
先说失败行为(这块值得单独讲,因为它的实现比想象中细):所有原语只返回 {"ok": false, "error": …},不抛 Java 异常;Python 侧把 HTTP 错误、超时、解析失败也全部吃成结构化 dict;loop 层再包一层 try/except,任何意外都转成结构化错误——三层保障,不抛穿。连败还会触发"换方案"提醒,不会静默空转。
但有一处细节,和直觉不太一样:视觉兜底也失败时,返回什么取决于树通道失败的具体形态:
| 树通道状态 | 兜底也失败时返回 |
|---|---|
ok: false(no active window) | 返回原始的错误(这是直觉里的行为) |
ok: true 但 tree: [](有 root、无节点) | 返回原始的 ok: true, tree: [] ——静默返回空树 |
也就是说,"双通道同时失败 = 直接报错"只在树通道本来就报错时成立。语义空树 + 视觉失败的组合,是静默返回一棵空树,靠模型下一轮自己注意到"树是空的"再去调 look / screenshot。
怎么读:这里有个设计取舍——依赖模型自己发现"树是空的"是有风险的(它可能没注意到,或者误判为"界面就该是空的")。更稳的做法是显式回一个
tree_empty: true之类的标记。目前没这么做,我先把它记成待办。
然后是边界:
- 单机型:所有数据来自一台真机(Android 12),跨品牌/跨 Android 版本的适配未系统验证。已知至少一处 ROM 差异:旧 Flyme 的系统手势区会吞底部触摸,需要在配置里对底部做 0.93 的钳制(通用设备不裁剪,避免误伤底部控件)。
- 树级 vs 任务级:对拍 100% 是树级口径;任务级口径另测。
- 时延样本小:N=12/原语,单机型,不做跨设备推广。
- "树不可用"的实测覆盖只有两类:①转场瞬态(有实测记录);②纯自绘(夹具实测,8 个裸节点零语义)。WebView、游戏、系统弹层都是"声明了范围但没实测" ——这条视觉兜底路径的稳定性还没有系统数据。
- 授权约束:MediaProjection 在 Android 14+ 每会话需重新确认,这是产品体验上的硬约束,属于平台设计,工程上无法规避。
附:这篇文章自己是怎么被验证的
- 时延基准:
harness/checks/m1_latency_benchmark.txt(2026-09-28,N=12/原语)
- 双后端对拍:
harness/checks/m1_parity_report.txt(2026-09-26)
- 坑 1 / 坑 2 的原始记录在
HachimiAccessibilityService.kt的注释里;坑 3 在ProjectionHolder.kt
- 代码:github.com/momo-null/h… (Android 壳 + Python 内核)
如果你也在做手机端 Agent,欢迎来评论区聊双通道的取舍——尤其是 WebView 那条兜底路径,我还没有系统数据。