主页 TCA - SwiftUI 的救星?(一)
Post
Cancel

TCA - SwiftUI 的救星?(一)

打算用几篇文章介绍一下 TCA (The Composable Architecture),这是一种看起来非常契合 SwiftUI 的架构方式。

四年多前我写过一篇关于使用单向数据流来架构 View Controller 的文章,因为 UIKit 中并没有强制的 view 刷新流程,所以包括绑定数据在内的很多事情都需要自己动手,这为大规模使用造成了不小的障碍。而自那时过了两年后, SwiftUI 的发布才让这套机制有了更加合适的舞台。在 SwiftUI 发布初期,我也写过一本相关的书籍,里面使用了一些类似的想法,但是很不完善。现在,我想要回头再看看这样的架构方式,来看看最近一段时间在社区帮助下的进化,以及它是否能成为现下更好的选择。

对于以前很少接触声明式或者类似架构的朋友来说,其中有一些概念和选择可能不太容易理解,比如为什么 Side Effect 需要额外对应,如何在不同 View 之间共享状态,页面迁移的时候如何优雅处理等等。在这一系列文章里,我会尽量按照自己的理解,尝试阐明一些常见的问题,希望能帮助读者有一个更加平滑的入门体验。

作为开篇,我们先来简单看一看现在 SwfitUI 在架构上存在的一些不足。然后使用 TCA 实现一个最简单的 View。

SwiftUI 很赞,但是…

iOS 15 一声炮响,给开发们送来了全新版本的 SwiftUI。它不仅有更加合理的异步方法和全新特性,更是修正了诸多顽疾。可以说,从 iOS 14 开始,SwiftUI 才算逐渐进入了可用的状态。而最近随着公司的项目彻底抛弃 iOS 13,我也终于可以更多地正式在工作中用上 SwiftUI 了。

Apple 并没有像在 UIKit 中贯彻 MVC 那样,为 SwiftUI “钦定” 一个架构。虽然 SwiftUI 中提供了诸多状态管理的关键字或属性包装 (property wrapper),比如 @State@ObservedObject 等,但是你很难说官方 SwiftUI 教程里关于数据传递状态管理的部分,足够指导开发者构建出稳定和可扩展的 app。SwiftUI 最基础的状态管理模式,做到了 single source of truth:所有的 view 都是由状态导出的,但是它同时也存在了很多不足。简单就可以列举一些:

  • 复杂的状态修饰,想要“正常”使用,你至少必须要记住 @State@ObservedObject@StateObject@Binding@EnvironmentObject 各自的特点和区别。
  • 很多修改状态的代码内嵌在 View.body 中,甚至只能在 body 中和其他 view 代码混杂在一起。同一个状态可能被多个不相关的 View 直接修改 (比如通过 Binding),这些修改难以被追踪和定位,在 app 更复杂的情况下会是噩梦。
  • 测试困难:这可能和直觉相反,因为 SwiftUI 框架的 view 完全是由状态决定的,所以理论上来说我们只需要测试状态 (也就是 model 层) 就行,这本应是很容易的。但是如果严格按照 Apple 官方教程的基本做法,app 中会存在大量私有状态,这些状态难以 mock,而且就算可以,如何测试对这些状态的修改也是问题。

当然,这些不足都可以克服,比如死记硬背下五种属性包装的写法、尽可能减少共享可变状态来避免被意外修改、以及按照 Apple 的推荐准备一组 preview 的数据然后打开 View 文件去挨个检查 Preview 的结果 (虽然有一些自动化工具帮我们解放双眼,但严肃点儿,别笑,Apple 在这个 session 里原本的意思就是让我们去查渲染结果!)。

我们真的需要一种架构,来让 SwiftUI 的使用更加轻松一些。

从 Elm 获得的启示

我估摸着前端开发的圈子一年能大约能诞生 500 多种架构。如果我们需要一种新架构,那去前端那边抄一下大抵是不会错的。结合 SwiftUI 的特点,Elm 就是非常优秀的“抄袭”对象。

