Agent 写移动端代码的真正瓶颈,已经不在 “写”,而在 “验证”。一个能在三分钟内吐出完整 Swift 或 Kotlin 的 Agent,写完最多跑个单元测试就停住了 —— 它没法自己打开模拟器、看界面、点按钮、判断结果对不对。于是开发者被拉回 “写 prompt→等出活→人肉跑 app→截图反馈→再改” 的循环。sim-use 这个跨平台 CLI 要解决的就是这件事:让 Agent 像人一样在 iOS 和 Android 上 “看见画面、点按元素、验证结果”。本文不讲怎么安装,只拆它背后五个不那么显然的技术决策。

问题:Agent 吃掉了 “产出”,却啃不动 “验证”

业内有个粗略判断:未来五年产出的代码量,可能超过过去所有年份的总和。方向没问题 ——Agent 正在迅速接管软件开发的 “产出” 端。但产出只是开发循环的一半,代码必须经过验证才能变成产品。

传统验证链路 —— 工程师手动点、CI 跑功能测试、发版前 QA 兜底 —— 本来就是研发流程里最贵最慢的一环。上游代码生成被加速十倍,下游验证瓶颈就被放大十倍。而在移动端,这个问题比 Web 严重得多。

Web 前端 Agent 有 “作弊” 优势:DOM 是结构化、可遍历、自带语义的一棵树;Playwright、Puppeteer 的 selector 很稳;控制台日志、网络请求全是文本可读。<button id="login">不需要视觉理解,它就是数据。Agent 写完可以自己跑、自己点、自己看,闭环极短。

移动端是 “黑盒”:iOS 和 Android 没有对外暴露的 DOM 等价物。现有两类方案都不够用 ——

表格

方案原理核心问题适用场景
截图 + 多模态模型截屏喂给 VLM,识别元素后回传坐标贵、慢、长尾控件识别差,坐标易漂移,调用成本爆炸一次性探索、非结构化界面
dump UI treeUIAutomator/AccessibilityService/AX API 导出 JSON原始输出几十到上百 KB,token 消耗惊人,且经常拿不到元素结构简单、元素少的页面
sim-use outline紧凑文本 DSL + 稳定 selector + 跨平台统一输出需额外维护解析层,对非标控件依赖探针兜底Agent 驱动的 E2E 验证、跨平台回归

Agent 看不清楚界面,就没法自己验证;没法自己验证,就只能把人拉回低效循环。让 Agent 高效、准确、可靠地拥有对 app 的视觉和操作能力,基本决定了它能在多大程度上接管验证,也决定了真实开发速度的上限。

sim-use 的解决路径:看、点、操作、跨平台

sim-use 是一个 Swift 写的 macOS CLI(带 daemon 加速),做四件事:

  • :把当前屏幕翻译成紧凑的文本格式 outline,一屏通常压缩到几百 token。
  • :通过@N#id这种简短 selector 选中元素并触发交互。
  • 操作:打字、手势、截图、录屏、多指、键盘事件全覆盖,为 Agent 审计留接口。
  • 跨平台:iOS 和 Android 同一套命令、同一种 selector、同一种 JSON 输出。

iOS 侧通过 Facebook 的 idb(FBSimulatorControl)连模拟器,UI 观测经 CoreSimulator 的 AccessibilityPlatformTranslation 取 accessibility tree,输入通过注入 HID 事件完成;Android 侧是一个跑 AccessibilityService 的 bridge APK,通过 adb forward 暴露 HTTP API。

典型验证循环长这样:

sim-use ui --device <UDID-or-emulator>
# 输出outline,Agent读完后
sim-use tap "@10"
sim-use type "hello world"
sim-use ui   # 再次获取屏幕状态,断言或继续操作

下面进入五个技术决策。

决策一:Outline—— 给 Agent 准备的 “方言”

最直觉的做法是把 accessibility tree 整棵 dump 成 JSON 喂给 Agent。我们也是这么做的,直到第一次在 LINE 新闻页上试了一下 —— 得到一个 24KB、pretty 打印 1600 多行的 JSON。

24KB 对普通工程不算什么,对 Agent 是灾难。一个验证循环里sim-use ui会被调几十次,每次输出混进上下文,两三步之后上下文就被 UI 树撑爆,Agent 开始忘掉之前的对话。于是我们在 CLI 层做了紧凑文本 DSL,叫 outline。同一个页面压到 1.2KB、不到 30 行,token 消耗降到原来的 1/20。

