iOS 工程

[iOS 架构 #7] TCA 与单向数据流

在 SwiftUI 时代的架构讨论中,有一个名字无法绕开:Point-Free 打造的 TCA(The Composable Architecture)。

4 分钟阅读
[iOS 架构 #7] TCA 与单向数据流 封面图

在 SwiftUI 时代的架构讨论中,有一个名字无法绕开:Point-Free 打造的 TCA(The Composable Architecture)

如果前几篇介绍的 MVVM(Model-View-ViewModel)和 Clean Architecture 讨论的是“如何划分职责”,那么 TCA 提出的问题就不同了:“能否用一套规则控制状态变化的所有路径?”

今天我们来总结 TCA 的单向数据流如何工作,以及我们能获得什么、又要付出什么。


三种材料:State、Action、Reducer

在 TCA 中,一个界面(或一个功能)由三部分定义。

  • State:保存该功能全部状态的一个结构体
  • Action:包含该功能可能发生的所有事件的 enum,包括按钮点击、响应到达和收到通知等
  • Reducer:定义“当前 State 收到此 Action 后,State 将如何变化”的纯函数
@Reducer
struct Counter {
    @ObservableState
    struct State: Equatable {
        var count = 0
    }
    enum Action {
        case incrementTapped
        case decrementTapped
    }
    var body: some ReducerOf<Self> {
        Reduce { state, action in
            switch action {
            case .incrementTapped:
                state.count += 1
                return .none
            case .decrementTapped:
                state.count -= 1
                return .none
            }
        }
    }
}

View 不能直接修改状态。它能做的只有向 Store 发送 Action。Store 运行 Reducer 并修改 State 后,View 会渲染更新后的 State。

사건 발생 → Action 전송 → Reducer가 State 변경 → View 갱신

这条单向循环就是全部。状态不存在其他隐蔽的变化路径。“这个值到底在哪里变了?”这样的调试迷宫会从结构上消失。


副作用也纳入规则

网络请求等异步操作不会由 Reducer 直接执行,而是以 Effect 的形式返回。Effect 完成后,结果会再次作为 Action 返回。

case .refreshTapped:
    state.isLoading = true
    return .run { send in
        let posts = try await postClient.fetch()
        await send(.postsLoaded(posts))
    }

case .postsLoaded(let posts):
    state.isLoading = false
    state.posts = posts
    return .none

postClient 等依赖会通过 TCA 的依赖注入系统注入,因此测试时可以替换为伪客户端。TCA 最强大的武器就在这里登场了:TestStore

let store = TestStore(initialState: Feed.State()) { Feed() }
await store.send(.refreshTapped) { $0.isLoading = true }
await store.receive(.postsLoaded(mockPosts)) {
    $0.isLoading = false
    $0.posts = mockPosts
}

对于每个 Action,它强制你完整明确地指定 State 应该如何变化。只要出现一次未预期的状态变化,测试就会失败。MVVM 测试很难达到这种完整性验证水平。

TestStore 强制明确指定每一次状态变化
TestStore 强制明确指定每一次状态变化

代价是什么?

这种精确性也伴随着成本。

**学习曲线很陡。**在获得生产力之前,需要先学习 Reducer、Effect、Store、Scope 和依赖系统等许多概念。对于没有函数式编程背景的团队来说更是如此。

**存在样板代码。**即使只修改一个值,也需要创建 Action case,并在 Reducer 中添加分支。对于简单界面来说,这种仪式过于繁琐。

**它是第三方依赖。**TCA 是 Point-Free 而非 Apple 开发的库。为了适配每年变化的 SwiftUI,TCA 也经历了多次重大迁移,应用中的所有界面最终都会建立在这个库之上。它是一个活跃维护的成熟项目,但决定把应用的骨架交给外部依赖,本身并不是一个轻松的决定。


它适合什么样的团队?

TCA 能发挥价值的条件相当明确。

  • 状态复杂交织的应用,例如协作文档、金融、复杂表单和实时同步
  • 完整测试状态变化非常重要的领域
  • 熟悉函数式概念,或能够投入学习成本的团队

反过来,如果应用的大多数界面只是“接收并展示”,那么像第 3 篇介绍的 MV(Model-View)/MVVM 加上 UseCase,通常就足够了。TCA 不是错误的选择,而是昂贵的选择;当状态复杂度足以证明这项成本合理时,它才能发挥价值。

TCA 不是错误的选择,而是昂贵的选择
TCA 不是错误的选择,而是昂贵的选择

总结

  • TCA 使用 State、Action 和 Reducer 定义功能,并强制所有状态变化路径遵循一个单向循环。
  • 副作用也会通过 Effect → Action 纳入规则,TestStore 则验证状态变化的完整性。
  • 代价是学习曲线、样板代码,以及把应用骨架交给第三方的决定。
  • 这是一种权衡明确的架构:应用的状态越复杂,收益就越大。

系列只剩最后一个问题。从 MVC 到 TCA,在这么多选项中,**我们的团队应该选择什么?**下一篇将整理选择标准,为这个系列收尾。

延伸阅读