Introduction

在过去的4周里,我们一直在探索一个从函数式编程中汲取灵感的应用程序架构。 它是原始的,原子单位只是一个函数,被称为reducer,我们发现了许多不同的方法可以将这些函数组合在一起,这是一个在Point-Free上多次上演的故事。

我们开始这一探索是因为我们发现尽管SwiftUI解决了我们在构建应用程序时面临的许多问题,而且是以一种漂亮而强大的方式,但仍然有一些问题没有解决。特别是,我们需要知道如何做以下事情:

  • 创建复杂的应用状态模型,最好使用简单的值类型。
  • 有一个一致的方式来改变应用状态,而不是仅仅在我们的视图里乱放突变代码。
  • 有一种方法可以将一个大的应用程序分解成小块,然后再粘在一起形成一个整体。
  • 有一个定义良好的机制来执行副作用并将结果反馈到应用程序中。
  • 用最少的设置和努力来测试我们的应用程序。

到目前为止,我们已经解决了其中的两个半问题:

  • 现在我们将状态建模为一个简单的值类型。
  • 我们现在以一致的方式改变我们的状态。
  • 最后,我们通过使用不同的组合物,将一个非常大的应用范围的reducer分解成小型的、针对屏幕的reducer

然而,当谈到这个体系结构时,我们认为最后一点只解决了模块化故事的一半。

尽管我们的reducer可以被分解和模块化,但是呈现state和向store发送action的视图却不能。它们仍然运行于整个应用state和应用actions

如果我们能够将store集中在特定视图关心的stateactions上,那么我们就增加了视图被提取到它自己的模块中的机会。这将是一场巨大的胜利。无法隔离应用程序的组件可能是我们在其他人的代码库中看到的最大的复杂性来源之一。组件开始变得不必要地相互纠缠,而且随时间变化很难理解组件的所有方式。

所以今天我们要完成的模块化架构故事,首先直接显示模块化的含义和为什么它是有益的,然后通过展示我们的Store类型如何支持两种类型的转换,这两种转换允许我们将其意图集中在视图真正关心的事情上。

让我们先快速浏览一下到目前为止所编写的代码。

Recap

我们正在开发的应用程序是“最受欢迎的质数计算应用程序”。

从根视图我们可以深入到一个counter,它显示了一个可以递增和递减的数字。我们可以询问当前数字是否为质数,如果是,我们可以将其保存到收藏夹中,或者如果我们改变主意,将其从收藏夹中删除。我们也可以请求第n个素数,这将把请求交给Wolfram Alpha,一个强大的科学计算平台。

如果我们回到根视图我们还可以深入到我们保存的所有收藏质数的列表。我们甚至可以去掉所有不喜欢的质数。

这是一个简单的示例,但它具有许多比较复杂的现实应用程序的特征。特别是:

  • 它管理跨多个屏幕持久存在的全局、可变state
  • 它以网络请求的形式执行副作用

在之前的章节中,我们在playground上构建所有内容,但为了准备模块化,我们将这些代码转移到一个专门的iOS项目中。我们使用“Single View”应用模板配置使用SwiftUI,并做了以下最小的更改:

  • 我们用playground的内容替换了有根的ContentView,不包括顶层的、特定于playground的逻辑
  • 我们在项目的场景委托中配置了根视图和一个store
  • 我们从playground的源代码目录中引入了一些utils,包括一些Wolfram Alpha API代码

让我们浏览一下包含到目前为止所有应用程序逻辑的文件。

在顶部,我们有驱动应用架构的核心库代码,从Store类开始。Store是一个容器,用来存放可变的应用状态和所有可以改变它的逻辑。它还通过遵循ObservableObject协议将我们的应用状态连接到SwiftUI

final class Store<Value, Action>: ObservableObject {
  let reducer: (inout Value, Action) -> Void
  @Published private(set) var value: Value

