1. Action adaptation

现在,我们在视图中有这个非常棒的属性在UI组件的每个动作闭包中我们不做任何逻辑,我们只向store发送一个动作。这很好,因为如果我们天真地构建一个普通的SwiftUI视图,我们会把各种混乱的逻辑和状态突变放在这些闭包中,它会掩盖这个视图负责的所有事情。此外,我们发送到store的操作准确地描述了导致这些操作发送的原因。所以,我们不会用我们想要改变应用的方式来描述它们,,像“increment count”, “request nth prime”, “dismiss alert”,而是我们只描述用户所做的,“increment button tapped”, “nth prime button tapped” and “prime modal dismissed”然后让reducer解释这些行为:

self.store.send(.counter(.decrTapped))
self.store.send(.counter(.incrTapped))
self.store.send(.counter(.isPrimeButtonTapped))
self.store.send(.counter(.nthPrimeButtonTapped))
self.store.send(.counter(.primeModalDismissed))
self.store.send(.counter(.alertDismissButtonTapped))

这使得我们的视图非常简单,当我们试图理解这些不同UI组件操作的目的时,没有留下太多解释的空间。6个月后,这些UI元素的操作可能会做更多的事情,比如跟踪分析、启动计时器等等。所以一般来说,描述用户在动作名称中做了什么要比描述要做什么改变或要执行什么效果要好得多。

然而,CounterAction枚举确实包含了一些不是直接的UI操作,而是一个从effect反馈到系统的操作。例如,当我们收到Wolfram Alpha API的响应时:

case nthPrimeResponse(n: Int, prime: Int?)

这让我们可以做一些在我们看来荒谬的事情,比如:

Button("What is the \(ordinal(self.viewStore.value.count)) prime?") {
  self.store.send(.counter(.nthPrimeResponse(n: 7, prime: 17)))
//  self.store.send(.counter(.nthPrimeButtonTapped))
}

我们永远不希望在视图中发送这个操作,因为它应该只来自于执行了一个effect,特别是Wolfram Alpha API请求。

此外,如果有一堆用户操作都在做相同的事情,那会怎么样呢? 例如,可能有多种方法来请求“nth prime”。我们听说手势很流行,所以如果你双击这个屏幕,它可能会调用“nth prime”请求。这意味着我们需要在CounterAction枚举中添加另一个动作,然后需要在reducer中处理该动作,即使它将执行与其他动作完全相同的操作:

public enum CounterAction: Equatable {
  …
  case doubleTap

我们需要在reducer中处理这个动作,这可能只是简单地重复当前nthprimebuttaapped所做的:

case .doubleTap:
  state.isNthPrimeRequestInFlight = true
  return [
    Current.nthPrime(state.count)
      .map(CounterAction.nthPrimeResponse)
      .receive(on: DispatchQueue.main)
      .eraseToEffect()
  ]

我们当然不想如此公然地重新创建这个工作,所以我们可以将这个工作提取到一个小的迷你reducer,每个动作调用,或者我们甚至可以同时处理两个动作:

case .nthPrimeButtonTapped, .doubleTap:

后一种技术可能是最直接的,但不幸的是,如果我们需要在这些操作中绑定一些数据,并且数据不完全匹配,那么它就会失效。比如其中一个操作持有一个整数,另一个持有一个布尔值。这样我们就无法同时处理这两种行为,我们就不得不将它们分开。

为了在视图中连接这个功能,我们可以在根视图中添加一个双击手势识别器,然后在调用时将这个新动作发送到store:

.onTapGesture(count: 2) {
  self.store.send(.counter(.doubleTap))
}

如果我们运行应用程序,我们会看到它并不像我们预期的那样正确。如果我们双击屏幕白色区域的任何地方,我们不会像预期的那样触发onTapGesture动作。这是因为手势只在根VStack内的单个视图上工作,所以触发它的唯一方法是双击显示计数的小文本。

一个快速的修复方法是将VStack包装在一个视图中,填充整个屏幕:

.frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity)

