1. What’s a higher-order reducer?
那么“higher-order reducer”到底是什么样的呢?一个函数以reducer作为输入,以reducer为输出,这意味着什么?事实上,我们甚至已经定义了一对!
-
combine 是一个higher-order reducer:它是一个函数,以许多reducer作为输入(只要它们使用相同的值和动作类型),并通过运行每个给定的reducer返回一个全新的reducer作为输出。
-
pullback是一个higher-order reducer:它是一个以reducer作为输入的函数,给定对局部状态和行为进行操作的关键路径,返回一个全新的reducer作为输出,对更多全局状态和行为进行操作。
考虑到这两个函数的强大功能,我们有理由问一下,还有什么 higher-order reducers 等着我们去发现呢? 为了探究这个问题,让我们写出一个higher-order reducers的签名来探究我们可以使用的东西。
func higherOrderReducer(
_ reducer: @escaping (inout AppState, AppAction) -> Void
) -> (inout AppState, AppAction) -> Void {
}
为了简单起见,这个higher-order reducers只是在一个reducer上运行,该reducer运行于完整的应用程序状态和应用程序行为。
一般来说,higher-order reducers没有必要保持所有这些类型相同,它肯定可以接受一个类型的reducer,并返回一个完全不同的类型。
无论我们需要什么来返回一个有state和action的函数,所以我们可以从那里开始:
func higherOrderReducer(
_ reducer: @escaping (inout AppState, AppAction) -> Void
) -> (inout AppState, AppAction) -> Void {
return { state, action in
}
}
在这里,我们可以做的事情没有限制。我们可以在将它们发送到reducer之前检查状态和行为:
func higherOrderReducer(
_ reducer: @escaping (inout AppState, AppAction) -> Void
) -> (inout AppState, AppAction) -> Void {
return { state, action in
// do some computations with state and action
reducer(&state, action)
}
}
或者我们可以将state和action发送到reducer,然后检查从另一边出来的是什么:
func higherOrderReducer(
_ reducer: @escaping (inout AppState, AppAction) -> Void
) -> (inout AppState, AppAction) -> Void {
return { state, action in
// do some computations with state and action
reducer(&state, action)
// inspect what happened to state?
}
}
我们可以过滤允许通过哪些actions。我们甚至可以在调用reducer之前和之后对state和action执行更改。这个函数有很大的幂,这完全是因为它是一个higher-order reducer。
事实证明,这个higher-order reducer正是用来修复一个bug的,这个bug是我们在用传统的SwiftUI概念构建应用时无意中引入的。几集之前,在构建了应用的基本版本后,我们决定添加一个活动feed的新功能。它只是跟踪一个时间戳和一些我们感兴趣的事件,特别是当一个素数被添加或删除到我们的收藏夹时。
我们很天真地实现了这个特性,只是对主要模式做了一些小修改,以便将这些事件添加到状态中。现在,这种逻辑存在于我们的reducer中:
func primeModalReducer(state: inout AppState, action: PrimeModalAction) -> Void {
switch action {
case .removeFavoritePrimeTapped:
state.favoritePrimes.removeAll(where: { $0 == state.count })
state.activityFeed.append(.init(timestamp: Date(), type: .removedFavoritePrime(state.count)))
case .saveFavoritePrimeTapped:
state.favoritePrimes.append(state.count)
state.activityFeed.append(.init(timestamp: Date(), type: .addedFavoritePrime(state.count)))
}
}
然而,这里的问题是,这并不是我们唯一改变喜爱的质数列表的地方。一个完全独立的屏幕,收藏质数列表,你也可以从收藏列表中删除质数。开始我们并没有想过改变我们的活动动态,所以我们错过了一些事件。
修复非常简单,我们只是在视图中添加了额外的突变逻辑。现在,这种逻辑就存在于reducer中:
func favoritePrimesReducer(state: inout FavoritePrimesState, action: FavoritePrimesAction) -> Void {
switch action {
case let .deleteFavoritePrimes(indexSet):
for index in indexSet {
state.activityFeed.append(.init(timestamp: Date(), type: .removedFavoritePrime(state.favoritePrimes[index])))
state.favoritePrimes.remove(at: index)
}
}
}
然而,这仍然不是很好。这两种逻辑是密切相关的,但在两个不同的reducer中又相隔甚远。这些reducer可能存在于不同的文件甚至不同的模块中,并且很难确保在这两个地方都进行更改。 如果我们想要添加新的活动类型呢? 我们需要审计我们的reducer中的所有actions,并确保我们连接到添加新事件的正确位置。
2. Higher-order activity feeds
然而,higher-order reducers可以让我们做得更好。因为它们使我们能够在较高的层次上检查传入的操作,所以我们可以将所有的活动跟踪逻辑集中到一个地方。
让我们通过更新这个higher-order reducers的模板来尝试一下。我们必须决定是希望传入的reducer在活动提要逻辑之前运行还是之后运行。 如果它在之前运行,那么我们就已经改变了最爱的质数数组,所以我们不一定知道哪个质数被删除了。所以我们想让reducer按照我们的逻辑运行。让我们从switching action作开始,这样我们就可以考虑所有可能出现的应用行为:
func higherOrderReducer(
_ reducer: @escaping (inout AppState, AppAction) -> Void
) -> (inout AppState, AppAction) -> Void {
return { state, action in
switch action {
case .counter(_):
case .primeModal(_):
case .favoritePrimes(_):
}
有许多跨屏幕的操作需要考虑,但有些操作对于我们的活动提要来说并不重要。所以我们可以在这些情况下忽略它们:
func higherOrderReducer(
_ reducer: @escaping (inout AppState, AppAction) -> Void
) -> (inout AppState, AppAction) -> Void {
return { state, action in
switch action {
case .counter:
break
case .primeModal(_):
case .favoritePrimes(_):
}
然后我们可以扩展我们关心的cases。
func higherOrderReducer(
_ reducer: @escaping (inout AppState, AppAction) -> Void
) -> (inout AppState, AppAction) -> Void {
return { state, action in
switch action {
case .counter:
break
case .primeModal(.removeFavoritePrimeTapped):
case .primeModal(.saveFavoritePrimeTapped):
case let .favoritePrimes(.deleteFavoritePrimes(indexSet)):
}
现在我们可以在这里添加活动提要逻辑,并相应地重命名函数:
func activityFeed(
_ reducer: @escaping (inout AppState, AppAction) -> Void
) -> (inout AppState, AppAction) -> Void {
return { state, action in
switch action {
case .counter:
break
case .primeModal(.removeFavoritePrimeTapped):
value.activityFeed.append(
.init(timestamp: Date(), type: .removedFavoritePrime(value.count))
)
case .primeModal(.addFavoritePrime):
value.activityFeed.append(
.init(timestamp: Date(), type: .saveFavoritePrimeTapped(value.count))
)
case let .favoritePrimes(.deleteFavoritePrimes(indexSet)):
for index in indexSet {
value.activityFeed.append(
.init(timestamp: Date(), type: .removedFavoritePrime(value.favoritePrimes[index]))
)
}
}
reducer(&state, action)
}
}
那么我们怎么用这个higher-order reducers呢? 我们只需要给它输入appReducer,它会给我们一个全新的reducer,这将是我们在商店中使用的reducer。
ContentView(
store: Store(
initialValue: AppState(),
reducer: activityFeed(appReducer)
)
)
我们可以从我们的其他reducer中删除所有的活动馈送逻辑,这使得它们更简短和甜蜜:
func counterReducer(state: inout Int, action: CounterAction) -> Void {
switch action {
case .decrTapped:
state -= 1
case .incrTapped:
state += 1
}
}
func primeModalReducer(state: inout AppState, action: PrimeModalAction) -> Void {
switch action {
case .addFavoritePrime:
state.favoritePrimes.append(state.count)
case .removeFavoritePrime:
state.favoritePrimes.removeAll(where: { $0 == state.count })
}
}
func favoritePrimesReducer(state: inout FavoritePrimesState, action: FavoritePrimesAction) -> Void {
switch action {
case let .removeFavoritePrimes(indexSet):
for index in indexSet {
state.favoritePrimes.remove(at: index)
}
}
}
提取活动提要逻辑还允许我们进一步简化!我们最喜欢的reducer不再需要所有state的支持。
struct FavoritePrimesState {
var favoritePrimes: [Int]
var activityFeed: [AppState.Activity]
}
它不再需要活动提要:它只关心最喜欢的质数。
我们现在可以去掉中间状态了。
//struct FavoritePrimesState {
// var favoritePrimes: [Int]
// var activityFeed: [AppState.Activity]
//}
更新reducer以处理整数数组。
func favoritePrimesReducer(state: inout [Int], action: FavoritePrimesAction) -> Void {
switch action {
case let .removeFavoritePrimes(indexSet):
for index in indexSet {
state.remove(at: index)
}
}
}
我们有两个编译器错误。一个是app state的扩展,用来处理最喜欢的质数子状态。
extension AppState {
var favoritePrimesState: FavoritePrimesState {
🛑 Use of undeclared type ‘FavoritePrimesState’
我们可以注释掉所有代码。
//extension AppState {
// var favoritePrimesState: FavoritePrimesState {
// get {
// return FavoritePrimesState(
// favoritePrimes: self.favoritePrimes,
// activityFeed: self.activityFeed
// )
// }
// set {
// self.activityFeed = newValue.activityFeed
// self.favoritePrimes = newValue.favoritePrimes
// }
// }
//}
在我们的pullback中,我们可以取出应用状态最喜欢的质数,将其传递给最喜欢的质数reducer。
pullback(favoritePrimesReducer, value: \.favoritePrimes, action: \.favoritePrimes)
activityFeed的higher-order reducer甚至允许我们通过从现有的reducers中删除原本不需要的功能来简化它们。
3. Higher-order logging
现在只有24行非常简洁的应用程序逻辑,所有的活动提要内容都移到了更高阶的reducers中。 如果我们运行应用程序,一切工作都像以前一样。但是这很男理解,因为我们没有活动提要的屏幕。没有办法真正确认活动提要状态的变化是否与之前完全一样。
通过为应用程序构建一个日志记录器,我们可以很容易地解决这个问题。如果我们自动记录用户执行的每一个操作以及该操作的结果状态,那不是很好吗? 这对于调试和检查应用程序中正在发生的事情非常有用。
我们可能会忍不住将此日志记录添加到我们的存储中。我们可以直接在send方法中添加一些日志记录:
func send(_ action: Action) {
self.reducer(&self.value, action)
print("Action: \(action)")
print("Value:")
dump(self.value)
print("---")
}
然而,这意味着任何使用这个store类(可以放到库中并共享的东西)的人都将被迫在其应用程序中进行日志记录。他们可能不想记录日志,或者他们甚至想用不同的方式格式化日志,甚至把日志发送到os_log,而不是像我们在这里做的那样做简单的打印语句。
幸运的是,我们不需要将logging的概念直接写入我们的store。相反,这个特性可以在用户世界中实现,在库的范围之外,我们将使用另一个higher-order reducer来实现它。
日志记录可以是一个更higher-order reducer,在该reducer中我们封装现有的reducer,并在reducer运行后进行一些打印。让我们这样做:
func logging(
_ reducer: @escaping (inout AppState, AppAction) -> Void
) -> (inout AppState, AppAction) -> Void {
return { value, action in
reducer(&value, action)
print("Action: \(action)")
print("State:")
dump(value)
print("---")
}
}
有了这个更高阶的higher-order reducer,我们可以包装我们的appReducer,以获得一个全新的reducer,进行日志记录。 然而,这种逻辑是不必要的具体。我们没有在主体中使用任何关于AppState或AppAction的内容,所以我们应该让它泛型地覆盖任何类型的value和action:
func logging<Value, Action>(
_ reducer: @escaping (inout Value, Action) -> Void
) -> (inout Value, Action) -> Void {
return { value, action in
reducer(&value, action)
print("Action: \(action)")
print("State:")
dump(value)
print("---")
}
}
由于这是一个函数,我们甚至可以引入额外的配置作为输入,比如指定日志记录如何以及在哪里发生。
要使用这种新的高阶减速器,我们要将appReducer插入其中。但是我们的appReducer已经被包装在另一个更高阶的reducer中,所以我们真的必须嵌套这些:
ContentView(
store: Store(
value: AppState(),
reducer: logging(activityFeed(appReducer))
)
)
现在,当我们运行这个应用时,我们可以看到一切都和之前一样,但我们现在在控制台得到一些日志记录。我们终于可以保证添加和删除收藏夹的质数会像我们预期的那样附加到我们的活动提要中。
我们组成这些高阶约化子的方法还不是很好。如果我们有一堆这样的东西,这个组合嵌套会很快变得混乱:
bar(foo(logger(activityFeed(appReducer))))
如果我们使用函数组合助手库(称为Overture)中提供的一些函数,就可以解决这个问题。在这个库中,有一个名为compose的函数用于将函数组合在一起,而调用with函数只是为函数应用程序指定一个名称。 我们已经将这些助手复制到我们的Sources目录中,所以我们可以像这样使用它们:
// import Overture
ContentView(
store: Store(
value: AppState(),
reducer: with(
appReducer,
compose(
logger,
activityFeed
)
)
)
)
这很好,很整洁。我们看到我们的主appReducer负责我们所有的应用程序逻辑,然后我们用它应用所有高阶reducer的组合,比如日志记录和活动提要。
我们有必要花点时间意识到这是多么强大。higher-order reducers使我们能够以很少的工作量为应用程序添加广泛的功能。这有时被称为“aspect oriented programming”或“横切关注点(cross-cutting concerns)”,因为我们已经能够深入到应用程序的许多基本方面,而不会让应用程序被它基本上不关心的逻辑所干扰。
而这一切都不可能是SwiftUI自身满足的。没有办法日志记录用户执行的每个操作,并在每次更改后记录组件的状态。而在这里,我们基本上是免费的。
而这只是higher-order reducers的开始。它们也为我们开启了提升reducers的能力,让它们可以在清单上工作。它们还可以将处理模型数组的reducers转换为也知道如何分页的reducers。 当涉及到higher-order reducers时,天空是没有限制的。
4. What’s the point?
我们现在已经介绍了“可组合(composable)”状态管理的许多不同方面。
我们已经展示了如何将应用程序状态管理的本质提取到reducers的概念中,在reducers中我们获取应用程序的当前state和用户action,并将它们组合起来获得应用程序的新状态。
然后我们展示了reducers衍生的4种类型的组成:
- 具有相同state和action的reducer可以组合在一起形成一个单一的reducer
- 在小块sub-state上操作的reducer可以被拉回全局state上工作
- 操作一小部分action的reducer可以被拉回用于全局action。
- 将reducer作为输入和返回reducer作为输出的函数可以组合在一起,以添加许多额外的功能。
使用所有这些组合形式,我们能够将应用程序逻辑分解成许多小的reducer,每一个都很容易理解,然后将它们组合在一起。
这一切都很酷,我可以明确地看到函数式编程在设计这个架构方面的影响,但我们必须问自己,“有什么意义?”当它不是苹果直接提供给我们的东西时,是否值得采用这种架构。我们在SwiftUI上引入这一层是不是太违背了初衷?
我们认为,在SwiftUI中探索应用架构的这个方向是绝对值得的。虽然苹果并没有直接支持这一架构,但苹果还有很多未解决的问题需要我们去解决。所以无论如何,我们必须在SwiftUI之上引入一些层来处理这些问题,而我们在这些章节中发现的层令人惊讶地轻量级。
让我们看看可以被视为“库”代码的部分:
class Store<Value, Action>: ObservableObject {
let reducer: (inout Value, Action) -> Void
@Published var value: Value
init(initialValue: Value, reducer: @escaping (inout Value, Action) -> Void) {
self.value = value
self.reducer = reducer
}
func send(_ action: Action) {
self.reducer(&self.value, action)
}
}
func combine<Value, Action>(
_ reducers: (inout Value, Action) -> Void...
) -> (inout Value, Action) -> Void {
return { value, action in
for reducer in reducers {
reducer(&value, action)
}
}
}
func pullback<GlobalValue, LocalValue, GlobalAction, LocalAction>(
_ reducer: @escaping (inout LocalValue, LocalAction) -> Void,
value: WritableKeyPath<GlobalValue, LocalValue>,
action: WritableKeyPath<GlobalAction, LocalAction?>
) -> (inout GlobalValue, GlobalAction) -> Void {
return { globalValue, globalAction in
guard let localAction = globalAction[keyPath: action] else { return }
reducer(&globalValue[keyPath: value], localAction)
}
}
这只有34行代码,包括换行和只是语法的行。真正的内容只有14行左右。
所以作为一个架构层,它真的没有那么多。但是,通过拥抱这一薄层,我们开始看到大量的好处。
首先,我们的views变得非常简单。它们不再直接改变state。它们是简单地将一些可观察对象转换为视图层次结构的函数,而我们需要进入动作回调的部分,我们简单地将动作转换为具体的数据类型并将其发送到store中。以前,我们在每个动作闭包中都有多行突变。
此外,应用程序中的哪些地方发生了突变变得非常清晰。实际上,我们所要做的就是搜索.send(在这个文件中,我们会看到每个state的变化。这是令人难以置信的强大功能,对于这个代码库的新手来说,知道有一个一致的地方可以搜索突变和执行突变的一种认可的方式是很有帮助的。
我们甚至可以更进一步。我们可以强制所有的突变通过store,这样就绝对不可能以任何其他方式改变应用状态。我们所要做的就是将store中的value属性更改为private(set):
@Published private(set) var value: Value
现在,如果不使用send方法,就不可能改变应用的状态。这意味着您可以搜索应用程序中发生的所有突变,只需搜索.**send(**这是非常强大的。
因此,在非常表面的层面上,我们所选择的架构已经给了我们某种程度的一致性,这是普通的SwiftUI所没有的。但是,无论何时我们在苹果提供的任何产品上添加一层,我们都有可能将自己陷入困境。也就是说,我们最终得到的东西不是超级灵活的,虽然我们可能解决了一个问题,但我们也可能会面对许多新的问题!
然而,我们不认为这里是这样。我们发现我们的架构是超级可组合的。事实上,它支持4种类型的组成,每一种类型都使我们能够创建只专注于完成工作的基本要素的reducer,同时仍将它们自己开放,以接入更大的应用程序。这是可能的,因为我们选择在简单的功能上为架构建模,这是可组合性的顶峰。
但也许这个架构最酷的部分,也是最大的好处之一,是它与我们过去在Point-Free上讨论过的许多概念惊人地相似。在讨论视图样式化、随机性、快照测试和解析等问题时,我们严格地关注于构建正确的原子原语以及允许我们从原子单元构建复杂对象的操作。
例如,对于随机性,我们从单个的uint32生成器开始,并使用map, zip和flatMap来构建密码生成器,甚至是创建随机艺术作品的生成器。然后对于快照测试,我们能够从快照UIImages的单一策略开始,然后使用pullback在CALayer, UIView和UIViewController上创建策略。然后再解析我们从一些基本的解析器开始,像文字的解析器,double和character,我们使用map,zip和flatMap建立真正复杂的解析器,像一个解析器的马拉松比赛从一个字符串,竞赛的名字,门票和路线地理坐标。
我们认为,我们可以用谈论随机性、快照测试和解析的方式来谈论应用程序架构,这是令人惊讶的。这表明这些想法是多么普遍。我们不断地使用相同的技术来解决问题,并不断地从中获得好处,并且偶然发现这些技术的新应用,这些应用从一开始就难以预测。
那么,关于可组合状态管理的系列文章到此结束。然而,我们还远远没有完成这个架构。首先,我们只解决了本系列文章开始时概述的5个问题中的2个半。
我们说过,我们想要一个模块化的架构,这样组件可以被隔离,甚至可以放到它们自己的模块中。通过将reducer分解成更小的reducer,我们已经实现了一半的目标,但视图本身仍然依赖于完整的应用state和应用action的store。我们需要一种方法来分解这个对象,我们下次会开始这个话题。
我们还说过,我们希望有一个关于我们的应用程序如何处理副作用的故事,我们甚至还没有触及这个主题。这也将很快实现。最后,我们声明,以这种架构风格开发应用程序将使我们得到一个更可测试的应用程序。我们还没有触及,但请放心,这是可能的,我们也将很快覆盖!
Until next time!