swift-composable-architecture 实战:用 TicTacToe 示例构建模块化、可测试的完整应用

原创2026-10-01 23:55:442,181 阅读
文章标签:前端移动开发

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) 把子 Reducer Login 与 \.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 把结果转回 loginResponse action,不直接持有 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 是否适合现有项目,还是准备开始一个全新的模块化应用,这个示例都值得逐行精读。

登录后查看全文
swift-composable-architecture