1. The favorite primes app

FavoritesPrimeView可能是最简单的开始,所有我们要做的是创建一个托管视图控制器,我们的视图配置了一个store:

import ComposableArchitecture
import FavoritePrimes
import SwiftUI
import PlaygroundSupport

PlaygroundPage.current.liveView = UIHostingController(
  rootView: FavoritePrimesView(
    store: Store<[Int], FavoritePrimesAction>(
      initialValue: [],
      reducer: favoritePrimesReducer
    )
  )
)

这个视图只包含一个空的this,但那是因为我们没有给它任何初始值的数据。

如果我们提供一些数据,我们会看到受欢迎的列表,并且我们可以从列表中删除:

PlaygroundPage.current.liveView = UIHostingController(
  rootView: FavoritePrimesView(
    store: Store<[Int], FavoritePrimesAction>(
      initialValue: [2, 3, 5, 7, 11],
      reducer: favoritePrimesReducer
    )
  )
)

所以这个视图可以单独运行,并使用一个store来保存它的突变,就在playground上。 然而,当它在完整的应用程序中运行时,它所提交的store实际上只是对驱动整个应用程序的全局store的一个视图。事实上,playground不准也不能访问主应用程序代码。

2. The prime modal app

最受欢迎质数视图很简单,让我们试试更复杂的东西:质数模态视图。我们能在一个完全独立的store里运行吗?

import PrimeModal

PlaygroundPage.current.liveView = UIHostingController(
  rootView: IsPrimeModalView(
    store: Store<PrimeModalState, PrimeModalAction>(
      initialValue: (0, []),
      reducer: primeModalReducer
    )
  )
)

这马上就告诉我们,当我们显示一个非素数时,视图是什么样子的。 如果我们更新初始值我们会看到质数是什么样子的:

initialValue: (2, []),

我们甚至可以点击添加/删除按钮几次,它实际上改变了这个收藏数组。

我们也可以在初始值中加入一些我们喜欢的元素,这样我们可以确保看起来是正确的:

initialValue: (2, [2, 3, 5]),

这是在操场上创建的store的另一个视图。它不是在我们的应用程序的全局store中运行的。

3. The counter app

因此,这两个屏幕非常容易加载并独立运行。让我们试试最后一个屏幕,counter view。如果我们尝试做之前的屏幕,我们将遇到一个问题:

import Counter

PlaygroundPage.current.liveView = UIHostingController(
  rootView: CounterView(
    store: Store<CounterViewState, CounterViewAction>(
      initialValue: (0, []),
      reducer: ???
    )
  )
)

我们这里没有计数器视图reducer。 我们有一个计数器reducer,但它只关注递增和递减的逻辑。我们也有一个主要模态reducer,它专注于显示主要模态和管理从一个收藏夹中添加和删除一个主要模态。我们需要一个把这两个reducer捆绑在一起的reducer

看起来好像我们的reducers没有被正确分解。这里的问题是,我们没有一个代表counter视图的单一reducer,而是我们有两个reducers合并在应用reducer中:

pullback(counterReducer, value: \.count, action: \.counter),
pullback(primeModalReducer, value: \.primeModal, action: \.primeModal),

这个reducer的组合实际上是counter view的动力,但它存在于我们的应用目标中,而不是Counter model。更糟糕的是,应用目标中的所有内容在playgrounds都是不可访问的。

也许这个组合的reducer应该被拉出到操作CounterViewcounterViewReducer中。让我们复制这些行,将它们注释掉,然后切换到Counter模块(实际上也将目标切换到Counter)来设置一个基本的reducer签名:

let counterViewReducer: (inout CounterViewState, CounterViewAction) -> Void = combine(
  pullback(counterReducer, value: \.count, action: \.counter),
  pullback(primeModalReducer, value: \.primeModal, action: \.primeModal),
)

这基本上是可行的,但这里的一个问题是,我们不再有用于回拉的enum键路径。我们希望能够将计数器和主要模态动作拉回CounterViewAction enum中。这些关键路径目前位于AppAction上,这正是我们想要提取到Counter模块中的内容。

我们把它们剪切粘贴到CounterViewAction枚举中:

var counter: CounterAction? {
  get {
    guard case let .counter(value) = self else { return nil }
    return value
  }
  set {
    guard case .counter = self, let newValue = newValue else { return }
    self = .counter(newValue)
  }
}