但 outline 不只是压缩,它有四条设计原则,每条都是踩坑后留下的。

第一,字节级稳定。坐标全部 Int 取整,元素按 (center-y, x) 确定性排序,同一画面两次 dump 一定字节一致。这意味着 Agent 可以对 “点之前” 和 “点之后” 做直接文本 diff—— 差了什么一眼可见,推理速度大幅提升。

第二,selector 设计。提供三种简写:@N是当前快照第 N 个元素;#N是页面主列表第 N 个 cell;#<id>直接引用 accessibility tree 自带的稳定 ID。初衷是让 Agent 少打字 ——tap "@5"--label "Login Button" --container "MainView"直白多了。意外的是,人也很喜欢用:手工调试失败 case 时,盯着输出敲tap @10比构造长串 selector 快得多。

第三,克制。outline 严格只写 accessibility tree 已声明的东西,不发明语义 —— 一片按钮就是 “Button × 5″,不会被擅自命名为 “NavBar”。判断依据是:Agent 看到 “位置在底部、横向排列、role 都是 RadioButton”,自己会推断这是 tab bar;但如果工具替它命名且猜错,Agent 就会跟着错。与其让工具猜,不如让 Agent 自己理解屏幕意图。唯一的例外是顶部[Top]/[Content]/[Bottom]三段分带,因为 status bar、内容区、tab bar 在 mobile UI 上足够稳定,属于几乎不可能出错的低成本提示。

第四,跨平台不强行对齐。iOS 按 (center-y, x) 排序 —— 一行按钮高度不同但居中对齐是常态;Android 的 AccessibilityNodeInfo.bounds 会把容器 padding 算进去,中线排序反而把父节点排到子节点中间,所以 Android 改成 (top-y, x)。跨平台不是把一边设计硬搬到另一边,而是给平台特性分歧留空间。

#N背后还有一个列表探测问题:怎么在运行时识别页面的 “主列表”?让用户指定容器违背了 “Agent 不需要知道结构” 的前提。我们做了启发式自动探测:一路看高度(找高度一致的兄弟节点),一路看间距(找行间距一致的节点),用cellCount × consistency × roleBonus × widthBonus打分,不需要训练。多列表共存时(比如 LINE 转发页同时有好友列表和群组列表),第二高的列表自动落到#N@2。对 Agent 来说,”点击聊天列表第三行” 现在直接对应tap #3,不需要视觉识别,E2E 脚本也不再绑死在会漂移的坐标或会随多语言变化的 label 上。

决策二:四叉树探针 —— 让看不见的元素 “现身”

iOS 自动化有个长期顽疾:部分节点在 AccessibilityPlatformTranslation 下拿不到内容。典型如 UITabBar—— 屏幕底部明明有四个 tab 按钮,遍历 accessibility tree 那个 AXGroup 下面却是空的。WebView、iOS 26 的 Liquid Glass 设计、SwiftUI 拼出来的非标控件也经常出这个问题。

更诡异的是,API 不是完全坏了:同一个元素,accessibilityChildren遍历不到,但objectAtPoint:点得到 —— 给 iOS 一个坐标,它能告诉你这里有什么;让它列出容器有哪些子元素,它就装作不知道。

问题变成:怎么用 hit-test 把遍历不到的元素 “钓” 出来?朴素做法是按 10pt 间距密集打点,一块 400×800 画布就是 3200 次 hit-test,每次都是 XPC 跨进程通信好几毫秒,几十秒起步,完全没法用。

我们用的是自适应四叉树。先把容器 frame 撒一层 160×80 的粗格子,每个格子中心做一次objectAtPoint:;命中的元素记下来、它的 bounding rect 标记为 “已覆盖”;没命中的格子切成四份继续探,单个粗格子最多切 16 次(Phase 1)或 6 次(Phase 2),最小尺寸不低于 14pt。

一个容易想错的细节:seed cell 命中一个小元素后,cell 剩下的部分会被切成最多 4 条矩形(hit 上下加左右),重新 push 回探测队列。hit 锁住的不是整个 seed cell,只是 hit 自己的 bounding rect—— 周围可能漏掉的兄弟元素仍有机会被探到。代码里叫 “opportunistic remainder subdivide”。