  init(initialValue: Value, reducer: @escaping (inout Value, Action) -> Void) {
    self.reducer = reducer
    self.value = initialValue
  }

  func send(_ action: Action) {
    self.reducer(&self.value, action)
  }
}

接下来,我们有两个功能,构成reducer组成的基础,首先是combine函数,它允许我们将多个reducers连接到一起,形成一个单一的大型reducer:

func combine<Value, Action>(
  _ reducers: (inout Value, Action) -> Void...
) -> (inout Value, Action) -> Void {
  return { value, action in
    for reducer in reducers {
      reducer(&value, action)
    }
  }
}

还有pullback函数,它让我们把一个理解局部state和actionreducer转换成一个理解全局state和actionreducer:

func pullback<LocalValue, GlobalValue, LocalAction, GlobalAction>(
  _ reducer: @escaping (inout LocalValue, LocalAction) -> Void,
  value: WritableKeyPath<GlobalValue, LocalValue>,
  action: WritableKeyPath<GlobalAction, LocalAction?>
) -> (inout GlobalValue, GlobalAction) -> Void {
  return { globalValue, globalAction in
    guard let localAction = globalAction[keyPath: action] else { return }
    reducer(&globalValue[keyPath: value], localAction)
  }
}

这种操作使我们能够让reducer只关注它所关心的state和action,而不是整个全局的state和action

我们还定义了“higher-order” reducers:以reducers作为输入并产生reducers作为输出的函数。 这让我们能够以一种中心方式实现应用级的“横切(cross-cutting)”关注点,比如日志记录并且不会污染更多本地reducers

func logging<Value, Action>(
  _ reducer: @escaping (inout Value, Action) -> Void
) -> (inout Value, Action) -> Void {
  …
}

然后我们有AppState,这是一个用简单值类型建模整个应用状态的结构体:

struct AppState {
  var count = 0
  var favoritePrimes: [Int] = []
  var loggedInUser: User? = nil
  var activityFeed: [Activity] = []

  struct Activity {
    let timestamp: Date
    let type: ActivityType

    enum ActivityType {
      case addedFavoritePrime(Int)
      case removedFavoritePrime(Int)
    }
  }

  struct User {
    let id: Int
    let name: String
    let bio: String
  }
}

我们有一堆枚举来描述应用在不同组件和屏幕上的所有用户动作。

enum CounterAction {
  case decrTapped
  case incrTapped
}

enum PrimeModalAction {
  case saveFavoritePrimeTapped
  case removeFavoritePrimeTapped
}

enum FavoritePrimesAction {
  case deleteFavoritePrimes(IndexSet)
}

这些枚举中的每一个都聚集在一个AppAction枚举中。

enum AppAction {
  case counter(CounterAction)
  case primeModal(PrimeModalAction)
  case favoritePrimes(FavoritePrimesAction)

  var counter: CounterAction? { … }
  var primeModal: PrimeModalAction? { … }
  var favoritePrimes: FavoritePrimesAction? { … }
}

值得注意的是,我们正在使用我们在以前的Point-Free章节中开发的代码生成工具来生成我们所说的“枚举属性”。这些计算属性通过提供对枚举案例关联值的点语法访问,在结构体和枚举之间架起了一个人机工程学的桥梁。定义了这些属性后,Swift将自动合成关键路径,这使我们能够将局部actionsreducer拉回全局actionsreducer

接下来是我们的reducer,它描述了应用程序的所有业务逻辑,并被分解成各种组件。每一个都负责处理我们应用程序中三个屏幕的状态和动作:计数器屏幕、主要模态和收藏列表。

func counterReducer(state: inout Int, action: CounterAction) {
  …
}

func primeModalReducer(state: inout AppState, action: PrimeModalAction) {
  …
}

func favoritePrimesReducer(state: inout [Int], action: FavoritePrimesAction) {
  …
}

在最终聚集在我们的巨型appReducer之前,这是通过拉回这些更小,更集中的reducers,并将它们组合而成。