说实话,要是你现在正好想要学习一门语言,那我想推荐的就是 Elm。不过虽然 Elm 是一门通用编程语言,但可以说这门语言实际上只为一件事服务,那就是 Elm 架构 ( The Elm Architecture, TEA)。一个最简单的 counter 在 Elm 中长成这个样子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
type Msg = Increment | Decrement

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
  case msg of
    Increment ->
      ( model + 1, Cmd.none )

    Decrement ->
      ( model - 1, Cmd.none )

view model =
  div []
    [ button [ onClick Decrement ] [ text "-" ]
    , div [] [ text (String.fromInt model) ]
    , button [ onClick Increment ] [ text "+" ]
    ]

如果有机会,我再写一些 Elm 或者 Haskell 的东西。在这里,我决定直接把上面这段代码“翻译”成伪 SwiftUI:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
enum Msg {
  case increment
  case decrement
}

typealias Model = Int
func update(msg: Msg, model: Model) -> (Model, Cmd<Msg>) {
  switch msg {
  case .increment:
    return (model + 1, .none)
  case .decrement:
    return (model - 1, .none)
  }
}

func view(model: Model) -> some View {
  HStack {
    Button("-") { sendMsg(.decrement) }
    Text("\(model)")
    Button("+") { sendMsg(.increment) }
  }
}

TEA 架构组成部件

整个过程如图所示 (为了简洁,先省去了 Cmd 的部分,我们会在系列后面的文章再谈到这个内容):

  1. 用户在 view 上的操作 (比如按下某个按钮),将会以消息的方式进行发送。Elm 中的某种机制将捕获到这个消息。
  2. 在检测到新消息到来时,它会和当前的 Model 一并,作为输入传递给 update 函数。这个函数通常是 app 开发者所需要花费时间最长的部分,它控制了整个 app 状态的变化。作为 Elm 架构的核心,它需要根据输入的消息和状态,演算出新的 Model
  3. 这个新的 model 将替换掉原有的 model,并准备在下一个 msg 到来时,再次重复上面的过程,去获取新的状态。
  4. Elm 运行时负责在得到新 Model 后调用 view 函数,渲染出结果 (在 Elm 的语境下,就是一个前端 HTML 页面)。用户可以通过它再次发送新的消息,重复上面的循环。

现在,你已经对 TEA 有了基本的了解了。我们类比一下这些步骤在 SwiftUI 中的实现,可以发现步骤 4 其实已经包含在 SwiftUI 中了:当 @State@ObservedObject@Published 发生变化时,SwiftUI 会自动调用 View.body 为我们渲染新的界面。因此,想要在 SwiftUI 中实现 TEA,我们需要做的是实现 1 至 3。或者换句话说,我们需要的是一套规则,来把零散的 SwiftUI 状态管理的方式进行规范。TCA 正是在这方面做出了非常多的努力。

第一个 TCA app

来实际做一点东西吧,比如上面的这个 Counter。新建一个 SwiftUI 项目。因为我们会涉及到大量测试的话题,所以记得把 “Include Tests” 勾选上。然后在项目的 Package Dependencies 里把 TCA 加入到依赖中:

在本文写作的 TCA 版本 (0.29.0) 中,使用 Xcode 13.2 的话将无法编译 TCA 框架。暂时可以使用 Xcode 13.1,或者等待 workaround 修正。

把 ContentView.swift 的内容替换为

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
struct Counter: Equatable {
  var count: Int = 0
}

enum CounterAction {
  case increment
  case decrement
}

struct CounterEnvironment { }

// 2
let counterReducer = Reducer<Counter, CounterAction, CounterEnvironment> {
  state, action, _ in
  switch action {
  case .increment:
    // 3
    state.count += 1
    return .none
  case .decrement:
    // 3
    state.count -= 1
    return .none
  }
}

struct CounterView: View {
  let store: Store<Counter, CounterAction>
  var body: some View {
    WithViewStore(store) { viewStore in
      HStack {
        // 1
        Button("-") { viewStore.send(.decrement) }
        Text("\(viewStore.count)")
        Button("+") { viewStore.send(.increment) }
      }
    }
  }
}

基本上就是对上面 Elm 翻译的伪 SwiftUI 代码进行了一些替换:Model -> CounterMsg -> CounterActionupdate(msg:model:) -> counterReducerview(model:) -> ContentView.body

