一句随口的「何不试试」,最后变成了原生 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.py | Mac 上 | 一个 FastMCP 服务器;把每条 HTTP 路由包成 MCP 工具 |
iproxy | 终端 | 走 USB 把 Mac 的 9876 端口转发到设备的 9876 端口 |
AppDebugServer | App 内(#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 工具 | 路由 | 返回 |
|---|---|---|
screenshot | GET /screenshot | 当前屏幕的 PNG |
list_actions | GET /actions | 当前屏幕上已注册的标识符 |
activate | POST /activate | 按标识符执行已注册的闭包 |
app_state | GET /state | App 实时状态的 JSON 快照 |
record_start | POST /record/start | 开始一段 ReplayKit 录制 |
record_stop | POST /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 系统框架(Network、UIKit、AVFoundation、ReplayKit)。唯一跟你 App 有关的,就俩:视图注册的动作闭包,和一个返回 Encodable 快照的小状态提供者。所以这整套东西,你换个 iOS App 直接塞进去就能用。
用注册表,别用坐标
activate 到底怎么点中一个东西的? 第一反应肯定是按坐标 (x, y) 点——window.hitTest 加 accessibilityActivate()。这套留着当兜底了。但你别指望它——没设无障碍特性的 SwiftUI 手势视图根本不鸟你,坐标还会随设备和布局到处漂。靠它?迟早翻车。
真正立得住的是注册表。每个界面出现时调 .debugAction("header.settings") { ... },消失时移除。activate 查到标识符,直接调闭包——不命中测试,不本地化,不算那些脆得要命的几何坐标。因为注册跟着视图生命周期走,可用的那批动作会随界面进进出出而变——所以节奏永远是 list_actions → activate:先问「现在有啥」,再动手。这些标识符就是自动化的契约;面向用户的标签会被本地化,所以绝不拿来当 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 第一次跑就把 mcp 和 requests 拉进一个隔离 venv——pip install 那一步省了。输出落在 debug-output/。
每次开工
- 拿一台 USB 连着、已信任的设备,跑调试构建。
- 终端里
iproxy 9876 9876(libimobiledevice自带)。 - 让 Claude 去操作:
list_actions→activate,再用screenshot或app_state看结果。值得留档的流程,用record_start…record_stop包起来(记得等{"ok": true}——iOS 会先弹录制授权框)。
对了,Xcode 里打的断点会暂停进程,所以进行中的调用会一直超时到你恢复执行为止。这是正常的,别慌。
4. 最后唠两句
整个工具,起点就是一句「何不试试」。然后 Claude 把里头几乎所有东西都写了——HTTP 服务器、注册表、ReplayKit 管线,连并发那些坑都算上——这期间我基本就在读 diff。一个能用的调试工具,一个下午,搞定。
所以结论很直接:同样这个思路,你也能给自己整一个——你的 App、你的技术栈,花不了几个力气。
但话别听岔了。「Claude 写的」不等于「Claude 一个人搞定的」。它写代码,剩下的我来——而恰恰是这「剩下的」,决定了这东西最后是好是烂:
- 想法。 Claude 可从来没主动说要给自己装眼睛和手——是我把目标往那儿一指,它才把这俩造了出来。看见这种可能性的,是人。
- 决策。 用注册表不用坐标点击,只在调试里编译不另起测试 target,薄 HTTP 桥不上重框架。Claude 把取舍一条条摆给你看;拍板的,是我。
- 审阅。 我读设计,觉得不对劲的地方就怼回去,一路顶到它站得住为止。Claude 又快又能干,但它不是永远对——总得有个人盯着,让它别飘。
这就是 vibe coding 顺手时该有的样子:不是把脑子交出去,而是动得更多——想清楚做什么、走哪条路、结果到底对不对。代码这活儿,现在归 Claude 了。判断,还是你的——而且比过去任何时候都更值钱。