如果你最近半年用 agent 写代码,大概率经历过这个循环:写 prompt → agent 出活 → 自己跑 app → 验证/截图/找 agent 抱怨 → agent 改 → 再跑 → 再截图。写代码这部分工作,agent 已经承担了大半,但验证正确性对 UI app 来说尤为困难。agent 能在几分钟内吐出一大段像模像样的 Swift 或 Kotlin,但写完最多跑跑单元测试,然后就停在那儿了——它等你来运行、来看、来告诉它哪不对,它再根据反馈修改。这是相当低效的开发方式。
为什么移动端验证这么难?做 Web 的同学会感觉,agent 在前端的闭环验证做得还不错,原因很朴素:agent 能毫不费力地直接拿到 DOM(文档对象模型,就是网页的结构化表示,像一棵树一样可以遍历),浏览器有成熟的自动化工具如 Playwright、Puppeteer,控制台日志、网络请求都是文本可读的。DOM 本身就是一份 agent 的天然语料,`<button id="login">` 这种不需要视觉理解,它就是结构化数据。
换到 mobile,问题立刻不一样:iOS 和 Android 都是相对封闭的环境,没有一个 DOM 等价物对外暴露。市面上能看到的两类方案都还不能让人满意:靠截图 + 多模态模型,贵、慢,对长尾控件识别能力有限,点坐标靠截图计算容易漂移,不稳定;dump UI tree 给 agent 看,UIAutomator / AccessibilityService / iOS 的 AX API 原始输出动不动几十甚至上百 KB JSON,token 消耗惊人,还经常拿不到 UI 元素。结果就是:agent 看不清楚界面,就没法自己验证;没法自己验证,就得把开发者拉回那个低效循环里。
sim-use 就是为解决这个问题而生的:一个跨平台 CLI,让 agent 可以像人一样操作 iOS 模拟器和 Android 模拟器/真机。它做四件事:看——把 app 当前屏幕翻译成一种紧凑、对 agent 友好的文本格式(叫 outline);点——通过 outline 里 `@N`、`#id` 这种简短 selector 让 agent 和人轻松选中元素并触发交互;打字/手势/截图/录屏/多指操作/键盘事件——提供一整套命令覆盖所有操作和证据留存;跨平台——iOS 和 Android 用同一套命令、同一种 selector、同一种 JSON 输出格式。
最让我意外的是 outline 的设计。最直觉的做法是把 accessibility tree 整棵 dump 成 JSON 喂给 agent,结构清楚、内容完整。但第一次在 LINE 的新闻页上试了一下,得到了一个 24 KB、pretty 打印 1600 多行的 JSON。24 KB 在普通工程里不算什么,但对 agent 是个灾难——一个验证循环里 `sim-use ui` 会被调几十次,每次的输出混进上下文,两三步之后上下文就被 UI 树撑爆,噪音让 agent 开始忘掉之前的对话。所以他们在 CLI 层做了一个紧凑的文本 DSL 输出,同一个 LINE 新闻页面 dump 出来 1.2 KB、不到 30 行,token 消耗压到原来的 1/20。
但 outline 不只是压缩,它有几条具体的设计原则,每一条都是踩过坑之后留下的。最朴素的是字节级稳定:坐标全部取整,元素按 (center-y, x) 确定性排序,同一画面两次 dump 一定字节一致,agent 可以对两次 dump 做直接的文本 diff,点之前和点之后差了什么一眼就能看出来。第二条是 selector 设计:`@N` 是当前快照里的第 N 个元素,`#N` 是页面主列表的第 N 个 cell,`#<id>` 直接引用 accessibility tree 自带的稳定 ID 能跨 dump 复用。这个 DSL 的初衷是让 agent 少打字,但出乎意料的是人也很喜欢用——手工调试失败的 case 时,盯着输出敲 `tap @10` 比构造一长串 selector 快得多。第三条是克制:outline 严格只写 accessibility tree 已经声明的东西,不发明语义——一片按钮就是 Button × 5,不会被擅自命名为 NavBar 或 CategoryTabs。因为 agent 看到位置在最底部、横向排列、role 都是 RadioButton,自己会推断这是 tab bar;但如果工具替它命名,一旦猜错 agent 就会跟着错。
`#N` 这种 selector 看起来简单,背后藏着一个不平凡的问题:怎么在运行时识别出页面的主列表?他们做了一个启发式的自动探测:扫一遍页面,找出所有看起来像列表的元素簇,按可信度排名。探测分两路并行——一路看高度,找一群高度一致的兄弟节点;另一路看间距,找一群行间距大致一致的节点。然后用一个朴素的乘法打分:cellCount × consistency × roleBonus × widthBonus,不需要训练,得分最高的那一簇胜出。多列表共存时第二高的列表也会被识别出来,给到 `#N@2` 这个 selector。对 agent 来说,这意味着点击聊天列表里的第三行这样的自然语言,现在可以直接对应 `tap #3`,不需要中间任何视觉识别、推理和计算。
iOS 自动化里还有个让人头疼的现象:部分节点的内容在 AccessibilityPlatformTranslation 框架下无法获取,比如 UITabBar 报回来的 accessibilityChildren 是空的——明明屏幕底部有四个 tab 按钮,遍历 accessibility tree 那个 AXGroup 下面却什么都没有。更诡异的是,同一个元素,accessibilityChildren 遍历不到,但 objectAtPoint: 点得到——你给 iOS 一个坐标,它能告诉你这里有什么;但你让它列出某个容器有哪些子元素,它就装作不知道。
他们的解法是一棵自适应的四叉树(quadtree,计算几何里的老方法,核心思路是把一块二维区域自顶向下递归切成四份,到某个条件满足就停)。先把容器 frame 撒一层 160×80 的粗格子,每个格子中心做一次 objectAtPoint:;命中的元素记下来、它的 bounding rect 标记成已覆盖;没命中的格子说明要么是真空白、要么跨过了几个小元素之间的缝隙,把它切成四份继续探。这里有个容易想错的细节:seed cell 命中一个小元素之后,cell 剩下的部分会被切成最多 4 条矩形再 push 回探测队列继续走——hit 锁住的不是整个 seed cell,只是 hit 自己的 bounding rect,周围那些可能漏掉的兄弟元素仍然有机会被探到。整体形态就是一棵粗到细的树:元素密集的地方树深,空白处树浅。把昂贵的 XPC 跨进程通信 hit-test 用在刀刃上,而不是均匀撒在整张画布上。
把这个抽象算法做成跑得动,靠的是几个朴素的工程优化。最关键的是种子用矩形不用正方形——mobile UI 的元素几乎都是横向多于纵向,nav link、文章标题、列表行都是横长条的。把初始 seed 改成 160×80 之后,LINE News 的探针次数和 wall time 下降了 20%。另一条是 XPC 调用前的覆盖剪枝——accessibility tree 已经知道的格子不应该再问一次,维护一个 CoveredSet,如果某个 seed cell 的中心点已经落在已知元素范围内直接跳过。还有一个看似无关紧要但实际影响很大的参数 `--min-cell-size`,从 20 降到 14 让 status bar 上的 SSID 图标、tab bar 上的小 badge 都能被识别出来,代价是中位数延迟从 ~470ms 涨到 ~520ms,但 agent 经常要点这些小图标,这 50ms 是值的。
sim-use 是一个 CLI,但 CLI 有一个不显眼的代价:每次调用都是一个新进程,所有重的初始化都得重做一遍。对 iOS 来说,simulator 框架的初始化加上 accessibility 子系统的初始化稳定占掉 ~200ms;对 Android 来说是 BridgeClient 启动、auth token 缓存、adb forward 端口准备,一整套 ~150ms。agent 在一个验证循环里会调几十次 sim-use,冷启动开销迅速变成主导成本。他们的解法是为每个设备起一个常驻进程:在 host 上跑一个 Unix-domain socket 的 daemon 服务,客户端跑命令时直接走 socket。效果是 iOS 每次 `sim-use ui` 节省 ~200ms,Android 把每次调用从 ~150ms 压到 ~10ms。
但 daemon 也带来几个正确性问题。第一个是二进制升级,daemon 还在跑旧逻辑——每次客户端调用前先发一个 `_ping`,对比 daemon 报的版本和当前 binary 的版本,不一致就 shutdown 重新 spawn。第二个是模拟器在 daemon 不知情的情况下被关了——daemon 自己检测 simulator 状态,发现宕了就主动退出返回一个可恢复错误,让 agent 拿到错误时有足够信息知道下一步该做什么。第三个是 stdin 输入跟 daemon 的 stdin 是 /dev/null 冲突——在命令上声明 daemonBypass,让这类命令直接走 in-process 路径绕过 daemon。
跨平台设计上有个反直觉的决定:主动从顶层命令面里删掉了 5 个 iOS-only 的命令——key、key-combo、key-sequence、stream-video、batch。它们仍然存在,但只能通过 `sim-use ios <verb>` 调用,不会再出现在顶层 --help 里。理由很简单:`sim-use --help` 列出来的应该都是真的两个平台都能用的东西。对一个给 agent 用的工具来说,命令面诚实比命令面稳定更重要——agent 不会带着对历史命名的怀念去使用工具,它只会被那些看起来应该能做、实际却做不到的命令坑到,每一个那样的命令都是一次额外的错误处理、一次额外的 fallback prompt、一次本可以避免的 token 消耗。
最后聊聊多指触控的技术验证,这是最让我觉得有意思的部分。他们想给 sim-use 加上两指捏合命令,起点是 facebook/idb#514 这个 2020 年开的 issue,标题大意是 idb 什么时候能支持两指手势。转机来自 SimulatorKit.framework 里一个叫 IndigoHIDMessageForMouseNSEvent 的私有符号。idb 上游用的是它 5 参数的调用形式,单指够用;但如果用 9 参数的形式调用同一个符号,它会生成一个结构完全不同的包——一个真正的 two-payload Indigo packet,两个 finger slot 的 state bits 都被正确初始化。5 参数版本不是不能多指,而是它根本不去初始化第二个 finger slot。一旦切到 9 参数版本,SimulatorKit 自己就会产生正确的包结构,不需要任何硬编码字节偏移。
但真正难的是让 iOS 认对手势。第一个意外是 iOS 不数事件——steps=1,一次 Down、一次 Move、一次 Up,iOS 照样把它当成完整的拖动,手势识别器认的是 finger identifier 的连续性,不是事件数量。第二个意外是把 start 和 end 设成同一个点不是 no-op,而是一次双指 tap——Maps 真的会接着这一下缩小一级。这意味着 pinch、rotate、two-finger tap、two-finger long-press 四个命令本质上是同一个 multi-touch 原语在不同 duration 和 endpoint 几何下的特例。第三个意外把他们卡了两天:直线插值的 rotate 转到 90° 时两指中点距离会收缩到 71%,转到 180° 时直接收缩到 0,UIRotationGestureRecognizer 看到中点距离持续变化会把它误识别为一个混入的 pinch 手势。所以正确 rotate 必须沿圆弧插值——几何上一个看起来无所谓的简化,到了识别器面前就是是非题。
这个项目最打动我的地方在于,它展示了 agent 时代工具设计的一个核心原则:让 agent 高效消化 UI,而不是让脚本能跑。Outline DSL 是这件事的直接结果;承认 iOS/Android 的 accessibility API 都不完整,并用具体的几何/hit-test 算法把缺口补上;跨平台不是把一边的设计复制到另一边,而是用一套统一的命令面 + 平台特定的实现 + 命令面诚实的纪律来保证 agent 真的能写出跨平台的脚本。如果你也在做 mobile + AI 这件事,或者你在设计给 agent 用的工具,这些设计取舍很值得借鉴——尤其是命令面诚实那条,在 agent 越来越普遍的今天,比向后兼容更重要。Agent 写代码的速度,本质上被它能多快验证自己写的代码所限制,sim-use 让这个限制变小了一点。
内容与图片版权归原作者所有 · 原文: https://onevcat.com/2026/07/sim-use/