漏元素分两种情况走两个 phase:Phase 1 是空容器复原(父节点 children 为空但视觉上不空,整块 frame 直接喂四叉树);Phase 2 是盲区扫描(父节点有 children 但只覆盖一部分,把容器 frame 减去已知元素覆盖的 “盲矩形” 再走一遍)。Phase 2 用了水平条带切割 + 矩形减法的取巧几何技巧,比通用多边形减法好写,对 mobile UI 这种 “元素几乎都是矩形且大致水平排列” 的输入分布非常合适。

几个工程优化直接决定了它能不能跑得动:

  • 种子用矩形不用正方形。mobile UI 元素几乎都是横长条(nav link、标题、列表行)。早期用正方形 seed 效率很低,改成 160×80 后,LINE News 页面探针次数和 wall time 下降 20%,seed cell 从 45 降到 27,典型复杂页面总耗时缩短约 30%。
  • XPC 调用前覆盖剪枝。维护 CoveredSet,seed cell 中心点落在已知元素范围内(带 2pt 松动)直接跳过,毕竟 XPC 是最贵的一段。
  • --min-cell-size从 20 降到 14。代价是中位数延迟从~470ms 涨到~520ms(+11%),但 status bar 的 SSID 图标、tab bar 的小 badge、设置页的小箭头都能被识别了。Agent 经常要点这些小图标,这 50ms 值。
  • 全局去重。UIKit 会把同一个容器藏在多个兄弟 AXGroup 里,各自探针下去会命中同一组真实元素。去重逻辑必须从单次探针提升到 traversal 全局,一个 SeenIdentitySet 贯穿整个 walk。最终代码只有 8 行,但该改哪 8 行,只能靠实际跑、反复试。

决策三:Daemon 架构 —— 让 200ms 消失

CLI 每次调用都是新进程,所有 “重” 初始化得重做。iOS 侧是 simulator 框架 + accessibility 子系统初始化,稳定占~200ms;Android 侧是 BridgeClient 启动 + auth token 缓存 + adb forward 端口准备,~150ms。单看不显眼,Agent 一个验证循环调几十次,冷启动开销迅速变成主导成本。

解法是为每个设备起一个常驻 daemon,host 上跑 Unix-domain socket,客户端命令直接走 socket。第一次调用 fork-exec 出 daemon 等 socket 起来,之后所有命令走 hot path,重初始化只付一次。空闲 600s 自动退出。

效果:iOS 每次sim-use ui省~200ms;Android 把每次调用从~150ms 压到~10ms。Android 改善幅度大,是因为 BridgeClient 那套初始化本来就比普通 adb 命令重得多。

但 daemon 带来三个正确性问题,加速容易,让加速后的系统在所有边界下还正确才是费功夫的部分。

第一,二进制升级后 daemon 还在跑旧逻辑。用户刚 brew upgrade,下一次调用命中旧 daemon,行为对不上、bug 复现不出来。解法是每次调用前发_ping对比版本,不一致就 shutdown 让 invoke 重新 spawn。Ping 本身~0.4ms,对~280ms 的 ui 调用几乎免费。

第二,模拟器在 daemon 不知情时被关了xcrun simctl shutdown或用户直接 quit Simulator.app,daemon 还握着 FBSimulator handle,下一次 verb 就 crash。我们让 daemon 自己检测 simulator 状态,发现宕了就主动退出并返回 staleSimulator 错误。细节是 iOS 在不同状态下吐的底层错误字符串不一样(关机、未启动、启动中各有说法),全部识别包装成统一可恢复错误,让 Agent 拿到错误时知道下一步该做什么 —— 比如自动尝试重启模拟器,而不是把一串原始 NSError 扔回给用户。

第三,stdin 输入跟 daemon 的 “stdin 是 /dev/null” 冲突sim-use ios type --stdin需要从用户终端读输入,但 daemon 的 stdin 早就指向 /dev/null。修法是在命令上声明daemonBypass,让这类命令直接走 in-process 路径绕过 daemon。不是所有命令都适合走 daemon,必须留一条逃生通道。

决策四:跨平台设计 —— 一套命令,两个世界

sim-use 一开始只支持 iOS Simulator,跑稳了才加 Android 后端。两边底层完全不同(iOS 走 FBSimulatorControl,Android 走 bridge APK+adb forward),但目标是命令面看起来一样 ——Agent 不需要为不同平台学两套 API,跨端实践能共享沉淀。

最关键的决定很简单:让命令自己判断设备类型,用户和 Agent 默认都不需要--platformPlatformRouter 用三条规则判设备编号:iOS Simulator 是 8-4-4-4-12 的标准 UUID;Android 模拟器是emulator-开头;Android 真机是 4-32 字符 ASCII serial 且必须包含数字。