ReducerStoreWithViewStore 是 TCA 中的类型:

  • Reducer 是函数式编程中的常见概念,顾名思意,它将多项内容进行合并,最后返回单个结果。
  • ContentView 中,我们不直接操作 Counter,而是将它放在一个 Store 中。这个 Store 负责把 Counter (State) 和 Action 连接起来。
  • CounterEnvironment 让我们有机会为 reducer 提供自定义的运行环境,用来注入一些依赖。我们会把相关内容放到后面再解释。

上面的代码中 1 至 3,恰好就对应了 TEA 组成部件中对应的部分:

1. 发送消息,而非直接改变状态

任何用户操作,我们都通过向 viewStore 发送一个 Action 来表达。在这里,当用户按下 “-” 或 “+” 按钮时,我们发送对应的 CounterAction。选择将 Action 定义为 enum,可以带来更清晰地表达意图。但不仅如此,它还能在合并 reducer 时带来很多便利的特性,在后续文章中我们会涉及相关话题。虽然并不是强制,但是如果没有特殊理由,我们最好跟随这一实践,用 enum 来表达 Action。

2. 只在 Reducer 中改变状态

我们已经说过,Reducer 是逻辑的核心部分。它同时也是 TCA 中最为灵活的部分,我们的大部分工作应该都是围绕打造合适的 Reducer 来展开的。对于状态的改变,应且仅应在 Reducer 中完成:它的初始化方法接受一个函数,其类型为:

1
(inout State, Action, Environment) -> Effect<Action, Never>

inoutState 让我们可以“原地”对 state 进行变更,而不需要明确地返回它。这个函数的返回值是一个 Effect,它代表不应该在 reducer 中进行的副作用,比如 API 请求,获取当前时间等。我们会在下一篇文章中看到这部分内容。

3. 更新状态并触发渲染

在 Reducer 闭包中改变状态是合法的,新的状态将被 TCA 用来触发 view 的渲染,并保存下来等待下一次 Action 到来。在 SwiftUI 中,TCA 使用 ViewStore (它本身是一个 ObservableObject) 来通过 @ObservedObject 触发 UI 刷新。

有了这些内容,整个模块的运行就闭合了。在 Preview 的部分传入初始的 model 实例和 reducer 来创建 Store:

