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

TCA - SwiftUI 的救星?(三)

上一篇关于 TCA 的文章中,我们看到了绑定的工作方式以及 Environment 在管理依赖和提供易测试性时发挥的作用。在这篇文章中,我们会继续深入,来看看 TCA 中的两个重要话题:Effect 角色到底是什么,以及如何通过组合的方式来把多个小 Feature 组合在一起,形成更加复杂的 UI 结构。

如果你想要跟做,可以直接使用上一篇文章完成练习后最后的状态,或者从这里获取到起始代码。

Effect

什么是 Effect

Elm-like 的状态管理之所以能够保持可测试及可扩展,核心要求是 Reducer 的纯函数特性。Environment 通过提供依赖解决了 reducer 输入阶段的副作用 (比如 reducer 需要获取某个 Date 等),而 Effect 解决的则是 reducer 输出阶段的副作用:如果在 Reducer 接收到某个行为之后,需要作出非状态变化的反应,比如发送一个网络请求、向硬盘写一些数据、或者甚至是监听某个通知等,都需要通过返回 Effect 进行。Effect 定义了需要在纯函数外执行的代码,以及处理结果的方式:一般来说这个执行过程会是一个耗时行为,行为的结果通过 Action 的方式在未来某个时间再次触发 reducer 并更新最终状态。TCA 在运行 reducer 的代码,并获取到返回的 Effect 后,负责执行它所定义的代码,然后按照需要发送新的 Action

Counter app 当前的实现里,在 counterReducer 的所有 case 中我们返回的都是 .none 这个 Effect,也就是说,一个什么都不做的 Effect。在这一节里,我们先试着来实现一个简单的带有计时 Effect 的 View。

Timer Effect

上一篇文章结束时,我们已经有一个猜数字的游戏了。但是单纯的猜数字似乎有点无聊,要作为一个游戏,我们来添加一些“竞争”的要素,比如为谜题计时。我们希望获得一个这样的 UI:

当谜题开始时,计时器获取当前日期并显示在第一行,并在第二行对所花费的时间用秒表进行计时。我们当然可以把它作为 CounterViewCounter State 的一部分,但是功能上其实两者应该是互相独立的,TCA 的核心优势就是将小部件进行组合,所以遵循最小化的原则,我们会为这个 Timer 创建它自己的 View,State 和 Action。关于这部分的内容,我们在前面已经做过一遍了,因此我会加快一些速度。

首先定义 State,我们需要记录开始时间和已经经过的时间,因此 Model 层很简单:

1
2
3
4
struct TimerState: Equatable {
  var started: Date? = nil
  var duration: TimeInterval = 0
}

然后来到 Action 的定义:开始计时结束计时这两个 action 是很明确的,问题在于我们要如何更新 TimerState.duration 呢?按照 TCA 的架构方式,reducer 是唯一能够设置 State 的地方,而 reducer 又需要接受某个 action 进行驱动。因此,我们显然也还是需要一个 action,来表示每次 timer duration 的更新,在这里我们把它叫做 timeUpdated

1
2
3
4
5
enum TimerAction {
  case start
  case stop
  case timeUpdated
}

有了 State 和 Action,接下来自然而然就是 Reducer 了。.timeUpdated 是最简单的,假如我们希望每次 .timeUpdated 的时候让 state.duration 增加 0.01s:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct TimerEnvironment {
}

let timerReducer = Reducer<TimerState, TimerAction, TimerEnvironment> {
  state, action, environment in
  switch action {
  case .start: 
    fatalError("Not implemented")
  case .timeUpdated:
    state.duration += 0.01
    return .none
  case .stop:
    fatalError("Not implemented")
  }
}

现在,我们只需要想办法在 .start 的 case 里进行一些奇妙的“设定”,让 TCA 运行时每隔 10ms 发送一次 .timeUpdated action 就可以了。把这类行为进行一些抽象:在处理 Action 时,进行一些 TCA 系统之外的操作,并把结果转换为新的 Action 反馈到 TCA 系统里,这类行为就是一个 Effect。在 reducer 中,我们通过返回一个 Effect 类型的值来描述这件事情。

