0
点赞
收藏
分享

微信扫一扫

TCA - SwiftUI 的救星?(二)

两岁时就很帅 2022-01-20 阅读 99

前言

在上一篇关于 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 中实现单个绑定

首先,为 CounterActioncounterReducer 添加对应的接受一个字符串值来设定 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 方法接受 getsend 两个参数,它们都是和当前 View Store 及绑定 view 类型相关的泛型函数。在特化 (将泛型在这个上下文中转换为具体类型) 后:

  • get: (Counter) -> String 负责为对象 View (这里的 TextField) 提供数据。
  • send: (String) -> CounterAction 负责将 View 新发送的值转换为 View Store 可以理解的 action,并发送它来触发 counterReducer
    counterReducer 接到 binding 给出的 setCount 事件后,我们就回到使用 reducer 进行状态更新,并驱动 UI 的标准 TCA 循环中了。

简化代码

做一点重构:现在 bindingget 是从 $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,然后在 bindingsend 它。这是千篇一律的模板代码,TCA 中设计了 @BindableStateBindableAction,让多个绑定的写法简单一些。具体来说,分三步:

  1. State 中的需要和 UI 绑定的变量添加 @BindableState
  2. Action 声明为 BindableAction,然后添加一个“特殊”的 case binding(BindingAction<Counter>)
  3. 在 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)
}

检查 countsecret 的关系,返回答案:

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。还有,别忘了写测试!

举报

相关推荐

0 条评论