然而,在没有对新的包装器视图做任何事情的情况下,SwiftUI将优化它,甚至不创建一个实际的UIView,所以我们需要进一步对它做一些事情,比如给它一个背景颜色:

.background(Color.white)

这也许不是最好的方法,但我们现在并不是在教授使用SwiftUI的最佳方法,我们只是需要解决目前的问题。

如果我们再次运行这个应用程序,我们最终会看到,当你双击屏幕白色区域的任何地方时,我们确实调用了“nth prime”effect来得到结果。

我们开始看到一些与我们看到的状态类似的恼人之处。 首先,我们看到这个视图中存在视图并不真正关心的可用操作,这正是我们在状态中看到的。对于状态,这意味着我们在过度计算视图,但对于操作,这意味着我们可以做一些不好的事情,比如发送不应该从视图发送的操作。然后,我们看到我们经常需要为我们的视图提供超领域特定的操作,而将所有这些操作添加到域将是一件痛苦的事情。对于状态,我们有一个类似的问题,我们希望添加对禁用UI更多部分的支持,但这需要向结构体添加更多的状态。所以,在我们在store中使用状态和在store中发送动作时看到的问题之间有一种二元性。


2. View store action sending

因此,也许我们应该通过删除向store发送操作的概念来解决这个问题,而只使用 view store来发送操作,因为这给了我们一个很好的地方来执行特定于视图的转换。store仍然会负责处理需要完成的工作,如运行reducer和运行reducer的effects,但我们不会直接将动作发送到store

让我们先把Store上的send方法设为私有,这样我们就不能直接给它发送动作了。

