1. Introduction

现在我们已经到了定义应用程序架构的最后阶段。我们已经解决了在任何中等复杂的应用程序中出现的五个问题中的三个,包括如何用一个简单的值类型建模整个应用程序状态,如何使用称为reducers的简单可组合函数来改变这个状态,以及如何将应用程序分解为更小的片段,这些片段可以在它们自己的模块中独立存在并被理解。

现在是时候解决我们在构建应用程序时面临的最大问题之一:副作用。事实上,我们的第二集致力于识别副作用,理解它们如何影响功能组合,并通过将它们推到函数的边界来恢复组合。我们还花了几集时间来介绍依赖注入,以一种轻量级的方式来控制副作用是完全可能的,我们称之为“Environment”,以及这如何使我们能够轻松地模拟和测试应用程序中的某些状态,否则这将是非常困难的。

我们希望了解如何在这个基于reducer的架构中建模副作用。有许多方法可以解决这个问题,它们都有各自的优缺点,但就像我们在Point-Free上做的所有事情一样,我们希望我们的解决方案是可转换和可组合的。

因此,我们将在应用程序中识别几种副作用,然后逐步了解如何隔离和控制这些影响。让我们先回顾一下我们正在构建的应用程序……

2. Adding some simple side effects

我们正在构建的应用程序是一个有一些铃声和哨声的计数应用程序。 您可以增加或减少当前计数,询问它是否是质数,如果是,您可以从跨屏幕的收藏素数列表中添加或删除它。

我们的应用程序还可以计算“第n个”质数,其中“n”是当前计数。为了做到这一点,它向Wolfram Alpha,一个强大的计算平台,发出了一个API请求:

func nthPrime(_ n: Int, callback: @escaping (Int?) -> Void) -> Void {
  wolframAlpha(query: "prime \(n)") { result in
    callback(
      result
        .flatMap {
          $0.queryresult
            .pods
            .first(where: { $0.primary == .some(true) })?
            .subpods
            .first?
            .plaintext
      }
      .flatMap(Int.init)
    )
  }
}

下面是执行副作用的函数。应该很清楚,这是一个副作用,因为它是一个返回类型为Void的函数。这样的函数别无选择,只能产生副作用,因为它们不会向调用者返回任何重要的东西。

到目前为止,这个网络请求是我们应用程序中唯一的副作用,但它是一个复杂的、异步的副作用。尽管网络请求可能是我们在应用程序中可以考虑的最重要的副作用之一,但它对我们来说有点太高级了,不能马上处理。我们需要从更简单的开始。

所以我们要做的是给我们的应用程序添加一个新功能,它使用了一个副作用,一个比网络请求更简单的功能,它将允许我们探索影响的问题空间,这样我们就可以从那里开始构建。虽然我们的架构支持在多个屏幕上持久化最受欢迎的prime列表,但它不支持在多个应用启动时持久化该列表。我们的应用程序状态目前只存在于内存中,所以每次我们的应用程序启动时,我们都会丢失所有用户以前保存的收藏夹。

让我们引入一个按钮,当点击它时,它会将当前收藏的素数列表保存到磁盘,以及一个按钮,当点击它时,它会从磁盘加载它并将其放入我们的状态。然对我们的用户来说,自动保存并加载他们最喜欢的质数可能是一个更好的体验,但让我们从一些更明确的东西开始,以便我们能够处理事情。我们将首先添加一个“保存到磁盘”按钮到收藏质数视图,当点击时,将写入当前所有收藏质数到磁盘。

我们可以通过使用navigationBarItems视图修饰符将该按钮添加到导航栏,该修饰符接受leading视图或trailing视图。

.navigationBarTitle("Favorite primes")
.navigationBarItems(
  trailing: Button("Save") {}
)

在这个按钮的动作闭包中,我们希望完成将收藏的质数保存到磁盘所需的所有工作。我们可以使用JSON编码器将状态编码为一些可序列化的数据。

.navigationBarItems(
  trailing: Button("Save") {
    let data = try! JSONEncoder().encode(self.store.value)

  }
)

我们可以把这些数据写到应用的文档目录。

