一句随口的「何不试试」,最后变成了原生 iOS 版的 Playwright——而且几乎全是 Claude Code 写的。
先说说那个让我受够了的场景。
我在用 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 要的料,现在直接从源头来。
整个工具,就是四个小部件串成的一条链:Claude Code 调 MCP 工具 → Mac 上的 MCP 服务器把调用变成 HTTP 请求 → iproxy 走 USB 把请求送到手机 → App 里的微型 HTTP 服务器干实际的活,再原路应答。

每一段连接都是刻意做简单的——清一色标准协议,中间不夹任何自己造的轮子:
| Claude Code | ||
app-debug-mcp.py | FastMCP 服务器;把每条 HTTP 路由包成 MCP 工具 | |
iproxy | ||
AppDebugServer | #if DEBUG) |
你可能会问:费这劲搞条 USB 隧道干嘛? 因为在 iOS 上 NWListener 只能绑回环地址(127.0.0.1),App 的服务器在网络上压根够不着。iproxy 就是来填这条缝的——把 Mac 一侧的端口直接怼到设备上。
设备上真正要紧的就 AppDebugServer 一个,而且它一直保持得很小。NWListener 收一个连接,请求手工解析,路由器按方法和路径分发,每条路由抵达一项能力。就这么点东西。

六条路由,一条对一个 MCP 工具:
screenshot | GET /screenshot | |
list_actions | GET /actions | |
activate | POST /activate | |
app_state | GET /state | |
record_start | POST /record/start | |
record_stop | POST /record/stop |
几处稍微硬一点的角落,多说两句:
\r\n\r\n,抠出方法、路径、Content-Length,再读 body。HTTP 库?不用,Network.framework 就够了。所有东西都跑在 @MainActor 上,因为截图和读 SwiftUI 状态都得在主线程。UIGraphicsImageRenderer 渲染当前窗口;Mac 那头的桥把 PNG 做 base64 编码,这样 Claude 才是真·看到了屏幕。状态来自 App 的提供者,把所有非 Codable 的类型摊平成干净 JSON。@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。
整个回路浓缩成一步,就长这样:

这些部件,单拎出来哪个都不大——一个手写 HTTP 循环、一个字典撑起来的注册表、一个视图修饰符、一条录制管线、一个 Python 桥。这是故意的。架构一旦定了,每一块都小到、职责清晰到可以直接扔给 Claude,一块接一块从头做到尾。
至于这「扔出去」实际长啥样、什么活儿最后还是落我头上——留到最后一节说。
iOS 这边每一行都待在 #if DEBUG 里头——服务器、提供者,还有 App 入口处那句 start():
ounter(lineounter(lineounter(lineounter(lineounter(line#if DEBUGlet provider = MyDebugStateProvider(/* 你的 coordinator */)let server = AppDebugServer(port: 9876, stateProvider: provider)Task { @MainActor in server.start() }#endif
Release 构建?一行都不编译:没服务器、没开着的端口、啥都不留。(iOS 的 NWListener 只能绑回环地址——这也正是 USB 隧道省不掉的原因。)
项目级,写 .mcp.json 里:
ounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(lineounter(line{"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/。
iproxy 9876 9876(libimobiledevice 自带)。list_actions → activate,再用 screenshot 或 app_state 看结果。值得留档的流程,用 record_start … record_stop 包起来(记得等 {"ok": true}——iOS 会先弹录制授权框)。对了,Xcode 里打的断点会暂停进程,所以进行中的调用会一直超时到你恢复执行为止。这是正常的,别慌。
整个工具,起点就是一句「何不试试」。然后 Claude 把里头几乎所有东西都写了——HTTP 服务器、注册表、ReplayKit 管线,连并发那些坑都算上——这期间我基本就在读 diff。一个能用的调试工具,一个下午,搞定。
所以结论很直接:同样这个思路,你也能给自己整一个——你的 App、你的技术栈,花不了几个力气。
但话别听岔了。「Claude 写的」不等于「Claude 一个人搞定的」。它写代码,剩下的我来——而恰恰是这「剩下的」,决定了这东西最后是好是烂:
这就是 vibe coding 顺手时该有的样子:不是把脑子交出去,而是动得更多——想清楚做什么、走哪条路、结果到底对不对。代码这活儿,现在归 Claude 了。判断,还是你的——而且比过去任何时候都更值钱。