Baguette架构解析:App/Domain/Infrastructure三层设计如何让私有API代码可测试
【免费下载链接】baguetteHeadless control for Apple's Simulators — 3D models, taps, swipes, multi-finger gestures, 60 fps streaming, and a multi-device farm项目地址: https://gitcode.com/gh_mirrors/baguette/baguette
Baguette 是一款针对 Apple iOS 模拟器的无头控制工具(headless control),支持 3D 设备模型、点击滑动、多指手势、60fps 屏幕流和多设备设备农场。它大量调用 Apple 未公开的私有 API(如 SimulatorKit 的 IndigoHID 管线),而这类代码通常是单元测试的重灾区——没法 mock、没法离线跑、换个 Xcode 版本就崩。本文完整拆解 Baguette 的 App / Domain / Infrastructure 三层架构,带你快速看懂它是如何用不到 20 行接口定义,把私有 API 代码变成"几秒跑完、无需启动模拟器"的纯测试。
为什么私有 API 代码很难测试?
先理解痛点。Baguette 的核心能力——在已启动的 iOS 26 模拟器里注入真实触摸——依赖 Xcode 26 中一个 9 参数签名的私有函数IndigoHIDMessageForMouseNSEvent,它通过 ObjC 运行时 +dlsym动态解析符号,而不是编译期链接。这意味着:
- 不能
import:Package.swift 刻意不链接 SimulatorKit / CoreSimulator,避免LC_LOAD_DYLIB在main()前就因 Xcode 路径不同而失败; - 不能起真模拟器:每次启动要数秒,CI 上还要抢 Xcode 授权;
- 线程敏感:该函数读取 AppKit 的线程局部状态,从 NIO 事件循环线程调用会构建出畸形消息并被模拟器静默丢弃。
如果测试必须驱动真实的模拟器,测试就慢、脆、不可移植。Baguette 的解法不是"想办法把私有调用隔离在某个测试桩里",而是把架构本身按"是否碰私有 API"切开。
三层架构总览:依赖只能向内
Baguette 的源码位于 Sources/Baguette/,划分为三层(见 docs/ARCHITECTURE.md):
| 层 | 目录 | 职责 | 依赖规则 |
|---|---|---|---|
| App | App/ | CLI 命令分发 + 用例编排 | 可依赖 Domain + Infrastructure |
| Infrastructure | Infrastructure/ | @Mockable协议的具体实现 | 只能依赖 Domain |
| Domain | Domain/ | 纯 Swift 值类型 + 边界协议 | 不依赖任何东西(仅 Foundation/IOSurface) |
依赖方向严格单向:App → Domain + Infrastructure → Domain。所有私有 API 调用都被关在 Infrastructure 一层的"门"后面,这是后面一切可测试性的前提。
三个目录内部还按"限界上下文"(bounded context)二次切分——Simulator/、Input/、Screen/、Stream/、Chrome/等子文件夹在两层之间一一镜像,Tests/BaguetteTests/ 也镜像同样的切分。一个功能在仓库里只有一个"家",找代码和找测试都不用跳来跳去。
Domain 层:纯值类型 +@Mockable协议
Domain 层是架构的心脏,它只回答一个问题:"一次输入长什么样、该产生什么行为?",完全不关心行为由谁执行。
以点击手势为例,Domain/Input/Tap.swift 定义了一个结构体:
struct Tap: Gesture, Equatable { let at: Point; let size: Size; let duration: Double func execute(on input: any Input) -> Bool { input.tap(at: at, size: size, duration: duration, edge: edge) } }Tap是一个值类型:可从 JSON 解析(parse(_:))、可比较(Equatable)、执行时只调用注入的Input抽象。而这个Input抽象本身也定义在 Domain 层——Domain/Input/Input.swift:
@Mockable protocol Input: Sendable { func tap(at point: Point, size: Size, duration: Double, edge: DeviceEdge?) -> Bool func swipe(from start: Point, to end: Point, size: Size, duration: Double) -> Bool // touch1 / touch2 / button / key / scroll / twoFingerPath … }两个细节是精华所在:
- 协议里没有任何 Apple 私有类型。
Point、Size、DeviceEdge全是 Domain 自有的普通类型(Domain/Common/CoordinateTypes.swift)。坐标系是"设备点数"而非私有框架的坐标空间,抽象边界上不存在任何测试环境无法满足的依赖。 @Mockable注解(来自 Package.swift 引入的 Mockable 库)会让编译器在调试构建时自动生成MockInput类,测试直接拿来就用,手写 mock 代码为零。
整个 Domain 层没有一行 ObjC 调用,没有一行dlsym,编译产物甚至可以脱离 Xcode 模拟器环境独立验证。
Infrastructure 层:私有 API 的"适配器"
Infrastructure 层持有全部"脏活"。以输入为例,Infrastructure/Input/IndigoHIDInput.swift 中的IndigoHIDInput实现了 Domain 的Input协议:
final class IndigoHIDInput: Input, @unchecked Sendable { // 通过 ObjC 运行时解析 9 参数版 IndigoHIDMessageForMouseNSEvent, // 将 Domain 的 (at:size:duration:) 翻译成私有 HID 消息并投递 }注意方向:是私有 API 适配 Domain 协议,而不是 Domain 适配私有 API。7 个以上的文件(IOHIDDigitizerDispatch.swift、IndigoHIDMessage.swift 等)共同把字节补丁、digitizer 目标0x32、服务预热这些 iOS 26 细节全部封装在这一层。
同一上下文下其他端口的分工(摘自 docs/ARCHITECTURE.md):
| 上下文 | 端口(Domain) | 具体实现(Infrastructure) | 包裹的私有/系统机制 |
|---|---|---|---|
| Simulator | Simulators | CoreSimulators | CoreSimulator 私有类,经 ObjC 运行时 |
| Screen | Screen | SimulatorKitScreen | SimDevice.io帧缓冲回调 |
| Input | Input | IndigoHIDInput | 9 参数 IndigoHID 管线 |
| Stream | Stream | MJPEGStream/AVCCStream | VideoToolbox 编码 |
| Chrome | Chromes | LiveChromes | 系统 profile.plist + PDF 栅格化 |
App 层:薄编排,两条入口共享一条路径
App 层只放 ArgumentParser 命令和"把请求变成 Domain 调用"的编排代码,本身不含业务逻辑。两条消费入口——stdin 子进程(供宿主编程器调用)和 WebSocket(baguette serve自带 Web UI)——最终汇合到同一个编排器 GestureDispatcher:
stdin JSON / WS 文本帧 │ ▼ GestureDispatcher ──► GestureRegistry.standard.parse(dict) → Tap │ ▼ Tap.execute(on: input) ──► input.tap(...) │ ▼ IndigoHIDInput(唯一碰私有 API 的地方)──► 已启动的 iOS 模拟器GestureDispatcher是纯函数式的:进一行 JSON,出一行 JSON 应答({"ok":true}/{"ok":false,"error":...})。它持有any Input,生产环境注入IndigoHIDInput,测试环境注入MockInput——App 层的逻辑因此一行不用改就能被完整验证。
私有 API 代码是如何被测试的?
这是三层设计的回报兑现处。看 Tests/BaguetteTests/App/GestureDispatcherTests.swift:
@Test func `dispatches a valid tap and returns ok=true`() { let input = MockInput() given(input).tap(at: .any, size: .any, duration: .any, edge: .any) .willReturn(true) let dispatcher = GestureDispatcher(input: input) let ack = dispatcher.dispatch( line: #"{"type":"tap","x":1,"y":2,"width":100,"height":200}"#) #expect(ack == #"{"ok":true}"#) }MockInput是@Mockable宏自动生成的——没有手写 stub,没有启动模拟器,没有 Xcode 授权弹窗。测试断言的是返回的状态(Chicago-school 状态断言),而非交互记录,整个测试套件"几秒内跑完且不需要已启动的模拟器"(docs/ARCHITECTURE.md)。
更妙的是,连 Infrastructure 层自己也能被测试。例如 IndigoHIDTouchTargetTests.swift 直接对IOHIDDigitizerDispatch.patch打的字节缓冲区做断言——IndigoHIDInput构造时注入的DeviceHost同样是@Mockable端口(MockDeviceHost()),所以即便被测对象是"最靠近私有 API 的代码",也不需要一个真实模拟器:
@Test func `IndigoHIDInput defaults touch target to phone digitizer`() { let host = MockDeviceHost() let input = IndigoHIDInput(udid: "ghost", host: host) #expect(input.touchTarget == 0x32) }还有两个工程细节值得新手记住:
- mock 代码不进生产包。Package.swift 中
MOCKING编译标志仅在.debug配置开启,release 构建里完全不含任何 mock 代码; - 分层测试金字塔。纯解析器(
DeviceChrome、GestureRegistry)喂 JSON 断言值;每个手势测"解析 + execute 调对了端口方法";聚合语义(running/available/listJSON)驱动 mock 聚合断言状态;组合型实现(如LiveChromes)则 mock 掉它的协作者(ChromeStore+PDFRasterizer)验证缓存命中/失败路径。
扩展一个新功能要动几处?
这种结构还带来"开闭原则"(OCP)红利,docs/ARCHITECTURE.md 给出的答案很干脆:
- 加一种手势= Domain/Input/ 下一个新的
Gesture结构体 +GestureRegistry.standard里注册一行。调用方(stdin、WebSocket、serve页面)零改动。 - 加一种流格式= Infrastructure/Stream/ 下一个
Stream实现 +StreamFormat.makeStream加一个 case。
新代码全部落在外层,既有逻辑不被触碰——这正是"三层"最实际的收益。
小结:这套模式能抄走什么?
Baguette 的做法可以浓缩成四句话,适用于任何要调用私有/难 mock 依赖(系统框架、C 库、进程间通信)的项目:
- 按"是否触碰难依赖"切层,而不是按技术类型(model/view/controller)切;
- 协议定义在外层(Domain),实现细节关在里层(Infrastructure),且协议签名里不出现任何私有类型;
- 用
@Mockable之类的代码生成消灭手写 stub,mock 只存在于 debug 构建; - 所有入口(CLI、HTTP、WebSocket)汇合到同一个纯函数编排器,让"最上层"也变成纯逻辑可测试。
想深入源码,建议从 docs/ARCHITECTURE.md 读起,配合 docs/commands.md(全部命令行参数)、docs/wire.md(手势线格式)和 docs/serve.md(serve全部路由);手势管线的字节级注释在 Sources/Baguette/Infrastructure/Input/IndigoHIDInput.swift 中写得相当详尽。
【免费下载链接】baguetteHeadless control for Apple's Simulators — 3D models, taps, swipes, multi-finger gestures, 60 fps streaming, and a multi-device farm项目地址: https://gitcode.com/gh_mirrors/baguette/baguette
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考