1. Introduction
在过去的几周里,我们探索了应用程序架构的问题空间,并试图揭示使其如此复杂的根源。在这个过程中,我们最终构建了一个中等复杂的应用程序,尽管它有点像一个玩具样例,但它强调了我们在构建应用程序时遇到的所有痛点。特别地,我们看到:
- 我们希望能够有复杂的应用状态,可以在多个屏幕上共享,这样,某个状态的变化可以立即反映到其他屏幕上。
- 我们希望能够以一致的方式改变状态,这样对于代码库的新手来说,数据是如何在应用程序中流动的就很明显了。
- 我们希望能够用简单、可组合的单元构建大型、复杂的应用程序。理想情况下,我们能够完全独立地构建组件,甚至可能在其自己的模块中,然后将该组件插入到更大的应用程序中。
- 我们想要一种定义良好的机制来执行副作用并将其结果反馈给应用程序。
- 最后,我们希望我们的架构是可测试的。理想情况下,我们应该能够用很少的设置来编写测试,允许我们描述用户在应用程序中所做的一系列操作,然后在这些操作执行后断言应用程序的状态。
这些都是需要解决的重要问题,因为它们允许我们扩展代码库,以处理许多功能和许多开发人员在同一个应用程序上工作。不幸的是,SwiftUI并没有完全解决这些问题。它为我们提供了许多自己解决它的工具,但它取决于我们采取额外的努力。
所以今天我们就开始这样做。我们将介绍解决这些问题的应用程序体系结构。它的固执己见和SwiftUI差不多。它准确地告诉我们应该如何对应用程序状态建模,告诉我们如何将突变应用到该状态,告诉我们如何执行副作用等等。如果我们遵循这些处方,一些真正惊人的好处将开始出现。当然,最重要的是,这个体系结构完全是受到函数式编程的启发!我们将从简单的函数和函数组合中获得灵感,从而理解如何解决所有这些问题。
当然,我们并不是说这个体系结构是一种灵丹妙药,可以解决您所有的问题,而且肯定会有一些时候,您正在处理的问题似乎根本不适合这个框架。然而,我们仍然觉得这些想法值得探索,而且如果你从正确的角度看待问题,可以用这种架构解决许多问题,这也会令人惊讶。
2. Recap: our app so far
让我们快速回顾一下上节课的内容。我们有标准的计数app,但有一些附加功能。
- 首先,我们可以深入到计数屏幕,点击+和-按钮来增加和减少计数器。
- 我们可以询问当前计数器值是否为素数,如果是,我们可以在收藏列表中添加或删除素数。
- 我们还可以求第n个质数,其中n是当前计数器的值。按下这个按钮实际上会触发一个API请求,并在得到响应时显示一个警报。
- 然后我们可以进入收藏界面查看所有的收藏,但我们也可以删除任何不再是我们收藏的数字,这些更改会在应用程序的各个屏幕上传播。
让我们快速浏览一下代码。
我们有根AppState,它保存了应用程序中的所有状态。
class AppState: ObservableObject {
@Published var count = 0
@Published var favoritePrimes: [Int] = []
@Published var loggedInUser: User?
@Published var activityFeed: [Activity] = []
struct Activity {
let timestamp: Date
let type: ActivityType
enum ActivityType {
case addedFavoritePrime(Int)
case removedFavoritePrime(Int)
}
}
struct User {
let id: Int
let name: String
let bio: String
}
}
然后我们有CounterView,它符合SwiftUI的View协议,对应计数器屏幕。
struct CounterView: View {
@ObservedObject var state: AppState
@State var isPrimeModalShown = false
@State var alertNthPrime: PrimeAlert?
@State var isNthPrimeButtonDisabled = false
var body: some View {
VStack {
HStack {
Button("-") { self.state.count -= 1 }
Text("\(self.state.count)")
Button("+") { self.state.count += 1 }
}
Button("Is this prime?") { self.isPrimeModalShown = true }
Button(
"What is the \(ordinal(self.state.count)) prime?",
action: self.nthPrimeButtonAction
)
.disabled(self.isNthPrimeButtonDisabled)
}
.font(.title)
.navigationBarTitle("Counter demo")
.sheet(isPresented: self.$isPrimeModalShown) {
IsPrimeModalView(state: self.state)
}
.alert(item: self.$alertNthPrime) { alert in
Alert(
title: Text("The \(ordinal(self.state.count)) prime is \(alert.prime)"),
dismissButton: .default(Text("Ok"))
)
}
}
func nthPrimeButtonAction() {
self.isNthPrimeButtonDisabled = true
nthPrime(self.state.count) { prime in
self.alertNthPrime = prime.map(PrimeAlert.init(prime:))
self.isNthPrimeButtonDisabled = false
}
}
}
我们还有IsPrimeModalView,它对应于模态视图,您可以在其中从您的收藏中添加或删除素数。
struct IsPrimeModalView: View {
@ObservedObject var state: AppState
var body: some View {
VStack {
if isPrime(self.state.count) {
Text("\(self.state.count) is prime 🎉")
if self.state.favoritePrimes.contains(self.state.count) {
Button("Remove from favorite primes") {
self.state.favoritePrimes.removeAll(where: { $0 == self.state.count })
self.state.activityFeed.append(.init(timestamp: Date(), type: .removedFavoritePrime(self.state.count)))
}
} else {
Button("Save to favorite primes") {
self.state.favoritePrimes.append(self.state.count)
self.state.activityFeed.append(.init(timestamp: Date(), type: .addedFavoritePrime(self.state.count)))
}
}
} else {
Text("\(self.state.count) is not prime :(")
}
}
}
}
之后是FavoritePrimesView,这是一个收藏素数列表,你可以在任何时候删除收藏。
struct FavoritePrimesView: View {
@ObservedObject var state: AppState
var body: some View {
List {
ForEach(self.state.favoritePrimes, id: \.self) { prime in
Text("\(prime)")
}
.onDelete { indexSet in
for index in indexSet {
let prime = self.state.favoritePrimes[index]
self.state.favoritePrimes.remove(at: index)
self.state.activityFeed.append(.init(timestamp: Date(), type: .removedFavoritePrime(prime)))
}
}
}
.navigationBarTitle("Favorite Primes")
}
}
最后我们有ContentView,它是一个根视图,将整个应用程序放在一起。
struct ContentView: View {
@ObservedObject var state: AppState
var body: some View {
NavigationView {
List {
NavigationLink(
"Counter demo",
destination: CounterView(state: self.state)
)
NavigationLink(
"Favorite primes",
destination: FavoritePrimesView(state: self.state)
)
}
.navigationBarTitle("State management")
}
}
}
在playground的最后,我们做了一些工作让它在实时视图中渲染。
import PlaygroundSupport
PlaygroundPage.current.liveView = UIHostingController(
rootView: ContentView(state: AppState())
)
整个应用程序只有大约140行代码,而且非常简单明了! SwiftUI正在为我们做一些不可思议的事情。然而,这并不意味着它没有一些小问题需要我们解决。
-
首先,我们的view中充斥着突变,这可能会模糊数据如何输入到我们的应用程序中。有些突变隐藏在helper方法中(这只是一个间接层,但您可以想象更多),而其他操作在视图中包含多个突变和大量代码。
-
我们也想要一种方法来分解视图,这样他们就不必接受整个应用状态。通常我们会有一个视图,只需要应用状态的很小一部分,我们想要一个非常简单的方式来完成它。
-
因为所有这些突变都被捆绑在SwiftUI视图中,没有更好的方法来测试它们,苹果也没有给我们提供如何测试的指导。
这是目前为止的应用程序。它非常简单,但它确实解决了我们在构建大型应用程序时需要解决的许多问题。
让我们着手解决这些问题。
3. A better way to model global state
我们将从一个需要解决的更简单的问题开始:一个更好的建模全局状态的方法。
早些时候当我们使用beta版本第一次构建这个应用程序,我们采用了BindableObject协议,这要求我们使用一个类,而不是一个值类型,声明一个发布者,它需要在任何时间状态改变时进行ping,然后手动进入每个字段的didSet,以ping那个发布者。
当你使用早期测试版时,生活就会来得很快! 苹果已经弃用了这些api,取而代之的是更新、更时髦的api。我们已经更新了代码以使用这些更新的api,事情看起来好多了。
class AppState: ObservableObject {
@Published var count = 0
@Published var favoritePrimes: [Int] = []
@Published var activityFeed: [Activity] = []
@Published var loggedInUser: User? = nil
struct Activity {
let type: ActivityType
let timestamp = Date()
enum ActivityType {
case addedFavoritePrime(Int)
case removedFavoritePrime(Int)
}
}
struct User {
let id: Int
let name: String
let bio: String
}
}
我们会注意到:
- SwiftUI的BindableObject协议已经被Combine的ObservableObject协议所取代。
- 当状态更新时,我们不再需要声明一个发布者来获取信息:ObservableObject会自动为我们合成一个objectWillChange发布者。
- 我们也不再需要手动ping发布者:任何带有@Published包装的属性都会在更新之前自动ping发布者。
很高兴苹果已经消除了早期测试版带来的许多迷雾,但仍有一些改进的空间:
- 我们的模型层与Combine框架紧密耦合:它符合来自Combine的协议,并且每个属性都由一个Combine publisher包装。这就引入了一个依赖关系,当我们在SwiftUI视图之外与模型层交互时,我们可能并不关心这个依赖关系。事实上,这种依赖可能会阻止我们与无法导入Combine的代码共享该模型,比如Linux上的服务器端Swift。
- 即使事情比以前少了很多干扰,我们仍然有使用@Published包装每个属性的干扰。
- 如果我们忘记用@Published包装属性,SwiftUI将不会收到它的更改通知。
- ObservableObject和BindableObject一样,仍然要求我们使用类而不是值类型。 值类型是很好的状态容器:它们为我们提供了对可变性的细粒度控制和保证。
将应用状态从Combine中解耦并从值语义中获益的一种方法是将AppState转换为一个结构体,然后围绕它创建一个非常轻量的ObservableObject包装器。
将我们的状态转换为结构体非常简单:
struct AppState {
var count = 0
var favoritePrimes: [Int] = []
var activityFeed: [Activity] = []
var loggedInUser: User? = nil
struct Activity {
let type: ActivityType
let timestamp = Date()
enum ActivityType {
case addedFavoritePrime(Int)
case removedFavoritePrime(Int)
}
}
struct User {
let id: Int
let name: String
let bio: String
}
}
现在这更简单了! 我们能够摆脱所有那些@Published注解,不再需要遵循ObservableObject。
为了恢复这个ObservableObject一致性,我们必须将这个结构体包装在一个类中。我们将其命名为Store:
final class Store: ObservableObject {
@Published var value: AppState
init(initialValue: AppState) {
self.value = value
}
}
现在我们有一些编译器错误需要修复,但在修复之前,我们可以看到这个类过于具体。 Store所要做的就是包装一个值类型,为它的观察者提供一个钩子。 它不需要知道任何关于AppState的信息。所以,让我们快速地将它的泛型设置为它包装的值:
final class Store<Value>: ObservableObject {
@Published var value: Value
init(initialValue: Value) {
self.value = value
}
}
Now if we form something like:
Store<AppState>
我们将获得一个可观察对象,一旦AppState发生任何变化,它就会通知发生了变化。我们已经有效地将所有的状态位合并到一个值中。
这很好,但现在我们有一堆破代码。我们可以很容易地通过一些搜索和替换来修复这段代码。首先我们替换这个:
@ObservedObject var state: AppState
With this:
@ObservedObject var store: Store<AppState>
每个视图现在都将对应用状态存储进行操作,这样任何应用状态的变化都会被正确地通知到SwiftUI视图。
接下来,我们需要替换所有的引用:
self.state -> self.store.value
这将确保所有视图都正确地从商店的状态读取并对商店的状态应用更改。
我们也有一些这样的地方:
(state: self.store.value) -> (store: self.store)
最后,当我们创建根内容视图时,我们必须传递一个store而不是app state:
rootView: ContentView(store: Store(value: AppState()))
4. Functional state management
我们现在已经从Combine和SwiftUI框架中解耦了应用状态,并在值类型中捕获它,而不是在类中。我们还可以随意添加新的状态,而不需要任何额外的工作。这已经是一个很大的胜利了
让我们来解决另一个问题,这个问题比上一个稍微复杂一些:我们如何处理状态突变,以便有一种单一、一致的方式来执行突变。目前,对于我们如何对我们的state进行突变,真的没有任何规律或理由。我们看到,在各种操作处理程序和事件回调中添加突变是相当容易的,但这样做了几次之后,就不清楚数据是如何在应用程序中流动的。我们希望有一种单一的、一致的方式来描述和执行应用程序中的变化,这样无论你在哪个文件中,你都可以很容易地看到你的应用程序是如何随着各种用户操作而发展的。
所以让我们试着提炼状态突变的本质,并提出一个描述,我们可能会编码到一个实际的程序中。状态突变是指获取当前状态和发生的事件(如用户点击按钮),并使用这两部分信息来派生一个全新的状态。例如,我们的状态count为0,然后一个用户操作显示用户点击了“+”按钮,因此我们应该生成一个新的状态count为1。这听起来像个函数!
唯一的问题是“用户动作”事件现在定义不清。目前,“用户动作”只是指那些在SwiftUI中执行的动作闭包中的一个。我们需要将这个概念转换为合适的数据类型,以便我们能够对其进行实际操作。因此,我们不是直接在视图中进行更改,而是创建一个数据类型来描述它们。
因为用户可以执行许多类型的操作,而一个操作可以是这些类型中的任何一种,所以枚举可能是合适的:
enum CounterAction {
case decrTapped
case incrTapped
}
现在,我们可以创建一个函数,它接受当前的状态片段,并将其组合成一个动作,以获得更新的状态。它可能看起来像这样:
func counterReducer(state: AppState, action: CounterAction) -> AppState {
switch action {
case .decrTapped:
return AppState(
count: state.count - 1,
favoritePrimes: state.favoritePrimes,
loggedInUser: state.loggedInUser,
activityFeed: state.activityFeed
)
case .incrTapped:
return AppState(
count: state.count + 1,
favoritePrimes: state.favoritePrimes,
loggedInUser: state.loggedInUser,
activityFeed: state.activityFeed
)
}
}
对于这么简单的事情来说,这是相当冗长的,因为我们必须为我们想要做的每个突变创建所有新的状态值。我们可以通过复制我们的状态来修复冗长的问题,但代价是使用一些样板文件:
func counterReducer(state: AppState, action: CounterAction) -> AppState {
var copy = state
switch action {
case .decrTapped:
copy.count -= 1
case .incrTapped:
copy.count += 1
}
return copy
}
它肯定更短,但每一个reducer都要做这个复制舞。 如果我们不小心把拷贝和状态弄混了,我们就会遇到一些非常微妙的错误。但是,我们现在就开始吧,我们一会儿会讲到这个问题。
还有,为什么我们叫它counterReducer? 好像是个奇怪的名字。这个名字的灵感来自于数组上reduce函数的签名,例如:
[1, 2, 3].reduce(
<#initialResult: Result#>,
<#nextPartialResult: (Result, Int) throws -> Result#>
)
Result值就像我们的状态,当我们将越来越多的整数合并到它时,这个值就会累积。所以我们输入到数组reduce中的累加器函数的签名与counterReducer的签名非常相似。这就是为什么我们这样命名这个函数。
但是,我们怎么用这个呢? 我们可以创建一些状态来玩,然后应用几次counterReducer函数,看看它是如何变化的:
let state = AppState()
counterReducer(value: value, action: .incrTapped)
print(state)
// AppState(count: 0, favoritePrimes: [], loggedInUser: nil, activityFeed: [])
状态没有改变,因为counterReducer返回一个全新的app状态,所以我们真正想打印的是函数返回的值。
print(counterReducer(value: value, action: .incrTapped))
// AppState(count: 1, favoritePrimes: [], loggedInUser: nil, activityFeed: [])
现在我们看到计数增加了。如果我们想减少计数,我们不能这样做:
print(counterReducer(value: value, action: .decrTapped))
由于每次通过counterReducer都会创建一个全新的状态,我们可以看到以后的应用程序不会改变以前的状态。为了获得这种行为,我们需要像这样嵌套:
print(
counterReducer(
value: counterReducer(
value: value,
action: .incrTapped
),
action:.decrTapped
)
)
我们看到这种嵌套的原因是counterReducer函数一次只代表一个突变,它将您从一个状态带到下一个状态。它没有能力随着时间的推移将多个突变积累成应用状态的一个巨大突变。我们需要另一种机制。但是它应该住在哪里呢?
我们已经有了这个Store类来保存应用程序的当前状态,这是我们想要改变的状态以便应用程序中的所有视图在发生变化时都能得到通知。因此,我们可以从做一些愚蠢的事情开始,将存储状态的直接突变替换为首先调用reducer的东西,然后将新状态填充到存储中。如减量按钮:
Button("-") {
self.store.value = counterReducer(state: self.store.value, action: .decrTapped)
// self.store.state.count -= 1
}
在增量按钮中:
Button("+") {
self.store.value = counterReducer(state: self.store.value, action: .incrTapped)
// self.store.state.count += 1
}
And everything compiles and works as it did before.
这要冗长得多,但至少有一个好处。我们不是直接改变按钮动作闭合内部的状态,而是将相应的动作值发送给reducer,让reducer做所有必要的变化。这就是用户操作的说明性的含义:我们描述用户所做的事情,而不是执行那些由用户操作导致的混乱的、循序渐进的变化。现在突变很简单,但是你可以想象reducer需要做很多工作,我们现在在reducer中进行了工作。
5. Ergonomics: capturing reducer in store
然而,这显然不是我们想要的使用reducer和store的方式。每次直接调用reducer并使用reducer的输出重新分配store的态将会有大量的样板文件。 似乎我们可以将这个样板代码移到store中,这样我们只需要做一次,然后视图中的call-sites就会很好,很整齐。
想象一下,如果我们通过调用一个简单的方法告诉store已经调用了一个用户操作,然后store负责运行reducer。下面是一些伪代码,演示如何减少我们的计数:
Button("-") { self.store.send(.decrTapped) }
Button("+") { self.store.send(.incrTapped) }
我们设想要发送这个方法的名称,因为它模仿了Combine框架用于向发布者发送数据的命名。
然后store可以使用这个动作在store的当前状态上运行reducer,它可以重新分配状态,这样我们就能得到所有视图更新。store开始封装越来越多的应用程序的运行时行为。
我们怎么做呢?
首先,由于我们的store现在必须知道应用程序的状态以及可以改变状态的操作类型,我们必须为我们识别的操作类型引入一个新的泛型:
final class Store<Value, Action>: ObservableObject {
…
func send(_ action: Action) {
}
}
现在,在这个send方法中,我们想用我们的当前状态调用reducer,然后用reducer产生的新状态替换当前状态。然而,我们没有reducer,所以听起来我们的store需要保留reducer:
class Store<Value, Action>: ObservableObject {
let reducer: (Value, Action) -> Value
…
init(initialValue: Value, reducer: @escaping (Value, Action) -> Value) {
self.value = value
self.reducer = reducer
}
}
现在我们可以实现send方法了:
func send(_ action: Action) {
self.value = self.reducer(self.value, action)
}
通过这些改变,我们需要修复一些编译器错误。我们需要更新对Store的引用,以包含额外的泛型:
@ObservedObject var store: Store<AppState, CounterAction>
现在我们需要用一个reducer来初始化我们的store,让我们这样做:
ContentView(store: Store(state: AppState(), reducer: counterReducer))
现在一切都重新编译了,一切都和以前一样。唯一的区别是,我们将少量的状态突变隔离到我们的reducer中,并且我们通过调用store上的send方法强制自己执行这些突变。我们不被允许在任何地方进行任何我们想要的突变,我们必须把它打包成一个action,然后送去reducer。很快,我们甚至可以将Store的value属性设置为私有!这意味着除非通过reducer,否则不可能改变应用程序的状态。
6. Ergonomics: in-out reducers
现在,我们可以继续从我们的view中提取更多的突变,并将它们塞进我们的action和reducer中,但我们还可以对我们的设置做出另一个改进。现在,关于我们定义reducer的方式,有两个烦人的地方。首先,我们将在每个reducer中有一些样板,我们创建状态的副本,对副本进行突变,然后返回副本。这个样板文件很容易出错,而且很麻烦。第二,复制过程不会非常高效如果我们有一个大的应用状态。每次用户执行一个操作并运行reducer时,都要复制完整的应用状态,这是无法很好地扩展的。
幸运的是,我们可以使用一个技巧,将这个看似低效的函数转换为一个等效的更高效的函数。事实上,我们在Point-Free的第二集讨论了这个技巧,我们讨论了副作用和Swift的inout功能。简而言之,形式的功能之间是等价的:
// (A) -> A ----> (inout A) -> Void
你可以把你对inout A做的任何突变看作是函数的隐藏输出,因此它的行为就像(A) - >A函数。
当你的函数有多个输入或输出时,他的技巧也有效。例如
// (A, B) -> (A, C) ----> // (inout A, B) -> C
通常,如果类型参数在函数箭头的两边恰好出现一次,则可以将其从右侧移除,但代价是在左侧引入inout参数。
所以让我们把这个想法应用到reducer签名上:
// (Value, Action) -> Value -----> // (inout Value, Action) -> Void
这种形式的reducer和之前的表现是一样的,只是效率更高一点。 事实上,Swift标准库有一个精确的这个签名的reduce重载。最初是由我们的老朋友Chris Eidhof提出的,后来在《Swift 3》中被引入:
[1, 2, 3].reduce(
into: <#Result#>,
<#updateAccumulatingResult: (inout Result, Int) throws -> ()#>
)
这正是我们想要的reducer。让我们从改变它的签名开始:
func counterReducer(value: inout AppState, action: CounterAction) {
然后,我们将通过执行突变而不是复制和返回新值来修复编译错误:
func counterReducer(value: inout AppState, action: CounterAction) {
switch action {
case .decrTapped:
value.count -= 1
case .incrTapped:
value.count += 1
}
}
我们可以像使用其他reducer一样使用这种reducer:
var state = AppState()
counterReducer(value: &state, action: .incrTapped)
counterReducer(value: &state, action: .decrTapped)
现在很容易关注每个action中发生了什么变化,而且我们还获得了更高效率的额外好处。但是我们有一些编译错误需要修复。
首先,让我们修复reducer在仓库的签名:
class Store<Value, Action>: ObservableObject {
let reducer: (inout Value, Action) -> Void
初始化器需要更新:
init(initialValue: Value, reducer: @escaping (inout Value, Action) -> Void) {
self.value = value
self.reducer = reducer
}
send方法现在简化为:
func send(_ action: Action) {
self.reducer(&self.value, action)
}
我们的应用程序现在编译,工作完全像以前一样,我们的代码变得更符合人体工程学,甚至性能更优。听起来是个双赢的局面。
7. Moving more mutations into the store
但是,我们现在仍然生活在两个世界。通过这个漂亮的新接口,我们的一些状态正在发生变化,它向我们的store发送动作,并让它通过我们的reducer处理变化逻辑,而我们的一些状态仍然在我们的视图中直接内联地发生变化。让我们解决这个问题。
IsPrimeModalView在按钮操作中直接发生了以下变化:
Button("Remove from favorite primes") {
self.store.value.favoritePrimes.removeAll(where: { $0 == self.store.value.count })
self.store.value.activityFeed.append(.init(timestamp: Date(), type: .removedFavoritePrime(self.store.value.count)))
}
…
Button("Save to favorite primes") {
self.store.value.favoritePrimes.append(self.store.value.count)
self.store.value.activityFeed.append(.init(timestamp: Date(), type: .addedFavoritePrime(self.store.value.count)))
}
这些突变负责向我们的收藏质数列表添加或删除当前计数器值,以及将该事件添加到用户在应用程序中所做的事情的活动feed中,尽管我们实际上还没有构建出那个屏幕。
让我们像处理增量突变和递减突变一样处理这些突变。我们将添加新的案例到我们的动作枚举中:
enum CounterAction {
…
case saveFavoritePrimeTapped
case removeFavoritePrimeTapped
}
这在我们的reducer中创建了一个编译错误,因为我们现在有新的情况需要处理:
func counterReducer(value: AppState, action: CounterAction) -> AppState {
switch action {
…
case .saveFavoritePrimeTapped:
fatalError()
case .removeFavoritePrimeTapped:
fatalError()
}
}
现在我们可以很容易地填入这些空白,但在此之前,让我们先解决一个奇怪的问题。为什么我们要把模态的逻辑塞进所谓的counterReducer和CounterAction中? 就像我们有一个叫做AppState的东西,我们可能应该有一个叫做AppAction的东西来保存应用的所有动作。
我们创建一个主AppAction枚举,它将保存每个屏幕的所有动作:
enum AppAction {
case decrTapped
case incrTapped
case saveFavoritePrimeTapped
case removeFavoritePrimeTapped
}
但我们现在失去了一些模块化。 我们将计数动作和模态动作捆绑在一起。将每一组操作保存在它们自己的枚举中可能会更好,AppAction只是在其中嵌套每个子操作。
enum CounterAction {
case decrTapped
case incrTapped
}
enum PrimeModalAction {
case saveFavoritePrimeTapped
case removeFavoritePrimeTapped
}
enum AppAction {
case counter(CounterAction)
case primeModal(PrimeModalAction)
}
然后我们的reducer可以是一个appReducer,通过对每个嵌套操作进行切换来处理这个完整的操作集:
func appReducer(value: inout AppState, action: AppAction) -> Void {
switch action {
case .counter(.decrTapped):
value.count -= 1
case .counter(.incrTapped):
value.count += 1
case .primeModal(.saveFavoritePrimeTapped):
fatalError()
case .primeModal(.removeFavoritePrimeTapped):
fatalError()
}
}
我们仍然需要填补这些空白,但让我们首先修复一些编译器错误。当向商店发送计数器屏幕的动作时,我们必须将动作包装在新的应用动作案例中:
Button("-") { self.store.send(.counter(.decrTapped)) }
Text("\(self.store.value.count)")
Button("+") { self.store.send(.counter(.incrTapped)) }
现在,事情正在编译,我们可以开始将我们的突变逻辑移出视图,进入reducer:
case .primeModal(.saveFavoritePrimeTapped):
value.favoritePrimes.append(value.count)
value.activityFeed.append(.init(timestamp: Date(), type: .addedFavoritePrime(value.count)))
case .primeModal(.removeFavoritePrimeTapped):
value.favoritePrimes.removeAll(where: { $0 == value.count })
value.activityFeed.append(.init(timestamp: Date(), type: .removedFavoritePrime(value.count)))
然后我们的按钮动作可以简单地将相应的动作发送到store:
Button("Remove from favorite primes") {
self.store.send(.primeModal(.removeFavoritePrimeTapped))
}
…
Button("Save to favorite primes") {
self.store.send(.primeModal(.saveFavoritePrimeTapped))
}
一切都像以前一样在编译和工作。除了需要重新命名和移动一些内容之外,我们很容易将突变内容提取到我们的store中。
还有一种变异很容易提取出来。在FavoritePrimesView中,我们在onDelete处理程序中执行以下操作:
.onDelete { indexSet in
for index in indexSet {
self.store.value.favoritePrimes.remove(at: index)
self.store.value.activityFeed.append(.init(timestamp: Date(), type: .removedFavoritePrime(prime)))
}
}
让我们遵循前面两组突变所使用的模式。这是一个新的屏幕,所以让我们屏幕一个新的enum并添加一个case到AppAction:
enum FavoritePrimesAction {
case deleteFavoritePrimes(IndexSet)
}
enum AppAction {
…
case favoritePrimes(FavoritePrimesAction)
}
我们立即在reducer中得到一个编译器错误,所以让我们解决这个问题:
func appReducer(value: inout AppState, action: AppAction) -> Void {
switch action {
…
case let .favoritePrimes(.deleteFavoritePrimes(indexSet)):
for index in indexSet {
let prime = value.favoritePrimes[index]
value.favoritePrimes.remove(at: index)
value.activityFeed.append(.init(timestamp: Date(), type: .removedFavoritePrime(prime)))
}
}
}
最后,我们在视图中使用内联突变,并将其替换为简单地将操作发送到store的代码:
.onDelete { indexSet in
self.store.send(.favoritePrimes(.removeFavoritePrimes(at: indexSet)))
}
现在,我们已经将所有的内联突变从按钮操作转移到我们的reducer中,一切仍然像以前一样工作。
虽然我们已经将很多突变转移到reducer中,从而清理了我们的view,但仍然有一些突变在view中发生。
这些是我们决定不需要在应用程序的全局级别上跟踪的所有局部突变。回想一下,SwiftUI中的本地状态是用@State建模的,而CounterView有3个本地状态实例:
@State var isPrimeModalShown = false
@State var alertNthPrime: Int?
@State var isNthPrimeButtonDisabled = false
有一些地方这些值是突变的,它是完全在我们的reducer权限之外做的。这是不幸的,因为将我们所有的突变统一到一个内聚的包中是很好的,但是处理alert和modal有点复杂,所以我们将在了解了reducers的基础知识后解决这个问题。
8. Till next time
我们现在已经有了一个非常基本的架构版本。我们有一个store类,它是状态类型的泛型,表示应用程序的完整状态,它是操作类型的泛型,表示应用程序中可能发生的所有用户操作。
- store类包装了一个状态值,这只是一个简单的值类型,这允许我们一次性地挂钩到观察者,以便我们可以在状态即将发生变化时通知SwiftUI。
- store类还保留了一个reducer,它是我们应用程序的大脑。 它描述了如何获取应用程序的当前状态和来自用户的传入操作,并生成应用程序的全新状态,然后将其呈现并显示给用户。
这个小小的工作已经解决了我们在本集开始时提到的5个问题中的2个。
但是,尽管这一切都很酷,我们还可以走得更远。让我们来解决在appReducer中开始发展的问题。现在它看起来相当庞大:一个巨大的reducer处理3个不同屏幕的突变。这似乎不是特别具有可扩展性。如果我们有24个屏幕我们真的需要一个开关语句来切换24个不同屏幕的每个动作吗? That’s not going to work.
我们需要研究将reducer合成成更大的reducer的方法。如何将一个大的reducer分解成许多小的只做一件特定事情的reducer然后将它们粘在一起形成我们的主reducer?