swift-composable-architecture 实战:用 TicTacToe 示例构建模块化、可测试的完整应用
swift-composable-architecture 实战:用 TicTacToe 示例构建模块化、可测试的完整应用
本篇指南以仓库内 Examples/TicTacToe/README.md 为骨架,剖析该示例如何用 Composable Architecture 构建一个中等复杂度的完整应用:登录 + 两步验证(Two-Factor)、多步骤状态驱动导航、SwiftUI 与 UIKit 双端共享同一套核心逻辑。读完本文,你将掌握如何将功能拆分为 *Core 与 UI 模块、如何用依赖注入完全控制副作用、以及如何用 TestStore 为每个功能编写单元与集成测试。
示例背景与定位
Tic-Tac-Toe(井字棋)示例演示了如何在 Composable Architecture 中构建一个完整、中等复杂度的真实应用。它的设计灵感来源于 Uber 的 RIBs 与 Square 的 Workflows 各自发布的 Tic-Tac-Toe 教程,因此它刻意覆盖了工程化应用中大量真实会遇到的需求与最佳实践,包括:
- 登录流程与两步验证(Two-Factor)界面;
- 从登录页 → 新游戏页 → 对局页的多步骤导航流;
- 覆盖每个功能的完整测试套件,包括多个功能协同工作的集成测试,以及对副作用端到端的测试;
- 完全受控的副作用:每个功能都被提供完成工作所需的全部依赖,这让测试变得非常简单;
- 高度模块化:每个功能都被隔离到独立模块中,模块间依赖最小化,可以不构建整个应用就单独编译、运行某个功能;
- 同时实现了 SwiftUI 与 UIKit 应用,两者共享同一套核心逻辑,证明用 Composable Architecture 编写 UIKit 应用与编写 SwiftUI 应用一样容易;
- 核心逻辑统一放在以
*Core命名的模块中,与包含 UI 的模块分离——这让我们可以跨平台(SwiftUI 与 UIKit)共享代码,甚至可以跨 iOS、macOS、watchOS、tvOS 应用共享; - 导航完全由状态驱动:可以打开 SwiftUI 对局玩几步,关掉弹窗再打开 UIKit 版本,会发现状态完整恢复到了你离开时的位置。
模块化:18 个库、零循环依赖
整个示例是一个独立的 SwiftPM 包,定义在 Examples/TicTacToe/tic-tac-toe/Package.swift 中(swift-tools-version:6.1,平台为 iOS 18,Swift 语言模式 v6)。包内共导出 18 个 library product,每个功能被切分成三种职责不同的模块:
| 模块分类 | 命名规则 | 职责 | 本示例中的模块 |
|---|---|---|---|
| 核心逻辑 | *Core |
Reducer、State、Action、纯业务逻辑,不依赖任何 UI 框架 | AppCore、LoginCore、TwoFactorCore、NewGameCore、GameCore |
| 依赖抽象 | *Client |
通过 @DependencyClient 声明功能所需的外部能力 |
AuthenticationClient |
| 依赖实现 | *ClientLive |
提供真实环境的依赖实现 | AuthenticationClientLive |
| SwiftUI 视图 | *SwiftUI |
只依赖对应的 *Core 与子功能 UI |
LoginSwiftUI、GameSwiftUI 等 |
| UIKit 视图 | *UIKit |
与 *SwiftUI 同构的另一套界面 |
LoginUIKit、GameUIKit 等 |
依赖关系严格单向:Core 不依赖 UI,UI 依赖 Core,UI 内部再按功能父子关系组合。以 AppCore 的依赖为例,AppCore 只依赖 AuthenticationClient、LoginCore、NewGameCore 和 ComposableArchitecture;而 LoginCore 又依赖 AuthenticationClient、TwoFactorCore。由于每个模块只暴露最小接口,包中的 5 个测试 target(AppCoreTests、GameCoreTests、LoginCoreTests、NewGameCoreTests、TwoFactorCoreTests)也都只依赖各自对应的 Core 模块,测试时无需编译任何 UI 代码。
值得一提的是 Three.swift(见 GameCore/Three.swift):它用 Swift 标准协议实现了一个固定大小为 3 的集合(MutableCollection + RandomAccessCollection),提供 map、下标读写、Equatable/Hashable/Sendable 条件遵循。棋盘的 3×3 网格因此可以天然表示为 Three<Three<Player?>>,把"集合"能力直接建立在类型系统上,而不是用数组加边界检查。
顶层组合:AppCore 的状态机式导航
应用的最顶层是 AppCore/AppCore.swift 中的 TicTacToe 枚举 Reducer。它用 @Reducer 宏配合 enum 声明两个子状态,形成一棵"登录 或 新游戏"的导航树:
@Reducer
public enum TicTacToe {
case login(Login)
case newGame(NewGame)
public static var body: some ReducerOf<Self> {
Reduce { state, action in
switch action {
case .login(.twoFactor(.presented(.twoFactorResponse(.success)))):
state = .newGame(NewGame.State())
return .none
case .login(.loginResponse(.success(let response))) where !response.twoFactorRequired:
state = .newGame(NewGame.State())
return .none
case .newGame(.logoutButtonTapped):
state = .login(Login.State())
return .none
case .login, .newGame:
return .none
}
}
.ifCaseLet(\.login, action: \.login) { Login() }
.ifCaseLet(\.newGame, action: \.newGame) { NewGame() }
}
}
这段代码集中体现了 Composable Architecture 的几个核心机制:
- 枚举 Reducer +
ifCaseLet:状态是一个枚举,ifCaseLet(\.login, action: \.login)把子 ReducerLogin与\.login这个 case 绑定,动作会自动按路径路由到对应子功能; - 跨层级订阅动作:顶层可以直接匹配
\.login.twoFactor.presented.twoFactorResponse.success这类深层动作,完成"两步验证通过 → 切换到新游戏"的状态机转移;.loginResponse(.success)且!twoFactorRequired则走"免两步验证直接进入"的路径; - 状态即导航:整个流程(登录 → 新游戏 → 对局,以及退出回到登录)都由状态枚举值驱动,没有命令式的跳转调用。
根应用同时被 SwiftUI 与 UIKit 复用:AppSwiftUI 与 AppUIKit 两个模块都只依赖 AppCore,分别渲染同一棵状态树。
依赖注入:完全受控的副作用
"完全受控的副作用"是这个示例反复强调的设计目标。以认证为例,AuthenticationClient/AuthenticationClient.swift 用 @DependencyClient 宏把依赖声明为纯接口:
@DependencyClient
public struct AuthenticationClient: Sendable {
public var login:
@Sendable (_ email: String, _ password: String) async throws -> AuthenticationResponse
public var twoFactor:
@Sendable (_ code: String, _ token: String) async throws -> AuthenticationResponse
}
同时声明 TestDependencyKey(testValue 为空实现)并把它注册进 DependencyValues。这样,Reducer 侧只需一行 @Dependency(\.authenticationClient) var authenticationClient 即可使用,测试时又能随时替换成假实现。
真实的实现放在独立的 AuthenticationClientLive 模块中(AuthenticationClientLive/LiveAuthenticationClient.swift),其中内置了演示用的凭据规则,方便任何人直接上手运行:
- 登录:邮箱需包含
@,密码必须是"password",否则抛出invalidUserPassword;成功返回token = "deadbeef";若邮箱包含"2fa",则twoFactorRequired = true,触发两步验证; - 两步验证:token 必须是
"deadbeef",验证码必须是"1234",否则分别抛出invalidIntermediateToken/invalidTwoFactor;成功后返回token = "deadbeefdeadbeef"; - 两个接口都通过
Task.sleep(for: .seconds(1))模拟 1 秒网络延迟,让加载态可见。
把"Live 实现"单独放在 *ClientLive 模块还有一个工程好处:Core 模块在编译期完全不接触真实网络层,包测试、预览、SwiftUI 和 UIKit 四个场景都能自由替换依赖。
登录与两步验证:子功能组合的范本
LoginCore/LoginCore.swift 演示了 @ObservableState、@Presents、BindingReducer 与 ViewAction 的组合用法:
@Reducer
public struct Login: Sendable {
@ObservableState
public struct State: Equatable {
@Presents public var alert: AlertState<Action.Alert>?
public var email = ""
public var isFormValid = false
public var isLoginRequestInFlight = false
public var password = ""
@Presents public var twoFactor: TwoFactor.State?
public init() {}
}
public enum Action: Sendable, ViewAction {
case alert(PresentationAction<Alert>)
case loginResponse(Result<AuthenticationResponse, any Error>)
case twoFactor(PresentationAction<TwoFactor.Action>)
case view(View)
@CasePathable
public enum View: BindableAction, Sendable {
case binding(BindingAction<State>)
case loginButtonTapped
}
}
// ...
public var body: some Reducer<State, Action> {
BindingReducer(action: \.view)
Reduce { state, action in
switch action {
// 表单绑定:任一字段为空则按钮不可用
case .view(.binding):
state.isFormValid = !state.email.isEmpty && !state.password.isEmpty
return .none
// 登录成功但需要两步验证:弹出 TwoFactor 子状态
case .loginResponse(.success(let response)):
state.isLoginRequestInFlight = false
if response.twoFactorRequired {
state.twoFactor = TwoFactor.State(token: response.token)
}
return .none
// 登录失败:弹窗展示错误
case .loginResponse(.failure(let error)):
state.alert = AlertState { TextState(error.localizedDescription) }
state.isLoginRequestInFlight = false
return .none
case .view(.loginButtonTapped):
state.isLoginRequestInFlight = true
return .run { [email = state.email, password = state.password] send in
await send(.loginResponse(Result {
try await self.authenticationClient.login(email: email, password: password)
}))
}
}
}
.ifLet(\.$alert, action: \.alert)
.ifLet(\.$twoFactor, action: \.twoFactor) { TwoFactor() }
}
}
值得注意的实现细节:
@Presents:twoFactor与alert都是"presentable"状态,配合ifLet(\.$twoFactor, action: \.twoFactor)做父子组合,子功能被独立 reducer 托管;- 副作用返回
Effect.run:登录请求放进.run闭包,通过send把结果转回loginResponseaction,不直接持有 store; ViewAction+BindableAction:View 只发送view命名空间下的动作(binding与loginButtonTapped),BindingReducer(action: \.view)负责把 UI 绑定写回状态;- 错误即弹窗:失败路径用
AlertState承载error.localizedDescription,测试可直接断言弹窗内容。
TwoFactorCore/TwoFactorCore.swift 结构与 Login 高度对称:持有 token(let,不可变),code.count >= 4 才允许提交,成功后只重置 isTwoFactorRequestInFlight,是否进入新游戏由顶层 AppCore 决策。这体现了 TCA 的组合哲学——子功能不知道"上级怎么用自己"。
对局核心:GameCore 与纯逻辑的胜利
GameCore/GameCore.swift 是整个应用中最"纯"的部分:不涉及任何外部依赖,除了一个用于退出的 @Dependency(\.dismiss)。它把棋盘建模为 Three<Three<Player?>>,落子、换手、判胜、重开全部是确定性的状态变换:
case .cellTapped(let row, let column):
guard state.board[row][column] == nil, !state.board.hasWinner else { return .none }
state.board[row][column] = state.currentPlayer
if !state.board.hasWinner { state.currentPlayer.toggle() }
return .none
case .playAgainButtonTapped:
state = Game.State(oPlayerName: state.oPlayerName, xPlayerName: state.xPlayerName)
return .none
case .quitButtonTapped:
return .run { _ in await self.dismiss() }
- 胜利判定:
hasWin(_:)用 8 条固定连线(3 横 + 3 竖 + 2 对角)逐一匹配当前玩家是否连成一线,hasWinner即hasWin(.x) || hasWin(.o); - 满盘判定:
Three<Three<Player?>>.isFilled通过allSatisfy检查所有格子非空; - 平局与结束:胜负或满盘后棋盘被禁用、显示"Play again?"按钮;标题随状态变化为"Winner! Congrats 玩家名"、"Tied game!" 或"当前玩家, place your 棋子"。
NewGameCore(NewGameCore/NewGameCore.swift)则负责收集两位玩家名字,点击 letsPlayButtonTapped 时用 Game.State(oPlayerName:xPlayerName:) 创建对局并通过 ifLet(\.$game, ...) 呈现。
SwiftUI 与 UIKit:同一状态树的两副面孔
SwiftUI 侧直接消费 Observable 化的状态。以 LoginSwiftUI/LoginView.swift 为例:
- 用
@ViewAction(for: Login.self)+@Bindable var store: StoreOf<Login>,表单通过$store.email、$store.password直接绑定到 reducer 状态; - 按钮禁用/加载态由 State 的扩展计算属性(
isLoginButtonDisabled、isActivityIndicatorVisible、isFormDisabled)驱动; - 导航完全声明式:
navigationDestination(item: $store.scope(\.twoFactor, action: \.twoFactor))在twoFactor状态非空时自动 push 两步验证页;.alert($store.scope(\.alert, action: \.alert))在alert非空时弹窗; #Preview通过withDependencies:注入假的登录实现,不碰真实网络即可预览。
对局页 GameSwiftUI/GameView.swift 用 GeometryReader 把九宫格均分为三等分,每个格子按钮 store.send(.cellTapped(row:column:)),棋盘绘制数据来自 store.rows(把 Three<Three<Player?>> 映射为 [[String]])。
UIKit 侧则完全复用 *Core:LoginUIKit、GameUIKit、AppUIKit 只依赖对应 Core 与子 UI 模块,用 ViewStore(或 UIKit 场景下对应的桥接方式)订阅状态并派发同样的 action。这就是 README 中"状态恢复"演示的底层原因——状态与 UI 分离,SwiftUI 与 UIKit 只是同一棵状态树的两个渲染器。
测试体系:单元、集成与端到端副作用
仓库内 Tests/ 目录与每个 Core 模块一一对应,全部基于 Swift Testing 框架与 TestStore。以 LoginCoreTests/LoginCoreTests.swift 中的 twoFactorSuccess 测试为例,它完整走通了"输入邮箱密码 → 登录 → 弹出两步验证 → 提交验证码 → 成功"的整条链路:
let store = TestStore(initialState: Login.State()) {
Login()
} withDependencies: {
$0.authenticationClient.login = { @Sendable _, _ in
AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: true)
}
$0.authenticationClient.twoFactor = { @Sendable _, _ in
AuthenticationResponse(token: "deadbeefdeadbeef", twoFactorRequired: false)
}
}
await store.send(\.view.binding.email, "2fa@pointfree.co") { $0.email = "2fa@pointfree.co" }
await store.send(\.view.binding.password, "password") {
$0.password = "password"
$0.isFormValid = true
}
await store.send(\.view.loginButtonTapped) { $0.isLoginRequestInFlight = true }
await store.receive(\.loginResponse.success) {
$0.isLoginRequestInFlight = false
$0.twoFactor = TwoFactor.State(token: "deadbeefdeadbeef")
}
await store.send(\.twoFactor.view.binding.code, "1234") { ... }
await store.send(\.twoFactor.view.submitButtonTapped) { ... }
await store.receive(\.twoFactor.twoFactorResponse.success) { ... }
要点在于:
withDependencies:注入假实现:登录/两步验证被替换为立即返回的闭包,测试无需真实网络,这正是"副作用完全受控"的直接收益;store.send断言副作用:每个发送都带一个闭包断言期望的状态变更,如isFormValid翻转、twoFactor被弹出;store.receive断言异步回流:loginResponse.success等由 Effect 产生的动作被显式接收并验证其状态效果,实现"端到端的副作用测试";- keyPath 动作寻址:
\.view.binding.email、\.twoFactor.view.binding.code这类 CasePath 让测试写法精确且可读。
twoFactorDismissInFlight 等测试还覆盖了"请求进行中用户取消/退出"的边界场景,验证 TestStore 对 in-flight 任务的取消处理。整套测试覆盖从单点 reducer(GameCore 的落子、判胜)到跨功能集成(Login 与 TwoFactor 协作、AppCore 的状态机流转),对应 README 中"每个功能的完整测试套件 + 集成测试 + 副作用端到端测试"的承诺。
如何运行与实验
该示例是仓库内独立的 SwiftPM 包,根仓库 Package.swift 之外,还提供了:
- SwiftPM 包:
Examples/TicTacToe/tic-tac-toe/,可用swift build/swift test在对应 target 上编译与跑测试; - App 工程:
Examples/TicTacToe/TicTacToe.xcodeproj,包含TicTacToeApp(SwiftUI 入口)等 scheme,可直接在 Xcode 中运行 iOS App; - 目录组织上,
App/内是应用入口与资源(RootView.swift、TicTacToeApp.swift),tic-tac-toe/内是模块化包源码与测试。
上手实验建议:先跑 LoginCoreTests 看纯逻辑测试;再分别运行 SwiftUI 版与 UIKit 版 App,用 2fa@pointfree.co / password 登录进入两步验证页、用 1234 通过验证,然后在对局中落几步棋,退出后再用另一种 UI 重新打开——对比两个界面间状态恢复的位置,即可直观验证"导航完全由状态驱动"这一设计目标。
小结
这个 TicTacToe 示例并非一个玩具 demo,而是一份"如何用 TCA 组织中型真实应用"的完整参考答案:*Core / *SwiftUI / *UIKit 三层模块划分让核心逻辑可在多端复用;@DependencyClient + withDependencies 让所有副作用可替换、可测试;枚举 Reducer + ifCaseLet + @Presents 把多步骤导航编码进状态树;而 5 个测试 target 覆盖了从单元到集成的完整验证链路。无论你是想评估 TCA 是否适合现有项目,还是准备开始一个全新的模块化应用,这个示例都值得逐行精读。