.navigationBarItems(
  trailing: Button("Save") {
    let data = try! JSONEncoder().encode(self.store.value)
    let documentsPath = NSSearchPathForDirectoriesInDomains(
      .documentDirectory, .userDomainMask, true
      )[0]
    let documentsUrl = URL(fileURLWithPath: documentsPath)
    let favoritePrimesUrl = documentsUrl
      .appendingPathComponent("favorite-primes.json")
    try! data.write(to: favoritePrimesUrl)
  }
)

我们还可以添加一个按钮来加载之前保存的favorite primes。 首先,我们将把按钮放入HStack中,这样它们就会在导航栏中彼此相邻:

.navigationBarItems(
  trailing: HStack {
    Button("Save") {
      …
    }
    Button("Load") {
    }
  }
)

要从磁盘加载数据,我们只需要从磁盘加载数据,并尝试将其反序列化为一个整数数组:

Button("Load") {
  let documentsPath = NSSearchPathForDirectoriesInDomains(
    .documentDirectory, .userDomainMask, true
    )[0]
  let documentsUrl = URL(fileURLWithPath: documentsPath)
  let favoritePrimesUrl = documentsUrl
    .appendingPathComponent("favorite-primes.json")
  guard
    let data = try? Data(contentsOf: favoritePrimesUrl),
    let favoritePrimes = try? JSONDecoder().decode([Int].self, from: data)
    else { return }
  ???
}

现在我们有了已加载的质数,但我们没有办法让这些质数回到app状态。记住,store中的value字段保存着屏幕的状态,它有一个私有setter,这意味着即使我们想要改变它,也不能改变它:

self.store.value = favoritePrimes

🛑 Cannot assign to property: ‘value’ setter is inaccessible

幸运的是,我们不想这样做,因为正如我们在过去几周所看到的,在这个架构中,将所有用户操作直接发送到store是有益的,并允许reducer为我们完成这项工作。

为了做到这一点,我们需要向FavoritePrimesAction枚举中引入一个新动作:

public enum FavoritePrimesAction {
  case deleteFavoritePrimes(IndexSet)
  case loadedFavoritePrimes([Int])
}

我们需要在reducer中实施这一action:

case let .loadedFavoritePrimes(favoritePrimes):
  state = favoritePrimes

现在我们可以在数据从磁盘加载后发送该事件:

self.store.send(.loadedFavoritePrimes(favoritePrimes))

虽然最爱的素数模块现在正在构建顺序,但我们的应用还没到那一步,因为我们需要在我们的activityFeed higher-order reducer中考虑这个新动作,它负责检查进入系统的每一个动作,这样我们就可以跟踪活动动态。

这实际上是一个很大的编译错误。这迫使我们意识到应用中有一个新的用户操作,我们有机会决定它是否会影响我们的活动feed。这是能够全局监视进入系统的所有动作的强大功能,而且添加这样的横切功能非常简单,这是非常棒的。

综上所述,这个动作对我们的activity feed并不重要,所以我们打算不做任何事情就让它过去:

case .counter,
     .favoritePrimes(.loadedFavoritePrimes):
  break

事情正在构建中,如果我们运行应用程序,功能将按照预期运行。我们可以添加一些最喜欢的质数,回到最喜欢的质数列表并保存它。 然后如果我们重新启动应用,点击load,我们会取回所有这些质数。

3. Effects in reducers

现在,我们向应用程序引入了两个简单的副作用,并展示了它们如何通过引入一个新操作将数据反馈到应用程序的状态。

然而,我们所做的一切都与我们的架构背道而驰。到目前为止,我们一直在努力使UI元素的动作闭包尽可能不符合逻辑。理想情况下,它们所做的就是将一些数据打包到action enum case中,然后将该数据发送到store,以便reducer能够处理所有逻辑。但这里我们做了很多工作来加载和保存数据到磁盘。

我们想找到一种方法,在我们的reducers中捕捉所有这些工作,以便我们的视图不关心它。

让我们从保存操作开始。与其在一条线上执行所有这些工作,不如向store发送一个操作,而在按钮闭包中不做其他工作。