var primeModal: PrimeModalAction? {
  get {
    guard case let .primeModal(value) = self else { return nil }
    return value
  }
  set {
    guard case .primeModal = self, let newValue = newValue else { return }
    self = .primeModal(newValue)
  }
}

现在我们的组合reducer已经接近编译,但我们不再需要回拉主模态状态,因为它现在在全计数器视图状态下运行。

这两个功能都使用计数器和最喜欢的质数。所以我们可以使用标识键路径来修复这个编译器错误:

public let counterViewReducer: (inout CounterViewState, CounterViewAction) -> Void = combine(
  pullback(counterReducer, value: \.count, action: \.counter),
  pullback(primeModalReducer, value: \.self, action: \.primeModal)
)

我们甚至可以删除显式的reducer签名如果我们只需要输入一些键路径类型,这可能会少一点干扰:

public let counterViewReducer = combine(
  pullback(counterReducer, value: \CounterViewState.count, action: \CounterViewAction.counter),
  pullback(primeModalReducer, value: \.self, action: \.primeModal)
)

我们的Counter模块现在正在构建,我们甚至可以切换回playground,更新代码来使用这个新的reducer,并完全独立地运行这个屏幕:

import Counter

PlaygroundPage.current.liveView = UIHostingController(
  rootView: CounterView(
    store: Store<CounterViewState, CounterViewAction>(
      initialValue: (2, []),
      reducer: counterViewReducer
    )
  )
)

但现在我们可以很容易地测试那些在其他情况下很难重建的状态。例如,我们可以模拟数到一百万。

initialValue: (1_000_000, []),

现在我们可以问Wolfram Alpha第一百万个质数。

这太不可思议了! 这3个屏幕可以完全独立运行,但它们也可以被拼接在一起,这样它们就都脱离了相同的全局状态。这就是我们所说的模块化、可组合的架构!

很快,我们甚至可以删除这个游乐场,而使用Xcode预览,它允许你直接在Xcode中运行你的视图。这将是非常强大的,但不幸的是,我们正在录制的电脑没有安装卡特琳娜来演示这一点,而且预览功能不够稳定,我们不能在一集中使用。

这里发生的事情很神奇。现在我们的store和SwiftUI几乎没有直接关系。事实上,我们将在未来的章节中展示如何将我们的store用于UIKit应用程序,甚至使用遗留的Objective-C代码! 所以这个架构远不止于SwiftUI,尽管它在使用SwiftUI时确实很出色,这意味着我们甚至可以像这样单独运行uiview和uiviewcontroller。您将能够使用适当的初始值和reducer创建一个store,将其交给控制器,然后您就可以离开了!这将“游乐场驱动开发”带到一个全新的水平。

4. Fixing the root app

但令人惊讶的是,我们的重构实际上破坏了我们的主应用目标,让我们快速修复它。

我们想要做的是用最喜欢的质数reducer和全新的计数器视图reducer组成应用程序reducer

let appReducer: (inout AppState, AppAction) -> Void = combine(
//  pullback(counterReducer, value: \.count, action: \.counter),
//  pullback(primeModalReducer, value: \.primeModal, action: \.primeModal),
  pullback(counterViewReducer, value: \.counterView, action: \.counterView),
  pullback(favoritePrimesReducer, value: \.favoritePrimes, action: \.favoritePrimes)
)

不幸的是,这些进入counterView的关键路径现在并不存在。我们来解决这个问题。

首先,让我们重构AppAction,因为我们现在想把所有的counter视图操作捆绑到一个case中:

//  case counter(CounterAction)
//  case primeModal(PrimeModalAction)
case counterView(CounterViewAction)

我们还想为counterView案例生成一个新的enum属性。每次我们在全局操作中嵌入一个更局部的操作时,我们都需要一个enum键路径,该路径将允许我们沿着该视图拉回以便将reducer组合在一起。Enum属性为我们提供了这些关键路径,在过去的几节课中,我们已经编写了一个工具来自动生成这些属性。

让我们跳到命令行并运行它。

$ swift run generate-enum-properties ./PrimeTime/ContentView.swift
Updating PrimeTime/ContentView.swift

该工具现在已经自动为counterView case插入了一个新的enum属性。

var counterView: CounterViewAction? {
  get {
    guard case let .counterView(value) = self else { return nil }
    return value
  }
  set {
    guard case .counterView = self, let newValue = newValue else { return }
    self = .counterView(newValue)
  }
}

