老胡茶室
老胡茶室

我 vibe coding 了一个 iOS 调试工具,让 Claude Code 自己去操作 App

胡键

一句随口的「何不试试」,最后变成了原生 iOS 版的 Playwright——而且几乎全是 Claude Code 写的。

1. 为什么要做这个东西

先说说那个让我受够了的场景。

我在用 Claude Code 写一个 iOS App。它改一个界面,然后呢?然后就轮到我了——我得拿起手机,在 App 里一通点,盯着结果看,再把我看到的东西用人话描述回去给它。

说白了,Claude 是在蒙着眼睛写代码。Swift 它能写一整天,可它既没有眼睛看正在跑的 App,也没有手去碰它。眼睛和手,都长在我身上。这活儿干久了你就会想:不对啊,这不科学。

灵感是从 Web 那边来的。你看 Claude 接上 Playwright 之后是怎么干活的——打开页面、点击、截图、读页面、改、再来一遍,自己就把结果验了,全程不用人插手。那「何不试试」就顺理成章了:让它对我的 iOS App 也来这么一套。 截图、看屏幕、点按钮、读内部状态。这几件事一旦能做,回路就闭合了。我也就不用再当那个在手机和模型之间来回传话的人肉中继。

于是我把这想法甩给 Claude Code,俩人(算它一个吧)一起捋。Web 有 Playwright;iOS 这边有 XCUITest——但那玩意儿又重、又是个独立的测试运行器,一个 agent 在对话中间根本没法随手够到它。

关键就在这儿:我要的是一个没有测试框架的 Playwright。 不要测试 target,不要 XCTest 那套配置,不要「构建 + 跑整套用例」的循环。我只要一座桥——通往已经在我手机上跑着的那个调试构建,Claude 随时能调。

最后造出来的就是这三样:App 里一个微型 HTTP 服务器(只在调试构建里有)、Mac 上一个把它包成工具的 MCP 服务器,中间一条 USB 隧道。Agent 拿到了眼睛和手;正式版 App 呢?一点痕迹都不带。

顺带还把另一半的活也办了——收集调试数据。 截图、实时状态快照、甚至一段操作流程的录屏,Claude 要用的时候自己去拿,不用我一张张手动抓了再粘进对话框。它推理 bug 要的料,现在直接从源头来。

2. 它是怎么设计的

先看全局

整个工具,就是四个小部件串成的一条链:Claude Code 调 MCP 工具 → Mac 上的 MCP 服务器把调用变成 HTTP 请求 → iproxy 走 USB 把请求送到手机 → App 里的微型 HTTP 服务器干实际的活,再原路应答。

