1. Introduction
现在,我们已经花了很多很多周的时间,从第一性原理;构建了我们的可组合架构。其核心设计的动机是试图解决五个问题,我们发现这五个问题对任何应用程序架构都至关重要。
然后,我们改进了这个设计,解决了一些内存泄漏问题,以及我们的架构最初如何与SwiftUI接口的潜在性能问题。解决后一个问题使我们有机会增强我们的体系结构,使其更能适应各种情况,这允许我们在多个平台上共享核心业务逻辑,同时改进每个平台与共享逻辑交互的方式。
在我们的架构中还有很多很多东西需要探索,但是在解决了这些漏洞和性能问题之后,我们认为是时候将这些东西打包在我们的应用程序中使用了。我们甚至可以把它作为一个开源项目与全世界分享。
但在此之前,我们觉得还有改进的空间。首先,我们没有花大量的时间在可组合架构的人体工学上。核心库非常小:不到几百行代码。
2. The architecture's surface area
它从其核心单元的定义开始:一个简单的函数,我们称之为“reducer”:
public typealias Reducer<Value, Action, Environment> = (inout Value, Action, Environment) -> [Effect<Action>]
这个签名描述了整个应用程序的逻辑:
- 它可以改变应用程序状态(这是由Value类捕获的)给定一个动作(通常是一个用户动作,比如点击按钮)
- 还提供了这个Environment类型,其中包含我们所有特性的依赖项,比如API客户端、文件客户端和其他需要进入混乱的外部世界的任何东西。
- 我们必须返回一个将在业务逻辑执行后运行的effects数组。这使我们能够与外部世界交互,并将外部世界的信息反馈到我们的应用程序中。
如果我们使用这个签名并试图在一个函数中为整个应用程序编写逻辑,随着状态和操作的增长,事情很快就会变得难以处理。但幸运的是,函数是超级可组合的,reducer也不例外。它们可以被分解成越来越小、更容易理解的单元,然后这些单元可以被粘合在一起,形成一个整体。
我们能够做到这一点的方法是通过两个强大的操作,combine和pullback。
public func combine<Value, Action, Environment>(
_ reducers: Reducer<Value, Action, Environment>...
) -> Reducer<Value, Action, Environment> {
…
}
public func pullback<LocalValue, GlobalValue, LocalAction, GlobalAction, GlobalEnvironment, LocalEnvironment>(
_ reducer: @escaping Reducer<LocalValue, LocalAction, LocalEnvironment>,
value: WritableKeyPath<GlobalValue, LocalValue>,
action: WritableKeyPath<GlobalAction, LocalAction?>,
environment: @escaping (GlobalEnvironment) -> LocalEnvironment
) -> Reducer<GlobalValue, GlobalAction, GlobalEnvironment> {
…
}
combine函数允许我们把一堆reducers工作在同一个域组合进一个,mega-reducer 运行引擎盖下的所有reducers,而pullback允许我们将一个在局部域中工作的reducer转换为一个在更全局域中工作的reducer。它们使我们能够将应用程序逻辑分解为一堆较小的简化程序,这些简化程序可以独立存在于它们自己的独立模块中。然后,在应用程序级别,我们可以把所有更小的、更特定于领域的reducer拉回全局的应用程序域,在那里它们可以合并成一个单一的reducer,为我们的应用程序提供动力。
在这些函数的正下方,我们有另一个函数,logging,我们称之为“高阶”reducer,因为它将reducer作为输入,并返回一个reducer作为输出。
public func logging<Value, Action, Environment>(
_ reducer: Reducer<Value, Action, Environment>
) -> Reducer<Value, Action, Environment> {
…
}
3. Free functions
在我们继续讨论这个文件的其余部分之前,让我们来解决一个事实,即这些api可能比较突出,因为它们都被定义为自由函数:“free”是因为它们没有被限制在一个类型中。尽管如此,它们在日常使用中还是会感觉有点不合适,而且它们可能会引入一些尴尬的人体工程学。
要查看pullback and combine在实践中的效果,我们可以跳到我们的应用目标,在那里我们构建了顶级的app reducer:
let appReducer = combine(
pullback(
counterViewReducer,
value: \AppState.counterView,
action: /AppAction.counterView,
environment: { $0.nthPrime }
),
pullback(
counterViewReducer,
value: \AppState.counterView,
action: /AppAction.offlineCounterView,
environment: { $0.offlineNthPrime }
),
pullback(
favoritePrimesReducer,
value: \.favoritePrimes,
action: /AppAction.favoritePrimesState,
environment: { ($0.fileClient, $0.nthPrime) }
)
)
像这样的函数调用对大多数Swift开发人员来说可能是一个奇怪的景象,对一些人来说甚至可能是一个不舒服的! 感觉上好像pullback and combine已经泄露到“global”名称空间中了。
函数调用也变得嵌套,在Swift中难以阅读:你的眼睛需要浏览圆括号层,以获得函数调用顺序的句柄。
现在有了解决嵌套问题的方法。事实上,早在Point-Free的第一集中,我们就看到,在一两个操作符的帮助下,可以使自由函数调用更具可读性。然而,我们理解,自定义操作符的话题在Swift社区讨论很激烈,而不是每个人或每个团队将舒适的引入他们代码库,所以一段时间以后,我们引入了另一个操作符的形式“Overture”,一组通用函数式编程的功能。它也旨在解决这种嵌套问题,但不需要自定义操作符。
虽然在combine中嵌套pullback并不是那么糟糕,但是嵌套会随着我们添加额外功能而增加,就像我们使用更高阶的reducers(如日志记录)一样。
事实上,如果我们跳转到我们的scene delegate,我们会看到:
rootView: ContentView(
store: Store(
initialValue: AppState(),
reducer: with(
appReducer,
compose(
logging,
activityFeed
)
),
…
)
)
这里我们看到一组嵌套函数调用,用于添加logging和另一个高阶reducer activityFeed,到我们的应用reducer。我们也可以通过with和compose函数看到这一点,它们来自我们的Overture库!如果没有它们,我们递给商店的减速器将是这样的:
logging(activityFeed(appReducer))
这个特殊的例子是相对简单的,所以我们要避免的嵌套并不是世界上最糟糕的,但是你可以想象当你有六个高阶的reducers时,嵌套会变得越来越笨拙。
logging(activityFeed(barEnhancer(fooEnhancer(appReducer))))
Overture是针对这些问题的完美解决方案,但理想情况下它不是可组合架构的依赖,也不是以人体工程学方式使用我们的架构的要求。
如果不是在自由函数中,我们应该在哪里定义这些东西? Swift似乎更喜欢将逻辑放在静态函数和类型的方法中,所以我们可能会把这个逻辑移动到一个Reducer扩展中:
extension Reducer {
}
🛑 Non-nominal type ‘Reducer’ cannot be extended
但这不会起作用,因为Reducer只是一个函数签名的类型别名,而函数是Swift称为“non-nominal”类型的一个例子,而非标称类型目前不能用静态函数或方法扩展。
幸运的是,我们还有另一个选择。有一种非常简单的方法可以为函数创建一个漂亮的名称空间,比如reducers,那就是将它们包装在一个结构体中。
为了人机工程学,我们将这个函数包装为一个类型:
事实上,这个故事甚至已经在可组合架构中出现了! 我们首先将Effect类型作为函数的简单类型别名引入。
typealias Effect<A> = (@escaping (A) -> Void) -> Void
但是当我们开始探索effects是如何组合和转换时,我们也把它包装在一个结构中!
//struct Effect<A> {
// let run: (@escaping (A) -> Void) -> Void
//}
每次我们将一个函数包装在一个类型中,在这个过程中它变得更加符合人体工程学。
4. Reducer as a struct
这里我们又有了另一个函数,reducer。让我们把它包装在一个结构体中,看看我们是否可以再次改善使用它们的人体工程学。
首先,我们将简单地注释掉定义的类型别名。
//public typealias Reducer<Value, Action, Environment> = (inout Value, Action, Environment) -> [Effect<Action>]
并在具有单个字段的结构体中进行交换。
public struct Reducer<Value, Action, Environment> {
let reducer: (inout Value, Action, Environment) -> [Effect<Action>]
}
我们希望能够从可组合架构模块外部实例化这些值,因此让我们在这里添加一个公共初始化器。
public init(
_ reducer: @escaping (inout Value, Action, Environment) -> [Effect<Action>]
) {
self.reducer = reducer
}
好了,这个模块中有很多东西坏了。让我们一次解决一个问题。
首先,我们在组合函数中有几个错误。
public func combine<Value, Action, Environment>(
_ reducers: Reducer<Value, Action, Environment>...
) -> Reducer<Value, Action, Environment> {
return { value, action, environment in
🛑 Cannot convert return expression of type ‘(_, _, _) -> _’ to return type ‘Reducer<Value, Action, Environment>’
既然reducers是一种类型,那么使用裸闭包创建它们就不再有效了。相反,我们可以将这个函数作为一个尾随闭包传递给Reducer初始化器。
public func combine<Value, Action, Environment>(
_ reducers: Reducer<Value, Action, Environment>...
) -> Reducer<Value, Action, Environment> {
return Reducer { value, action, environment in
let effects = reducers.flatMap { $0.reducer(&value, action, environment) }
🛑 Cannot call value of non-function type ‘Reducer<Value, Action, Environment>’
第二个问题是Reducer现在是一个结构体,我们不能再把它作为函数直接调用。
我们的一个选择是进入它的reducer字段:
public func combine<Value, Action, Environment>(
_ reducers: Reducer<Value, Action, Environment>...
) -> Reducer<Value, Action, Environment> {
return Reducer { value, action, environment in
let effects = reducers.flatMap { $0.reducer(&value, action, environment) }
但我们也可以依赖Swift5.2 的全新功能“callable values”。如果类型定义了callAsFunction方法,那么可以直接调用它,就像它是一个函数一样。
extension Reducer {
public func callAsFunction(
_ value: inout Value,
_ action: Action,
_ environment: Environment
) -> [Effect<Action>] {
self.reducer(&value, action, environment)
}
}
现在reducer可以像以前一样愉快地被调用。
let effects = reducers.flatMap { $0(&value, action, environment) }
我们的下一个错误是pullback。
public func pullback<LocalValue, GlobalValue, LocalAction, GlobalAction, LocalEnvironment, GlobalEnvironment>(
_ reducer: @escaping Reducer<LocalValue, LocalAction>,
value: WritableKeyPath<GlobalValue, LocalValue>,
action: WritableKeyPath<GlobalAction, LocalAction?>,
environment: @escaping (GlobalEnvironment) -> LocalEnvironment
) -> Reducer<GlobalValue, GlobalAction> {
return { globalValue, globalAction, globalEnvironment in
guard let localAction = globalAction[keyPath: action] else { return [] }
let localEffects = reducer(&globalValue[keyPath: value], action: localAction, environment: environment(globalEnvironment))
🛑 @escaping attribute only applies to function types 🛑 Cannot convert return expression of type ‘(_, _, _) -> _’ to return type ‘Reducer<Value, Action, Environment>’
-
我们不再需要@escaping属性,因为它只适用于函数类型,而Reducer现在是一个结构体,而且Reducer结构体的初始化器已经捕获了函数已转义的事实。
-
我们还需要将返回的函数包装在Reducer初始化器中。
public func pullback<LocalValue, GlobalValue, LocalAction, GlobalAction, LocalEnvironment, GlobalEnvironment>(
_ reducer: Reducer<LocalValue, LocalAction>,
value: WritableKeyPath<GlobalValue, LocalValue>,
action: WritableKeyPath<GlobalAction, LocalAction?>,
environment: @escaping (GlobalEnvironment) -> LocalEnvironment
) -> Reducer<GlobalValue, GlobalAction> {
return .init { globalValue, globalAction, globalEnvironment in
guard let localAction = globalAction[keyPath: action] else { return [] }
let localEffects = reducer(&globalValue[keyPath: value], localAction, environment(globalEnvironment))
我们需要对日志的高阶reducer做同样的更改:
public func logging<Value, Action, Environment>(
_ reducer: Reducer<Value, Action, Environment>
) -> Reducer<Value, Action, Environment> {
return .init { value, action, environment in
只剩下几个错误了,它们在Store中,Store是支持我们架构的运行时类。
Stores是用一个reducer初始化的,所以我们需要再次删除@escaping要求:
public init<Environment>(
initialValue: Value,
reducer: Reducer<Value, Action, Environment>,
environment: Environment
) {
在初始化器的主体中,我们用一个新的reducer包装给定的reducer,以便“消除”它的环境,这是我们在模块依赖管理的章节中提到的。这个reducer也需要调用Reducer的初始化器:
) {
self.reducer = Reducer { value, action, environment in
reducer(&value, action, environment as! Environment)
}
self.value = initialValue
self.environment = environment
}
最后,stores有一个scope方法,它可以将全局状态和全局操作上的stores转换为更多局部状态和局部操作上的stores。 正是这个操作允许我们将应用程序的视图隔离到它们自己的模块中。
在内部,它创建了一个需要新的reducer的新store,因此我们需要调用reducer。再次初始化:
public func view<LocalValue, LocalAction>(
value toLocalValue: @escaping (Value) -> LocalValue,
action toGlobalAction: @escaping (LocalAction) -> Action
) -> Store<LocalValue, LocalAction> {
let localStore = Store<LocalValue, LocalAction>(
initialValue: toLocalValue(self.value),
reducer: .init { localValue, localAction, _ in
就像这样,可组合架构又开始构建了!
5. Reducer methods
好了,现在我们有了一个自然的命名空间,我们所有的组合函数可以更新为更符合人体工程学的静态函数和方法:
让我们从combine开始。它是一个自由的、可变的函数,结合了任意数量的给定reducers。我们可以把它作为一个静态函数嵌套在Reducer扩展中,然后去掉泛型,因为它们现在在类型中是隐式的:
extension Reducer {
public static func combine(
_ reducers: Reducer...
) -> Reducer {
return Reducer { value, action, environment in
let effects = reducers.flatMap { $0(&value, action, environment) }
return effects
}
}
}
这个函数体中的任何东西都不需要改变。
我们将其作为一个静态函数而不是方法,因为不清楚是否有一个特定的reducer可以进行点连接。相反,我们有一个完整的列表,所有的reducers都是同等重要的。
接下来:pullback。因为pullback以单个reducer作为输入,而不是创建另一个静态函数,我们可能可以更好地将其功能表达为增强当前selfreducer的方法。
我们也可以将pullback移动到我们的Reducer结构体中,消除Reducer参数:
extension Reducer {
// …
public func pullback<GlobalValue, GlobalAction, GlobalEnvironment>(
value: WritableKeyPath<GlobalValue, Value>,
action: WritableKeyPath<GlobalAction, Action?>,
environment: @escaping (GlobalEnvironment) -> Environment
) -> Reducer<GlobalValue, GlobalAction, GlobalEnvironment> {
return .init { globalValue, globalAction, globalEnvironment in
guard let localAction = globalAction[keyPath: action] else { return [] }
let localEffects = self(&globalValue[keyPath: value], localAction, environment(globalEnvironment))
我们甚至能够通过利用reducer的Value, Action, and Environment摆脱3个本地泛型。
让我们继续讨论logging的高阶reducer。它是一个以单个reducer为输入的函数。因此,就像pullback一样,它应该是另一个被定义为操作self的方法的候选:
extension Reducer {
…
public func logging() -> Reducer {
return .init { value, action, environment in
let effects = self.reduce(into: &value, action: action, environment: environment)
好吧! 一切都是建立,我们已经移动了所有的操作组合和转化reducer到新的reducer类型。
但是在继续之前,当我们在这里对架构的工效学进行改进时,让我们先概括一下日志功能,因为它可以访问环境。我们目前使用Swift标准库的打印函数,但是我们可能想调用其他的日志记录方法。
那么,如果我们可以通过描述如何从一个环境中取出一台打印机来定制在引线罩下使用的打印机类型,会怎么样呢?
public func logging(
printer: (Environment) -> (String) -> Void,
) -> Reducer {
return .init { value, action, environment in
let effects = self.reduce(into: &value, action: action, environment: environment)
let newValue = value
let print = printer(environment)
return [.fireAndForget {
print("Action: \(action)")
print("Value:")
var dumpedNewValue = ""
dump(newValue, to: &dumpedNewValue)
print(dumpedNewValue)
print("---")
}] + effects
现在这个函数中所有的打印都要通过我们从环境中提取的打印机完成。
如果在我们的环境中,当Swift标准库的打印功能在手边时,要求我们总是持有一台打印机,这似乎有点笨拙,我们可以默认这个参数忽略环境,直接访问该功能。
public func logging(
printer: (Environment) -> (String) -> Void = { _ in { print($0) } }
) -> Reducer {
这表明,通过允许我们描述我们对环境的需求,提高具有更强能力的高阶reducer是多么容易。
6. Updating the app's modules
一切都是在可组合架构中构建的。当然,每当我们对架构进行更改时,我们都需要在应用的模块中做大量工作来重新构建所有内容。所以让我们一次更新每个模块,看看人体工程学有什么变化。
如果我们切换到最喜欢的质数模块,我们会发现,有趣的是,所有的东西仍然可以构建! 这是因为我们在这个文件中没有一个对Reducer的引用,这是因为我们将favoritePrimesReducer直接定义为一个函数:
public func favoritePrimesReducer(
state: inout FavoritePrimesState,
action: FavoritePrimesAction,
environment: FavoritePrimesEnvironment
) -> [Effect<FavoritePrimesAction>] {
相反,让我们将其定义为一个Reducer值。这只是调用一个带尾闭包的初始化器的问题:
public let favoritePrimesReducer
= Reducer<FavoritePrimesState, FavoritePrimesAction, FavoritePrimesEnvironment> { state, action, environment in
这似乎是本模块中需要更改的所有内容。我们甚至可以跳转到这个模块的playground,以确保一切仍在工作。
让我们进入主模态。
它也仍然构建,但也需要实例化它的reducer作为一个值:
public let primeModalReducer
= Reducer<PrimeModalState, PrimeModalAction, Void> { state, action, _ in
需要注意的一点是,prime modal reducer没有执行任何副作用,因此我们给它一个Void环境,这表示这个环境不保存任何有意义的东西,因此不需要任何依赖项来完成它的工作
计数器模块有点复杂,并且不再在建造秩序中。但是在我们处理这些错误之前,让我们更新counter reducer为一个reducer值:
public let counterReducer
= Reducer<CounterState, CounterAction, CounterEnvironment> { state, action, environment in
那么,为什么模块构建不成功呢? 它负责使用回调和组合将一对reduce组合在一起,它仍然使用旧的、自由的函数接口,它不再存在:
public let counterViewReducer = combine(
pullback(
counterReducer,
value: \CounterViewState.counter,
action: /CounterViewAction.counter,
environment: { $0 }
),
pullback(
primeModalReducer,
value: \.primeModal,
action: /CounterViewAction.primeModal,
environment: { _ in () }
)
)
🛑 Use of unresolved identifier ‘combine’ 🛑 Use of unresolved identifier ‘pullback’
combine函数现在静态地存在于Reducer上,所以我们可以调用它。我们可以在每个Reducer的值上直接调用callback作为方法。
public let counterViewReducer = Reducer.combine(
counterReducer.pullback(
value: \CounterViewState.counter,
action: /CounterViewAction.counter
environment: { $0 }
),
primeModalReducer.pullback(
value: \.primeModal,
action: /CounterViewAction.primeModal,
environment: { _ in () }
)
)
这就是在计数器模块中需要改变的一切!
主应用目标也还没有完全构建,这是因为,像counter模块一样,它负责回调和合并一些reducers,而这些调用需要更新到新的接口:
let appReducer Reducer<AppState, AppAction, AppEnvironment> = .combine(
counterViewReducer.pullback(
value: \AppState.counterView,
action: /AppAction.counterView
environment: { $0.counter }
),
counterViewReducer.pullback(
value: \AppState.counterView,
action: /AppAction.offlineCounterView,
environment: { $0.offlineNthPrime }
),
favoritePrimesReducer.pullback(
value: \.favoritePrimes,
action: /AppAction.favoritePrimes
environment: { $0.favoritePrimes }
)
)
activityFeed高阶reducers也有一些错误。我们可以删除@escaping并使用reducer初始化器包装它返回的reducer。
func activityFeed(
_ reducer: Reducer<AppState, AppAction, AppEnvironment>
) -> Reducer<AppState, AppAction, AppEnvironment> {
return .init { state, action, environment in
但更好的是,我们可以将这个逻辑移动到Reducer类型中,就像我们对高阶日志记录函数所做的那样。
我们只需要有条件地扩展Reducer,其中值、动作和环境都在我们的应用程序域中,然后定义activityFeed作为一个计算属性来增强self。
extension Reducer where Value == AppState, Action == AppAction, Environment == AppEnvironment {
func activityFeed() -> Reducer {
.init { state, action, environment in
…
return self(&state, action, environment)
}
}
}
在创建根视图并将其传递给store时,我们只出现了几个构建错误:
window.rootViewController = UIHostingController(
rootView: ContentView(
store: Store(
initialValue: AppState(),
reducer: with(
appReducer,
compose(
logging,
activityFeed
)
),
…
)
)
)
🛑 Use of unresolved identifier ‘logging’ 🛑 Use of unresolved identifier ‘activityFeed’
logging和activityFeed都转移到了reducer类型,这意味着我们不再需要依赖Overture的with和compose函数。相反,我们可以将这些属性直接链在appReducer上,我们只需要确保我们获得了正确的顺序,以确保活动提要被记录:
window.rootViewController = UIHostingController(
rootView: ContentView(
store: Store(
initialValue: AppState(),
reducer: appReducer
.activityFeed()
.logging(),
…
)
)
)
这读起来真的很好!我们可以很容易地看到,我们正在通过一个活动feed和日志记录来增强我们的app reducer。
更好的是,使用方法可以更容易地将这些更改本地化到我们所关心的reducer。假设我们不想记录整个应用中的每一个动作,但是我们现在只关心counter reducer。我们可以从这里移除.logger,并将其连接到counter reducer的末端:
public let counterReducer = Reducer<CounterState, CounterAction, CounterEnvironment> { state, action, environment in
...
}
.logging()
现在我们将只记录通过counter reducer的操作。这对于将功能本地化到特定的reducer来说是非常强大的。