let appReducer: (inout AppState, AppAction) -> Void = combine(
  pullback(counterReducer, value: \.count, action: \.counter),
  pullback(primeModalReducer, value: \.self, action: \.primeModal),
  pullback(favoritePrimesReducer, value: \.favoritePrimes, action: \.favoritePrimes)
)

我们还定义了一个higher-order reducer,它更特定于领域:

func activityFeed(
  _ reducer: @escaping (inout AppState, AppAction) -> Void
) -> (inout AppState, AppAction) -> Void {
  …
}

最后,我们有了所有视图,这在这个架构中是非常简单的,因为我们已经将所有的应用程序逻辑提取到一个由Store持有的reducer中。视图现在仅描述给定store当前值的子视图的层次结构,并将用户操作反馈给store

struct CounterView: View {
  …
}

struct IsPrimeModalView: View {
  …
}

struct FavoritePrimesView: View {
  …
}

struct ContentView: View {
  …
}

What does modularity mean?

现在,我们已经准备好开始模块化我们的应用程序了,但是在开始之前,我们需要定义什么是“模块化”。

在这种情况下,字面上的意思是“模块”。模块是自包含代码的单元,可以导入并在应用程序中使用。这包括Swift自带的模块,如FoundationDispatch,特定于平台的模块,如Combine和SwiftUI,甚至是你可能已经引入到代码库中的第三方库。

模块是他们选择公开的各种功能和行为的公共接口。但最重要的是,模块无法访问importer正在做的任何事情:它们无法了解你的类型、你的视图控制器等等,这就是它的强大之处:模块与依赖它们的代码隔离开来。

我们认为将应用程序分解成模块是很重要的。通过这样做,您可以创建更容易理解的单元,这些单元可以单独构建、测试和分发。 然后,您的应用程序可以导入所有这些单元并将它们组合在一起。

那么我们该如何将应用分解成更小的单元呢?有许多方法可以为Swift构建模块。 我们有框架、静态库,甚至还有Swift包。自从2015年Swift 2.2第一个开源版本发布以来,Swift包管理器就一直存在。 然而,直到最近它才被集成到Xcode 11测试版中。我们对SwiftPM非常兴奋,但它还处于起步阶段,还不支持我们在UI开发中需要的一些特性,比如保存资源的能力,比如图像,所以今天我们将使用框架来模块化。

Modularizing our reducers

我们已经声明过我们的reducer是模块化的,所以是时候进行测试了:让我们将它们提取到一些一流的模块中。

我们定义的每个reducer都是一个描述组件逻辑的自包含单元,这意味着我们应该能够将每个reducer提取到它自己的模块中。我们有三个reducercounterReducer, primeModalReducer,和favoritePrimesReducer,它们中的每一个都代表了可以对counter screen,prime modal,和favorite primesreducer进行相应的突变。

让我们转到我们的项目文件,并为每个reducer添加一个框架目标。我们可以创建一个“Counter”框架、一个“PrimeModal”框架和一个“favorite primesreducer”框架。

我们还有支持架构的核心库代码,包括Store类和reducr组合函数,所以让我们再创建一个名为“可组合架构(ComposableArchitecture)”的框架。

Modularizing the Composable Architecture

我们可以通过向名为“composableararchitecture .swift”的“composableararchitecture”框架中添加一个源文件来提取库代码。

然后我们可以剪切并粘贴我们的库代码到它:

final class Store<Value, Action>: ObservableObject {
  …
}

func combine<Value, Action>(
  _ reducers: (inout Value, Action) -> Void...
) -> (inout Value, Action) -> Void {
  …
}

func pullback<LocalValue, GlobalValue, LocalAction, GlobalAction>(
  _ reducer: @escaping (inout LocalValue, LocalAction) -> Void,
  value: WritableKeyPath<GlobalValue, LocalValue>,
  action: WritableKeyPath<GlobalAction, LocalAction?>
) -> (inout GlobalValue, GlobalAction) -> Void {
  …
}