private func send(_ action: Action) {

然后我们想给ViewStore添加一个send方法,这意味着ViewStore也需要一个新的泛型:

public final class ViewStore<Value, Action>: ObservableObject {
  …

  public func send(_ action: Action) {
    ???
  }
}

当这个send方法被调用时,它实际上应该只是调用这个view store的派生库。这是因为store是负责处理操作的实际运行时。view store send只是我们放在storestore用户之间的一个层,这样我们就可以控制我们正在使用的动作的形状。

这意味着我们不想在ViewStore中有一个send方法,而是想要一个包含send函数的属性,以便它可以在创建view store时从外部自定义:

public final class ViewStore<Value, Action>: ObservableObject {
  @Published public fileprivate(set) var value: Value
  fileprivate var cancellable: Cancellable?
  public let send: (Action) -> Void

  init(
    initialValue: Value,
    send: @escaping (Action) -> Void
  ) {
    self.value = initialValue
    self.send = send
  }
}

这会导致我们的视图函数的编译器错误,因为我们改变了创建view store所需要的内容。但这个问题很容易解决,我们只需要在商店中引入一个Action通用的send函数:

extension Store {
  public func view(removeDuplicates predicate: (Value, Value) -> Bool) -> ViewStore<Value, Action> {
    let viewStore = ViewStore(
      initialValue: self.value,
      send: self.send
    )
    viewStore.viewCancellable = self.$value
      .removeDuplicates(by: predicate)
      .sink { newValue in
        viewStore.value = newValue
        self
    }
    return vs
  }
}

现在构建了可组合体系结构,但在继续之前,我们可以清理一些东西。请记住,前面我们将这个self捕获添加到sink闭包中,以便view store能够保留它所派生的store。这是必要的,这样如果我们首先确定一个store的范围,然后查看它,我们就不会失去创建的中间store。我们现在可以摆脱它因为view store现在正确地持有store因为我们传递了self.send到初始化器。

.sink { newValue in
  viewStore.value = newValue
//  self
}

这两个对象之间的所有权关系很自然一旦我们让view store负责发送动作。为了完成剩下的应用构建,我们可以做最简单的事情,也就是暂时不改变动作。

例如,在PrimeModal模块中,我们需要执行以下操作:

public struct IsPrimeModalView: View {
  …
  @ObservedObject private var viewStore: ViewStore<State, PrimeModalAction>
  …

  public var body: some View {
    …
    self.viewStore.send(.removeFavoritePrimeTapped)
    …
    self.viewStore.send(.saveFavoritePrimeTapped)
    …
  }
}

类似地,可以对FavoritePrimes模块做一些小改动,让它编译:

public struct FavoritePrimesView: View {
  @ObservedObject var viewStore: ViewStore<[Int], FavoritePrimesAction>
  …

  public var body: some View {
    …
    self.viewStore.send(.deleteFavoritePrimes(indexSet))
    …
    self.viewStore.send(.saveButtonTapped)
    …
    self.viewStore.send(.loadButtonTapped)
    …
  }
}

最后,Counter模块的类似更改将再次获得整个应用程序构建:

public struct CounterView: View {
  @ObservedObject private var viewStore: ViewStore<State, CounterFeatureAction>
  …

  public var body: some View {
    …
    self.viewStore.send(.counter(.decrTapped))
    …
    Button("+") { self.viewStore.send(.counter(.incrTapped)) }
    …
    Button("Is this prime?") { self.viewStore.send(.counter(.isPrimeButtonTapped)) }
    …
    self.viewStore.send(.counter(.nthPrimeButtonTapped))
    …
    onDismiss: { self.viewStore.send(.counter(.primeModalDismissed)) }
    …
    self.viewStore.send(.counter(.alertDismissButtonTapped))
    …
  }
}

3. View actions

现在我们的应用程序正在构建,但我们当然没有利用view store中的动作转换的力量。我们希望它们不仅能让我们有机会将一些不需要担心的操作隐藏起来,还能让我们整合许多类似的操作,同时仍然为这些操作保留特定于域名的名称。

特别地,让我们再来看看counter操作,因为我们有一个视图不需要知道的操作,并且我们有两个操作最终在reducer中导致相同的行为。 我们希望我们可以使用新的view store适配工具来改善这种情况。正如我们之前提到的,从reducer的角度来看,它是有点奇怪的,不断引入越来越多的行动,在引擎盖下做同样的事情,然而从视图的角度来看,它是完美的意义,因为它帮助我们的视图尽可能简单。

视图逻辑和reducer逻辑之间的差异正是view store可以帮助我们解决的。我们应该允许为操作想出对业务逻辑领域最有意义的名称,同时也允许使用对视图有意义的操作名称。

首先,让我们重新定义特性的操作领域,以便更好地描述业务逻辑。与其为nthPrimeButtonTapped和doubleTap创建单独的操作,不如创建一个单独的操作来请求“第n个素数”:

public enum CounterAction: Equatable {
  …
//   case nthPrimeButtonTapped:
//   case doubleTap:
  case requestNthPrime:

然后我们需要更新reducer:

// case .nthPrimeButtonTapped, .doubleTap
case .requestNthPrime:
  state.isNthPrimeButtonDisabled = true
  return [
    Current.nthPrime(state.count)
      .map(CounterAction.nthPrimeResponse)
      .receive(on: DispatchQueue.main)
      .eraseToEffect()
  ]

为了编译,我们还可以在视图的任何地方发送.requestnthprime操作:

self.viewStore.send(.requestNthPrime)

这当然不理想,因为用户所做的与我们发送给store的内容之间存在脱节。如果视图只关心告诉store用户到底做了什么,然后让store将其解释为请求“第n个质数”,那么它可以变得更简单。

因此,就像我们对状态所做的那样,让我们为这个视图编写一个领域特定操作的枚举。在这个视图中会发生什么?它基本上是CounterAction枚举中的一切,但添加了一些操作,删除了一些操作:

enum Action {
  case decrTapped
  case incrTapped
  case nthPrimeButtonTapped
  case alertDismissButtonTapped
  case isPrimeButtonTapped
  case primeModalDismissed
  case doubleTap
}

特别地,我们现在将requestNthPrime分离为nthprimebuttontapping和doubleTap的两个操作,并且我们不再有nthprimerresponse的操作,因为它从未从视图中发送。

为了使用它,我们希望视图中的viewStore不再与CounterActions一起工作,而只是使用这个内部的Action:

@ObservedObject private var viewStore: ViewStore<State, Action>

这会产生一堆错误:在我们的viewStore转换中,以及在body属性中我们发送CounterFeatureActions而不是这个新类型的动作的一些地方。

后一个错误是最容易修复的。我们只需要从正在发送的操作中删除.counter包装器,现在我们就可以发送我们点击了“n个prime”按钮或双击了的特定于域的操作。

为了修复viewStore转换,我们只需要描述如何将一个传入的内部操作转换为reducer实际使用的CounterFeatureAction。这个转换逻辑可以在视图调用中直接发生:

self.viewStore = self.store
    .view(
    value: primeModalViewState,
    action: {
        switch $0 {
        case .decrTapped:
        return .counter(.decrTapped)
        case .incrTapped:
        return .counter(.incrTapped)
        case .nthPrimeButtonTapped:
        return .counter(.requestNthPrime)
        case .alertDismissButtonTapped:
        return .counter(.alertDismissButtonTapped)
        case .isPrimeButtonTapped:
        return .counter(.isPrimeButtonTapped)
        case .primeModalDismissed:
        return .counter(.primeModalDismissed)
        case .doubleTap:
        return .counter(.requestNthPrime)
        }
    },
    removeDuplicates: ==
)

这种转换非常简单,我们只需考虑每个局部的、特定于领域的操作,并确定它对reducer的业务逻辑意味着什么。

当然,这确实膨胀了初始化器的代码,所以就像我们对状态做的那样,我们可以把它放到一个小助手函数中,但这次它需要把我们的局部Action转换成CounterFeatureAction。 所以我们选择将它表示为本地 Action enum中的一个静态函数:

extension CounterFeatureAction {
  init(counterViewAction action: CounterView.Action) -> CounterFeatureAction {
    switch action {
    case .decrTapped:
      self = .counter(.decrTapped)
    case .incrTapped:
      self = .counter(.incrTapped)
    case .nthPrimeButtonTapped:
      self = .counter(.requestNthPrime)
    case .alertDismissButtonTapped:
      self = .counter(.alertDismissButtonTapped)
    case .isPrimeButtonTapped:
      self = .counter(.isPrimeButtonTapped)
    case .primeModalDismissed:
      self = .counter(.primeModalDismissed)
    case .doubleTap:
      self = .counter(.requestNthPrime)
    }
  }
}

然后我们可以更新视图的初始化器。

public init(store: Store<CounterFeatureState, CounterFeatureAction>) {
  print("CounterView.init")
  self.store = store
  self.viewStore = self.store
    .scope(
      value: State.init,
      action: Action.init
  )
    .view(removeDuplicates: ==)
}

我们现在已经完成了一些很酷的事情。让我们后退一步,看看我们的view是如何形成的。

该视图首先为自己的小局部域声明一个结构体和enum,即它所关心的数据块,以便body属性完成它的工作:

public struct CounterView: View {
  typealias State = (
    alertNthPrime: PrimeAlert?,
    count: Int,
    isNthPrimeButtonDisabled: Bool,
    isPrimeModalShown: Bool
  )
  enum Action {
    case decrTapped
    case incrTapped
    case nthPrimeButtonTapped
    case alertDismissButtonTapped
    case isPrimeButtonTapped
    case primeModalDismissed
    case doubleTap
  }
  …
}

注意,所有这些属性和用例都专门针对UI关注点进行了调优。 我们按照在UI中直接表示的方式来描述状态,比如显示alert时,以及禁用按钮时。我们描述的操作就是用户在UI中可以做的事情,比如点击一个按钮,取消一个模态,或者双击屏幕。字段和用例的名称与它们在UI中所代表的内容之间的解释空间应该很小。

接下来我们有这个视图的运行时对象:

let store: Store<CounterFeatureState, CounterFeatureAction>
@ObservedObject var viewStore: ViewStore<State, Action>

首先是store,它是实际运行我们的业务逻辑和effect的运行环境,它的域包含该特性的所有内容,以及执行其工作所需的所有子特性。然后我们有view store,它支持这个视图的呈现。我们读取当前状态,以便实现构建UI的body属性,并向它发送用户操作,以便运行业务逻辑。

接下来我们有了初始化器,它只需要从父视图传入一个store,然后我们查看该store,以便只提取视图实际关心的状态和动作的基本要素。我们甚至通过将状态和动作转换提取到帮助函数中来清理这一点,在初始化器中一切都很好和整洁:

public init(store: Store<CounterFeatureState, CounterFeatureAction>) {
  self.store = store
  self.viewStore = self.store
    .scope(
      value: State.init,
      action: CounterFeatureAction.init
   )
    .view
}

然后在body属性中,UI的实际工作发生的地方,我们有一些非常直接和完全没有逻辑的东西。例如,为了显示计数器和按钮的核心UI:

VStack {
  HStack {
    Button("-") { self.viewStore.send(.decrTapped) }
      .disabled(self.viewStore.value.isDecrementButtonDisabled)
    Text("\(self.viewStore.value.count)")
    Button("+") { self.viewStore.send(.incrTapped) }
      .disabled(self.viewStore.value.isIncrementButtonDisabled)
  }
  Button("Is this prime?") { self.viewStore.send(.isPrimeButtonTapped) }
  Button("What is the \(ordinal(self.viewStore.value.count)) prime?") {
    self.viewStore.send(.nthPrimeButtonTapped)
  }
  .disabled(self.viewStore.value.isNthPrimeButtonDisabled)
}

按钮只是向view store区发送操作,而这些操作准确地描述了导致它们的原因,所以没有空间解释我们应该发送什么操作。类似地,从view store区读取状态来构造这些UI组件也非常简单。

如果我们想这么做,我们甚至可以删除按钮的逻辑:

Button("What is the \(ordinal(self.viewStore.value.count)) prime?") {

为什么要在视图中进行这种计算而不是把它移到view store中呢?

Button(self.viewStore.value.nthPrimeButtonTitle) {

然后我们只需要更新本地视图状态。

public struct CounterView: View {
  struct State: Equatable {
    let alertNthPrime: PrimeAlert?
    let count: Int
    let isDecrementButtonDisabled: Bool
    let isIncrementButtonDisabled: Bool
    let isNthPrimeButtonDisabled: Bool
    let isPrimeModalShown: Bool
    let nthPrimeButtonTitle: String
  }
  …
}

extension CounterView.State {
  init(counterFeatureState state: CounterFeatureState) {
    self.alertNthPrime = state.alertNthPrime
    self.count = state.count
    self.isDecrementButtonDisabled = state.isNthPrimeRequestInFlight
    self.isIncrementButtonDisabled = state.isNthPrimeRequestInFlight
    self.isNthPrimeButtonDisabled = state.isNthPrimeRequestInFlight
    self.isPrimeModalShown = state.isPrimeModalShown
    self.nthPrimeButtonTitle = "What is the \(ordinal(state.count)) prime?"
  }
}

继续在视图中,我们看到更复杂的UI类型,它们仍然非常简单,因为它们没有逻辑。例如,要显示和隐藏主模态,我们可以这样做:

.sheet(
  isPresented: .constant(self.viewStore.value.isPrimeModalShown),
  onDismiss: { self.viewStore.send(.primeModalDismissed) }
) {
  IsPrimeModalView(
    store: self.store.scope(
      value: { ($0.count, $0.favoritePrimes) },
      action: { .primeModal($0) }
    )
  )
}

同样,view store的状态完美地描述了何时应该显示模态,以及当模态解散时该做什么。然后展示的模态视图准确地描述了它需要什么样的store来完成它的工作,我们可以通过确定store的范围来传递给它。

接下来我们有了“nth prime”警报:

.alert(
  item: .constant(self.viewStore.value.alertNthPrime)
) { alert in
  Alert(
    title: Text(alert.title),
    dismissButton: .default(Text("Ok")) {
      self.viewStore.send(.alertDismissButtonTapped)
    }
  )
}

同样,状态精确地描述了何时显示警报。

最后我们有了tap手势:

.onTapGesture(count: 2) {
  self.viewStore.send(.doubleTap)
}

同样的,动作准确地描述了应该在什么时候发送。

至此,我们在这里看到的是,有一个非常直接的配方来构造我们的视图,以便它们与可组合架构一起工作,以便它们包含尽可能少的逻辑。

  • 首先,使用状态结构和操作枚举描述视图的本地域。
  • 然后,通过确定支持该视图的业务逻辑的store域以及它的所有子视图,以及仅支持该视图的本地域的view store域,来描述视图的运行时。
  • 接下来,实现视图的初始化器,它将以store作为参数,然后在初始化器中,您需要描述如何将整个特性的域转换为视图的域。
  • 最后你需要实现视图的主体,它应该做尽可能少的工作来将view store的状态转换为UI。

4. Tests and the view store

好了,我们已经对架构做了一些相当全面的改变。在进一步深入之前,我们应该考虑它们对架构测试故事的影响。

如果我们试着构建并运行最喜欢的质数测试,我们会发现它不仅仍然在构建,而且一切都通过了。这是有意义的,因为我们对架构所做的更改都是围绕Stores和ViewStores进行的。这些测试是简单的单元测试,它们使用一些状态和操作直接调用reducer,自上次以来这些状态和操作都没有更改。

质数模态测试也是如此:一切都建立并通过。这太酷了!这意味着对reducer的测试并没有从根本上改变。

So what about the counter tests? 不幸的是,它们已经不再井然有序了。我们不仅在计数器模块中进行了一些重要的域重构,而且还进行了一个集成风格的测试,该测试创建了一个视图,并使用存储和快照测试了计数器屏幕。

让我们从单元测试开始。计数器模块的单元测试使用可组合架构的assert助手,可以以声明式的方式,描述用户在应用程序中执行的一系列步骤和断言状态如何在每一步之后发生变化,同时隐藏管理和运行副作用的细节。

我们的第一个错误是在一个测试中,该测试断言不按增量和递减按钮,因为它仍然引用CounterViewState,我们之前将其重命名为CounterFeatureState

func testIncrDecrButtonTapped() {
  assert(
    initialValue: CounterFeatureState(count: 2),
    reducer: counterFeatureReducer,
    environment: { _ in .sync { 17 } },
    steps:
    Step(.send, .counter(.incrTapped)) { $0.count = 3 },
    Step(.send, .counter(.incrTapped)) { $0.count = 4 },
    Step(.send, .counter(.decrTapped)) { $0.count = 3 }
  )
}

下一个错误是在一个描述请求“n个”素数的“快乐流”的测试中。我们已经重构了核心域逻辑,因此不再正确地断言isNthPrimeButtonDisabled状态或nthPrimeButtonTapped动作。相反,我们可以使用isNthPrimeRequestInFlight和requestNthPrime更抽象地描述这个状态和操作。

func testNthPrimeButtonHappyFlow() {
  assert(
    initialValue: CounterFeatureState(
      alertNthPrime: nil,
      count: 7,
      isNthPrimeRequestInFlight: false
    ),
    reducer: counterFeatureReducer,
    environment: { _ in .sync { 17 } },
    steps:
    Step(.send, .counter(.requestNthPrime)) {
      $0.isNthPrimeRequestInFlight = true
    },
    Step(.receive, .counter(.nthPrimeResponse(n: 7, prime: 17))) {
      $0.alertNthPrime = PrimeAlert(n: $0.count, prime: 17)
      $0.isNthPrimeRequestInFlight = false
    },
    Step(.send, .counter(.alertDismissButtonTapped)) {
      $0.alertNthPrime = nil
    }
  )
}

“不快乐流”需要做出同样的改变:

func testNthPrimeButtonUnhappyFlow() {
  assert(
    initialValue: CounterFeatureState(
      alertNthPrime: nil,
      count: 7,
      isNthPrimeRequestInFlight: false
    ),
    reducer: counterFeatureReducer,
    environment: { _ in .sync { nil } },
    steps:
    Step(.send, .counter(.requestNthPrime)) {
      $0.isNthPrimeRequestInFlight = true
    },
    Step(.receive, .counter(.nthPrimeResponse(n: 7, prime: nil))) {
      $0.isNthPrimeRequestInFlight = false
    }
  )
}

最后我们需要更新testPrimeModal来使用CounterFeatureState而不是CounterViewState

就像这样,我们的单元测试再次编译!如果我们认真使用Xcode的重构工具,这类工作甚至可以自动完成。

我们应该提到的一点是,这些测试不再用非常具体的术语描述用户的操作或屏幕保持的状态。在这些更改之前,这些测试准确地描述了用户所做的操作,因为操作直接映射到正在点击的第n个主按钮,而状态直接映射到第n个被禁用的主按钮。我们现在已经将跨平台的东西普遍化了,其中特定的用户操作和屏幕状态可能会有一些差异。需要指出的是,assert helper在描述和测试用户意图脚本方面仍然做得很好,只是恰好映射到不同平台上的不同UI。

接下来我们有了testsnapshot测试用例,这是一个集成风格的测试,在这个测试中,我们创建了一个存储、一个视图,并沿着该存储发送了一个操作脚本,在此过程中根据屏幕截图进行断言。

但随着我们对架构的改变,我们不能再在store中直接调用send:

store.send(.counter(.incrTapped))

🛑 ‘send’ is inaccessible due to ‘private’ protection level

我们可以做的一件事是将Storesend方法放在internal,这样我们就可以通过@testable导入来访问它。然后我们可以继续向store发送功能级别的CounterFeatureActions操作。也许更简单一点,我们可以在store上添加一个.view来访问它的视图商店。

let viewStore = store.view

然后更新所有发送操作到store的调用,将它们发送到view store :

viewStore.send(.counter(.incrTapped))
…
viewStore.send(.counter(.incrTapped))
…
viewStore.send(.counter(.nthPrimeButtonTapped))
…
viewStore.send(.counter(.alertDismissButtonTapped))
…
viewStore.send(.counter(.isPrimeButtonTapped))
…
viewStore.send(.primeModal(.saveFavoritePrimeTapped))
…
viewStore.send(.counter(.primeModalDismissed))

现在我们只剩下一个错误:

viewStore.send(.counter(.nthPrimeButtonTapped))
🛑 Type ‘CounterAction’ has no member ‘nthPrimeButtonTapped’

我们只需要将nthPrimeButtonTapped更新为requestNthPrime,我们又在构建顺序了。当我们进行测试时

❌ failed - Snapshot does not match reference.

我们得到一个快照断言失败。如果我们看看差异,这是有意义的。

我们更新了视图,在第n个素数请求正在运行时禁用incr和decr按钮,快照正确地捕获了该行为。这样我们就可以重新重录了。

record = true
❌ failed - Record mode is on. Turn record mode off and re-run “testSnapshots” to test against the newly-recorded snapshot.

不过,关于这些测试还有更多要说的,因为我们失去了一些覆盖范围。在counter模块中,我们有一些将CounterFeatureState和CounterFeatureAction映射到CounterView.State和CounterView.Action

extension CounterView.State {
  init(counterFeatureState state: CounterFeatureState) {
    self.alertNthPrime = state.alertNthPrime
    self.count = state.count
    self.isDecrementButtonDisabled = state.isNthPrimeRequestInFlight
    self.isIncrementButtonDisabled = state.isNthPrimeRequestInFlight
    self.isNthPrimeButtonDisabled = state.isNthPrimeRequestInFlight
    self.isPrimeModalShown = state.isPrimeModalShown
    self.nthPrimeButtonTitle = "What is the \(ordinal(state.count)) prime?"
  }
}

extension CounterFeatureAction {
  init(counterViewAction action: CounterView.Action) -> CounterFeatureAction {
    switch action {
    case .decrTapped:
      return .counter(.decrTapped)
    case .incrTapped:
      return .counter(.incrTapped)
    case .nthPrimeButtonTapped:
      return .counter(.requestNthPrime)
    case .alertDismissButtonTapped:
      return .counter(.alertDismissButtonTapped)
    case .isPrimeButtonTapped:
      return .counter(.isPrimeButtonTapped)
    case .primeModalDismissed:
      return .counter(.primeModalDismissed)
    case .doubleTap:
      return .counter(.requestNthPrime)
    }
  }
}

而且我们的测试都没有覆盖到这些转换。

现在,这些函数和数据类型非常简单,因此可以合理地预测它们对于单元测试非常简单。

然而,在我们的快照测试中,最好知道视图状态是直接从视图操作脚本更改的。因此,与其在整个计数器特性的状态和操作上使用viewStore,不如创建一个counter-scopedviewStore

let counterViewStore = store
  .scope(value: CounterView.State.init, action: CounterFeatureAction.init))
  .view
…
counterViewStore.send(.incrTapped)
…
counterViewStore.send(.incrTapped)
…
counterViewStore.send(.nthPrimeButtonTapped)
…
counterViewStore.send(.alertDismissButtonTapped)
…
counterViewStore.send(.isPrimeButtonTapped)
…
counterViewStore.send(.primeModal(.saveFavoritePrimeTapped))
…
counterViewStore.send(.primeModalDismissed)

🛑 Type ‘CounterView.Action’ has no member ‘primeModal’

现在我们看到我们的一个操作实际上来自于 prime modal viewStore,所以我们也要引入其中一个:

let primeModalViewStore = store
  .scope(value: { $0.primeModal }, action: { .primeModal($0) })
  .view(removeDuplicates: ==)
…
primeModalViewStore.send(.saveFavoritePrimeTapped)

现在我们在构建顺序中,我们可以构建并运行我们的测试,它们仍然通过,这可以给我们相当好的信心,我们通过viewStore发送的动作被正确映射到我们的期望。

必须承认的是,我们在获得粘合代码的过程中产生了一些样板,但同样重要的是要指出,我们的架构和以往一样是可测试的!


5. conclution

过去的几集已经很长了,我们已经走了很长一段路。我们最初的动机是一个简单的性能问题,即天真地将视图中的每个store都设置为@ObservableObject,这样就会意外地过度渲染视图。解决方案很简单,我们从存储中派生了一种新的对象,叫做view store,它只包含视图真正关心的状态位,因此只有当它所依赖的东西发生变化时才渲染视图。

但我们不想只停留在这里,因为这个view store抽象帮助我们清理了视图中的一些粗糙边缘。特别是,它允许我们将视图中的一些逻辑移动到一个很好的可测试区域,从而使我们的视图更简单和更可测试,它允许我们描述视图的更特定于领域的状态,而不必在应用程序状态中表示它。

但我们也不想就此止步! 通过让view store负责发送操作,我们进一步推动了view store的想法,这给了我们另一个机会来精确地塑造视图所关心的领域。

所有这些加在一起意味着我们的观点更没有逻辑性,更直接,更容易理解。

虽然可以认为视图更简单一些,但这是有代价的。我们必须为视图引入一个全新的状态结构和操作枚举,我们必须在视图中保留一个全新的对象,我们必须提供转换来构造view store。对于每个视图,似乎要做很多额外的工作。

这一切是否真的值得?

首先,如果你的视图很简单,或者如果你不认为你的应用或特定屏幕有任何性能问题,你可以通过点击.view来派生你的视图商店,你就完成了。不需要额外的仪式!

然而,一旦应用程序的大小和用例增加,您可能会发现view store抽象正是您需要讨论的复杂性和解锁新功能。

也许对Mac版本的计数和素数计算器应用的需求并不大,但它会帮助我们演示使用view store的一个关键优势。我们可以将业务逻辑一般化,这样就不需要考虑平台特性,然后使用view store将这些通用功能适应于特定的平台,如iOS、watchOS、macOS、tvOS或所有4。

为了演示这个,我们会创建一个Mac版本的应用它与之前创建的iOS版本有一些细微的区别。特别地,现在当我们问当前计数是否为质数时,我们会显示一个模态,但在Mac上,我们会显示它为弹窗,因为macOS上的弹窗是超轻的,不幸的是,iphone不支持。我们还将删除请求“n个质数”的双击手势,因为这种手势在Mac上不是很常见……