这已经满足了一半的拉回计数器视图reducer

pullback(counterViewReducer, value: \.counterView, action: \.counterView),

对于计数器视图状态,获得一个可写的键路径是相当容易的,我们已经有了一个getter,我们以前用过拉回主要模式状态,并将其升级到计数器视图状态:

extension AppState {
  var counterView: CounterViewState {
    get {
      CounterViewState(
        count: self.count,
        favoritePrimes: self.favoritePrimes
      )
    }
    set {
      self.count = newValue.count
      self.favoritePrimes = newValue.favoritePrimes
    }
  }
}

我们可以在适当的地方更新它,因为我们甚至不需要应用程序目标来担心主要模态状态。所有这些问题都被下推到计数器模块中。

app reducer 现在已经成功地由计数器视图reducer和最喜爱的primereducer组成,但我们还没有完全构建,因为活动feed仍然在counter和prime模态动作的平面世界中工作。我们可以通过进一步嵌套这些情况来轻松解决这个问题。

case .counterView(.counter):
  …
case .counterView(.primeModal(.removeFavoritePrimeTapped)):
  …
case .counterView(.primeModal(.saveFavoritePrimeTapped)):

我们还需要处理最后一组错误,即将全局store转换为计数器视图store

NavigationLink(
  "Counter demo",
  destination: CounterView(
    store: self.store
      .view(
        value: { ($0.count, $0.favoritePrimes) },
        action: {
          switch $0 {
          case let .counter(action):
            return AppAction.counter(action)
          case let .primeModal(action):
            return AppAction.primeModal(action)
          }
      }
    )
  )
)

🛑 Type ‘AppAction’ has no member ‘counter’
🛑 Type ‘AppAction’ has no member ‘primeModal’

这里我们手动切换每个计数器视图,以便将其重新嵌入到适当的应用程序操作中。但现在AppAction有一个特定的counterView案例,我们可以将其简化为一行代码。

NavigationLink(
  "Counter demo",
  destination: CounterView(
    store: self.store
      .view(
        value: { ($0.count, $0.favoritePrimes) },
        action: { .counterView($0) }
      }
    )
  )
)

我们在AppState上也有counterView属性,所以如果我们想让东西看起来漂亮和对称,我们可以使用它而不是手动构建元组。

NavigationLink(
  "Counter demo",
  destination: CounterView(
    store: self.store
      .view(
        value: { $0.counterView },
        action: { .counterView($0) }
      }
    )
  )
)

值得注意的是主应用变得多么简单! 它所负责的只是声明完整的应用状态、应用动作,然后从它导入的模块中将所有其他内容组合到一个app reducer和根内容视图中。它甚至比这个关于模块化的系列开始时缩减了200行!

在我们最近的重构中,我们把关于prime模态模块的所有东西都推到counter视图模块中,所以我们甚至可以删除额外的一行,在这一行我们导入prime模态模块,这保证了主应用程序对prime模态屏幕一无所知。

//import PrimeModal

5. Conclusion

我们已经做了我们想做的事情:通过将每个屏幕的状态、动作、减速器和视图移动到它们自己的模块中,我们已经完全模块化了应用程序。我们对它进行了模块化,这样我们就可以在它自己的操场上运行每个屏幕,与主应用程序的其余部分完全隔离。

所以,这就是在减速器架构上投入更多能量的要点。通过专门研究如何分解和组合reducerstore,我们已经为自己提供了将应用程序分解为许多更小的应用程序的工具。这些应用程序甚至可以存在于它们自己的Swift模块中,这意味着一个应用程序完全不可能与另一个应用程序交互,除非其中一个应用程序与另一个应用程序存在显式依赖关系。

我们可以进一步测试这些模块:不仅是单元测试,还有快照测试! 您将能够创建一个具有您想要的任何初始状态的store,将其传递给视图,然后快照控制器。你甚至可以更进一步,向store发送动作,模拟用户在屏幕上点击时会发生什么,然后再对控制器进行快照。

好的,我们已经看到我们可以完全模块化我们的reducers和它们的视图。我们已经完全解决了5个问题中的3个,这些问题往往会阻碍应用程序架构。这意味着我们还有两个问题要解决:

  • 我们还没有介绍副作用。
  • 我们还需要证明我们的架构是如何可测试的。

这是两个很重要的问题,解决起来会很有趣。期待下次吧!