public func logging<Value, Action>(
  _ reducer: @escaping (inout Value, Action) -> Void
) -> (inout Value, Action) -> Void {
  …
}

因为这段代码是自包含的,所以已经可以构建模块了。

但是,它不能被使用,因为它的api还没有公开。默认情况下,每个Swift接口(类型、属性、方法、函数等等)都具有“internal”可见性。这意味着同一目标中的每个源文件默认情况下都可以访问它,但它将对外部模块不可见。 接口必须以public修饰符为前缀,以便在模块被导入时可以访问。

public final class Store<Value, Action>: ObservableObject {

但我们不需要它的减速器公开。实际上,我们可以将它设置为私有,因为它不能在类之外访问。

private let reducer: (inout Value, Action) -> Void

它的value应该可以被应用程序的视图访问,因此我们将其设置为public,而保留其setter private

@Published public private(set) var value: Value

它的初始化器和send方法应该是公共的,这样我们的应用程序就有能力创建store并向它们发送用户操作。

public init(
  initialValue: Value,
  reducer: @escaping (inout Value, Action) -> Void
) {
…
public func send(_ action: Action) {

最后,combine、pullback和logging函数应该是公共的,这样我们的应用程序就可以用更小的部分组成根reducer

public func combine<Value, Action>(
  _ reducers: (inout Value, Action) -> Void...
) -> (inout Value, Action) -> Void {
…
public func pullback<LocalValue, GlobalValue, LocalAction, GlobalAction>(
  _ reducer: @escaping (inout LocalValue, LocalAction) -> Void,
  value: WritableKeyPath<GlobalValue, LocalValue>,
  action: WritableKeyPath<GlobalAction, LocalAction?>
) -> (inout GlobalValue, GlobalAction) -> Void {
…
public func logging<Value, Action>(
  _ reducer: @escaping (inout Value, Action) -> Void
) -> (inout Value, Action) -> Void {

在提取代码并审计其接口之后,我们终于可以将模块导入到我们的应用程序中了。

我们需要在“ContentView.swift”中这样做:

import ComposableArchitecture

在“SceneDelegate.swift”:

import ComposableArchitecture

一切都将构建并运行,因为没有更改代码,所以它的工作方式与以前一样。

我们应该注意到,ComposableArchitecture是非常通用的、可重用的代码,我们甚至可以从当前的应用程序中提取它到它自己的repo中,这样我们就可以在许多不同的应用程序中使用它。也许有一天我们可以把它开源😉。

Modularizing the favorite primes reducer

让我们回到更多的应用级模块化。我们可以从一些简单的东西开始:最受欢迎的质数reducer

首先,我们需要向“FavoritePrimes”模块引入一个源文件。我们可以叫它“FavoritePrimes.swift”。然后我们可以移动favoritePrimesReducer到它里面。

func favoritePrimesReducer(state: inout [Int], action: FavoritePrimesAction) {
  switch action {
  case let .deleteFavoritePrimes(indexSet):
    for index in indexSet {
      state.remove(at: index)
    }
  }
}

这个减速器依赖于FavoritePrimesAction enum,所以我们也想移动它。

enum FavoritePrimesAction {
  case deleteFavoritePrimes(IndexSet)
}

就像这样,“favoritprime”模块已经可以构建了,但应用程序无法构建,因为它不再访问“favorite primeaction or reducer

我们可以导入“ContentView.swift”中的模块:

import FavoritePrimes

但是我们还需要在收藏素数模块中将其标记为公有,以便在导入时可以访问它们。

public enum FavoritePrimesAction {
…
public func favoritePrimesReducer(state: inout [Int], action: FavoritePrimesAction) {

一切都构建得很好。

这句话很快就过去了,所以让我们再来看看“favoriteprime .swift”:

public enum FavoritePrimesAction {
  case deleteFavoritePrimes(IndexSet)
}

public func favoritePrimesReducer(state: inout [Int], action: FavoritePrimesAction) {
  switch action {
  case let .deleteFavoritePrimes(indexSet):
    for index in indexSet {
      state.remove(at: index)
    }
  }
}

我们能够将这些行提取到一个模块中,并将它们重新整合到我们的应用程序中,速度如此之快,而且所做的工作如此之少,几乎让人感觉我们什么都没做:我们只是将代码混放到了另一个文件中。但是因为这个文件位于它自己的模块中,所以我们实际上已经做了很多了!

这里我们有11行高度集中、易于阅读的代码。这段代码无法访问全局应用状态或全局用户动作——如果尝试使用AppStateAppAction,则无法使用。它也不能访问任何东西,除了它的内容和Swift标准库。我们已经将它与应用程序的其他部分完全隔离,使得任何代码都不可能溢出。

Modularizing the counter reducer

一个减速器已经完成,还有两个。counter reducer非常简单,所以让我们快速通过它。

public enum CounterAction {
  case decrTapped
  case incrTapped
}

public func counterReducer(state: inout Int, action: CounterAction) {
  switch action {
  case .decrTapped:
    state -= 1

  case .incrTapped:
    state += 1
  }
}

只是一些剪切和粘贴,和一些publics,我们现在可以:

import Counter

所有的构建,运行,和我们应用的另一部分已经完全从其他一切隔离。

Modularizing the prime modal reducer

我们还有一个reducer要提取:the prime modal reducer。它有点复杂,但让我们用和其他reducer相同的方法看看哪里出了问题。

首先,我们将在“PrimeModal”框架中添加一个“PrimeModal.swift”源文件,该文件将包含primeModalReducer及其相关的操作。

public enum PrimeModalAction {
  case saveFavoritePrimeTapped
  case removeFavoritePrimeTapped
}

public func primeModalReducer(state: inout AppState, action: PrimeModalAction) {
  switch action {
  case .removeFavoritePrimeTapped:
    state.favoritePrimes.removeAll(where: { $0 == state.count })

  case .saveFavoritePrimeTapped:
    state.favoritePrimes.append(state.count)
  }
}

🛑 Use of undeclared type ‘AppState’

我们有一个问题:primeModalReducer依赖于AppState,但我们肯定不想把所有的AppState都放到“PrimeModal”模块中,特别是当reducer只依赖于该状态的一小部分时。在本例中,它只需要当前计数和收藏素数数组。

我们可以做的一件事是引入一个全新的类型,PrimeModalState,它捕获主要primeModalReducer所关心的state

public struct PrimeModalState {
  public var count: Int
  public var favoritePrimes: [Int]
}

然后我们可以更新primeModalReducer的签名。

func primeModalReducer(state: inout PrimeModalState, action: PrimeModalAction) {

因为字段是相同的,所以body的任何部分都不需要改变。“PrimeModal”框架现在独立构建。

现在我们应该能够将内容导入到“ContentView.swift”中,看看会发生什么。

import PrimeModal

当我们将primeModalReducer传递给回拨函数时,只有一个错误:

pullback(primeModalReducer, value: \.self, action: \.primeModal),

🛑 Type of expression is ambiguous without more context

错误消息不是很好,但问题是primeModalReducer以前与AppState一起工作,现在它与PrimeModalState一起工作。 以前,我们可以使用标识键路径,.self,以保持AppState不变,当将reducer拉回主要模态动作时。但是我们如何将PrimeModalState拉回AppState呢?我们需要一个从AppState到PrimeModalState的键路径,但是不存在这样的键路径。

我们可能会想要做些改变,让AppState保持顶层的primeModal,它嵌入count和favoritprime

struct AppState {
//  var count = 0
//  var favoritePrimes: [Int] = []
  var primeModal: PrimeModalState

但是,我们将被迫通过primeModal属性向下钻取count和favoritprime。这两个属性都可以在主模态以外的屏幕上访问:计数器屏幕上显示当前计数,最喜欢的质数显示在最喜欢的质数屏幕上。这不是建模的方法。

相反,我们可以向AppState添加一个computed属性,该属性负责接收这两位状态并将它们打包为PrimeModalState

extension AppState {
  var primeModal: PrimeModalState {
    PrimeModalState.init
  }

我们不能实例化一个值,因为我们不能访问Swift为我们生成的那个结构体初始化器。 这些默认的“成员式”初始化器不是公有的,所以PrimeModalState目前只能在它自己的模块中初始化。

相反,我们必须定义一个公共初始化式。这是再简单不过了:

public struct PrimeModalState {
  public var count: Int
  public var favoritePrimes: [Int]

  public init(count: Int, favoritePrimes: [Int]) {
    self.count = count
    self.favoritePrimes = favoritePrimes
  }
}

这是我们在模块化代码时需要做的一点额外工作。 我们认为这个样板是值得的,尽管我们必须手动编写内容,但Xcode的未来版本应该能够为我们做这些工作。

我们现在可以构建以下值之一:

extension AppState {
  var primeModal: PrimeModalState {
    PrimeModalState(count: self.count, favoritePrimes: self.favoritePrimes)
  }

但我们还没有完成。pullback函数需要一个可写的键路径来实现突变。我们可以用set块使这个属性可写,它只是在给定一个新的素数模态的情况下设置这两个属性。

extension AppState {
  var primeModal: PrimeModalState {
    get {
      PrimeModalState(
        count: self.count,
        favoritePrimes: self.favoritePrimes
      )
    }
    set {
      self.count = newValue.count
      self.favoritePrimes = newValue.favoritePrimes
    }
  }

最后我们可以用一个进入素数态的键路径来替换恒等键路径:

pullback(primeModalReducer, value: \.primeModal, action: \.primeModal),

一切都像以前一样在建造和工作。

我们现在已经完全模块化了所有应用程序的reducer。其中两个非常容易提取:我们只需要移动、发布和导入一些代码。模块化prime modal reducer并不是那么简单,因为它必须访问应用状态的几个独立部分。 但是,我们发现,通过引入一个只保存它所关心的状态的中间结构,我们可以限定访问的范围。然后,我们可以将全局状态的computed属性引入到这个更局部的状态中,它为我们提供了一个键路径,当我们将局部状态的reducer转换为全局状态的reducer时,可以通过该键路径进行回拉。

当我们需要将多个全局state传递给一个更局部的组件时,我们可以一次又一次地使用这个方便的技巧。不幸的是,它付出了样板的代价:我们创建了一个全新的类型来表示这种状态,我们定义了一个公共初始化器以便其他模块可以实例化它,我们定义了一个computed属性以便使用pullback

可以通过使用元组的类型别名来消除初始化式样板:

public typealias PrimeModalState = (count: Int, favoritePrimes: [Int])

一切都可以正常构建,这很好。然而,Swift与元组之间有一种奇怪的关系,当你过多地使用它们时,它们经常会引起麻烦。
所以,如果您更愿意使用元组还是结构,这取决于您和您的团队。甚至可以从一开始就使用元组,然后在需要额外结构时才移到结构体中。

样板文件的其余部分是不可避免的,但它是相当机械且易于编写的,当最终结果是另一个孤立的、易于推理的模块时,我们认为它是非常值得的。

我们将这些简化器模块化的轻松程度说明了该体系结构在默认情况下是多么模块化。我们不需要对逻辑进行任何显著的重构或更改,因为边界已经非常清晰地定义了。最糟糕的情况是,需要为状态更复杂的组件引入一些额外的样板文件。

Till next time...

虽然我们的体系结构已经极大地简化了视图,但它仍然没有将视图模块化。我们已经将我们的reducer从整个应用state和应用actions中分离出来,但我们还没有将我们的视图分离出来。所以,让我们开始解决这个问题的一部分……下次!