1
2
3
4
5
6
7
8
9
10
struct ContentView_Previews: PreviewProvider {
  static var previews: some View {
    CounterView(
      store: Store(
        initialState: Counter(),
        reducer: counterReducer,
        environment: CounterEnvironment()
    )
  }
}

最后,在 App 的入口将 @main 的内容也替换成带有 store 的 CounterView,整个程序就可以运行了:

1
2
3
4
5
6
7
8
9
10
11
12
13
@main
struct CounterDemoApp: App {
  var body: some Scene {
    WindowGroup {
      CounterView(
        store: Store(
          initialState: Counter(),
          reducer: counterReducer,
          environment: CounterEnvironment())
      )
    }
  }
}

Debug 和 Test

这一套机制能正常运行的一个重要前提,是通过 model 对 view 进行渲染的部分是正确的。也就是说,我们需要相信 SwiftUI 中 State -> View 的过程是正确的 (实际上就算不正确,作为 SwiftUI 这个框架的使用者来说,我们能做的事情其实有限)。在这个前提下,我们只需要检查 Action 的发送是否正确,以及 Reducer 中对 State 的变更是否正确就行了。

TCA 中 Reducer 上有一个非常方便的 debug() 方法,它会为这个 Reducer 开启控制台的调试输出,打印出接收到的 Action 以及其中 State 的变化。为 counterReducer 加上这个调用:

1
2
3
let counterReducer = Reducer<Counter, CounterAction, CounterEnvironment> {
  // ...
}.debug()

这时,点击按钮会给我们这样的输出,State 的变化被以 diff 的方式打印出来:

.debug() 只会在 #if DEBUG 的编译条件下打印,也就是说在 Release 时其实并不产生影响。另外,当我们有更多更复杂的 Reducer 时,我们也可以选择只在某个或某几个 Reducer 上调用 .debug() 来帮助调试。在 TCA 中,一组关联的 State/Reducer/Action (以及 Environment) 统合起来称为一个 Feature。我们总是可以通过把小部件的 Feature 整体一起,组合形成更大的 Feature 或是添加到其他 Feature 上去,形成一组更大的功能。这种依靠组合的开发方式,可以让我们保持小 Feature 的可测试和可用性。而这种组合,也正是 The Composable Architecture 中 Composable 所代表的意涵。

现在我们还只有 Counter 这一个 Feature。随着 app 越来越复杂,在后面我们会看到更多的 Feature,以及如何通过 TCA 提供的工具,将它们组合到一起。

使用 .debug() 可以让我们在控制台实际看到状态变化的方式,但如果能用单元测试确保这些变化,会更加高效和有意义。在 Unit Test 里,我们添加一个测试,来验证发送 .increment 时的情况:

1
2
3
4
5
6
7
8
9
10
func testCounterIncrement() throws {
  let store = TestStore(
    initialState: Counter(count: Int.random(in: -100...100)),
    reducer: counterReducer,
    environment: CounterEnvironment()
  )
  store.send(.increment) { state in
    state.count += 1
  }
}

TestStore 是 TCA 中专门用来处理测试的一种 Store。它在接受通过 send 发送的 Action 的同时,还在内部带有断言。如果接收到 Action 后产生的新的 model 状态和提供的 model 状态不符,那么测试失败。上例中,store.send(.increment) 所对应的 State 变更,应该是 count 增加一,因此在 send 方法提供的闭包部分,我们正确更新了 state 作为最终状态。

在初始化 Counter 提供 initialState 时,我们传递了一个随机值。通过使用 Xcode 13 提供的“重复测试”功能 (右键点击对应测试左侧的图标),我们可以重复这个测试,这可以让我们通过提供不同的初始状态,来覆盖更多的情况。在这个简单的例子中可能显得“小题大作”,但是在更加复杂的场景里,这有助于我们发现一些潜藏的问题。

如果测试失败,TCA 也会通过 dump 打印出非常漂亮的 diff 结果,让错误一目了然:

除了自带断言,TestStore 还有其他一些用法,比如用来对应时序敏感的测试。另外,通过配置合适的 Environment,我们可以提供稳定的 Effect 作为 mock。这些课题其实在我们使用其他架构时,也都会遇到,在有些情况下会很难处理。这种时候,开发者们的选择往往是“如果写测试太麻烦,那要不就算了吧”。在 TCA 这一套易用的测试套件的帮助下,我们大概很难再用这个借口逃避测试。大多数时候,书写测试反而变成一种乐趣,这对项目质量的提升和保障可谓厥功至伟。

Store 和 ViewStore

切分 Store 避免不必要的 view 更新

在这个简单的例子中,有一个很重要的部分,我决定放到本文最后进行强调,那就是 StoreViewStore 的设计。Store 扮演的是状态持有者,同时也负责在运行的时候连接 State 和 Action。Single source of truth 是状态驱动 UI 的最基本原则之一,由于这个要求,我们希望持有状态的角色只有一个。因此很常见的选择是,整个 app 只有一个 Store。UI 对这个 Store 进行观察 (比如通过将它设置为 @ObservedObject),攫取它们所需要的状态,并对状态的变化作出响应。

通常情况下,一个这样的 Store 中会存在非常多的状态。但是具体的 view 一般只需要一来其中一个很小的子集。比如上图中 View 1 只需要依赖 State 1,而完全不关心 State 2。

如果让 View 直接观察整个 Store,在其中某个状态发生变化时,SwiftUI 将会要求所有对 Store 进行观察的 UI 更新,这会造成所有的 view 都对 body 进行重新求值,是非常大的浪费。比如下图中,State 2 发生了变化,但是并不依赖 State 2 的 View 1 和 View 1-1 只是因为观察了 Store,也会由于 @ObservedObject 的特性,重新对 body 进行求值:

TCA 中为了避免这个问题,把传统意义的 Store 的功能进行了拆分,发明了 ViewStore 的概念:

Store 依然是状态的实际管理者和持有者,它代表了 app 状态的纯数据层的表示。在 TCA 的使用者来看,Store 最重要的功能,是对状态进行切分,比如对于图示中的 StateStore

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct State1 {
  struct State1_1 {
    var foo: Int
  }
  
  var childState: State1_1
  var bar: Int
}

struct State2 {
  var baz: Int
}

struct AppState {
  var state1: State1
  var state2: State2
}

let store = Store(
  initialState: AppState( /* */ ),
  reducer: appReducer,
  environment: ()
)

在将 Store 传递给不同页面时,可以使用 .scope 将其“切分”出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
let store: Store<AppState, AppAction>
var body: some View {
  TabView {
    View1(
      store: store.scope(
        state: \.state1, action: AppAction.action1
      )
    )
    View2(
      store: store.scope(
        state: \.state2, action: AppAction.action2
      )
    )
  }
}

这样可以限制每个页面所能够访问到的状态,保持清晰。

最后,再来看这一段最简单的 TCA 架构下的代码:

1
2
3
4
5
6
7
8
9
10
11
12
struct CounterView: View {
  let store: Store<Counter, CounterAction>
  var body: some View {
    WithViewStore(store) { viewStore in
      HStack {
        Button("-") { viewStore.send(.decrement) }
        Text("\(viewStore.count)")
        Button("+") { viewStore.send(.increment) }
      }
    }
  }
}

TCA 通过 WithViewStore 来把一个代表纯数据Store 转换为 SwiftUI 可观测的数据。不出意外,当 WithViewStore 接受的闭包满足 View 协议时,它本身也将满足 View,这也是为什么我们能在 CounterViewbody 直接用它来构建一个 View 的原因。WithViewStore 这个 view,在内部持有一个 ViewStore 类型,它进一步保持了对于 store 的引用。作为 View,它通过 @ObservedObject 对这个 ViewStore 进行观察,并响应它的变更。因此,如果我们的 View 持有的只是切分后的 Store,那么原始 Store 其他部分的变更,就不会影响到当前这个 Store 的切片,从而保证那些和当前 UI 不相关的状态改变,不会导致当前 UI 的刷新。

当我们在 View 之间自上向下传递数据时,尽量保证把 Store 进行细分,就能保证模块之间互不干扰。但是,实际上在使用 TCA 做项目时,更多的情景时我们从更小的模块进行构建 (它会包含自己的一套 Feature),然后再把这些本地内容“添加”到它的上级。所以 Store 的切分将会变得自然而然。现在你可能对这部分内容还有怀疑,但是在后面的几篇文章中,会逐步深入 feature 划分和组织,在那里你可以看到更多的例子。

跨 UI 框架的使用

另一方面,StoreViewStore 的分离,让 TCA 可以摆脱对 UI 框架的依赖。在 SwiftUI 中,body 的刷新是 SwiftUI 运行时通过 @ObservedObject 属性包装所提供的特性。现在这部分内容被包含在了 WithViewStore 中。但是 StoreViewStore 本身并不依赖于任何特定的 UI 框架。也就是说,我们也可以在 UIKit 或者 AppKit 的 app 中用同一套 API 来使用 TCA。虽然这需要我们自己去将 View 和 Model 绑定起来,会有些麻烦,但是如果你想要尽快尝试 TCA,却又不能使用 SwiftUI,也可以在 UIKit 中进行学习。你得到的经验可以很容易迁移到其他的 UI 平台 (甚至 web app) 中去。

练习

为了巩固,我也准备了一些练习。完成后的项目将会作为下一篇文章的起始代码使用。不过如果你实在不想进行这些练习,或者不确定是否正确完成,每一篇文章也提供了初始代码以供参考,所以不必担心。如果你没有跟随代码部分完成这个示例,你可以在这里找到这次练习的初始代码。参考实现可以在这里找到。

为数据文本添加颜色

为了更好地看清数字的正负,请为数字加上颜色:正数时用绿色显示,负数时用红色显示。

添加一个 Reset 按钮

除了加和减以外,添加一个重置按钮,按下后将数字复原为 0。

为 Counter 补全所有测试

现在测试中只包含了 .increment 的情况。请添加减号和重置按钮的相关测试。

该博客文章由作者通过 CC BY 4.0 进行授权。

Swift 结构化并发

TCA - SwiftUI 的救星?(二)