.navigationBarItems(
  trailing: HStack {
    Button("Save to disk") {
      self.store.send(.saveButtonTapped)

为了实现这一目标,我们需要引入一个新的行动,看看迫使我们考虑的是什么:

public enum FavoritePrimesAction {
  case deleteFavoritePrimes(IndexSet)
  case loadedFavoritePrimes([Int])
  case saveButtonTapped
}

这打破了最受欢迎的质数REDUCER

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

🛑 Switch must be exhaustive

We can switch on the new case.

case .saveButtonTapped:

让我们以按钮中所做的工作为例,将self.store.value替换为state

case .saveButtonTapped:
  let data = try! JSONEncoder().encode(state)
  let documentsPath = NSSearchPathForDirectoriesInDomains(
    .documentDirectory, .userDomainMask, true
    )[0]
  let documentsUrl = URL(fileURLWithPath: documentsPath)
  try! data.write(to: documentsUrl.appendingPathComponent("favorite-primes.json"))

编译器似乎对这个变化很满意,但如果我们构建应用程序,我们会在activity feedhigher-order reducer中得到另一个错误。

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

  return { state, action in
    switch action {

🛑 Switch must be exhaustive

虽然让编译器强制我们考虑每种情况是件好事,但这会妨碍一些更快速的反馈,所以让我们切换到只构建最喜欢的质数模块,它可以在我们上次创建的playground中运行。

在这里,我们可以测试保存是否仍然有效,就像之前一样,即使我们将代码移动到reducer中。

4. Reducers as pure functions

但我们做了什么来实现这一点!? 到目前为止,我们的简化程序一直是所谓的“纯”函数,也就是说,它们所做的一切都体现在它们的签名中:它们将某些状态和动作作为输入,并决定如何在给定动作的情况下改变状态。它们完全是由给出的数据确定的。

另一方面,所谓的“不纯”函数是那些要么需要访问外部世界中没有作为参数传入的东西,要么需要对外部世界进行更改,而这些更改没有被描述为函数的输出。这意味着副作用只不过是函数的隐藏输入或输出,函数隐式地依赖于这些输入或输出,或者隐式地改变这些输入或输出。我们可能都知道,隐式并不会在代码库中创建一个受欢迎的环境。

我们刚刚对我们最喜欢的质数reducer进行了修改,它不再纯粹了。它通过将数据保存到外部世界(在本例中是磁盘)直接在reducer中执行一个副作用,这意味着这个函数的隐藏输出潜伏在阴影中。这使得这个reducer很难一眼就理解,也更难测试。

我们在Point-Free的第二集“Side Effects”中讨论了隐藏输入和输出的副作用。我们描述了在这个问题上争论的一种方法是将副作用推出边界。也就是说,与其在函数中直接执行效果,不如在一个值中描述它,并将其作为函数的新输出返回,从而将执行的责任传递给调用者。

例如,在本集中,我们考虑了这个隐藏在其体内的副作用的简单功能:

func compute(_ x: Int) -> Int {
  let computation = x * x + 1
  print("Computed \(computation)")
  return computation
}

它的副作用是打印到控制台,这是无害的,但有些东西是完全无法测试的,也没有反映在compute函数的签名中。

但是,我们看到我们可以稍微增加compute的签名来描述打印到控制台的效果,而不是执行效果本身。

func computeAndPrint(_ x: Int) -> (Int, [String]) {
  let computation = x * x + 1
  return (computation, ["Computed \(computation)"])
}

现在由调用者来执行打印的副作用。现在,打印并不是副作用的最好例子,因为人们通常希望在调试代码时将这些内容分散到代码中。但想象一下,我们不是在打印,而是在跟踪一个分析事件。然后我们就有了一个非常重要的业务逻辑,没有简单的方法来测试它。

事实证明,我们可以将这种提取副作用的策略应用到我们的reducers上。目前,reducers签名如下:

(inout Value, Action) -> Void

在接下来的几集里,我们会对这个函数签名做很多改变,因为我们会尝试不同的效果模型。因此,引入一个类型别名来描述什么是reducers签名可能是一个好主意。我们可以从目前的无效果状态开始:

public typealias Reducer<Value, Action> = (inout Value, Action) -> Void

我们可以更新可组合架构的签名来直接使用这个typealias,包括storeprivate reducer属性:

public final class Store<Value, Action>: ObservableObject {
  private let reducer: Reducer<Value, Action>

store的初始化:

public init(initialValue: Value, reducer: @escaping Reducer<Value, Action>) {

还有higher-order reducers,比如combine,它将多个reducers组合在一起:

public func combine<Value, Action>(
  _ reducers: Reducer<Value, Action>...
) -> Reducer<Value, Action> {

以及pullback,它通过描述如何使用关键路径从全局状态和全局动作中提取局部状态和局部动作,将工作在局部状态上的reducer转换为可以工作在更全局状态上的reducer:

public func pullback<LocalValue, GlobalValue, LocalAction, GlobalAction>(
  _ reducer: @escaping Reducer<LocalValue, LocalAction>,
  value: WritableKeyPath<GlobalValue, LocalValue>,
  action: WritableKeyPath<GlobalAction, LocalAction?>
) -> Reducer<GlobalValue, GlobalAction> {

最后,higher-order logging reducer,它将控制台日志添加到任何reducer:

public func logging<Value, Action>(
  _ reducer: @escaping Reducer<Value, Action>
) -> Reducer<Value, Action> {

一切仍然建立,但现在我们可以集中在reducer的签名在一个单一的地方,我们决定什么需要改变。

public typealias Reducer<Value, Action> = (inout Value, Action) -> Void

5. Effects as values

好的,我们现在准备在一个地方更新reducer签名,那么我们要改变什么? 我们可以返回副作用的描述,而不是简单的返回Void。但是,什么样的值可以描述写入磁盘的副作用呢?

好吧,我们最初是在按钮主体中直接执行副作用,它有以下签名。

Button.init(<#title: StringProtocol#>, action: <#() -> Void#>)

初始化该按钮不会执行给定的操作。只有当点击按钮时,操作才会被执行。而action仅仅是一个void-to-void闭包,对于如何在代码中描述副作用来说,它似乎是一个很好的定义。

现在我们将使用一个类型别名来表达我们的效果的形状。

public typealias Effect = () -> Void

这样,我们就可以通过返回这些闭包来改变reducer签名来描述effects

public typealias Reducer<Value, Action> = (inout Value, Action) -> Effect

6. Updating our architecture for effects

有了这个签名,我们可以允许我们的reducers在内部做任何它们需要的状态改变,同时给它们一种将它们的效果打包到一个稍后执行的闭包的方法。这使得我们的reducers更容易保持纯净。

不幸的是,这个更改破坏了现有架构如何工作的一切,所以让我们检查所有的错误以使其再次工作。

第一个编译错误是在send方法中。

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

🛑 Expression resolves to an unused function

有趣的是,在语句中有一个未使用的函数是一个Swift编译器错误,而通常未使用的值只是一个警告。这可能是为了帮助那些可能更熟悉允许你从函数调用中删除括号的语言的人:

[1, 2].dropFirst

🛑 Expression resolves to an unused function

无论哪种方式,编译器现在都强迫我们处理reducer返回给我们的效果。所以让我们在一个变量中捕捉它:

let effect = self.reducer(&self.value, action)

store所要做的就是运行effect

let effect = self.reducer(&self.value, action)
effect()

combine函数以一堆reducers作为输入,并返回一个全新的reducer,它简单地调用所有给定的reducers。但是现在每个reducer返回一个effect闭包,编译器告诉我们我们没有处理任何一个effect

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

🛑 Expression resolves to an unused function
每当我们运行reducer时,我们需要处理effect

let effect = reducer(&value, action)

我们仍然生活在reducer函数的世界里,这意味着我们不想在这里计算效果。相反,我们需要一种方法来跟踪每个reducer的效果,然后以某种方式将它们合并成一个单独的effect闭包。

我们可以通过引入一个数组来跟踪所有的效果。

var effects: [Effect] = []

现在我们可以循环遍历reducer,当我们调用每个reducer时,我们可以将其效果添加到数组中。

var effects: [Effect] = []
for reducer in reducers {
  let effect = reducer(&value, action)
  effects.append(effect)
}

最后,我们必须返回一个单独的effect,所以让我们打开一个闭包。

var effects: [Effect] = []
for reducer in reducers {
  let effect = reducer(&value, action)
  effects.append(effect)
}
return {

}

在这个闭包中,我们可以循环所有累积的effect,并运行每个effect

var effects: [() -> Void] = []
for reducer in reducers {
  let effect = reducer(&value, action)
  effects.append(effect)
}
return {
  for effect in effects {
    effect()
  }
}

我们可以使用map来构建effect函数数组来缩短这一点:

let effects = reducers.map { $0(&value, action) }
return {
  for effect in effects {
    effect()
  }
}

那么pullback函数呢?它有几个错误。

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

🛑 Non-void function should return a value

第一个是在尝试从全局操作中提取局部操作的一行上。如果失败,它返回,因为它没有一个局部动作可以提供给给定的reducer
现在我们需要返回一个effect,在本例中,我们可以返回一个空的无操作闭包。

guard let localAction = globalAction[keyPath: action] else { return {} }

当我们调用本地reducer时,会出现其他错误。

reducer(&globalValue[keyPath: value], localAction)

🛑 Expression resolves to an unused function

这将返回一个effect

let effect = reducer(&globalValue[keyPath: value], localAction)

我们可以从reducer的主体上立即返回。

return effect

这个模块中只剩下logging高阶reducer。我们可以更新它的签名与有效的REDUCERs工作。

public func logging<Value, Action>(
  _ reducer: @escaping Reducer<Value, Action>
) -> Reducer<Value, Action> {
  return { value, action in
    reducer(&value, action)

🛑 Expression resolves to an unused function

编译器再次提醒我们,我们需要处理reducer返回的effect。我们可以做的一件事是将它store在一个局部变量中,这样我们就可以在做一些日志记录之后返回它。

let effect = reducer(&value, action)
print("Action: \(action)")
print("Value:")
dump(value)
print("---")
return effect

这可以构建,但是我们应该注意一个问题。logging 函数产生了一些副作用! 它将大量内容打印到控制台,这似乎不是什么大问题,但正如我们之前提到的,可以很容易地将其更改为日志输出到磁盘,甚至通过网络。

既然我们的reducer将副作用的概念直接融入到它的特征中,我们应该能够以类似的方式捕捉这些副作用。 一旦给定的reducer被调用,我们可以把工作推到一个effect闭包而不是打印。

let effect = reducer(&value, action)
return {
  print("Action: \(action)")
  print("Value:")
  dump(value)
  print("---")
  effect()
}

🛑 Escaping closure captures ‘inout’ parameter ‘value’

这个报错实际上是一个很好的东西。它表示不能通过逃逸闭包来捕获inout值。记住inout是关于超局部作用域突变的。在函数中使用inout作为参数意味着函数通过用inout注释其参数而显式地需要一个可变值,调用者通过用**&**符号注释传递的值而显式地允许突变。你可以把它看作是调用者和被调用者之间签订的契约,这个契约只在函数调用的那一刻有效。

如果允许一个可变的inout值在很远的地方移动到一个可以在未来任何时候执行的逃逸闭包中,那么我们就会破坏该位置,因为该值可能在以后的某个时间发生突变。

为了解决这个问题,我们只需要一个对该数据的不可变引用,这是可以的,因为我们不打算改变任何东西。

let effect = reducer(&value, action)
let newValue = value
return {
  print("Action: \(action)")
  print("Value:")
  dump(newValue)
  print("---")
  effect()
}

我们在视图方法中还有一个错误,它失败了,因为reducer初始化器需要返回一个effect

public func view<LocalValue, LocalAction>(
  value: @escaping (Value) -> LocalValue,
  action: @escaping (LocalAction) -> Action
) -> Store<LocalValue, LocalAction> {
  return Store<LocalValue, LocalAction>(
    initialValue: value(self.value),
    reducer: { localValue, localAction in
      self.send(action(localAction))
      localValue = value(self.value)
  }

🛑 Missing return in a closure expected to return ‘Effect’ (aka ‘() -> ()’)

记住,view是一种方法,它允许我们获取一个处理全局状态和全局操作的store,并让它聚焦于更局部的状态和操作子集。它在内部调用send,它执行根storeeffect,所以我们可以返回一个空的、无操作的闭包,因为创建视图的行为不应该引入任何新的副作用。

reducer: { localValue, localAction in
    self.send(action(localAction))
    localValue = value(self.value)
    return {}
}

我们现在已经完全升级了可组合架构模块,以使用有效的reducer,但我们现有的应用程序代码都不兼容。让我们跳转到最受欢迎的质数模块,并更新其reducer的签名。

public func favoritePrimesReducer(
  state: inout [Int], action: FavoritePrimesAction
) -> Effect {

然后我们可以评估每种情况来确定它是否会产生副作用。删除最喜欢的素数不应该执行副作用,所以我们可以返回一个无操作闭包。

case let .deleteFavoritePrimes(indexSet):
  for index in indexSet {
    state.remove(at: index)
  }
  return {}

替换最喜欢的质数也不会产生副作用,所以这里也可以返回一个无操作闭包。

case let .loadedFavoritePrimes(favoritePrimes):
  state = favoritePrimes
  return {}

最后,我们可以将现有的副作用内联执行,并将其封装在一个闭包中,以便稍后store对其进行执行:

case .saveButtonTapped:
  return {
    let data = try! JSONEncoder().encode(state)
    let documentsPath = NSSearchPathForDirectoriesInDomains(
      .documentDirectory, .userDomainMask, true
      )[0]
    let documentsUrl = URL(fileURLWithPath: documentsPath)
    try! data.write(to: documentsUrl.appendingPathComponent("favorite-primes.json"))
  }

🛑 Escaping closure captures ‘inout’ parameter ‘state’

我们只需要一个局部的,不可变的引用来引用可以在effect中使用的状态:

case .saveButtonTapped:
  let state = state
  return {
    let data = try! JSONEncoder().encode(state)
    let documentsPath = NSSearchPathForDirectoriesInDomains(
      .documentDirectory, .userDomainMask, true
      )[0]
    let documentsUrl = URL(fileURLWithPath: documentsPath)
    try! data.write(to: documentsUrl.appendingPathComponent("favorite-primes.json"))
  }

一切都在构建中,我们可以运行最喜欢的prime屏幕,整个功能应该像以前一样工作,尽管现在reducer不再执行副作用。

7. Reflecting on our first effect

但我们刚刚把第一个效应提取到我们的架构中。让我们回顾一下我们刚刚完成了什么。

在我们看来,我们有一个副作用,将最喜欢的质数保存到磁盘上,我们知道我们需要一些方法来控制。一方面,它是不可测试的代码,另一方面,我们发现简化视图的有效方法是将所有视图的逻辑移动到reducers中,并简单地让视图负责向store发送用户操作。

因此,我们很天真地将副作用代码移动到reducer中。这在技术上完成了任务,但却破坏了reducer的所有良好的可测试性和可理解性。

因此,我们回顾了之前关于副作用学到的一些经验教训,特别是我们经常可以通过引入一个新的输出到函数的边界,表示我们想要执行的效果,而不实际执行它。

这导致我们将reducer签名更改为一个返回void-to-void闭包的函数,该函数可以保存副作用工作,而无需实际执行它,从而将执行工作的责任传递给store

在修正了一些编译器错误之后,我们终于能够将保存工作封装在一个闭包中,并让store库为我们运行它,而不是在reducer中运行它。最重要的是,像这样改变reducer签名并没有阻止我们仍然拥有可组合reducerstore,这是这种类型架构背后的真正力量。

这是我们在架构中引入效果的第一步,这是一个简单的效果。我们还有几个步骤要做,因为我们需要建模更复杂的效果。例如,加载最喜欢的质数列表与保存有一点不同。保存主要是一个“射后不理”的操作,我们只运行工作,不需要向reducer反馈发生了什么事情。

然而,加载做了一些工作来从磁盘加载数据,然后我们想要将这些工作反馈给reducer

所以,让我们想想如何改变这个效果模型,以便获得这种能力……下次吧!