前言
在上一篇关于 TCA 的文章中,我们通过总览的方式看到了 TCA 中一个 Feature 的运作方式,并尝试实现了一个最小的 Feature 和它的测试。在这篇文章中,我们会继续深入,看看 TCA 中对 Binding 的处理,以及使用 Environment 来把依赖从 reducer 中解耦的方法。
关于绑定
绑定和普通状态的区别
在上一篇文章中,我们实现了“点击按钮” -> “发送 Action” -> “更新 State” -> “触发 UI 更新” 的流程,这解决了“状态驱动 UI”这一课题。不过,除了单纯的“通过状态来更新 UI” 以外,SwiftUI 同时也支持在反方向使用 @Binding
的方式把某个 State 绑定给控件,让 UI 能够不经由我们的代码,来更改某个状态。在 SwiftUI 中,我们几乎可以在所有既表示状态,又能接受输入的控件上找到这种模式,比如 TextField
接受 String
的绑定 Binding<String>
,Toggle
接受 Bool
的绑定 Binding<Bool>
等。
当我们把某个状态通过 Binding
交给其他 view 时,这个 view 就有能力改变去直接改变状态了,实际上这是违反了 TCA 中关于只能在 reducer 中更改状态的规定的。对于绑定,TCA 中为 View Store 添加了将状态转换为一种“特殊绑定关系”的方法。我们来试试看把 Counter 例子中的显示数字的 Text
改成可以接受直接输入的 TextField
。
在 TCA 中实现单个绑定
首先,为 CounterAction
和 counterReducer
添加对应的接受一个字符串值来设定 count
的能力:
enum CounterAction {
case increment
case decrement
+ case setCount(String)
case reset
}
let counterReducer = Reducer<Counter, CounterAction, CounterEnvironment> {
state, action, _ in
switch action {
// ...
+ case .setCount(let text):
+ if let value = Int(text) {
+ state.count = value
+ }
+ return .none
// ...
}.debug()
接下来,把 body
中原来的 Text
替换为下面的 TextField
:
var body: some View {
WithViewStore(store) { viewStore in
// ...
- Text("\(viewStore.count)")
+ TextField(
+ String(viewStore.count),
+ text: viewStore.binding(
+ get: { String($0.count) },
+ send: { CounterAction.setCount($0) }
+ )
+ )
+ .frame(width: 40)
+ .multilineTextAlignment(.center)
.foregroundColor(colorOfCount(viewStore.count))
}
}
viewStore.binding
方法接受 get
和 send
两个参数,它们都是和当前 View Store 及绑定 view 类型相关的泛型函数。在特化 (将泛型在这个上下文中转换为具体类型) 后:
get: (Counter) -> String
负责为对象 View (这里的TextField
) 提供数据。send: (String) -> CounterAction
负责将 View 新发送的值转换为 View Store 可以理解的 action,并发送它来触发counterReducer
。
在counterReducer
接到binding
给出的setCount
事件后,我们就回到使用 reducer 进行状态更新,并驱动 UI 的标准 TCA 循环中了。
简化代码
做一点重构:现在 binding
的 get
是从 $0.count
生成的 String
,reducer 中对 state.count
的设定也需要先从 String
转换为 Int
。我们把这部分 Mode 和 View 表现形式相关的部分抽取出来,放到 Counter
的一个 extension 中,作为 View Model 使用:
extension Counter {
var countString: String {
get { String(count) }
set { count = Int(newValue) ?? count }
}
}
把 reducer 中转换 String
的部分替换成 countString
:
let counterReducer = Reducer<Counter, CounterAction, CounterEnvironment> {
state, action, _ in
switch action {
// ...
case .setCount(let text):
- if let value = Int(text) {
- state.count = value
- }
+ state.countString = text
return .none
// ...
}.debug()
在 Swift 5.2 中,KeyPath
已经可以被当作函数使用了,因此我们可以把 \Counter.countString
的类型看作 (Counter) -> String
。同时,Swift 5.3 中 enum case 也可以当作函数,可以认为 CounterAction.setCount
具有类型 (String) -> CounterAction
。两者恰好满足 binding
的两个参数的要求,所以可以进一步将创建绑定的部分简化:
// ...
TextField(
String(viewStore.count),
text: viewStore.binding(
- get: { String($0.count) },
+ get: \.countString,
- send: { CounterAction.setCount($0) }
+ send: CounterAction.setCount
)
)
// ...
最后,别忘了为 .setCount
添加测试!
多个绑定值
如果在一个 Feature 中,有多个绑定值的话,使用例子中这样的方式,每次我们都会需要添加一个 action,然后在 binding
中 send
它。这是千篇一律的模板代码,TCA 中设计了 @BindableState
和 BindableAction
,让多个绑定的写法简单一些。具体来说,分三步:
- 为
State
中的需要和 UI 绑定的变量添加@BindableState
。 - 将
Action
声明为BindableAction
,然后添加一个“特殊”的 casebinding(BindingAction<Counter>)
。 - 在 Reducer 中处理这个
.binding
,并添加.binding()
调用。
直接用代码说明会更快:
// 1
struct MyState: Equatable {
+ @BindableState var foo: Bool = false
+ @BindableState var bar: String = ""
}
// 2
- enum MyAction {
+ enum MyAction: BindableAction {
+ case binding(BindingAction<MyState>)
}
// 3
let myReducer = //...
// ...
+ case .binding:
+ return .none
}
+ .binding()
这样一番操作后,我们就可以在 View 里用类似标准 SwiftUI 的做法,使用 $
取 projected value 来进行 Binding 了:
struct MyView: View {
let store: Store<MyState, MyAction>
var body: some View {
WithViewStore(store) { viewStore in
+ Toggle("Toggle!", isOn: viewStore.binding(\.$foo))
+ TextField("Text Field!", text: viewStore.binding(\.$bar))
}
}
}
这样一来,即使有多个 binding 值,我们也只需要用一个 .binding
action 就能对应了。这段代码能够工作,是因为 BindableAction
要求一个签名为 BindingAction<State> -> Self
且名为 binding
的函数:
public protocol BindableAction {
static func binding(_ action: BindingAction<State>) -> Self
}
再一次,利用了将 enum case 作为函数使用的 Swift 新特性,代码可以变得非常简单优雅。
环境值
猜数字游戏
回到 Counter 的例子来。既然已经有输入数字的方式了,那不如来做一个猜数字的小游戏吧!
最简单的方法,是在 Counter
中添加一个属性,用来持有这个随机数:
struct Counter: Equatable {
var count: Int = 0
+ let secret = Int.random(in: -100 ... 100)
}
检查 count
和 secret
的关系,返回答案:
extension Counter {
enum CheckResult {
case lower, equal, higher
}
var checkResult: CheckResult {
if count < secret { return .lower }
if count > secret { return .higher }
return .equal
}
}
有了这个模型,我们就可以通过使用 checkResult
来在 view 中显示一个代表结果的 Label
了:
struct CounterView: View {
let store: Store<Counter, CounterAction>
var body: some View {
WithViewStore(store) { viewStore in
VStack {
+ checkLabel(with: viewStore.checkResult)
HStack {
Button("-") { viewStore.send(.decrement) }
// ...
}
func checkLabel(with checkResult: Counter.CheckResult) -> some View {
switch checkResult {
case .lower:
return Label("Lower", systemImage: "lessthan.circle")
.foregroundColor(.red)
case .higher:
return Label("Higher", systemImage: "greaterthan.circle")
.foregroundColor(.red)
case .equal:
return Label("Correct", systemImage: "checkmark.circle")
.foregroundColor(.green)
}
}
}
最终,我们可以得到这样的 UI:
外部依赖
当我们用这个 UI “蒙对”答案后,Reset 按钮虽然可以把猜测归零,但它并不能为我们重开一局,这当然有点无聊。我们来试试看把 Reset 按钮改成 New Game 按钮。
在 UI 和 CounterAction
里我们已经定义了 .reset
行为了,进行一些重命名的工作:
enum CounterAction {
// ...
- case reset
+ case playNext
}
struct CounterView: View {
// ...
var body: some View {
// ...
- Button("Reset") { viewStore.send(.reset) }
+ Button("Next") { viewStore.send(.playNext) }
}
}
然后在 counterReducer
里处理这个情况,
struct Counter: Equatable {
var count: Int = 0
- let secret = Int.random(in: -100 ... 100)
+ var secret = Int.random(in: -100 ... 100)
}
let counterReducer = Reducer<Counter, CounterAction, CounterEnvironment> {
// ...
- case .reset:
+ case .playNext:
state.count = 0
+ state.secret = Int.random(in: -100 ... 100)
return .none
// ...
}.debug()
运行 app,观察 reducer debug()
的输出,可以看到一切正常!太好了。
随时 Cmd + U 运行测试是大家都应该养成的习惯,这时候我们可以发现测试编译失败了。最后的任务就是修正原来的 .reset
测试,这也很简单:
func testReset() throws {
- store.send(.reset) { state in
+ store.send(.playNext) { state in
state.count = 0
}
}
但是,测试的运行结果大概率会失败!
这是因为 .playNext
现在不仅重置 count
,也会随机生成新的 secret
。而 TestStore
会把 send
闭包结束时的 state
和真正的由 reducer 操作的 state 进行比较并断言:前者没有设置合适的 secret
,导致它们并不相等,所以测试失败了。
我们需要一种稳定的方式,来保证测试成功。
使用环境值解决依赖
在 TCA 中,为了保证可测试性,reducer 必须是纯函数:也就是说,相同的输入 (state, action 和 environment) 的组合,必须能给出相同的输入 (在这里输出是 state 和 effect,我们会在后面的文章再接触 effect 角色)。
let counterReducer = // ... {
state, action, _ in
// ...
case .playNext:
state.count = 0
state.secret = Int.random(in: -100 ... 100)
return .none
//...
}.debug()
在处理 .playNext
时,Int.random
显然无法保证每次调用都给出同样结果,它也是导致 reducer 变得无法测试的原因。TCA 中环境 (Environment) 的概念,就是为了对应这类外部依赖的情况。如果在 reducer 内部出现了依赖外部状态的情况 (比如说这里的 Int.random
,使用的是自动选择随机种子的 SystemRandomNumberGenerator
),我们可以把这个状态通过 Environment
进行注入,让实际 app 和单元测试能使用不同的环境。
首先,更新 CounterEnvironment
,加入一个属性,用它来持有随机生成 Int
的方法。
struct CounterEnvironment {
+ var generateRandom: (ClosedRange<Int>) -> Int
}
现在编译器需要我们为原来 CounterEnvironment()
的地方加上 generateRandom
的设定。我们可以直接在生成时用 Int.random
来创建一个 CounterEnvironment
:
CounterView(
store: Store(
initialState: Counter(),
reducer: counterReducer,
- environment: CounterEnvironment()
+ environment: CounterEnvironment(
+ generateRandom: { Int.random(in: $0) }
+ )
)
)
一种更加常见和简洁的做法,是为 CounterEnvironment
定义一组环境,然后把它们传到相应的地方:
struct CounterEnvironment {
var generateRandom: (ClosedRange<Int>) -> Int
+ static let live = CounterEnvironment(
+ generateRandom: Int.random
+ )
}
CounterView(
store: Store(
initialState: Counter(),
reducer: counterReducer,
- environment: CounterEnvironment()
+ environment: .live
)
)
现在,在 reducer
中,就可以使用注入的环境值来达到和原来等效的结果了:
let counterReducer = // ... {
- state, action, _ in
+ state, action, environment in
// ...
case .playNext:
state.count = 0
- state.secret = Int.random(in: -100 ... 100)
+ state.secret = environment.generateRandom(-100 ... 100)
return .none
// ...
}.debug()
万事俱备,回到最开始的目的 - 保证测试能顺利通过。在 test target 中,用类似的方法创建一个 .test
环境:
extension CounterEnvironment {
static let test = CounterEnvironment(generateRandom: { _ in 5 })
}
现在,在生成 TestStore
的时候,使用 .test
,然后在断言时生成合适的 Counter
作为新的 state,测试就能顺利通过了:
store = TestStore(
initialState: Counter(count: Int.random(in: -100...100)),
reducer: counterReducer,
- environment: CounterEnvironment()
+ environment: .test
)
store.send(.playNext) { state in
- state.count = 0
+ state = Counter(count: 0, secret: 5)
}
其他常见依赖
除了像是 random 系列以外,凡是会随着调用环境的变化 (包括时间,地点,各种外部状态等等) 而打破 reducer 纯函数特性的外部依赖,都应该被纳入 Environment 的范畴。常见的像是 UUID
的生成,当前 Date
的获取,获取某个运行队列 (比如 main queue),使用 Core Location 获取现在的位置信息,负责发送网络请求的网络框架等等。
它们之中有一些是可以同步完成的,比如例子中的 Int.random
;有一些则是需要一定时间才能得到结果,比如获取位置信息和发送网络请求。对于后者,我们往往会把它转换为一个 Effect
。我们会在下一篇文章中再讨论 Effect
。
练习
如果你没有跟随本文更新代码,你可以在这里找到下面练习的起始代码。参考实现可以在这里找到。
添加一个 Slider
用键盘和加减号来控制 Counter 已经不错了,但是添加一个 Slider 会更有趣。请为 CounterView 添加一个 Slider
,用来来和 TextField
以及 “+” “-“ Button
一起,控制我们的猜数字游戏。
期望的 UI 大概是这样:
别忘了写测试!
完善 Counter,记录更多信息
为了后面功能的开发,我们需要更新一下 Counter 模型。首先,每个谜题添加一些元信息,比如谜题 ID:
在 Counter 中加上下面的属性,然后让它满足 Identifiable
:
- struct Counter: Equatable {
+ struct Counter: Equatable, Identifiable {
var count: Int = 0
var secret = Int.random(in: -100 ... 100)
+ var id: UUID = UUID()
}
在开始新一轮游戏的时候,记得更新 id
。还有,别忘了写测试!