对于 Timer,TCA 框架直接定义了 Effect.timer。在 timerReducer 中,我们直接使用它来返回一个按时间触发的 effect:

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
40
41
struct TimerEnvironment {
+ // 1
+ var date: () -> Date
+ var mainQueue: AnySchedulerOf<DispatchQueue>

+ static var live: TimerEnvironment {
+   .init(
+     date: Date.init,
+     mainQueue: .main
+   )
+ }
}

let timerReducer = Reducer<TimerState, TimerAction, TimerEnvironment> {
  state, action, environment in
+ // 2  
+ struct TimerId: Hashable {} 

  switch action {
  case .start: 
-   fatalError("Not implemented")
+   if state.started == nil {
+     state.started = environment.date()
+   }
+   // 3
+   return Effect.timer(
+     id: TimerId(),
+     every: .milliseconds(10),
+     tolerance: .zero,
+     on: environment.mainQueue
+   ).map { time -> TimerAction in
+     // 4
+     return TimerAction.timeUpdated
+   }
  case .timeUpdated:
    state.duration += 0.01
    return .none
  case .stop:
    fatalError("Not implemented")
  }
}
  1. 类似上一篇文中,对于外部输入,我们使用环境值来进行注入。
  2. 为了能够实现 Effect 的取消,我们需要为创建的 Effect 指定一个 id。这里 TimerId 是一个最简单的满足了 Hashable 的类型。
  3. TCA 中直接提供了创建一个 timer 的方法,我们创建一个 TimerId 的实例作为这个 Effect 的 id。
  4. Effect.timer 返回类型是 Effect<DispatchQueue.SchedulerTimeType, Never>。而在 timerReducer 中,我们要求返回值为 Effect<Action, Never>。TCA 为 Effect 的 output 转换提供了人见人爱的 map 方法。用它就可以把返回结果转换为我们需要的类型了。

遇到 .start 后,reducer 返回一个 timer Effect,开启一个“副作用”。之后,每隔 10 毫秒,.timeUpdated 就将被发送一次,reducer 获取到这个 action,并用它来更新 duration

Effect 的取消

.stop 中我们需要让这个 timer 停止,我们通过返回一个特殊的 Effect.cancel 来实现取消操作:

