在上一篇关于 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:
当谜题开始时,计时器获取当前日期并显示在第一行,并在第二行对所花费的时间用秒表进行计时。我们当然可以把它作为 CounterView
和 Counter
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")
}
}
- 类似上一篇文中,对于外部输入,我们使用环境值来进行注入。
- 为了能够实现 Effect 的取消,我们需要为创建的 Effect 指定一个 id。这里
TimerId
是一个最简单的满足了Hashable
的类型。 - TCA 中直接提供了创建一个 timer 的方法,我们创建一个
TimerId
的实例作为这个 Effect 的 id。 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 系统外部的部分都囊括了进来,包括 date
和 mainQueue
。在实际的 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)
}
advance(by:)
将这个scheduler
的“时针”前进给定的时间,也就是说,让时间流逝。我们不再依赖于不精确的现实世界,也不依赖于运行这个测试的具体设备和环境,而可以准确地将计时器调到 35 毫秒的位置。- 使用
.receive
来断言接收到了某个事件,并且在闭包中验证 State 的改变。这里由于 1 中scheduler.advance
的原因,我们会期望收到三次.timeUpdated
(因为在timerReducer
的实现中我们指定了 10 毫秒触发一次 timer)。 - 最后,向
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
}
}
在接受到
.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)
的转换,就能用这个方法把Effect
从Effect<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 的部分的代码简化很多。在
.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 注入的方式,提供合适的 loadText
和 mainQueue
,就能精确控制 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"
}
}
}
- 相对于提供一个实际的
dataTask
publisher,这里直接返回了一个 “Hello World” 作为完成值的Effect
。它代表了一个“即将发生”的外部“返回值”。 - 和上面 timer 的例子相似,使用
.test
和advance
让测试向前运行。不添加参数时,.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
无法对应和测试像是Debounce
、Throttle
或者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
,它代表了一对 Counter
和 TimerState
的模型:
1
2
3
4
struct GameState: Equatable {
var counter: Counter = .init()
var timer: TimerState = .init()
}
Game Action
接下来是 Action
,和 State
类似,我们可以简单地组合 CounterAction
和 TimerAction
:
1
2
3
4
enum GameAction {
case counter(CounterAction)
case timer(TimerAction)
}
Game Environment
我们已经定义了 CounterEnvironment
和 TimerEnvironment
,对于 GameEnvironment
来说,我们暂时定义一个空的环境类型就好:
1
struct GameEnvironment { }
在实际的 app 开发中,你可能会发现有很多时候我们会重复定义一些相同的环境值,比如 date
或者 mainQueue
等。这类相同的环境其实我们可以添加包装,让它们的复用更容易一些,我们会在本文练习部分稍微提及。在那之前,由于 GameState
的状态转变并不涉及更多的外部副作用,所以为了说明简便,暂时留空。
实际上“不涉及副作用”这个论断是错误的,更准确来说,GameState
内部的 Counter
和 TimerState
都是有副作用的。这些副作用,在 Game
的层级上不应该由 CounterEnvironment.live
或者 TimerEnvironment.live
来定义,而应该从 GameEnvironment
中转换过去。这部分内容也被当作了练习,还请确认一下。
Game Reducer
最后,是最艰难的部分 Reducer
了。这里的核心思想有下面三条:
- 组件的行为都是由 reducer 定义的。子组件的行为,也应该由子组件的 reducer 自己决定。因此我们需要使用已有的
counterReducer
和timerReducer
,并把GameAction
转换为子组件所需要的CounterAction
或TimerAction
并传递给它们。 - 子组件对各自 State 进行修改的结果,需要反应到父组件中,这样才能完成父组件
View
的刷新。在这个例子中,counterReducer
和timerReducer
会更改各自的Counter
和TimerState
,但是GameState
中的counter
和timer
并不会被子组件的 reducer 更改 (因为GameState
是一个 struct),因此我们需要一种方式让子组件 reducer 能够设置父组件对应的 state。 - 多个组件需要联合起来工作,因此各个组件的 reducer 需要进行合并。
TCA 中,在将多个子组件的 Reducer 组合成父组件 Reducer 时,通常结合使用 combine
和 pullback
。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 变化设置回到父组件中。具体来说,上面的代码中对应的编号:
/GameAction.counter
来自一个为 TCA 开发的工具库 CasePaths,它通过在 enum case 之前添加斜杠,来把这个 case 转换为一个具有更丰富特性的CasePath
struct。在这里,CasePath
主要承担从接收到的父组件 Action 中将对应的子组件的 Action 提取出来的工作,这样在子组件的 reducer 中,就可以使用它们了。- 对于
state
,使用 Key path 的语法创建一个WritableKeyPath
。子组件 reducer 中对子组件 state 的变更,最终会通过这个WritableKeyPath
写回到GameState
相关的属性里,最后触发 View 的刷新。 pullback
是一个转换器,它把子组件的 reducer 类型转换为父组件的 reducer 类型。最后,我们使用Reducer.combine
把counterReducer
和timerReducer
转换后的结果合并起来,这样它们就可以同时工作了。
理解 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()
)
}
用序列图可以更直观地显示 pullback
和 scope
在组件组合的时候到底做了什么。对于更大的组件,我们也可以用类似的方式一点点从小组件搭建:
练习
如果你没有跟随本文更新代码,你可以在这里找到下面练习的起始代码。参考实现可以在这里找到。
验证各个 View 的刷新
最坏情况
在 GameView
中我们使用了 stateless
来切分出一个无状态的 Store。请试试看把这个 stateless
去掉,然后在 TimerLabelView
和 CounterView
的 body
中添加一些打印语句 (比如 Self._printChanges()
),验证一下在最差状态下 View
的刷新机理。
占位 View
在 GameView
中,我们现在选择了在 VStack
的 onAppear
中发送 .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 无法测试。
pullback
的 environment
参数其实是一个函数:它负责将 GameEnvironment
转变为子组件所需要的环境。所以这里的解决方案是,在 GameEnvironment
中定义所有我们需要的环境值,然后在 pullback
时用这些环境值创建新的子组件环境,层层注入。请你试试看!
在实际 app 中你可能会发现,许多组件都会共享部分状态,比如
date
,mainQueue
等。 TCA 在示例 app 中给出了一种更加通用的方式,使用@dynamicMemberLookup
来巧妙地把子组件的环境包装到一个SystemEnvironment
中。像是date
和mainQueue
这类通用的环境值,就不需要每次在子组件环境中定义了。
记录结果并显示数据
在 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:
提示:可以在
gameReducer
的combine
中创建并添加一个新的 reducer,让它截取.counter(.playNext)
事件并更新GameState.results
。