1. Transforming a store’s action
我们现在已经改进了在使用可观察的Store对象的视图中访问state的模块化和人机工程学, 但我们不能将任何视图提取到独立的模块中,因为每个视图的store仍然依赖于AppAction。因此,听起来我们需要另一个操作来将发送全局actions的store转换为发送本地actions的store。我们发现我们可以转换store的value,那么我们也可以转换store的actions吗?
首先,这意味着什么? 目前,我们在外部处理actions and the store的唯一时间是通过调用send方法向store发送action的时候。如果我们能发送局部actions到那个方法而不是全局actions,然后在store的某个地方,也许它可以自动地将局部actions包装成全局actions。
让我们从为这个转换准备另一个函数签名开始。我们将从另一个未命名的方法开始,以便专注于使转换工作。
final class Store<Value, Action>: ObservableObject {
…
func ___()
}
再说一次,我们想退回一家全新的商店。
final class Store<Value, Action>: ObservableObject {
…
func ___() -> Store
}
我们希望这个store能够处理比当前store更本地化的操作。
final class Store<Value, Action>: ObservableObject {
…
func ___<LocalAction>() -> Store<Value, LocalAction>
}
我们可以再次打开函数体并立即返回Store<Value, LocalAction>。
final class Store<Value, Action>: ObservableObject {
…
func ___<LocalAction>() -> Store<Value, LocalAction> {
return Store<Value, LocalAction>(
initialValue: <#Value#>,
reducer: <#(inout Value, LocalAction) -> Void#>
)
}
}
为了给store提供一个初始值,我们只需传递当前的self.value即可。类型不变。
initialValue: self.value,
现在,我们只需要弄清楚如何实现另一个奇怪的、类似于reducer的函数。让我们打开闭包。
initialValue: self.value,
reducer: { value, localAction in
}
在这里,当我们转换一个store的值时,我们所做的主要工作是调用根store的self.send方法并使用其更新的self.value以相应地更新sub-store的值。为了调用self.send在这个方法中,我们需要一个Action,但我们只有一个LocalAction。 如果我们能把LocalActions转换成Actions,我们应该能够实现这个。听起来像是另一个函数!
让我们引入另一个f从LocalAction到Action。
func ___<LocalAction>(
_ f: (LocalAction) -> Action
) -> Store<Value, LocalAction> {
在将localAction传递给store之前,我们可以使用这个f将localAction转换为更全局的action。
reducer: { value, localAction in
self.send(f(localAction))
}
但是我们需要标记f为逃逸,因为它被store捕获了。
func ___<LocalAction>(
f: @escaping (LocalAction) -> Action
) -> Store<Value, LocalAction> {
最后,我们需要用self.value重新分配输入输出的可变值。
reducer: { value, localAction in
self.send(f(localAction))
value = self.value
}
好吧,可以正常编译,但这又是一个奇怪的reducer。它取决于store的内部行为来改变它的值,然后它会重新赋值给可变的值即使它根本没用那个值。
但撇开奇怪不谈,这个函数有一个我们熟悉的形状吗?
让我们来看看它的签名:
// ((LocalAction) -> Action) -> ((Store<_, Action>) -> Store<_, LocalAction>)
将通用名称归纳为A和B:
// ((B) -> A) -> ((Store<A, _>) -> Store<B, _>)
并从Store中归纳出上下文来得到一个非常清晰的画面。
// ((B) -> A) -> (F<A>) -> F<B>)
这是我们在Point-Free上遇到过多次的函数签名,我们将操作命名为pullback。
// pullback: ((A) -> B) -> (F<B>) -> F<A>)
这是我们在深入研究“逆变性”时首次探索的函数形状,我们探索了一种逆转函数箭头的组合形式,就像我们在这里看到的那样。
所以我们可能会调用这个操作pullback:
public func pullback<LocalAction>(
f: @escaping (LocalAction) -> Action
) -> Store<Value, LocalAction> {
然而,由于类似于我们在Store上描述的map操作的原因,我们不喜欢调用这个pullback方法。它具有所有相同的问题,因为它作用于引用类型,并依赖于对象的行为,而不仅仅是纯粹的数学属性。
相反,我们可以将其命名为“view”,因为它返回一个只能发送本地操作集的store视图。
final class Store<Value, Action>: ObservableObject {
…
public func view<LocalAction>(
f: @escaping (LocalAction) -> Action
) -> Store<Value, LocalAction> {
return Store<Value, LocalAction>(
initialValue: self.value,
reducer: { value, localAction in
self.send(f(action))
value = self.value
}
)
}
}
2. Combining view functions
这个函数可以将全局操作中的store转换为更多本地操作中的store,但现在还不是这样。它仍然需要处理对更多全局store的本地store的订阅,也许我们应该将这两个view函数合并到一个一次性完成所有工作的函数中。
如果将这些函数紧密地粘贴在一起,就可以将所有action转换嵌入到同样处理state转换的方法中。首先,我们可以引入LocalAction泛型,并将这两个转换函数都作为输入。
public func view<LocalValue, LocalAction>(
_ f: @escaping (Value) -> LocalValue,
_ g: @escaping (LocalAction) -> Action
现在很清楚了,同时,变换函数的方向是完全相反的。
让我们更新参数名以使内容更具可读性。
func view<LocalValue, LocalAction>(
value toLocalValue: @escaping (Value) -> LocalValue,
action toGlobalAction: @escaping (LocalAction) -> Action
现在我们需要更新所有对视图转换函数的调用,并处理从局部actions到全局actions的转换。
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: { localValue, localAction in
self.send(toGlobalAction(localAction))
localValue = toLocalValue(self.value)
}
)
localStore.cancellable = self.$value.sink { [weak localStore] newValue in
localStore?.value = toLocalValue(newValue)
}
return localStore
}
3. Focusing on favorite primes actions
我们的应用无法正常编译,因为无论“ContentView.swift”使用Store的视图函数,它只传递一个转换函数的值。让我们通过为每个动作传递一个transform函数来再次进行编译。因为我们所有的视图都期望使用AppAction的store,所以我们可以使用标识函数,拼写为{$0}。
在初始化计数器屏幕的地方,可以做以下更改:
NavigationLink(
"Counter demo",
destination: CounterView(
store: self.store.view(
value: { ($0.count, $0.favoritePrimes) },
action: { $0 }
)
)
)
在初始化收藏素数屏幕的地方,我们可以做以下更改。
NavigationLink(
"Favorite primes",
destination: FavoritePrimesView(
store: self.store.view(
value: { $0.favoritePrimes },
action: { $0 }
)
)
)
最后,在主模态视图被构造的地方,我们也可以更新它对视图的调用:
IsPrimeModalView(
Store: self.store
.view(
value: { ($0.count, $0.favoritePrimes) },
action: { $0 }
)
)
好了,又可以正常编译了,但是我们的视图仍然与全局AppActions一起工作。让我们更新视图以使用更多的本地actions。
我们可以从FavoritePrimesView开始:
struct FavoritePrimesView: View {
@ObservedObject var store: Store<[Int], AppAction>
并将其更新为与FavoritePrimesAction一起工作:
struct FavoritePrimesView: View {
@ObservedObject var store: Store<[Int], FavoritePrimesAction>
它打破了我们的变异:
.onDelete { indexSet in
self.store.send(.favoritePrimes(.deleteFavoritePrimes(indexSet)))
self.store.send(.counter(.incrTapped))
}
🛑 Type ‘FavoritePrimesAction’ has no member ‘favoritePrimes’ 🛑 Type ‘FavoritePrimesAction’ has no member ‘counter’
对于第一个问题,解决方法很简单:我们可以解除action的嵌套:
self.store.send(.deleteFavoritePrimes(indexSet))
第二个错误是很好的错误! favorite质数视图发送了一个不应该发送的counter操作,因此,我们可以删除这行代码,并欣赏这个视图变得多么集中。
我们还有一件事要解决。初始化FavoritePrimesView:
NavigationLink(
"Favorite primes",
destination: FavoritePrimesView(
store: self.store.view(
value: { $0.favoritePrimes },
action: { $0 }
)
)
)
🛑 ‘Store<[Int], AppAction>’ is not convertible to ‘Store<[Int], FavoritePrimesAction>’
我们需要将应用程序操作的store,生成一个应用程序操作的store视图。我们可以通过将FavoritePrimesAction传递给action闭包,并将其嵌入到AppAction的favoritePrimes case中来实现:
NavigationLink(
"Favorite primes",
destination: FavoritePrimesView(
store: self.store.view(
value: { $0.favoritePrimes },
action: { AppAction.favoritePrimes($0) }
)
)
)
我们甚至可以使用类型推断来简化事情:
NavigationLink(
"Favorite primes",
destination: FavoritePrimesView(
store: self.store.view(
value: { $0.favoritePrimes },
action: { .favoritePrimes($0) }
)
)
)
现在甚至有了一个很好的对称。
4. Extracting our first modular view
在我们更新所有其他视图之前,让我们停下来欣赏一下我们所做的:FavoritePrimesView现在已经尽可能地集中了。
它不知道AppState:它只能访问它渲染的最喜欢的素数的[Int]。而且它没有办法发送AppActions:它只能发送FavoritePrimesActions。这就是我们一直在努力追求的模块化。这意味着我们最终可以将这个视图提取到它自己的模块中。
虽然它可以位于自己的模块中,但现在让我们将它移到“FavoritePrimes”模块。我们可以剪切粘贴:
struct FavoritePrimesView: View {
@ObservedObject var store: Store<[Int], FavoritePrimesAction>
var body: some View {
…
}
}
🛑 Use of undeclared type ‘View’
但是它依赖于一堆其他模块,包括Combine、SwiftUI,甚至我们的“ComposableArchitecture”框架,所以让我们导入它们。
import ComposableArchitecture
import SwiftUI
所有的东西都是编译的,所以它们是独立的,但是我们需要让所有的东西都是public,也就是:结构体本身和它的body。
public struct FavoritePrimesView: View {
@ObservedObject var store: Store<[Int], FavoritePrimesAction>
public var body: some View {
…
}
}
如果我们尝试构建我们的应用程序,我们会得到一个错误。
🛑 ‘FavoritePrimesView’ initializer is inaccessible due to ‘internal’ protection level
在将视图移动到模块时,我们遇到了一个类似于将prime modal state移动到模块时的问题:Swift自动为我们生成的成员初始化式是internal,但我们需要一个public初始化式来在我们的应用程序中生成值。
所以我们需要从头开始定义它:
public struct FavoritePrimesView: View {
@ObservedObject var store: Store<[Int], FavoritePrimesAction>
public init(store: Store<[Int], FavoritePrimesAction>)
self.store = store
}
public var body: some View {
…
}
}
有了它,一切都构建起来了,我们真的,用编译器检查的方式,把这个视图从应用程序的其他部分分离出来了。这样做伴随着一些显式初始化式形式的样板文件,但就像我们在本系列早期采用的样板文件一样,它完全是机械编写的,未来的Xcode版本将能够为我们编写它。 这是完全值得的,以确保我们的view有一个非常有限的能力,以获得更多的全局actions和state。
5. Focusing on prime modal actions
我们还有一些视图可以从AppAction中分离出来,比如IsPrimeModalView:
struct IsPrimeModalView: View {
@ObservedObject var store: Store<PrimeModalState, AppAction>
它只需要发送PrimeModalActions。
struct IsPrimeModalView: View {
@ObservedObject var store: Store<PrimeModalState, PrimeModalAction>
它通过消除更多的嵌套来简化操作的调用:
Button("Remove from favorite primes") {
self.store.send(.removeFavoritePrimeTapped)
}
…
Button("Save to favorite primes") {
self.store.send(.saveFavoritePrimeTapped)
}
在视图初始化的地方,我们必须提供一个限制其操作的store。
IsPrimeModalView(
store: self.store.view(
value: { ($0.count, $0.favoritePrimes) }
action: { .primeModal($0) }
)
)
更多的本地操作需要嵌入到AppAction中。
现在我们有另一个完全孤立的view。
让我们将视图及其isPrime助手函数剪切并粘贴到“PrimeModal”模块中。
struct IsPrimeModalView: View {
@ObservedObject var store: Store<PrimeModalState, PrimeModalAction>
var body: some View {
…
}
}
func isPrime(_ p: Int) -> Bool {
…
}
同样,我们要添加一些导入。
import ComposableArchitecture
import SwiftUI
我们必须公开视图的界面。
public struct IsPrimeModalView: View {
@ObservedObject var store: Store<PrimeModalState, PrimeModalAction>
public var body: some View {
…
}
}
我们需要定义一个公共初始化式。
public init(store: Store<PrimeModalState, PrimeModalAction>) {
self.store = store
}
过程和上次完全一样。当我们构建我们的应用程序时,所有的编译都很好,整个过程只花了几分钟。
6. Focusing on counter actions
现在,我们已经从应用程序中提取了两个视图到它们自己的模块中,并极大地限制了它们的表面积。它们不再能够访问所有的应用state和应用actions。
我们还有一个视图,但它比其他视图更复杂一些,所以让我们尝试处理它,看看会发生什么。
如果我们看我们的counter视图,它是一个大的。
struct CounterView: View {
@ObservedObject var store: Store<CounterViewState, AppAction>
它仍然适用于所有的app动作。
它需要访问它所显示的计数器的CounterActions,并且它需要访问PrimeModalActions以便将它们传递给IsPrimeModalView。它不需要访问FavoritePrimesActions,也不应该有将它们发送到store的能力。但我们如何限制视图发送这些动作,同时保留发送counter动作或主要模态动作的能力?
我们可以用一种类似于限制主模态所能访问的state的方式来实现:通过创建一个中间类型。但这一次,我们想要定义的不是一个结构体,而是一个enum,它处理的每个操作都有一个case。
enum CounterViewAction {
case counter(CounterAction)
case primeModal(PrimeModalAction)
}
有了这个定义,我们可以换出store的动作类型:
struct IsPrimeModalView: View {
@ObservedObject var store: Store<CounterViewState, CounterViewAction>
没有什么需要改变的。因为case名称匹配,所以我们能够进行这个重构,并保持视图主体完全完整。
事情还没有完全建立,尽管,因为CounterView被初始化与错误的store类型。
NavigationLink(
"Counter demo",
destination: CounterView(
store: self.store.view(
value: { ($0.count, $0.favoritePrimes) },
action: { $0 }
)
)
)
🛑 Cannot convert value of type ‘CounterViewAction’ to closure result type AppAction
action块现在传递了一个CounterViewAction,我们需要返回一个AppAction。没有AppAction case会嵌入CounterViewAction,但是,我们可以切换容器动作,打开每个case,并在适当的AppAction中重新包装它们。
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)
}
}
)
)
)
这已经足够满足编译器的要求了,但是内联编写有很多内容,特别是当我们致力于简化视图时。幸运的是,这是非常简单的逻辑,我们将进一步简化它。
好的,counter视图已经完全准备好被移动到“counter”模块。我们可以剪切和粘贴我们需要的部分,包括视图、PrimeAlert包装结构、中间的CounterViewAction和几个助手。
struct PrimeAlert: Identifiable {
…
}
enum CounterViewAction {
…
}
struct CounterView: View {
@ObservedObject var store: Store<CounterViewState, CounterViewAction>
…
var body: some View {
…
}
}
func nthPrime(_ n: Int, callback: @escaping (Int?) -> Void) -> Void {
…
}
func ordinal(_ n: Int) -> String {
…
}
让我们添加这些导入。
import ComposableArchitecture
import SwiftUI
我们有一个编译器错误,因为我们依赖于PrimeModalAction,它存在于自己的模块中。
enum CounterViewAction {
case counter(CounterAction)
case primeModal(PrimeModalAction)
🛑 Use of undeclared type ‘PrimeModalAction’
因此,在导入框架之前,我们也需要更新框架以依赖于这个模块。
import PrimeModal
But we still have one error:
🛑 Use of unresolved identifier ‘wolframAlpha’
因为我们还需要将“WolframAlpha.swift”移动到模块中,因为nthPrime助手使用它。
好了,框架似乎可以编译了,让我们来审计公共接口。中间的计数器容器操作需要是public,这样我们的应用程序就可以创建一个专注于这些操作的store。
public typealias CounterViewState = (count: Int, favoritePrimes: [Int])
public enum CounterViewAction {
…
}
最后,视图还是需要是公共的,并给出一个显式的公共初始化式。
public struct CounterView: View {
@ObservedObject var store: Store<CounterViewState, CounterViewAction>
…
var body: some View {
…
}
}
内部的助手都不需要公开。它们甚至可以变成私有的!
一切都可以正常构建,但是counter视图已经被完全提取到它自己的模块中。我们甚至可以运行应用程序来确保一切正常,就像以前一样,即使每个屏幕都存在于它自己的模块中。
7. Next time: what’s the point?
好了,现在我们已经完成了模块状态管理系列文章开始时要做的事情。 首先,我们定义了“模块化”的含义:我们确定模块化是将代码移动到字面上的Swift模块的能力,这样它就完全与应用程序的其余部分隔离。然后,我们展示了我们的reducers已经是多么模块化,通过将每个reducer分解成一个自己的框架。最后,为了模块化视图,我们在Store类型上发现了两种组合形式:一种允许我们将现有store集中在state的某个子集上,另一种允许我们将现有store集中在actions的某个子集上。这些操作允许我们将所有的视图从更全局的、应用级的关注点中分离出来,并将它们移动到各自独立的模块中。
但每当我们在point - free上结束这些探索时,我们总是会停下来反思,然后问:“有什么意义?”
为了满足我们对模块化的要求,我们必须:
- 在Xcode中为每个组件创建一个新框架。这可能是一件麻烦的事情,并可能导致复杂的项目构建设置。
- 识别并移动应该移动到模块的代码,并审计模块需要公开的公共接口
- 围绕公共初始化器添加样板,有时甚至为state引入中间struct,为actions引入中间enum。
我们真的需要这种级别的模块化吗? 为什么我们不能把东西移到单独的文件中,然后引入私有和文件私有访问呢? 如果可以,为什么不避免额外的样板文件呢? 这是相当多的额外工作。这真的值得去做吗?
我们已经在整个系列中说过了,但是我们还要再说一遍:我们绝对相信这是值得做的。
每个模块现在都高度关注一小部分应用状态、逻辑和视图,由编译器强制执行。您的队友和未来的自己现在可以阅读这些模块,并以一种可能需要更多时间和精力的方式一目了然地理解它们。
的确,您可以将代码组织到更独立的文件中,并且引入private and fileprivate访问可以限制泄漏的代码数量。但是,它并没有阻止这些文件访问模块中的所有其他内容。在一个大型应用目标中,这可能是很多。
成本是一些创建模块、移动代码和编写有限数量的样板文件的前期工作。但好处在于都在以后所节省下来。
我们还可以通过展示这些屏幕如何独立运行来测试这种模块化。例如,我们可以在playground上加载任何这些屏幕,并在那里进行试验,就像它们是自己的应用程序一样使用它们……下一次!