1. Multiple environments
我们提到的Current环境技术的第一个问题是,当每个特性模块都有自己的环境时,事情变得有点笨拙。这意味着每次我们想要编写一个包含两个特性的逻辑的测试时,我们必须记住模拟它们的每个环境。
func testIntegration() {
Counter.Current = .mock
FavoritePrimes.Current = .mock
}
新的环境技术完全解决了这个问题。为了演示这一点,我们将编写一个集成测试,测试两个不同特性中的效果。为了做到这一点,我们需要使用assert helper,它在过去非常有用。我们首先在CounterTests模块中定义它,但在各集之间,我们将它提取到一个专用的ComposableArchitectureTestSupport模块,以便任何模块都可以使用它。
我们可以导入这个新的测试支持模块,并且可以立即访问assert helper:
import ComposableArchitectureTestSupport
我们想要写一个测试完整的应用程序state,应用程序actions,应用程序environment和应用程序reducer。因此,让我们从一个assert的存根开始:
func testIntegration() {
assert(
initialValue: AppState(),
reducer: appReducer,
environment: (
fileClient: .mock,
nthPrime: { _ in .sync { 17 } }
)
)
}
现在这个还不能编译,因为AppState和AppAction需要是相等的,所以让我们快速地做一下:
struct AppState: Equatable {
…
struct Activity: Equatable {
…
enum ActivityType: Equatable {
…
}
}
struct User: Equatable {
…
}
}
enum AppAction: Equatable {
…
}
现在测试目标正在构建,从技术上讲,测试将通过,因为我们实际上没有测试任何步骤。让我们测试一下,当我们在counter视图中要求第n个质数时,会发生什么,然后我们从磁盘中加载最喜欢的质数列表。在一个测试中,我们将执行来自两个不同特性模块的副作用。
这个测试写起来很简单:
func testIntegration() {
var fileClient = FileClient.mock
fileClient.load = { _ in
return Effect<Data?>.sync {
try! JSONEncoder().encode([2, 31, 7])
}
}
assert(
initialValue: AppState(count: 4),
reducer: appReducer,
environment: (
fileClient: fileClient,
nthPrime: { _ in .sync { 17 } }
),
steps:
Step(.send, .counterView(.counter(.nthPrimeButtonTapped))) {
$0.isNthPrimeButtonDisabled = true
},
Step(.receive, .counterView(.counter(.nthPrimeResponse(17)))) {
$0.isNthPrimeButtonDisabled = false
$0.alertNthPrime = PrimeAlert(prime: 17)
},
Step(.send, .counterView(.counter(.alertDismissButtonTapped))) {
$0.alertNthPrime = nil
},
Step(.send, .favoritePrimes(.loadButtonTapped)),
Step(.receive, .favoritePrimes(.loadedFavoritePrimes([2, 31, 7]))) {
$0.favoritePrimes = [2, 31, 7]
}
)
}
我们忘记在视频中测试一个步骤,在这里我们模拟了alert关闭按钮被点击。
这个测试模拟的用户脚本为:
-
用户点击“what is the nth prime”按钮
-
“nth prime”按钮被禁用
-
副作用将一个action输入系统,这是一个带有数字17的响应。这个值直接来自我们环境中的依赖性
-
“nth prime”按钮重新启用,并显示一个alert
-
用户点击alert上的dismiss按钮,alert被dismissed
-
alert dismissed
-
然后用户点击最喜欢的prime屏幕上的“load”按钮
-
State 没有变化
-
最后从一个effect中接收一个action,将一些质数加载到state中。同样,这个值直接来自我们环境中的依赖性。
-
设置state的最喜欢的质数数组
这个测试很酷的一点是,编译器强迫我们提供一个完整的环境来运行这段代码。assert helper需要一个单独的环境:
environment: (
fileClient: fileClient,
nthPrime: { _ in .sync { 17 } }
),
因此,不可能忘记提供环境的特定部分,而且如果添加了新的环境字段,编译器将强制我们提供这些新的依赖项。
与我们以前处理它的方式相比,我们的测试代码即使没有mock出单个依赖项也会运行。
因此,我们对环境技术的新改造无疑解决了需要担心多个环境的混乱。
2. Local dependencies
我们所描述的全局环境的下一个问题是,我们没有机会定制每个特性所使用的effects类型。例如,当在生产环境中运行应用程序时,Counter特性使用Wolfram Alpha API。
在应用程序的两个不同地方使用CounterView是不可能的,其中一个实例使用Wolfram Alpha API,而另一个实例使用完全不同的API。
也许我们想尝试一种新的计算API,但只针对特定的用户。允许CounterView的某些实例使用一种effect而另一些实例使用另一种effect是非常困难的。
然而,在我们新的环境风格下,这是非常容易的。为了说明这一点,我们将使用nthPrime函数的一个版本,它将简单地在本地进行计算,而不是调用外部API。它以一种非常天真的方式工作,但它完成了工作:
public func offlineNthPrime(_ n: Int) -> Effect<Int?> {
Future { callback in
var nthPrime = 1
var count = 0
while count <= n {
nthPrime += 1
if isPrime(nthPrime) {
count += 1
}
}
callback(.success(nthPrime))
}
.eraseToEffect()
}
它通过返回Future来模拟我们的effect,这允许我们执行一些可能需要很长时间的工作,一旦完成,我们可以通过调用回调从发布者发出值。内部循环遍历每个大于1的数,每次遇到质数时增加一个计数。一旦质数计数达到我们要查找的数字,我们就调用future的回调,从而完成publisher。
现在让我们使用这个新的脱机依赖项来演示我们可以在应用程序中多次使用CounterView,每次都有不同的依赖项。让我们创建一个全新的顶级导航链接,它允许我们深入到离线运行的CounterView版本。
我们可以从一个导航链接的存根开始:
NavigationLink(
"Offline counter demo",
destination: CounterView(
store: self.store.view(
value: { $0.counterView },
action: { .counterView($0) }
)
)
)
这是当前导航到与其他导航链接相同的counter视图。相反,如果我们想让它导航到具有自身effects运行的CounterView版本,我们需要在特性的领域中对其建模。特别是,我们需要引入一组特定于脱机counter的新操作。
这意味着我们可以将AppAction改为:
enum AppAction: Equatable {
case counterView(CounterViewAction)
case offlineCounterView(CounterViewAction)
case favoritePrimes(FavoritePrimesAction)
}
然后导航链接变成:
NavigationLink(
"Offline counter demo",
destination: CounterView(
store: self.store.view(
value: { $0.counterView },
action: { .offlineCounterView($0) }
)
)
)
这允许新屏幕拥有自己的一组不同于“online”counter视图的操作。
我们有一个编译器错误,那是在我们的activity feed高阶reducer中。这实际上是一个编译器错误,因为我们确实想要进入这些新动作,并在收藏的质数被添加和从离线计数器中删除时追加新的活动提要条目:
case .counterView(.counter),
.offlineCounterView(.counter),
.favoritePrimes(.loadedFavoritePrimes),
.favoritePrimes(.loadButtonTapped),
.favoritePrimes(.saveButtonTapped):
break
case .counterView(.primeModal(.removeFavoritePrimeTapped)),
.offlineCounterView(.primeModal(.removeFavoritePrimeTapped)):
state.activityFeed.append(.init(timestamp: Date(), type: .removedFavoritePrime(state.count)))
case .counterView(.primeModal(.saveFavoritePrimeTapped)),
.offlineCounterView(.primeModal(.saveFavoritePrimeTapped)):
state.activityFeed.append(.init(timestamp: Date(), type: .addedFavoritePrime(state.count)))
如果我们现在运行应用程序,我们会发现离线计数器demo根本不起作用。点击任何按钮都不会做任何事情,这是因为我们没有一个reducer来处理它的功能。
要添加这个功能,我们需要将另一个回拉的counterViewReducer实例合并到appReducer中,但是它的动作会沿着offlineCounterView case被回拉:
pullback(
counterViewReducer,
value: \AppState.counterView,
action: /AppAction.offlineCounterView,
environment: { $0.nthPrime }
),
但这还不是正确的。在这种情况下,离线的计数器demo将与在线演示完全一样,这是因为我们将它交给一个环境,其nthPrime effect 使用实时Wolfram API。
相反,我们希望传递离线效果。
pullback(
counterViewReducer,
value: \AppState.counterView,
action: /AppAction.offlineCounterView,
environment: { $0.offlineNthPrime }
),
为了做到这一点,我们需要在我们的应用环境中表示它:
public typealias AppEnvironment = (
fileClient: FileClient,
nthPrime: (Int) -> Effect<Int?>,
offlineNthPrime: (Int) -> Effect<Int?>
)
现在,我们唯一的编译器错误是,当我们为整个应用程序创建store时,我们需要在环境中指定offlineNthPrime效应:
environment: AppEnvironment(
fileClient: .live,
nthPrime: Counter.nthPrime,
offlineNthPrime: Counter.offlineNthPrime
)
现在我们的整个应用程序都构建好了,新的离线计数器视图特性如预期的那样工作。当我们询问一个特定count的“nth prime'”时,我们几乎立即得到结果,因为我们是在局部进行计算。
然而,在你问为什么不使用offlineNthPrimeeffect 代替Wolfram API之前,让我们看看当我们试图要求“nth prime”的非常大的数字时会发生什么:
initialValue: AppState(count: 20_000),
现在,当我们点击“第n个质数是什么”按钮时,我们看到界面在显示警报之前会挂起一段时间。如果我们把数字调高一点,比如4万:
initialValue: AppState(count: 40_000),
我们会发现完成计算的时间要长得多。您还会注意到,在进行计算时,整个UI都冻结了,我们为查看器做了一些练习来解决这个问题。
这表明我们确实从CounterView中解锁了一个新功能。考虑到它可以自由处理多种不同的effects,而不仅仅是Wolfram Alpha API。当将它的reducer与应用程序的reducer结合时,我们可以选择当某些动作在它的域中发生时,它将执行哪些effects。
这可能看起来是一个愚蠢的例子,但确实有实际应用。我们可能想要在我们的应用程序上执行一个A/B实验,其中一半用户通过Wolfram API获得反视图,另一半用户进行离线的“nth prime”计算。在这种情况下,我们肯定需要告诉counter视图它应该如何执行其副作用的能力。
我们可以通过计算一个随机布尔值来证明这一点,然后使用该值来确定我们显示的导航链接:
let isInExperiment = Bool.random()
struct ContentView: View {
@ObservedObject var store: Store<AppState, AppAction>
var body: some View {
NavigationView {
List {
if !isInExperiment {
NavigationLink(
"Counter demo",
destination: CounterView(
store: self.store.view(
value: { $0.counterView },
action: { .counterView($0) }
)
)
)
} else {
NavigationLink(
"Offline counter demo",
destination: CounterView(
store: self.store.view(
value: { $0.counterView },
action: { .offlineCounterView($0) }
)
)
)
}
你的A/B测试需要更加细致入微,但至少要证明这是可能的。
3. Sharing dependencies
我们描述的关于全局环境技术的第三个也是最后一个问题是很难在模块之间共享依赖关系。如果两个不同的模块需要访问相同的依赖项,我们要么必须为每个模块创建该依赖项的新实例,要么需要进行更高级别的协调,以确保每个模块使用相同的依赖项。
为了表明我们已经解决了这个问题,我们将在我们这个小小的计数应用程序中添加另一个功能。当在最喜欢的质数屏幕上,我们将允许用户点击任意一行来询问这个数字的“nth prime”是多少。是的,我们将使用质数来问“nth prime”是什么。
这将展示如何在多个模块中使用单个依赖项,在本例中是Wolfram Alpha API端点。
首先,让我们把收藏的质数列表视图中的每一行都变成一个按钮:
public var body: some View {
List {
ForEach(self.store.value.favoritePrimes, id: \.self) { prime in
Button("\(prime)") {
}
}
在这个按钮的动作闭包里面,我们想要发送一个动作到store。我们可以把采集的精华传递下去。
public var body: some View {
List {
ForEach(self.store.value.favoritePrimes, id: \.self) { prime in
Button("\(prime)") {
self.store.send(.primeButtonTapped(prime))
}
}
然后我们需要在action enum中表示这个动作:
public enum FavoritePrimesAction: Equatable {
…
case primeButtonTapped(Int)
}
并处理我们的reducer的新动作:
case let .primeButtonTapped(prime):
return [
]
我们在这里要做的工作是产生一个能计算nth prime的副作用,我们不关心它是用Wolfram Alpha API还是其他方法来做的。这意味着我们需要更新我们的环境来获得这种effect。为此,我们将简单类型别名升级为元组:
public typealias FavoritePrimesEnvironment = (
fileClient: FileClient,
nthPrime: (Int) -> Effect<Int?>
)
这意味着我们需要更新在reducer中访问fileClient的方式:
environment.fileClient.save("favorite-primes.json", try! JSONEncoder().encode(state.favoritePrimes))
…
environment.fileClient.load("favorite-primes.json")
现在我们可以在点击质数时访问nthPrime依赖项。当我们触发这个effect时,它最终会返回一个可选的整数。我们希望将这些数据打包到一个新的操作中,以便将其反馈到store中。但是,我们还需要跟踪我们点击了哪个数字因为我们在警报中显示了这个信息,“The 5th prime is 11””。
所以,让我们添加一个新动作来接受这两种信息:
case nthPrimeResponse(n: Int, prime: Int?)
这是当你点击prime时,我们的effect需要发出的动作:
case let .primeButtonTapped(prime):
return [
environment.nthPrime(prime)
.map { FavoritePrimesAction.nthPrimeResponse(n: prime, prime: $0) }
.receive(on: DispatchQueue.main)
.eraseToEffect()
]
我们在counterReducer中处理nthprimerresponse动作的方法是有一个可选的PrimeAlert值,这样当它是非nil时,就会显示警报,当它是nil时,警报就会被隐藏。我们想在这里做类似的事情,这意味着我们需要在我们的状态中表示这个:
public typealias FavoritePrimesState = (
alertNthPrime: PrimeAlert?,
favoritePrimes: [Int]
)
然而,在这个模块中我们没有访问PrimeAlert类型的权限,它目前存在于Counter模块中。但实际上没有理由让它存在于Counter模块中。可能有许多功能需要显示a prime alert。
public struct PrimeAlert: Equatable, Identifiable {
public let n: Int
public let prime: Int
public var id: Int { self.prime }
public init(n: Int, prime: Int) {
self.n = n
self.prime = prime
}
public var title: String {
return "The \(ordinal(self.n)) prime is \(self.prime)"
}
}
public func ordinal(_ n: Int) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .ordinal
return formatter.string(for: n) ?? ""
}
因此,让我们将它提取到它自己的模块中,这样我们就可以简单地导入该模块来访问该类型:
import PrimeAlert
现在我们可以升级我们的模块来使用FavoritePrimesState。
public func favoritePrimesReducer(state: inout FavoritePrimesState, action: FavoritePrimesAction, environment: FavoritePrimesEnvironment
) -> [Effect<FavoritePrimesAction>] {
…
然后在reducer中创建新的动作,我们可以创建PrimeAlert值:
case let .nthPrimeResponse(n, prime):
state.alertNthPrime = prime.map { PrimeAlert(n: n, prime: $0) }
return []
有了这个新状态,我们就可以实现SwiftUI视图部分。我们将更新FavoritePrimesView,使用一个store来保存完整的收藏状态:
public struct FavoritePrimesView: View {
@ObservedObject var store: Store<FavoritePrimesState, FavoritePrimesAction>
public init(store: Store<FavoritePrimesState, FavoritePrimesAction>) {
self.store = store
}
有了视图主体中可用的状态,我们可以在视图层次结构中添加一个.alert修饰符:
.alert(
item: .constant(self.store.value.alertNthPrime)
) { primeAlert in
Alert(
title: ???,
dismissButton: .default(Text("Ok")) {
???
}
)
}
我们想在这里使用与counter视图相同的标题,所以也许我们应该提取一点逻辑到PrimeAlert类型:
extension PrimeAlert {
public var title: String {
return "The \(ordinal(self.n)) prime is \(self.prime)"
}
}
public func ordinal(_ n: Int) -> String {
let formatter = NumberFormatter()
formatter.numberStyle = .ordinal
return formatter.string(for: n) ?? ""
}
现在我们使用标题:
Alert(
title: Text(primeAlert.title),
dismissButton: .default(Text("Ok")) {
???
}
)
当点击Ok按钮时,我们想要发送一个新的动作到store:
dismissButton: .default(Text("Ok")) {
self.store.send(.alertDismissButtonTapped)
}
当然,我们必须把它添加到action enum中:
public enum FavoritePrimesAction: Equatable {
…
case alertDismissButtonTapped
}
并在我们的reducer中处理它:
case .alertDismissButtonTapped:
state.alertNthPrime = nil
return []
现在FavoritePrimes模块正在构建。在尝试修复应用程序的其余部分之前,让我们尝试在我们的专用playground上运行它。 我们只需要指定特性的新状态和环境:
var environment: FavoritePrimesEnvironment = (
fileClient: .mock,
nthPrime: { _ in .sync { 17 } }
)
environment.fileClient.load = { _ in
Effect.sync { try! JSONEncoder().encode(Array(1...10)) }
}
PlaygroundPage.current.liveView = UIHostingController(
rootView: NavigationView {
FavoritePrimesView(
store: Store<FavoritePrimesState, FavoritePrimesAction>(
initialValue: (
alertNthPrime: nil,
favoritePrimes: [2, 3, 5, 7, 11]
),
reducer: favoritePrimesReducer,
environment: enviornment
)
)
}
)
When we tap a row we see that it causes an alert to show.
要构建整个应用程序,我们只需要做一些小事情。首先,我们需要修复回调:
pullback(
favoritePrimesReducer,
value: \.favoritePrimes,
action: /AppAction.favoritePrimes,
environment: { $0.fileClient }
)
首先,沿着AppState的favoritePrimes字段回调状态不再正确,我们需要favoritePrimes和alertNthPrime字段。
要做到这一点,我们只需要一个自定义的计算属性来获取关键路径:
extension AppState {
var favoritePrimesState: FavoritePrimesState {
get {
(self.alertNthPrime, self.favoritePrimes)
}
set {
(self.alertNthPrime, self.favoritePrimes) = newValue
}
}
}
然后我们可以在回调中使用这个新的键路径:
pullback(
favoritePrimesReducer,
value: \.favoritePrimesState,
action: /AppAction.favoritePrimes,
environment: { $0.fileClient }
)
只传递fileClient沿环境回调也不再有效,它还需要nthPrime effect。我们可以在回调中去掉最喜欢的质数特性需要的两个依赖项:
pullback(
favoritePrimesReducer,
value: \.favoritePrimesState,
action: /AppAction.favoritePrimes,
environment: { ($0.fileClient, $0.nthPrime) }
)
我们只需要再修一些东西就可以开工了。我们需要导入prime alert模块,将新的收藏质数状态发送到收藏质数视图,并更新活动提要以忽略这些新操作。
import PrimeAlert
…
case .favoritePrimes(.primeButtonTapped),
.favoritePrimes(.nthPrimeResponse),
.favoritePrimes(.alertDismissButtonTapped):
break
…
NavigationLink(
"Favorite primes",
destination: FavoritePrimesView(
store: self.store.view(
value: { $0.favoritePrimesState }
action: { .favoritePrimes($0) }
)
)
)
就像这样,应用程序再次构建,这可能看起来令人惊讶,但请记住,我们重用了一个现有的依赖,所以应用程序环境不需要改变。
4. Conclusion
唷,好吧! 这是相当多的工作,但我们能够证明这种新的环境依赖解决了全局环境的3个关键问题。
-
我们可以一次从许多不同的特性模块中简洁地联合环境。
-
我们可以允许特性用不同的依赖项被多次实例化。
-
我们可以共享特性模块之间的依赖关系。
我们做了很多工作才走到这一步,以这种新风格重构可组合架构,并重构我们的应用程序以利用它,但现在我们在编写表达性强、可测试的应用程序方面处于更有利的地位。
现在,我们有了一个单独的顶级环境,该环境被分割并传递给每个单独的模块,我们不仅可以获得应用程序中状态更改的全局视图,还可以了解应用程序中依赖关系和效果的使用情况。这为潜在的复杂情况提供了清晰度和理解。如果一个依赖项出了问题,我们有一个单一的一致的方法来跟踪它的起源,一直追溯到根。
我们所做的一些重构可能看起来很费力,但你也必须记住,我们所做的工作是为了在我们的模块之间有一个强大的、静态的联系。当回调编译时,我们得到了静态的确认,我们的模块被正确地插入到一起了。不仅如此,模块之间的这种静态粘合意味着,当我们为应用程序编写集成测试时,我们要从上到下测试架构的每个方面。
我认为这种重构最令人惊奇的事情是,我们能够在不破坏可组合性或可测试性的情况下从中获得许多好处。我们仍然可以独立地构建自己的功能,而那些小功能仍然可以插入到更大的应用程序中。这真的很强大。