1. Introduction

现在,我们已经进入了润色可组合架构的最后阶段,使其具有生产价值。我们已经用了今年的前几个月来完成了架构的一些粗糙的边缘。比如通过使用case paths消除代码生成,通过将environment技术直接融入到架构中,使副作用和测试故事更加可靠。

今天我们将开始研究如何改进可组合架构的性能,因为我们正在做的一件非常简单的事情,它很容易解决。但是,在解决性能问题的同时,我们实际上会偶然发现一种使架构适应多种情况的绝妙方法。例如,你可能正在制作一款面向iOS和macOS的应用,或者你甚至正在制作一款面向所有4个平台(iOS、macOS、tvOS和watchOS)的应用。如果您可以一次性编写应用程序的业务逻辑,并让它很容易地适应这些平台,这难道不是一件很棒的事情吗?这就是我们要做的。

2. Fixing a couple memory leaks

在开始改进体系结构的性能改进之前,让我们先解决一个在我们的体系结构中潜伏了相当一段时间的小问题。我们有两个可以很容易地修复的内存泄漏。在应用程序中发现内存泄漏需要很多技巧,就像所有技巧一样,你练习得越多,就会越熟练,但幸运的是,Xcode提供了一个出色的专门针对内存泄漏的调试工具。它被称为Memory Graph Debugger,它可以帮助指出代码中的任何循环引用。

要看到这个问题,我们所要做的就是启动应用程序,增加一次计数,然后打开Memory Graph Debugger,我们会看到一些紫色的感叹号,表明我们可能在某个地方有一个循环引用

我们甚至可以点击“!””图标,以仅显示内存泄漏行。

在顶层,我们可以看到哪些框架泄漏了对象,因此,如果展开一个框架,我们可以看到框架中泄漏了哪些类型的对象。

然后进一步单击泄漏的对象,将显示一个图表,演示循环引用是如何发生的:

这里我们看到创建了一个Sink值,并且通过它的receiveCompletion属性引用了一个闭包,该闭包又捕获了一个闭包,该闭包又捕获了一个AnyCancellable。在这之后,我们有了某种到malloc<48>的链接,我不知道这是什么意思,但最终看起来它保存了开始整个循环的Sink对象。

这很清楚地向我们展示了,我们有一串物体彼此相连最终回到它开始的地方。现在我们的工作是获取这些循环引用的小证据,并找出在我们的代码中可能发生这种情况的地方。

var effectCancellable: AnyCancellable?
var didComplete = false
effectCancellable = effect.sink(
  receiveCompletion: { [weak self] _ in
    didComplete = true
    guard let effectCancellable = effectCancellable else { return }
    self?.effectCancellables.remove(effectCancellable)
},
  receiveValue: self.send
)
if !didComplete, let effectCancellable = effectCancellable {
  self.effectCancellables.insert(effectCancellable)
}
  • 最有力的证据是Sink对象,因为我们调用来运行一个effect的方法称为Sink。表面上,这个方法正在创建某种我们不公开看到的Sink对象。
effectCancellable = effect.sink
  • 而且,sink方法确实有一个名为receiveCompletion的参数,而且它确实接受一个闭包。