1
2
3
4
5
6
7
8
9
10
11
12
let timerReducer = Reducer<TimerState, TimerAction, TimerEnvironment> {
  state, action, environment in
  
  struct TimerId: Hashable {}
  switch action {
  // ...
  case .stop:
-   fatalError("Not implemented")
+   return .cancel(id: TimerId())

  // ...
} 

通过把哈希值相同的 TimerId 内部类型实例传递个 .cancel,TCA 就会帮我们寻找到之前开始的 timer,并将它停下来了。

最困难的 reducer 部分已经搞定了,接下来创建 TimerLabelView,并按要求画 UI,就很简单了。和前面文章的做法完全一样,使用 WithViewStore 将 store 进行转换:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
struct TimerLabelView: View {
  let store: Store<TimerState, TimerAction>
  var body: some View {
    WithViewStore(store) { viewStore in
      VStack(alignment: .leading) {
        Label(
          viewStore.started == nil ? "-" : "\(viewStore.started!.formatted(date: .omitted, time: .standard))",
          systemImage: "clock"
        )
        Label(
          "\(viewStore.duration, format: .number)s", 
          systemImage: "timer"
        )
      }
    }
  }
}

想要进行一些直观上的控制的话,在 Preview 中为它再加上合适的按钮:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct TimerLabelView_Previews: PreviewProvider {
  static let store = Store(initialState: .init(), reducer: timerReducer, environment: .live)
  static var previews: some View {
    VStack {
      WithViewStore(store) { viewStore in
        VStack {
          TimerLabelView(store: store)
          HStack {
            Button("Start") { viewStore.send(.start) }
            Button("Stop") { viewStore.send(.stop) }
          }.padding()
        }
      }
    }
  }
}

在上面的例子中,多次点击 “Start” 按钮也不会造成什么问题,这是因为在通过 Effect.timer 创建新的计时 Effect 时,它的内部已经使用传入的 id 先进行了一次 .cancel 处理。

测试 Effect

在把 TimerLabelView 组合到我们的 app 之前,先来看看怎么测试。经常写测试的小伙伴们肯定都遇到过这样的难题:如何写好一个异步操作的测试。这类异步操作不仅仅涉及到像是本例中 timer 这种类型,也可能有像是网络请求或者等待用户输入等更具普遍意义的情形。在传统作法中,我们往往会依靠测试桩 (test stub) 和模拟 (mock) 对象加上一定的注入,或者干脆直接等待固定的时间,然后再验证结果。这些手段是有效的,但是 stub 和 mock 不仅为测试带来了更多的外部依赖和复杂度,也许要我们对实际代码进行修改,让它可以被注入;而强行等待的方法,不仅会拉长测试所需要的时间,而且随着环境不同,这些测试失效也面临着失效的可能性。

在 TCA 中,由于存在 Environment 类型,我们“天然”拥有了一个系统外部的注入点。在这一部分,我们会来看看如何通过使用注入的 scheduler 完成 timerReducer 的测试。

在定义 TimerEnvironment 时,我们将 State 系统外部的部分都囊括了进来,包括 datemainQueue。在实际的 app 代码里,我们把 AnySchedulerOf<DispatchQueue>.main (它其实就是 DispatchQueue.main) 赋给了 mainQueue,来让 timer 的事件运行在主队列上。.main 是和 app 以及真实世界绑定的队列,对 State 体系来说,这是一个巨大的“副作用”。在测试中,我们需要一个能被我们精确控制和操作的队列,来保证测试不被外界影响。TCA 中为我们定义了一个简单好用的类型,TestScheduler

TimerLabel 添加测试:

1
2
3
4
5
6
7
8
9
10
// TimerLabelTests.swift
import XCTest
import ComposableArchitecture
@testable import CounterDemo

class TimerLabelTests: XCTestCase {
  let scheduler = DispatchQueue.test
  
  // ...
}

DispatchQueue.test 是 TCA 专门为测试定义的 ,它的类型为 TestSchedulerOf<DispatchQueue>TestSchedulerOf 不像 .main 这样的队列,会随着 app 和真实时间向前运行,它上面定义了一系列操作方法,让我们可以手动控制时刻。我们会在稍后看到具体的用法。

接下来添加对 timer 的实际测试:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
class TimerLabelTests: XCTestCase {
  let scheduler = DispatchQueue.test
  
  func testTimerUpdate() throws {
    let store = TestStore(
      initialState: TimerState(),
      reducer: timerReducer,
      environment: TimerEnvironment(
        date: { Date(timeIntervalSince1970: 100) },
        mainQueue: scheduler.eraseToAnyScheduler()
      )
    )
    // ...
  }
}

正如上面提到的,我们使用 TimerEnvironment 进行环境注入,除了为 date 设定固定值外,还将 test scheduler 赋值给了 mainQueue。如果你已经忘了为什么需要 Environment 注入,可以复习一下这个系列的上一篇文章

最后就是操作 scheduler,然后判断状态的部分了:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
  func testTimerUpdate() throws {
    // ...
    
    store.send(.start) {
      $0.started = Date(timeIntervalSince1970: 100)
    }
    
    // 1
    scheduler.advance(by: .milliseconds(35))
    // 2
    store.receive(.timeUpdated) {
      $0.duration = 0.01
    }
    store.receive(.timeUpdated) {
      $0.duration = 0.02
    }
    store.receive(.timeUpdated) {
      $0.duration = 0.03
    }
    // 3
    store.send(.stop)
  }
  1. advance(by:) 将这个 scheduler 的“时针”前进给定的时间,也就是说,让时间流逝。我们不再依赖于不精确的现实世界,也不依赖于运行这个测试的具体设备和环境,而可以准确地将计时器调到 35 毫秒的位置。
  2. 使用 .receive 来断言接收到了某个事件,并且在闭包中验证 State 的改变。这里由于 1 中 scheduler.advance 的原因,我们会期望收到三次 .timeUpdated (因为在 timerReducer 的实现中我们指定了 10 毫秒触发一次 timer)。
  3. 最后,向 store 发送 .stop action 来取消 timer,让它停下。

在上面的断言中,删除 2 中的任意一个 receive 调用或者是移除掉 3 中的 send(.stop),都会导致测试的失败。 TCA 在对应 Effect 测试时,会对还未被 receive 的 action 以及还在运行的 Effect 进行断言,这个特性非常优秀,保证了涉及的异步操作处理“万无一失”。

其他 Effect 和测试

除了 Timer 之外,我们在实际开发中还会遇到各种各样的异步操作,其中最常见的大概就是网络请求了。TCA 提供了一系列方法,来把基于闭包或者 Publisher 的异步操作封装成一个可供 reducer 返回的 Effect

网络请求 Effect

我们来看一个网络请求的例子,这个举例和正在做的猜数字 app 无关,但是却是 app 开发最常见的任务,而且它是很典型的把 Publisher 包装成 Effect 的例子,所以我希望单独来说说。

假设我们有这样的 Request,它以 Publisher 的形式从网络加载一些数据:

1
2
3
4
5
6
7
import Combine

let sampleRequest = URLSession.shared
  .dataTaskPublisher(for: URL(string: "https://example.com")!)
  .map { element -> String in
    return String(data: element.data, encoding: .utf8) ?? ""
  }

在 TCA 中,我们已经看到了很多将外部作用放在 Environment 中的例子了,网络请求是一个非常大的副作用,它也不例外:

1
2
3
4
5
6
7
8
struct SampleTextEnvironment {
  var loadText: () -> Effect<String, URLError>
  var mainQueue: AnySchedulerOf<DispatchQueue>
  static let live = SampleTextEnvironment(
    loadText: { sampleRequest.eraseToEffect() },
    mainQueue: .main
  )
}

eraseToEffect 是 TCA 中定义在 Publisher 上的辅助方法,它把这个 Publisher 包装成 TCA 可用的 Effect

剩下的部分就是定义相关的 State 和 Reducer 了:

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
enum SampleTextAction: Equatable {
  case load
  case loaded(Result<String, URLError>)
}

struct SampleTextState: Equatable {
  var loading: Bool
  var text: String
}

let sampleTextReducer = Reducer<SampleTextState, SampleTextAction, SampleTextEnvironment> {
  state, action, environment in
  switch action {
  case .load:
    state.loading = true
    // 1
    return environment.loadText()
      .receive(on: environment.mainQueue)
      .catchToEffect(SampleTextAction.loaded)
  case .loaded(let result):
    // 2
    state.loading = false
    do {
      state.text = try result.get()
    } catch {
      state.text = "Error: \(error)"
    }
    return .none
  }
}
  1. 在接受到 .load 后,我们返回一个 Effect 来加载数据。environment.loadText 的结果会在 mainQueue 上处理。最后,我们需要把这个 Effect 的结果 (在这里是一个可能失败的类型:Effect<String, URLError>,它对应的结果可能是 String,也可能是 URLError 值) 转换为 reducer 初始化方法所要求的 Effect<SampleTextAction, Never>。这个转换通过 catchToEffect 来实现,它的函数签名是:

    1
    2
    3
    
     func catchToEffect<T>(
       _ transform: @escaping (Result<Output, Failure>) -> T
     ) -> Effect<T, Never>
    

    参数 transform 是一个接受 (Result<Output, Failure>) -> T 的函数,因此,我们只需要提供一个 (Result<String, URLError> -> SampleTextAction) 的转换,就能用这个方法把 EffectEffect<String, URLError> 转换到 Effect<SampleTextAction, Never>,用来提供给 reducer 做返回。这也是为什么我们将 .loaded 定义为 loaded(Result<String, URLError>) 的原因:这样一来,我们就可以使用 Swift 中 enum case 的名字可以当作函数的方法,简单地通过 .catchToEffect(SampleTextAction.loaded) 来完成转换了。如果你觉得难以理解,也可以把这部分代码写全,它相当于:

    1
    2
    3
    4
    5
    
     return environment.loadText()
       .receive(on: environment.mainQueue)
       .catchToEffect({ result in 
         return SampleTextAction.loaded(result) 
       })
    

    这种做法在 TCA 中处理 Effect 时很常见,对于一个接收 Effect 结果的 Action,把它的关联值定义为 Result<Value, Error> 的形式,可以让 reducer 的部分的代码简化很多。

  2. .load 中返回的 Effect 执行完成,并经过转换后,.loaded action 被发送。这给了 Reducer 一个处理 Effect 结果和更新状态的机会。在 TCA 中,对于异步操作我们会大量看到这种模式。

最后,管理这个请求的 View 的部分就非常简单了,仅供参考:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
  struct SampleTextView: View {
  
    let store: Store<SampleTextState, SampleTextAction>
  
    var body: some View {
      WithViewStore(store) { viewStore in
        ZStack {
          VStack {
            Button("Load") { viewStore.send(.load) }
            Text(viewStore.text)
          }
          if viewStore.loading {
            ProgressView().progressViewStyle(.circular)
          }
        }
      }
    }
  }

测试网络请求

你可能已经猜到了,对于网络请求 Effect 的测试,和之前的 Timer 测试应该是相似的:我们通过 Environment 注入的方式,提供合适的 loadTextmainQueue,就能精确控制 Effect 的行为了:

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
class SampleTextTests: XCTestCase {
  
  let scheduler = DispatchQueue.test
  
  func testSampleTextRequest() throws {
    let store = TestStore(
      initialState: SampleTextState(loading: false, text: ""),
      reducer: sampleTextReducer,
      environment: SampleTextEnvironment(
        // 1
        loadText: { Effect(value: "Hello World") },
        mainQueue: scheduler.eraseToAnyScheduler()
      )
    )
    store.send(.load) { state in
      state.loading = true
    }
    // 2
    scheduler.advance()
    store.receive(.loaded(.success("Hello World"))) { state in
      state.loading = false
      state.text = "Hello World"
    }
  }
}
  1. 相对于提供一个实际的 dataTask publisher,这里直接返回了一个 “Hello World” 作为完成值的 Effect。它代表了一个“即将发生”的外部“返回值”。
  2. 和上面 timer 的例子相似,使用 .testadvance 让测试向前运行。不添加参数时,.zero 会被使用,这代表 scheduler 不会发生时间流逝,但会把所有当前“堆积”的 Effect 事件都发送出去。TCA 也为我们准备了一个特殊的 .immediate 来简化这个过程:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
class SampleTextTests: XCTestCase {
  
- let scheduler = DispatchQueue.test
  
  func testSampleTextRequest() throws {
    let store = TestStore(
      initialState: SampleTextState(loading: false, text: ""),
      reducer: sampleTextReducer,
      environment: SampleTextEnvironment(
        loadText: { Effect(value: "Hello World") },
-       mainQueue: scheduler.eraseToAnyScheduler()
+       mainQueue: .immediate
      )
    )
    store.send(.load) { state in
      state.loading = true
    }
-   scheduler.advance()
    store.receive(.loaded(.success("Hello World"))) { state in
      state.loading = false
      state.text = "Hello World"
    }
  }
}

.immediate 会无视掉 Effect (或者说 Publisher) 中的有关时间的部分,而立即让这些 Effect 完成,因此我们从上例中把 scheduler 都移除掉,让代码更简化。

不过相应地,.immediate 无法对应和测试像是 DebounceThrottle 或者 Timer 这类行为。对于这种需要验证时间的行为,还是应该使用 TestScheduler

更多类型的 Effect 以及 Effect 操作

除了 Timer 和 Publisher 外,像是传统的基于闭包回调的异步方法,或者是基于全新的 Swift Concurrency 的操作,TCA 都在 Effect 类型中为它们提供了相应的封装方式。

另外,对于需要进行多个异步操作的情况,TCA 也提供了诸如 concatenate (顺次执行多个 Effect) 和 merge (同时执行多个 Effect) 这样的手段。对于只需要执行,不关心返回也不需要在完成时触发新 action 的操作,使用 fireAndForget 就能简易地执行它们。

这篇文章并不打算对这些部分再进行详细介绍,您可以参考 TCA 的示例或者文档,找到关于它们的更多说明。

Composable

现在让我们回到 Demo 中来。我们有一个可以用来猜数字的 CounterView,还有一个用来表示时间的 TimerLabelView 了。现在我们来看看怎么把两者结合起来,完成一个带有计时的猜数字小游戏:

这涉及到 TCA 的核心概念,如何把不同的组件进行组合。在前面的几个例子中,我们已经看到一个小型的组件特性是怎么工作的了:每一个特性都是一组 State,Action,Reducer 和 Environment 的结合。在把小的组件进行组合时,所生成的较大组件也遵循着完全一样的方式:它也是 State,Action,Reducer 和 Environment 的结合,不过其中每个角色,也都是更小特性中对应角色的组合。

Game State

先从 State 开始,创建 GameState,它代表了一对 CounterTimerState 的模型:

1
2
3
4
struct GameState: Equatable {
  var counter: Counter = .init()
  var timer: TimerState = .init()
}

Game Action

接下来是 Action,和 State 类似,我们可以简单地组合 CounterActionTimerAction

1
2
3
4
enum GameAction {
  case counter(CounterAction)
  case timer(TimerAction)
}

Game Environment

我们已经定义了 CounterEnvironmentTimerEnvironment,对于 GameEnvironment 来说,我们暂时定义一个空的环境类型就好:

1
struct GameEnvironment { }

在实际的 app 开发中,你可能会发现有很多时候我们会重复定义一些相同的环境值,比如 date 或者 mainQueue 等。这类相同的环境其实我们可以添加包装,让它们的复用更容易一些,我们会在本文练习部分稍微提及。在那之前,由于 GameState 的状态转变并不涉及更多的外部副作用,所以为了说明简便,暂时留空。

实际上“不涉及副作用”这个论断是错误的,更准确来说,GameState 内部的 CounterTimerState 都是有副作用的。这些副作用,在 Game 的层级上不应该由 CounterEnvironment.live 或者 TimerEnvironment.live 来定义,而应该从 GameEnvironment 中转换过去。这部分内容也被当作了练习,还请确认一下。

Game Reducer

最后,是最艰难的部分 Reducer 了。这里的核心思想有下面三条:

  1. 组件的行为都是由 reducer 定义的。子组件的行为,也应该由子组件的 reducer 自己决定。因此我们需要使用已有的 counterReducertimerReducer,并GameAction 转换为子组件所需要的 CounterActionTimerAction 并传递给它们
  2. 子组件对各自 State 进行修改的结果,需要反应到父组件中,这样才能完成父组件 View 的刷新。在这个例子中,counterReducertimerReducer 会更改各自的 CounterTimerState,但是 GameState 中的 countertimer 并不会被子组件的 reducer 更改 (因为 GameState 是一个 struct),因此我们需要一种方式让子组件 reducer 能够设置父组件对应的 state
  3. 多个组件需要联合起来工作,因此各个组件的 reducer 需要进行合并

TCA 中,在将多个子组件的 Reducer 组合成父组件 Reducer 时,通常结合使用 combinepullback。TCA 为我们提供了一些特殊的写法,让整个过程看起来非常简洁:

1
2
3
4
5
6
7
8
9
10
11
12
let gameReducer = Reducer<GameState, GameAction, GameEnvironment>.combine( // 3
  counterReducer.pullback(
    state: \.counter, // 2
    action: /GameAction.counter, // 1
    environment: { _ in .live }
  ),
  timerReducer.pullback(
    state: \.timer,
    action: /GameAction.timer,
    environment: { _ in .live }
  )
)

在子组件 reducer 上调用 pullback 函数是整个过程的关键:pullback 负责将子组件的 reducer “拉回”成为父组件 reducer 的一部分,它首先把父组件 Action 进行转换并发送给子组件,然后把子组件的 State 变化设置回到父组件中。具体来说,上面的代码中对应的编号:

  1. /GameAction.counter 来自一个为 TCA 开发的工具库 CasePaths,它通过在 enum case 之前添加斜杠,来把这个 case 转换为一个具有更丰富特性的 CasePath struct。在这里,CasePath 主要承担从接收到的父组件 Action 中将对应的子组件的 Action 提取出来的工作,这样在子组件的 reducer 中,就可以使用它们了。
  2. 对于 state,使用 Key path 的语法创建一个 WritableKeyPath。子组件 reducer 中对子组件 state 的变更,最终会通过这个 WritableKeyPath 写回到 GameState 相关的属性里,最后触发 View 的刷新。
  3. pullback 是一个转换器,它把子组件的 reducer 类型转换为父组件的 reducer 类型。最后,我们使用 Reducer.combinecounterReducertimerReducer 转换后的结果合并起来,这样它们就可以同时工作了。

理解 pullback 在 TCA 里非常重要,作为参考,我把这个函数的签名写在下面,你可以对照各个参数再梳理一遍:

1
2
3
4
5
6
7
8
9
struct Reducer<State, Action, Environment> {
  func pullback<GlobalState, GlobalAction, GlobalEnvironment>(
    state toLocalState: WritableKeyPath<GlobalState, State>,
    action toLocalAction: CasePath<GlobalAction, Action>,
    environment toLocalEnvironment: @escaping (GlobalEnvironment) -> Environment
  ) -> Reducer<GlobalState, GlobalAction, GlobalEnvironment>
  
  // ...
}

Game View

最后,View 的部分就很简单了:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct GameView: View {
  let store: Store<GameState, GameAction>
  var body: some View {
    WithViewStore(store.stateless) { viewStore in
      VStack {
        TimerLabelView(store: store.scope(state: \.timer, action: GameAction.timer))
        CounterView(store: store.scope(state: \.counter, action: GameAction.counter))
      }.onAppear {
        viewStore.send(.timer(.start))
      }
    }
  }
}

在系列的第一篇文章中,我们就已经看到过使用 scope 切分 Store 的例子了。被切分后的 Store 类型上满足子组件 View 的需求,子组件 View 向这个被切分后的 Store 发送的 action (比如点击 CounterView 中的加号所发送的 CounterAction.increment),将被嵌入 (embed) 成为父组件的 action GameAction.counter(.increment),并交给 gameReducer 处理。gameReducer 用我们上面提到的手段,通过 CasePath 把子组件的 action 进行提取 (extract),再交给回 counterReducer 处理。

唯一要注意的是,在创建 WithViewStore 时,我们给了 store.stateless。这是因为我们在 GameView 中其实没有用到其中的任何 State,这个 store 并不需要驱动 view,我们也就不需要订阅这个 Store 的内容变更。如果不添加 stateless,那么 GameState 中的任何变化,都将会触发 WithViewStore 中 View 内容的更新,这会带来不必要的刷新工作,降低 app 效率。

stateless 相当于用 Void 对原来的 store 进行切分:store.scope(state: { _ in () })

现在,把 App 的初始的 View 换成 GameView,运行 app,就能看到计时器和猜数字游戏一同工作了。

1
2
3
4
5
6
7
8
9
10
11
12
WindowGroup {
- CounterView(
+ GameView(
    store: Store(
-     initialState: Counter(),
-     reducer: counterReducer,
-     environment: .live
+     initialState: GameState(),
+     reducer: gameReducer,
+     environment: GameEnvironment()
  )
}

用序列图可以更直观地显示 pullbackscope 在组件组合的时候到底做了什么。对于更大的组件,我们也可以用类似的方式一点点从小组件搭建:

练习

如果你没有跟随本文更新代码,你可以在这里找到下面练习的起始代码。参考实现可以在这里找到。

验证各个 View 的刷新

最坏情况

GameView 中我们使用了 stateless 来切分出一个无状态的 Store。请试试看把这个 stateless 去掉,然后在 TimerLabelViewCounterViewbody 中添加一些打印语句 (比如 Self._printChanges()),验证一下在最差状态下 View 的刷新机理。

占位 View

GameView 中,我们现在选择了在 VStackonAppear 中发送 .timer(.start) 来使计时器开始工作。ViewStore 仅仅只是用来做这件事,其实它并没有直接驱动这个 View 的显示。因此,我们也许可以尝试这样的写法:

1
2
3
4
5
6
7
8
9
10
VStack {
  TimerLabelView(store: store.scope(state: \.timer, action: GameAction.timer))
  CounterView(store: store.scope(state: \.counter, action: GameAction.counter))
      
  WithViewStore(store) { viewStore in
    Color.clear
      .frame(width: 0, height: 0)
      .onAppear { viewStore.send(.timer(.start)) }
  }
}

你可以会想用 EmptyView 进行占位,来作为发送 .start 的“载体”,但不幸的是,EmptyView 不会对包括 onAppear 在内的各种 modifier 作出任何响应和改变。所以我们这里用了 Color.clear,在 SwiftUI 中,这也算是一种常见的占位方式。

请验证一下这种方式和“最坏情况”的区别。

重新考虑计时器的开始行为

如果我们选择把开始计时的操作移动到 TimerLabelView 的 body 中去:

1
2
3
4
5
6
7
8
9
10
11
struct TimerLabelView: View {
  let store: Store<TimerState, TimerAction>
  var body: some View {
    WithViewStore(store) { viewStore in
      VStack(alignment: .leading) {
        //...
      }
+     .onAppear { viewStore.send(.start) }
    }
  }
}

那么显然,在 GameView 中我们就完全不需要 WithViewStore 了。这是一种权衡:我们失去了从外界控制计时器行为的能力,但是 GameView 的代码会更加简单,而且 TimerLabelView 也更加“自包容”了。根据情景的不同,也许我们会有不一样的选择。

改造 GameEnvironment 以及测试

在引入 GameEnvironment 时,为了简化问题,我们将它留空,并且在 gameReducer 时直接使用 .live 来作为子组件的环境:

1
2
3
4
5
6
7
8
9
10
11
12
struct GameEnvironment { }

let gameReducer = Reducer<GameState, GameAction, GameEnvironment>.combine(
  counterReducer.pullback(
    // ...
    environment: { _ in .live /* CounterEnvironment */ }
  ),
  timerReducer.pullback(
    // ...
    environment: { _ in .live /* TimerEnvironment */ }
  )
)

这是有缺陷的:我们在测试 gameReducer 时,将无法通过测试环境中对 GameEnvironment 进行注入,来控制这两个子组件的环境,这导致 Game feature 无法测试。

pullbackenvironment 参数其实是一个函数:它负责将 GameEnvironment 转变为子组件所需要的环境。所以这里的解决方案是,在 GameEnvironment 中定义所有我们需要的环境值,然后在 pullback 时用这些环境值创建新的子组件环境,层层注入。请你试试看!

在实际 app 中你可能会发现,许多组件都会共享部分状态,比如 datemainQueue 等。 TCA 在示例 app 中给出了一种更加通用的方式,使用 @dynamicMemberLookup 来巧妙地把子组件的环境包装到一个 SystemEnvironment 中。像是 datemainQueue 这类通用的环境值,就不需要每次在子组件环境中定义了。

记录结果并显示数据

CounterView 的 “Next” 按钮被按下后,我开启新的题目。我们想要把每一次猜数字的结果 (无论对错) 在按下 “Next” 的时候记录下来,这样我们之后就可以查询我们猜测了哪些数据。

每次猜测的结果用下面的 GameResult 类型表示:

1
2
3
4
5
struct GameResult: Equatable {
  let secret: Int
  let guess: Int
  let timeSpent: TimeInterval
}

举例来说,如果第一个数字是 10,我们在按下 “Next” 之前已经让 counter 变成了 10,且耗时 5 秒,那么我们需要记录 GameResult(secret: 10, guess: 10, timeSpent: 5.0);对于没有猜对就继续的情况,我们也用同样的类型记录下来。记录的结果保存在 GameState 的一个数组中:

1
2
3
4
5
struct GameState: Equatable {
  var counter: Counter = .init()
  var timer: TimerState = .init() 
+ var results: [GameResult] = []
}

现在,请你在 GameView 上显示猜测过的总数,以及其中正确的个数:类似这样的 UI:

提示:可以在 gameReducercombine 中创建并添加一个新的 reducer,让它截取 .counter(.playNext) 事件并更新 GameState.results

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

2021 年终总结

TCA - SwiftUI 的救星?(四)