第三条 “必须包含数字” 是防御性的。--device foo这种笔误如果走 Android 路径,会等adb -s foo超时 5 秒才报错;走 iOS 路径能立刻报更清楚的错误。让错误尽早、尽具体地暴露,是 Agent CLI 设计里一条值得贯彻的原则——Agent 处理一次模糊错误的代价远高于人,一次失败工具调用可能触发完整 LLM 推理、几千 token 上下文消耗。

Android 后端跟 iOS 命令面 1:1 对齐后,我们做了一件不太常见的事:主动从顶层命令面删掉 5 个 iOS-only 命令(key、key-combo、key-sequence、stream-video、batch)。它们仍存在,但只能通过sim-use ios <verb>调用,不再出现在顶层--help里。老命令保留兼容,但 exit 64 并附迁移提示。

理由:sim-use --help列出来的,应该都是两个平台真的都能用的东西。用户读到 tap、type、swipe,应该可以放心写跨平台脚本。这是一个反直觉的决定 —— 多数项目把 “向后兼容” 当第一价值,命令面慢慢膨胀。但对给 Agent 用的工具来说,“命令面诚实” 比 “命令面稳定” 更重要。Agent 不会带着对历史命名的怀念去使用工具,它只会被那些 “看起来应该能做、实际却做不到” 的命令坑到 —— 每一个都是一次额外错误处理、一次额外 fallback prompt、一次本可避免的 token 消耗。

决策五:多指触控 —— 没法被规划,只能贴着一线磨

给 sim-use 加 “两指捏合” 看起来不算大需求,但开发过程最难忘。起点是 idb 一个 2020 年开的 issue,躺了六年。Meta 自家试过 “5 参数调用 + 手动 patch 报文字节” 的路线,能用但脆 —— 任何一次 SimulatorKit 二进制变动都可能让 hardcoded 偏移失效。

转机来自 SimulatorKit.framework 里一个叫IndigoHIDMessageForMouseNSEvent的私有符号。idb 上游用它 5 参数形式,单指够用。但用 9 参数形式调用同一个符号,会生成真正的 two-payload Indigo packet,两个 finger slot 的 state bits 都被正确初始化 —— 不需要任何硬编码字节偏移,同一份 patch 在 iOS 18.6 和 iOS 26.2 上都直接跑通。

但真正难的不是 “让 iOS 认两个手指”,而是 “让 iOS 认对手势”,三个意外把我们教育了一遍:

第一,iOS 不数事件。以为得在 Down 和 Up 之间塞很多 Move 才会被识别成连续手势,结果 steps=1—— 一次 Down、一次 Move、一次 Up——iOS 照样当成完整拖动。手势识别器认的是 finger identifier 的连续性,不是事件数量。这直接简化了顶层 API:一个原语足够,不需要为不同手势构造不同复杂度的事件流。

第二,start 和 end 设成同一个点,不是 no-op,而是一次双指 tap。Maps 真的会接着这一下缩小一级。这意味着 pinch、rotate、two-finger tap、two-finger long-press 四个命令,本质是同一个 multi-touch 原语在不同 duration 和 endpoint 几何下的特例。CLI 里最终也是这样组织的 —— 一个底层原语,几个 preset。

第三,rotate 必须沿圆弧插值,卡了我们两天。直线插值转到 90° 时两指中点距离收缩到 71%,转到 180° 直接收缩到 0。UIRotationGestureRecognizer 看到中点距离持续变化,会误识别为混入的 pinch 手势。几何上一个看起来无所谓的简化(直线 vs 弧线),到了识别器面前就是是非题。

接入产品时又被识别器教育两次:rotate 默认 270°/0.5s 跑出来 iOS 跟到~360°(识别器自带惯性),Android 反过来跟到~210°(dispatchGesture 受帧率限制),两边 “舒服速度” 基本重合在~180°/s,最终改成根据角度自适应 duration 让角速度恒定。另一个是--radius默认 80 在 iOS 上是 20% 屏宽,切到 1080+px Android 上变成 7% 屏宽,低于 rotate 阈值静默失败 —— 没有任何错误,只是看上去没反应。修法一行max(80, min(w, h) * 0.15),但这种 bug 不在两台设备上分别跑过根本注意不到。

和前几个决策不同:outline 是产品决策,四叉树是工程兜底,daemon 是性能问题 —— 它们都是 “被规划出来的”。多指触控这些收尾工作没法被规划,只能贴着一线实际使用一点点磨出来。

