1. Introduction
我们将这些reducers模块化的轻松程度说明了该体系结构在默认情况下是多么模块化。我们不需要对逻辑进行任何显著的重构或更改,因为边界已经非常清晰地定义了。最糟糕的情况是,需要为状态更复杂的组件引入一些额外的样板文件。
2. Modularizing our views
虽然我们的体系结构已经极大地简化了视图,但它仍然没有将视图模块化。我们已经将我们的reducer从整个应用state和应用actions中分离出来,但我们还没有将我们的视图分离出来。所以让我们开始解决这个问题。
我们所有的视图都能访问所有的应用state,它们都有能力发送任何应用action。这是因为它们都拥有一个中央Store,它是AppState和AppAction的通用代表。每当我们将这个中央store传递给视图时,我们都会给视图提供比它需要的更多的信息和功能。我们也没有办法看一个视图,并一目了然地了解它能获取什么信息,它能产生什么突变。
例如,我们必须扫描FavoritePrimesView的整个body,以知道它只需要访问favorit素数数组和deletefavorit素数操作,但它可以访问所有内容。
struct FavoritePrimesView: View {
...
}
这意味着,没有什么能阻止我们将更多的全局state和全局actions引入这个view。事实上,这很容易做到!
例如,当delete被调用时,我们可以发送计数器屏幕的incrTapped动作:
.onDelete { indexSet in
self.store.send(.favoritePrimes(.deleteFavoritePrimes(indexSet)))
self.store.send(.counter(.incrTapped))
}
虽然这似乎是一个愚蠢的例子,但在复制-粘贴重构过程中,这些不相关的突变可以很容易地潜入。理想情况下,这个视图应该位于一个模块中,与全局state和actions完全隔离,这样编译器就可以为我们捕获这些类型的错误。
如果我们继续向上滚动,我们需要阅读IsPrimeModalView来理解它只需要AppState的几个部分:当前count和favorite素数数组。它只需要几个模态动作。
struct IsPrimeModalView: View {
...
}
在CounterView中有很多内容:
struct CounterView: View {
...
}
我们在这里看到的是,尽管我们的reducer已经进行了重构,以获得完成任务所需的最小值,但视图并没有这么好的质量。我们希望重构这些屏幕,使它们采取的state和actions更接近于表示它们行为的reducer。
那么我们如何解决这个问题呢?它们都有一个共同点:
@ObservedObject var store: Store<AppState, AppAction>
如果我们能够传递一个更具体的store,它只处理视图关心的state和actions,这不仅会使它们更容易理解,而且还可以将它们提取到自己的模块中,并将它们与应用程序的其余部分隔离开来。但是我们怎么做呢?
这不是我们第一次表演了! 我们一次又一次地看到,通过专注于小型、可组合、可变形的单元,我们可以解锁各种可能性。到目前为止,我们的重点一直是reducer函数的组合,但是Store类型呢? 我们是否可以转换Store的Value和Action,以便将全局Store转换为更局部的Store? 答案是肯定的!
3. Transforming a store's value
让我们从如何转换store的value开始。
目前,我们与store's value互动的唯一方式是通过属性获得它。这是因为value字段有一个私有的setter,所以在ComposableArchitecture模块之外,除了获取值,我们什么也做不了。我们想要的是有一个转换自动应用到store,这样当我们尝试访问value时,我们只得到我们真正关心的应用状态的部分。
让我们通过在Store中创建一个方法来探索这个问题。我还不确定该怎么称呼它,所以让我们使用下划线,这样我们就可以完全专注于我们想让它做的工作。
public final class Store<Value, Action>: ObservableObject {
…
func ___
我们知道我们希望这个方法返回另一个Store:
public final class Store<Value, Action>: ObservableObject {
…
func ___() -> Store
我们还知道要将当前store的值转换为更局部的值,所以让我们引入一个泛型。
public final class Store<Value, Action>: ObservableObject {
…
func ___<LocalValue>() -> Store
返回的Store将是这个LocalValue的泛型,而Action将保持不变。
public final class Store<Value, Action>: ObservableObject {
…
func ___<LocalValue>() -> Store<LocalValue, Action>
让我们打开body的这个函数,看看它的工作部件。我们知道我们需要返回一个全新的Store<LocalValue, Action>,所以让我们调用它的初始化式。
public final class Store<Value, Action>: ObservableObject {
…
func ___<LocalValue>() -> Store<LocalValue, Action> {
return Store<LocalValue, Action>(
initialValue: <#LocalValue#>,
reducer: <#(inout LocalValue, Action) -> Void#>
)
}
Store初始化式接受一个初始值和一个reducer。 为了提供初始值,我们需要以某种方式将value转换为LocalValue。 听起来我们需要一个函数! 让我们引入一个论点。
public final class Store<Value, Action>: ObservableObject {
…
func ___<LocalValue>(_ f: (Value) -> LocalValue) -> Store<LocalValue, Action> {
return Store<LocalValue, Action>(
initialValue: <#LocalValue#>,
reducer: <#(inout LocalValue, Action) -> Void#>
)
}
现在我们可以取当前store的self.value,并应用f将其从value转换为LocalValue。
public final class Store<Value, Action>: ObservableObject {
…
func ___<LocalValue>(_ f: (Value) -> LocalValue) -> Store<LocalValue, Action> {
return Store<LocalValue, Action>(
initialValue: f(self.value),
reducer: <#(inout LocalValue, Action) -> Void#>
)
}
但是reducer呢? 我们如何实现它? 我们至少可以打开闭包,在那里我们可以访问LocalValue和Action。
public final class Store<Value, Action>: ObservableObject {
…
func ___<LocalValue>(_ f: (Value) -> LocalValue) -> Store<LocalValue, Action> {
return Store<LocalValue, Action>(
initialValue: f(self.value),
reducer: { localValue, action in
}
)
}
用f转换store的value很容易,但是转换store的reducer似乎是不可能的。我们手头的东西似乎不太合适。
例如,我们有一个局部值,但我们的函数将全局值转换为局部值,所以这没有帮助。
实际上,在某种意义上,实现这个函数是不可能的。为了清除一些杂音,让我们粘贴到我们试图实现的实际签名:
func transform<A, B, Action>(
_ reducer: (inout A, Action) -> Void,
_ f: (A) -> B
) -> (inout B, Action) -> Void {
fatalError()
}
我们在关于逆变性的一节中讨论了为什么这种函数不可能实现,在这一节中我们讨论了函数中类型参数的正负位置的概念。 我们强烈鼓励大家去看这一集,但归根结底,为了转换函数签名中的类型参数,它必须要么处于正位置,要么处于负位置。但是,这里的A参数既处于正位置也处于负位置。这是因为,正如我们多次提到的,接受inout参数的函数有点像返回一个全新值的函数。
所以实际上,这个函数签名等价于:
func transform<A, B, Action>(
_ reducer: (A, Action) -> A,
_ f: (A) -> B
) -> (B, Action) -> B {
fatalError()
}
现在我们看到A出现在函数箭头的两边,因此不可能实现这个函数。
这是对问题的一个非常简短的描述,但我们再次强烈建议你观看我们的逆变视频如果你想更深入地理解这里发生了什么。
那么,如果我们不能改造现有的reducer,我们该怎么办? 事实上,我们有更多的信息在这里,只是有点隐藏。我们有self的引用,它有一个send方法。我们可以将可用的action传递给该方法:
reducer: { localValue, action in
self.send(action)
}
这将导致self.value转换为突变值,我们可以使用f将其转换为一个局部值。
reducer: { localValue, action in
self.send(action)
let updatedLocalValue = f(self.value)
}
记住,localValue是一个可变的in-out变量,所以我们可以用更新后的本地state重新赋值。
reducer: { localValue, action in
self.send(action)
let updatedLocalValue = f(self.value)
localValue = updatedLocalValue
}
我们还需要标记f为escaping,因为它现在正在被store捕获。
func ___<LocalValue>(
_ f: @escaping (Value) -> LocalValue
) -> Store<LocalValue, Action> {
我们甚至可以在一行代码上做简化运算。
reducer: { localValue, action in
self.send(action)
localValue = f(self.value)
}
所以现在它可以编译了,但是事情有点奇怪。
-
我们不能仅仅使用手头的数据来实现reducer,我们必须通过向store发送一个action转向store的内部行为,然后依赖于store在过程中改变其值的事实。
-
此外,我们甚至没有真正使用这个reducer中提供给我们的localValue。我们使用它只是为了用新的局部值覆盖它。这似乎很奇怪。
4. A familiar-looking function
但撇开奇怪不谈,至少它现在正在编译。此外,它的形状可能看起来很熟悉。
如果我们把它重写成一个函数,它可能是这样的:
// ((Value) -> LocalValue) -> ((Store<Value, _>) -> Store<LocalValue, _>
如果我们用更简单的A和B来抽象一般化的名称。
// ((A) -> B) -> ((Store<A, _>) -> Store<B, _>)
如果我们更进一步,我们甚至可以将Store抽象为另一个通用容器。
// ((A) -> B) -> ((F<A>) -> F<B>)
现在我们的一些观众可能对这个形状非常熟悉。这是map!
// map: ((A) -> B) -> ((F<A>) -> F<B>)
map函数是我们在第13集中首次深入探讨的操作。 在本文中,我们探讨了map是一种操作,它允许我们将类型之间的函数提升到泛型类型之间的函数。Swift标准库定义了数组和可选项上的map。它甚至在结果类型上定义了两次,每个泛型分别定义一次:成功和失败。我们还探讨了在我们自己的类型上定义map,包括惰性值、并行值、验证值、随机值生成器,甚至解析器。
我们在商店找到另一个map了吗?
public final class Store<Value, Action>: ObservableObject {
…
func map<LocalValue>(
_ f: @escaping (Value) -> LocalValue
) -> Store<LocalValue, Action> {
return Store<LocalValue, Action>(
initialValue: f(self.value),
reducer: { localValue, action in
self.send(action)
localValue = f(self.value)
}
)
}
}
让我们来看看这个map是如何运作的。
我们可以从创建一个非常简单的store整数和一个Void操作开始。
let store = Store<Int, Void>(initialValue: 0, reducer: { count, _ in count += 1 })
然后我们可以发送一些void操作,看看它如何改变商店的值。
store.send(())
store.send(())
store.send(())
store.send(())
store.send(())
store.value // 5
到目前为止,这完全符合我们的预期。
现在,让我们通过用恒等函数映射这个store来创建一个新的store,它不会改变值:
let newStore = store.map { $0 }
newStore.value // 5
我们可以开始发送新的store actions,并在最后检查它的值。
newStore.send(())
newStore.send(())
newStore.send(())
newStore.value // 8
然而,如果我们回头看看原始store的价值,我们会发现一些奇怪的事情:
store.value // 8
由于某种原因,它也增加到了8,尽管我们没有向它发送任何操作。原因很简单:原来的store和新的store不是不同的实体,而是不可分割地联系在一起。 派生的store保存原始store的引用,并在我们发送操作时改变它。
这肯定感觉有点奇怪,但这也是我们想要的行为:每当本地store被发送一个操作时,我们希望通知全局store,以便在应用程序中全局地表示本地更改。
不幸的是,事情还没有完全正确。要查看问题,我们可以将操作发送到根store:
store.send(())
store.send(())
store.send(())
当根store的值增加时,新store的值没有增加:
newStore.value // 8
store.value // 11
本地store将本地更改通信回全局store,因为它直接调用全局store的send方法。但是本地store无法从全局store接收更新。如果本地store能够以某种方式观察到对其根store的更新,那么我们就可以在本地传播这些全局更改。
5. What's in a name?
我们可以解决这个问题,我们马上就会解决,但在此之前,让我们稍微回顾一下刚才做的。不过,我们现在已经实现了一个方法,它可以将处理全局state的store转换为处理局部state的store。它似乎有map函数的形状,这个函数不像我们在Point-Free上遇到的任何其他map函数。
在我们最初关于map函数的章节中,我们展示了它是一个非常数学的概念,它甚至满足唯一性。这意味着,如果你的map满足一个简单的属性,那么它将在所有可能的具有map签名的函数中唯一地决定该类型的map。从本质上讲,map是由我们的类型唯一决定的东西,而不是我们可以选择如何实现的东西。
然而,只有当我们所映射的类型是纯结构时,我们才能理解这些陈述和结果,因为这是我们在数学世界中所拥有的。
不幸的是,Store类型是一个绑定了许多行为的类。 它不像值类型那样由简单的数据定义,它保存随时间变化的可变数据,并公开有助于更改该数据的方法。所有这些都超出了map的数学公式的范围,这也正是map的强大之处。
这个观察结果在一些现实世界的困惑中体现出来,比如对本地store的更改如何影响其全局store,以及对全局store的更改如何影响任何本地store! 这是我们在任何其他map操作中都没有观察到的一种行为。
想象一下,如果我们有一个整数数组,通过用恒等函数对原始数组进行映射来形成一个新数组:
var xs = [1, 2, 3]
var ys = xs.map { $0 }
如果我们在结果后面加上另一个数会怎样呢?
ys.append(4)
如果我们检查每个值,它们是不同的:
xs // [1, 2, 3]
ys // [1, 2, 3, 4]
如果xs数组发生了突变,我们会发现这是非常令人惊讶的,并且会在数组上使用map时引入很多潜在的复杂性。
但是我们在Store类型中看到的正是这种情况,在某种程度上,通过改变本地store,我们偷偷地改变了全局store。
因此,由于我们在这里所观察到的,我们不喜欢把这个操作称为map。一般来说,我们希望鼓励每个人在定义引用类型上的map和相关操作时小心谨慎,因为它们可能导致非常复杂的对象,很多时候它们的行为与您期望的不一样,所以不要有同样的直觉。当然,您可以自由地在泛型引用类型上定义map,没有人会阻止您。它只是不会像我们在Point-Free上定义的map那样运行,可能会导致令人困惑的结果。
这并不是说有时不能以在其上定义map以有意义的方式控制引用类型,只是这样做本质上比简单值类型更复杂。
因此,与其调用这个操作map,我们更愿意用一个更特定于域的名称来调用它:view。我们喜欢这个名称,因为它向商店返回一个“view”,在那里我们只允许查看全局值的本地版本。
public final class Store<Value, Action>: ObservableObject {
…
func view<LocalValue>(
_ f: @escaping (Value) -> LocalValue
) -> Store<LocalValue, Action> {
return Store<LocalValue, Action>(
initialValue: f(self.value),
reducer: { localValue, action in
self.send(action)
localValue = f(self.value)
}
)
}
}
6. Propagating global changes locally
我们现在有一个函数,允许我们将全局store转换为本地store,但是本地store仍然有一个问题:当它们派生的全局store发生变化时,它们没有得到通知。
由于绑定了SwiftUI机制,store已经是可观察的了,这正是允许store更改立即在SwiftUI视图中反映出来的原因。
Store符合ObservableObject协议,它的值用@Published属性包装器包装。
final class Store<Value, Action>: ObservableObject {
…
@Published public private(set) var value: Value
这个协议和属性包装器来自苹果的新Combine框架,使用它们可以合成对象,可以向任何感兴趣的人发布更改,包括SwiftUI视图。
SwiftUI视图可以使用@ObservedObject属性包装器包装这些ObservableObjects,就像我们的store一样:
@ObservedObject var store: Store<AppState, AppAction>
尽管这些publishers被隐藏起来了,但我们仍然可以接触到他们。 例如,通过遵循ObservableObject,我们的商店自动获得一个objectWillChange publisher。
self.objectWillChange // ObservableObjectPublisher
这是SwiftUI订阅的内容,以便通过确定哪些变化来快照当前state并有效地重新呈现内容。
与此同时,@Published属性包装器引入了一个store值更新的Combine publisher。它可以通过在值前加上$来访问。
self.$value // Published<Value>.Publisher
任何相关方都可以通过其sink方法订阅Combine发布者,该方法接受一个回调闭包,每当传入新值时就会调用该闭包。
self.$value.sink(receiveValue: <#((Value) -> Void)#>)
这意味着当我们创建一个本地store时,我们可以通过订阅全局store的值发布者来通知它全局更新,并相应地传递这些更新。
为了通知本地store,让我们在返回之前在引用中捕获它。
func view<LocalValue>(
_ f: @escaping (Value) -> LocalValue
) -> Store<LocalValue, Action> {
let localStore = Store<LocalValue, Action>(
initialValue: f(self.value),
reducer: { localValue, action in
self.send(action)
localValue = f(self.value)
}
)
return localStore
}
接下来,我们可以调用根store的值publisher上的sink。
let localStore = Store<LocalValue, Action>(
initialValue: f(self.value),
reducer: { localValue, action in
self.send(action)
localValue = f(self.value)
}
)
self.$value.sink(receiveValue: <#((Value) -> Void)#>)
return localStore
当我们打开订阅块时,我们可以使用transform函数将每个新值提供给本地store。
self.$value.sink { newValue in
localStore.value = f(newValue)
}
记住,这个setter value对Store仍然是完全私有的,模块之外的任何东西都不能直接更新Store的值。
⚠️ Result of call to ‘sink(receiveValue:)’ is unused
我们确实收到了一个警告,因为sink方法返回了Combine调用的“cancellable”。 这是一个可以取消订阅的对象,甚至可以在取消可取消初始化时自动这么做。
因此,我们需要确保只要本地store被保留,这个返回值就会被保留。我们可以做的一件事是通过引入可选属性直接在本地store中保留可取消属性。
final class Store<Value, Action>: ObservableObject {
let reducer: (inout Value, Action) -> Void
@Published private(set) var value: Value
private var cancellable: Cancellable?
这允许我们在第一次订阅时直接分配可取消。
localStore.cancellable = self.$value.sink { newValue in
localStore.value = f(newValue)
}
但我们得小心点。这个代码引入了一个循环引用。本地store保留了一个保留本地store的可取消项。这意味着本地store是一个内存泄漏,永远不会从内存中释放。
我们可以通过weakifying块中的本地store来打破这个循环引用。
localStore.cancellable = self.$value.sink { [weak localStore] newValue in
localStore?.value = f(newValue)
}
有了这个订阅,我们希望本地store将收到关于全局store的任何更改的通知,如果我们回到我们的playground,我们就能证实这是真的!
store.send(())
newStore.value // 11
store.value // 11
新store的值已经更新,正如我们所期望的,在发送给它的父store的操作之后。
7. Focusing on view state
通过定义一个将全局store转换为更多本地store的函数,我们已经了解了Store是如何成为一种可转换类型的,我们已经确保这些本地store不仅将本地的变化传播给他们的全局同行,而且他们的全局同行将变化传播给他们的本地孩子。
现在我们有了这个工具,让我们使用它来让我们的视图占用部分的AppState。
让我们尝试更新我们的FavoritePrimesView来使用Store<[Int], AppAction>。
struct FavoritePrimesView: View {
@ObservedObject var store: Store<[Int], AppAction>
商店的价值现在是最受欢迎的质数数组,所以我们不再需要通过应用状态层。
ForEach(self.store.value, id: \.self) { prime in
然后,在ContentView中,我们需要传递一个根store的收藏素数的视图给FavoritePrimesView。
NavigationLink(
"Favorite primes",
destination: FavoritePrimesView(
store: self.store.view { $0.favoritePrimes }
)
)
当我们添加一些喜欢的质数,并导航到“喜欢的质数”屏幕时,它们就会像以前一样显示出来。但是现在我们的FavoritePrimesView对状态的访问有了更多的限制。它不能询问当前计数、当前用户、活动提要或其他任何东西。它仍然需要AppAction的全部,我们待会再讲。
那么IsPrimeModalView呢?它只需要当前计数和喜爱的素数列表,这正是我们创建PrimeModalState的目的。
struct IsPrimeModalView: View {
@ObservedObject var store: Store<PrimeModalState, AppAction>
正如我们在重构主模态reducer以使用此状态时所看到的,主体中不需要更改任何内容,但它不再能够访问appstate的其余部分。
当实例化IsPrimeModalView时,可以将根store prime modal state的视图传递给它。
IsPrimeModalView(
store: self.store.view { ($0.count, $0.favoritePrimes) }
)
最后我们有CounterView,它直接使用count,但也需要生成一个包含count和favoritprime的store 视图,以便将其传递给prime模态视图。
typealias CounterViewState = (count: Int, favoritePrimes: [Int])
struct CounterView: View {
@ObservedObject var store: Store<CounterViewState, AppAction>
...
}
CounterView编译正常,但我们必须修正编译错误。首先,当把一个store从counter视图传递给prime模态时,我们不再需要在store中执行一个视图,因为两个视图使用相同的状态:
.sheet(isPresented: self.$isPrimeModalShown) {
IsPrimeModalView(store: self.store)
}
然后在内容视图中,我们有一个深入计数器屏幕的按钮,我们需要在store中执行一个视图来传递适当的状态:
NavigationLink(
"Counter demo",
destination: CounterView(
store: self.store
.view { ($0.count, $0.favoritePrimes) }
)
)
就像我们的视图现在采取的状态子集比完整的AppState更小,这意味着支持视图的store开始看起来更像支持它们的reducer。
同样值得注意的是,这些更改是多么简单,而我们又是多么迅速地完成了它们。我们只是改变了视图操作的状态,聚焦在它们需要的东西上,然后我们确保我们传递给那些视图的store被转换以便取出这些值。
8. Till next time
我们现在已经改进了在使用可观察的Store对象的视图中访问状态的模块化和人机工程学,但我们不能将任何视图提取到独立的模块中,因为每个视图的store仍然依赖于AppAction。因此,听起来我们需要另一个操作来将发送全局action的store转换为发送本地action的store。我们发现我们可以转换store的价值,那么我们也可以转换store的actions吗?
First, what does that mean? 目前,我们在外部处理actions和store的唯一时间是通过调用send方法向store发送操作的时候。如果可以将局部actions而不是全局actions发送到该方法,我们将非常高兴, 然后在store的某个地方,也许它可以自动地将局部actions包装成全局actions。