receiveCompletion: { [weak self] _ in
  • 更有罪证的是,我们在这个闭包中有一个cancelableeffectCancellable。因此,这似乎是导致内存泄漏的真正原因。
guard let effectCancellable = effectCancellable else { return }

事实上,这里有一个相当明显的循环引用。sink方法捕获了effectCancellable,但是我们也使用了sink的输出来定义effectCancellable。因此,我们似乎需要通过捕获effectCancellable来打破这个循环:

effectCancellable = effect.sink(
  receiveCompletion: { [weak self, weak effectCancellable] _ in
    didComplete = true
    guard let effectCancellable = effectCancellable else { return }
    self?.effectCancellables.remove(effectCancellable)
},

这里貌似有点问题。

添加effectCancellablecapture block会立即捕获它,所以它将始终为nil,永远不会从effectCancellables中删除。相反,我们应该更进一步地为cancellable创建一个标识符,并使用字典存储[UUID: AnyCancellable]:

public final class Store<Value, Action>: ObservableObject {
  …
  private var effectCancellables: [UUID: AnyCancellable] = [:]
  …
  func send(_ action: Action) {
    let effect = self.reducer(&self.state, action)
    var didComplete = false
    let uuid = UUID()
    let effectCancellable = effect.sink(
      receiveCompletion: { [weak self] _ in
        didComplete = true
        self?.effectCancellables[uuid] = nil
      },
      receiveValue: { [weak self] in self?.send($0) }
    )
    if !didComplete {
      self.effectCancellables[uuid] = effectCancellable
    }
  }
  …
}

现在,当我们运行应用程序,点击周围,并检查内存调试器,我们将不会看到紫色的惊叹号。

因此,我们刚刚看到,我们需要小心传递给sink方法的闭包,因为它们可能导致循环引用。sink方法接受另一个闭包,称为receiveValue,每次effect产生一个值时都会调用该闭包。 我们想要将这些值发送回store,所以我们:

receiveValue: self.send

从技术上讲,这现在是一个循环引用,因为sink持有self,但self也持有sink返回的cancellable。 运行这些effectsstore是派生所有其他stores的根store。请记住,我们有一个视图方法,它允许我们将一个现有的store集中到一个只暴露状态和操作的子集的store中,我们使用这些较小的store传递给视图。所有这些派生的stores实际上只调用底层的根store,而根store,实际上是驱动一切的store,只在应用程序的最高层创建一次,即SceneDelegate:

window.rootViewController = UIHostingController(
  rootView: ContentView(
    store: Store(
      initialValue: AppState(),
      reducer: with(
        appReducer,
        compose(
          logging,
          activityFeed
        )
      )
    )
  )
)

因此,如果我们试图多次从头重新创建根store,那么这个潜在的内存泄漏只会出现它丑陋的头,而我们在这个应用程序中没有这样做。然而,可能有一些人想在他们的应用程序中这样做,所以我们应该修复它。幸运的是,修复很简单:

receiveValue: { [weak self] in self?.send($0) }

现在,我们已经修复了在可组合架构中发现的两个内存泄漏。不幸的是,架构中混乱的运行时部分(即这个Store类中的所有内容)总是最难调试和理解的部分。但幸运的是,它的所有好的功能部分,如reducers,可以保持简单和易于理解。


3. View.init/body: tracking

现在回到主要吸引人的地方: 性能。

现在,我们的架构中存在一个潜在的性能问题。我们说潜在是因为很有可能你永远不会看到这个问题,但如果应用程序足够复杂,可能会出现卡顿和延迟。

问题的根源与store有关:

public final class Store<Value, Action>: ObservableObject {
  @Published public private(set) var value: Value

  …
}

注意,它是一个可观察对象,并且它有一个@Published字段,叫做value。这意味着每次值发生突变时,都会通知相关方去更改。从技术上讲,这种通知发生在突变发生之前,但这不是重点。

SwiftUI视图是对这些突变“相关方”的一个例子。事实上,每个持有store的视图都使用@ObservedObject属性包装器,以便将store的变化链接到视图的重绘,例如我们的根ContentView:

struct ContentView: View {
  @ObservedObject var store: Store<AppState, AppAction>

  …
}

这意味着每当存储的底层值发生改变时,ContentView就会收到改变的通知,并可能会重新呈现其内容。

为了看到这一点,让我们为每次ContentView被创建和每次body字段被访问添加一些打印语句:

struct ContentView: View {
  @ObservedObject var store: Store<AppState, AppAction>

  init(store: Store<AppState, AppAction>) {
    self.store = store
    print("ContentView.init")
  }

  var body: some View {
    print("ContentView.body")
    return …
  }
}

当应用程序第一次启动时,我们会得到一些合理的日志:

ContentView.init
ContentView.body

然而,如果我们深入到计数器演示并增加计数,我们会得到一个新的日志:

ContentView.body

为什么ContentView的body属性需要重新计算? ContentView甚至没有使用任何来自store的数据来渲染它的视图,它只是一些导航链接的静态列表。而且,我们在这个屏幕上做的任何事情似乎都触发了body属性,比如如果我们再次增加,询问它的质数,将质数保存为最喜欢的数,然后询问第二个质数。每一个动作都会触发body属性:

ContentView.body
ContentView.body
ContentView.body
ContentView.body

虽然这看起来很奇怪,但它发生的原因很明显。我们的ContentView持有整个应用程序状态的store,Store <AppState, AppAction>,因此对应用程序任何部分的任何更改都将导致该视图重新计算自己。现在,我们不知道SwiftUI在重新计算主体的基础上到底在做什么。我们知道苹果说它使用了一些先进的差异技术,所以它只渲染屏幕上需要的东西,而SwiftUI可能非常聪明,因为我们的UI没有任何变化,所以它可能根本就不渲染任何东西。

但是除了渲染的问题之外,您可能还会考虑调用body属性。如果在这个属性内发生的计算是超级密集的呢?

好吧,苹果也在这方面给了我们指导,并说我们应该保持身体属性的实现尽可能简单和精益。它们应该只是将视图的状态简单地转换成一些视图层次结构。这些值的构造,比如NavigationView, List和NavigationLink都是超轻量级的,我们不应该在运行中费力地创建一堆。

因此,我们没有理由认为这应该是性能问题。苹果告诉我们,他们做了一些强大的改变,以防止过度渲染我们的UI,并且这些视图的构造是超级轻量级的,所以不应该是太多的负担。

但如果我们多放一些打印语句,事情就变得有点奇怪了。让我们在应用程序中每个视图的初始化和主体中添加一个打印:

public struct CounterView: View {
  @ObservedObject var store: Store<CounterViewState, CounterViewAction>

  public init(store: Store<CounterViewState, CounterViewAction>) {
    self.store = store
    print("CounterView.init")
  }

  public var body: some View {
    print("CounterView.body")
    return …
  }
}

public struct IsPrimeModalView: View {
  @ObservedObject var store: Store<PrimeModalState, PrimeModalAction>

  public init(store: Store<PrimeModalState, PrimeModalAction>) {
    self.store = store
    print("IsPrimeModalView.init")
  }

  public var body: some View {
    print("IsPrimeModalView.body")
    return …
  }
}

public struct FavoritePrimesView: View {
  @ObservedObject var store: Store<[Int], FavoritePrimesAction>

  public init(store: Store<[Int], FavoritePrimesAction>) {
    self.store = store
    print("FavoritePrimesView.init")
  }

  public var body: some View {
    print("FavoritePrimesView.body")
    return …
  }
}

让我们运行应用程序。首先我们会看到以下日志:

ContentView.init
ContentView.body
CounterView.init
FavoritePrimesView.init

注意,创建了CounterView和FavoritePrimesView,但没有调用它们的body属性。这是很重要的。我们不应该害怕创建这些小的视图结构,因为这样做是非常轻量级的,它们的body只在需要时被调用。

如果我们深入到CounterView中,我们将得到一个新的日志:

CounterView.body

只有在需要渲染计数器视图时,我们才访问它的body字段。

如果我们在这个视图中增加计数,我们将在日志中看到以下内容:

CounterView.body
ContentView.body
CounterView.init
FavoritePrimesView.init
CounterView.body

这已经重新计算了CounterView,但也重新计算了ContentView,这随后创建了一个新的CounterViewFavoritePrimesView,然后重新计算了CounterView。这很奇怪,但我们希望如此,因为根内容视图持有所有应用程序状态的观察存储,当状态中发生任何变化时,它将触发这些事情。

让我们更进一步,让计数增加到2,然后问它是否是质数。这将打印更多的日志,但现在让我们清除这些日志,然后将2保存为我们最喜欢的质数。所有这些日志只出现在一个操作上:

IsPrimeModalView.body
ContentView.body
CounterView.init
FavoritePrimesView.init
CounterView.body
IsPrimeModalView.init
IsPrimeModalView.body
CounterView.body
IsPrimeModalView.init
IsPrimeModalView.body

4. View.init/body: analysis

这里发生了什么? 整个视图层次结构被重新创建,从根视图一直到模态视图,有时视图甚至被创建多次。

但是为什么会这样呢? 我们只是在收藏质数的数组中添加一个数字,而CounterView和ContentView甚至不关心这个数组。他们根本不用它来显示他们的UI。更糟糕的是,这个视图层次结构越深,这个问题就会变得越严重。

发生这种情况的原因很简单。让我们来看看CounterView:

public struct CounterView: View {
  @ObservedObject var store: Store<CounterViewState, CounterViewAction>

  …
}

即使这说它只需要一个CounterViewState的store,它将通知视图的任何部分的变化,应用程序状态,而不仅仅是CounterViewState。这是因为交给这个视图的store是从使用所有AppState的根全局store派生的。

正因为如此,当对应用程序状态做出任何更改时,它将被通知,而不仅仅是counter视图状态。

如果我们查看视图的实现,就可以直接看到这一点,视图是我们将全局域的store转换为局部域的store的转换方法。特别是这几行:

localStore.viewCancellable = self.$value.sink { [weak localStore] newValue in
  localStore?.value = toLocalValue(newValue)
}

这就是说,当全局store的值发生变化时,我们将立即将该变化重现到本地store。无论局部值如何变化,都会发生这种情况。全局值的某些完全不相关的部分可能发生了改变,但我们仍然会通知子值。

我们可能想做的一件事是,以某种方式去重复我们发送到本地store的值流。例如,如果收藏的质数数组没有改变,那么就没有理由重新计算IsPrimeModalView视图。多亏了Combine的强大功能,我们可以很容易地通过映射值发布者来计算本地值,然后删除重复值:

localStore.viewCancellable = self.$value
  .map(toLocalValue)
  .removeDuplicates()
  .sink { [weak localStore] newValue in localStore?.value = newValue }

当然,我们需要LocalValue泛型为Equatable,这样才能工作,但这很容易适应。然而,这只是解决了问题的一半。这是为了确保与全局状态无关的突变不会触发本地store中的值更改。但我们也有一个问题,视图持有的store所代表的状态比它们当前显示的要多。例如,当最喜欢的素数数组发生突变时,CounterView重新计算了它的主体,尽管它并不显示任何最喜欢的素数。更糟糕的是,当应用程序状态发生任何变化时,根ContentView都会被重新计算,即使它不显示任何动态内容。在这里添加removeDuplicates不会有任何帮助。


5. View.init/body: stress test

所以,这不是我们想要的解决方案,我们现在看到了这个问题是多么微妙。可组合架构的一个主要好处是,我们有一个统一的、组合的reducer and store ,这样我们就有了一个单一的、一致的方式来改变我们的应用程序状态,这样这些变化就可以在整个应用程序中共享。然而,这个选择也给了我们一些奇怪的SwiftUI行为,导致超过我们实际需要的方式重新计算我们的视图。

现在,再次,我们不知道SwiftUI在做什么,所以我们不知道这是一个多大的问题。苹果告诉我们,他们在差异方面做得很好,创建视图是轻量级的,所以我们不应该费力地创建它们。然而,如果我们只是天真地到处重新计算视图,那么一个足够大的应用程序可能会遇到性能问题。

为了了解这一点,让我们对其中一个视图进行压力测试,看看是否可以重现任何性能问题。让我们向根ContentView添加大量的行。我们可以使用ForEach视图:

ForEach(Array(1...500_000), id: \.self) { value in
  Text("\(value)")
}

这将向我们的列表视图添加500,000行整数。这比我们在现实生活中看到的应用要多得多,但这是一个压力测试。

如果我们运行应用程序,并深入到计数器演示,我们将看到我们所采取的任何行动都延迟了大约一秒。点击一个按钮后,UI就卡住了,表面上是因为SwiftUI正在做大量的工作来找出如何渲染根ContentView。因此,我们对store的设计选择肯定有可能在SwiftUI中造成一些性能问题,但在现实应用中这是一个多大的问题还不清楚。


6. Conclution

因此,我们现在已经看到,我们设置可组合架构的方式存在一个潜在的问题。再说一次,我们说“潜力”是因为它实际上可能不会成为一个问题,直到你有一个大规模的应用程序。但是,与其坐等那一天的到来,事实证明,我们可以做一些相当简单的事情来解决这个问题。甚至更好的是,通过解决这个问题,我们实际上会发现一种奇妙的方法,使我们的reducers中的所有业务逻辑更能适应我们在现实世界应用程序中面临的平台和约束。

问题的关键在于,我们在视图中有一个单一的store,它不仅保存显示其UI所需的所有状态,而且还保存其子视图的所有状态,这些状态可能要到以后才会显示。似乎我们的视图需要持有一个不同的对象,这个对象只能访问视图所关心的状态,仅此而已。这样我们就更有可能只得到我们真正关心的状态变化的通知。如果我们成功做到这一点,那么Store可能根本就不需要成为可观察对象,只有这个次要的东西需要成为可观察对象。

在弄清楚如何做到这一点之前,让我们做一件我们知道需要做的事情:让我们从Store中删除ObservableObject的一致性,这样我们就可以停止通知视图的状态变化了