结论:验证速度决定 Agent 开发的真实上限

sim-use 不是新点子,它站在 axe、Appium、idb、Maestro 等前人的肩上,但做了几件具体的事:第一目标是 “让 Agent 高效消化 UI” 而不是 “让脚本能跑”,outline DSL 是直接结果;承认 iOS/Android 的 accessibility API 都不完整,用几何和 hit-test 算法把缺口补上;跨平台用统一命令面 + 平台特定实现 +”surface honesty” 纪律保证 Agent 真能写出跨平台脚本;足够轻量,一个 7MB binary 加几百行 SKILL.md 就能补上 Agent 开发的最后一块拼图。

给正在做 mobile+AI 的团队三条落地建议:

  1. 先解决 “看见” 再解决 “操作”。没有紧凑、稳定、字节可 diff 的 UI 表示,Agent 的验证循环会被上下文噪音拖垮,后续所有操作优化都是空中楼阁。
  2. 为 Agent 设计 CLI 时,把 “错误暴露速度” 和 “命令面诚实” 当成一等指标。一次模糊错误对 Agent 的代价远高于对人,不要用面向人类用户的向后兼容惯性来设计 Agent 工具。
  3. 性能优化的收尾工作比优化本身更耗时。daemon、缓存、常驻进程这类加速手段引入的边界 case(版本不一致、资源失效、stdin 冲突)才是真正的工程成本,预算要留足。

Agent 写代码的速度,本质上被它能多快验证自己写的代码所限制。在移动端这件事上,这个限制至少今天还是真实存在的。如果你也在做移动端 + AI,2026 年 AI 智能体落地避坑这件事,值得从验证闭环开始认真对待。

效率龙虾 会带着下面这段开聊

按文章《AI Agent 写移动端代码总卡在验证?sim-use 五大技术决策深度拆解》把卡点收成可执行步骤:先做什么、别踩哪条、怎么验证。

用效率龙虾试这篇

本文侧重全链路风控方法论。落地时请用自身业务单据做回放验证,不要把示例阈值直接当生产策略。 相关:风控体检 · 方案资源

常见问题 FAQ

什么是sim-use中的Outline?

Outline是sim-use为AI Agent设计的紧凑文本DSL,用于压缩屏幕信息。它把accessibility tree的JSON转成几百token的文本,比如LINE新闻页从24KB压到1.2KB,避免Agent上下文爆炸。设计原则包括字节级稳定、简单selector如@N、克制不发明语义,以及跨平台适配。这让Agent快速diff界面变化,提高验证效率。

为什么移动端验证比Web更难?

移动端是黑盒,没有像Web的DOM那样结构化的元素树,截图识别慢且贵,dump UI tree又token爆炸。Agent无法自己看界面、点按钮,验证循环只能靠人肉截图反馈,拉回低效循环。而Web有Playwright等工具,DOM可遍历,验证闭环短。sim-use通过outline和selector解决这个问题。

如何用sim-use让Agent看到并操作屏幕?

Agent用'sim-use ui'命令获取屏幕outline文本,比如输出@10表示第10个元素。然后通过'sim-use tap "@10"'点击元素,或'sim-use type "hello"'输入文本。典型循环是先获取outline、操作、再获取断言。iOS通过idb连模拟器,Android用bridge APK,都支持这套命令。

四叉树探针解决sim-use的什么问题?

iOS上有些元素如UITabBar在accessibility tree中拿不到,API遍历不到但objectAtPoint:点得到。四叉树探针就是让这些'隐形'元素现身,确保Agent能完整感知屏幕。它补充了accessibility API的不足,避免验证时元素遗漏,提高可靠性。

sim-use如何跨平台支持iOS和Android?

sim-use用同一套CLI命令、selector和JSON输出工作在两个平台。iOS侧通过Facebook的idb和CoreSimulator的accessibility树,Android侧用AccessibilityService的bridge APK。决策四强调不强行对齐平台差异,比如排序方式不同,但Agent看到相同接口,简化开发。

为什么验证速度决定Agent开发上限?

因为代码产出被Agent加速后,验证环节成了最慢的一环。如果Agent不能快速、可靠地验证自己写的代码,比如点按钮、断言结果,开发者就得手动循环。sim-use通过优化验证速度,如200ms延迟的daemon架构,让Agent真正接管验证,提高整体开发效率。