flowchart LR
    CC["Claude Code"]
    MCP["MCP 服务器
app-debug-mcp.py
(Mac)"] Proxy["iproxy
USB 隧道"] App["iOS App
AppDebugServer
(#if DEBUG)"] CC <-->|"MCP, stdio"| MCP MCP <-->|"HTTP 127.0.0.1:9876"| Proxy Proxy <-->|"USB"| App

每一段连接都是刻意做简单的——清一色标准协议,中间不夹任何自己造的轮子:

部件在哪干啥
Claude Code对话里决定做什么;调 MCP 工具
app-debug-mcp.pyMac 上一个 FastMCP 服务器;把每条 HTTP 路由包成 MCP 工具
iproxy终端走 USB 把 Mac 的 9876 端口转发到设备的 9876 端口
AppDebugServerApp 内(#if DEBUG)负责截图、执行动作、上报状态、录屏的 HTTP 服务器

你可能会问:费这劲搞条 USB 隧道干嘛? 因为在 iOS 上 NWListener 只能绑回环地址(127.0.0.1),App 的服务器在网络上压根够不着。iproxy 就是来填这条缝的——把 Mac 一侧的端口直接怼到设备上。

App 内部:那个调试服务器

设备上真正要紧的就 AppDebugServer 一个,而且它一直保持得很小。NWListener 收一个连接,请求手工解析,路由器按方法和路径分发,每条路由抵达一项能力。就这么点东西。

flowchart TB
    Listener["NWListener · 127.0.0.1:9876
(手工解析 HTTP)"] Router["路由器(方法 + 路径)"] Listener --> Router Router --> R1["GET /screenshot"] Router --> R2["GET /actions"] Router --> R3["POST /activate"] Router --> R4["GET /state"] Router --> R5["POST /record/start · stop"] R1 --> Window["主窗口
UIGraphicsImageRenderer → PNG"] R2 --> Registry["AppDebugActionRegistry"] R3 --> Registry R4 --> Provider["状态提供者
Encodable → JSON"] R5 --> Replay["ReplayKit + AVAssetWriter → MP4"] Views["SwiftUI 视图 · .debugAction"] -. "出现时注册
消失时注销" .-> Registry

六条路由,一条对一个 MCP 工具:

MCP 工具路由返回
screenshotGET /screenshot当前屏幕的 PNG
list_actionsGET /actions当前屏幕上已注册的标识符
activatePOST /activate按标识符执行已注册的闭包
app_stateGET /stateApp 实时状态的 JSON 快照
record_startPOST /record/start开始一段 ReplayKit 录制
record_stopPOST /record/stop结束并返回 MP4

几处稍微硬一点的角落,多说两句:

  • 服务器极简。 HTTP 自己手撸——读到 \r\n\r\n,抠出方法、路径、Content-Length,再读 body。HTTP 库?不用,Network.framework 就够了。所有东西都跑在 @MainActor 上,因为截图和读 SwiftUI 状态都得在主线程。
  • 截图UIGraphicsImageRenderer 渲染当前窗口;Mac 那头的桥把 PNG 做 base64 编码,这样 Claude 才是真·看到了屏幕。状态来自 App 的提供者,把所有非 Codable 的类型摊平成干净 JSON。
  • 录屏是唯一一处真有点磨人的地方,而且坑在 Swift 6 的并发上。ReplayKit 在后台队列触发回调,服务器却活在 @MainActor。这俩要是直接对上——那些闭包一旦继承了主 actor 隔离,运行时当场就崩给你看。所以回调工厂得标 nonisolated,每一帧塞进一个 @unchecked Sendable 的盒子送回主 actor,再让 AVAssetWriter 把帧缝成 H.264。

这种切分才是重点:这段 App 内代码是跟具体 App 无关的——只 import 系统框架(NetworkUIKitAVFoundationReplayKit)。唯一跟你 App 有关的,就俩:视图注册的动作闭包,和一个返回 Encodable 快照的小状态提供者。所以这整套东西,你换个 iOS App 直接塞进去就能用。

用注册表,别用坐标

activate 到底怎么点中一个东西的? 第一反应肯定是按坐标 (x, y) 点——window.hitTestaccessibilityActivate()。这套留着当兜底了。但你别指望它——没设无障碍特性的 SwiftUI 手势视图根本不鸟你,坐标还会随设备和布局到处漂。靠它?迟早翻车。

真正立得住的是注册表。每个界面出现时调 .debugAction("header.settings") { ... },消失时移除。activate 查到标识符,直接调闭包——不命中测试,不本地化,不算那些脆得要命的几何坐标。因为注册跟着视图生命周期走,可用的那批动作会随界面进进出出而变——所以节奏永远是 list_actionsactivate:先问「现在有啥」,再动手。这些标识符就是自动化的契约;面向用户的标签会被本地化,所以绝不拿来当 key。

整个回路浓缩成一步,就长这样:

sequenceDiagram
    participant CC as Claude Code
    participant MCP as app-debug (Mac)
    participant Proxy as iproxy (USB)
    participant App as iOS App (AppDebugServer)

    CC->>MCP: list_actions()
    MCP->>Proxy: GET /actions
    Proxy->>App: GET /actions
    App-->>CC: ["header.settings", "tab.history", ...]

    CC->>MCP: activate("header.settings")
    MCP->>Proxy: POST /activate {identifier}
    Proxy->>App: POST /activate
    App->>App: AppDebugActionRegistry → 执行 @MainActor 闭包
    App-->>CC: {"ok": true}

    CC->>MCP: screenshot()
    MCP->>Proxy: GET /screenshot
    Proxy->>App: GET /screenshot
    App-->>CC: PNG 字节(Claude 看到图像)

实现:拆成小块,扔出去

这些部件,单拎出来哪个都不大——一个手写 HTTP 循环、一个字典撑起来的注册表、一个视图修饰符、一条录制管线、一个 Python 桥。这是故意的。架构一旦定了,每一块都小到、职责清晰到可以直接扔给 Claude,一块接一块从头做到尾。

至于这「扔出去」实际长啥样、什么活儿最后还是落我头上——留到最后一节说。

3. 怎么用

只在调试时存在

iOS 这边每一行都待在 #if DEBUG 里头——服务器、提供者,还有 App 入口处那句 start():

#if DEBUG
let provider = MyDebugStateProvider(/* 你的 coordinator */)
let server = AppDebugServer(port: 9876, stateProvider: provider)
Task { @MainActor in server.start() }
#endif

Release 构建?一行都不编译:没服务器、没开着的端口、啥都不留。(iOS 的 NWListener 只能绑回环地址——这也正是 USB 隧道省不掉的原因。)

MCP 配置

项目级,写 .mcp.json 里:

{
  "mcpServers": {
    "app-debug": {
      "type": "stdio",
      "command": "uv",
      "args": ["run", "--script", "tools/app-debug-mcp.py"],
      "env": { "APP_DEBUG_OUTPUT": "debug-output" }
    }
  }
}

依赖是内联声明的,所以 uv 第一次跑就把 mcprequests 拉进一个隔离 venv——pip install 那一步省了。输出落在 debug-output/

每次开工

  1. 拿一台 USB 连着、已信任的设备,跑调试构建。
  2. 终端里 iproxy 9876 9876(libimobiledevice 自带)。
  3. 让 Claude 去操作:list_actionsactivate,再用 screenshotapp_state 看结果。值得留档的流程,用 record_startrecord_stop 包起来(记得等 {"ok": true}——iOS 会先弹录制授权框)。

对了,Xcode 里打的断点会暂停进程,所以进行中的调用会一直超时到你恢复执行为止。这是正常的,别慌。

4. 最后唠两句

整个工具,起点就是一句「何不试试」。然后 Claude 把里头几乎所有东西都写了——HTTP 服务器、注册表、ReplayKit 管线,连并发那些坑都算上——这期间我基本就在读 diff。一个能用的调试工具,一个下午,搞定。

所以结论很直接:同样这个思路,你也能给自己整一个——你的 App、你的技术栈,花不了几个力气。

但话别听岔了。「Claude 写的」不等于「Claude 一个人搞定的」。它写代码,剩下的我来——而恰恰是这「剩下的」,决定了这东西最后是好是烂:

  • 想法。 Claude 可从来没主动说要给自己装眼睛和手——是我把目标往那儿一指,它才把这俩造了出来。看见这种可能性的,是人。
  • 决策。 用注册表不用坐标点击,只在调试里编译不另起测试 target,薄 HTTP 桥不上重框架。Claude 把取舍一条条摆给你看;拍板的,是我。
  • 审阅。 我读设计,觉得不对劲的地方就怼回去,一路顶到它站得住为止。Claude 又快又能干,但它不是永远对——总得有个人盯着,让它别飘。

这就是 vibe coding 顺手时该有的样子:不是把脑子交出去,而是动得更多——想清楚做什么、走哪条路、结果到底对不对。代码这活儿,现在归 Claude 了。判断,还是你的——而且比过去任何时候都更值钱。

精品内容