1. Introduction

我们最终完全提取了应用程序中最复杂的异步副作用,使其在我们的架构中完全像以前一样工作。虽然一路磕磕绊绊,但我们能够解决每一个问题。

这个effect肯定比其他的更复杂,有两个原因:

  • 首先,这种effect与显示和取消警报的想法捆绑在一起,这是我们之前在架构中没有考虑到的。解决这个问题需要我们考虑提取与警报表示相关联的本地状态意味着什么,如何管理绑定、删除等等。大部分的bug都围绕着这个问题,所以在未来我们将探索与SwiftUI api更好的接口方式。

  • 其次,效果是异步的!它本质上比同步效应更复杂。我们需要考虑线程问题,尽管这不是架构的错误,而是任何从视图提取逻辑到可观察对象的人都会遇到的问题。

现在我们有了我们的effect类型:类似于平行的形状,在这里您可以将一个函数传递给其他人,他们可以做他们的工作,并在准备好的时候调用该函数。我们有reducer的形状,它可以在数组中返回任意数量的effect,结果可以反馈到store中。
做一个异步的函数重构,让一切都能正常工作。

我们还能够接受“单向数据流”的想法。即使有了效果和异步性带来的复杂性,我们仍然能够推断出数据如何在应用中流动,因为effect只能通过通过store发送回的动作改变应用的状态。store是突变的唯一入口。这是我们架构中effects故事的基本版本。

2. Composable, transformable effects

然而,现在还很难看到这些好处,因为effect类型是如何表达的。现在它是函数签名的简单类型别名。当我们第一次讨论这个签名的形状时,回到它被称为Parallel时,我们将它包装在一个结构体中,并在其上定义了一个map操作,以便我们可以以轻量级的方式转换它。让我们这样做,看看会发生什么:

public struct Effect<A> {
  public let run: (@escaping (A) -> Void) -> Void

  public init(run: @escaping (@escaping (A) -> Void) -> Void) {
    self.run = run
  }

  public func map<B>(_ f: @escaping (A) -> B) -> Effect<B> {
    return Effect<B> { callback in self.run { a in callback(f(a)) }
  }
}

现在,这导致了许多我们必须修复的编译器错误,但在修复它们之前,让我们记住,使用单个的、可转换的单元来表示应用程序的某些部分是多么强大。无论它是样式函数、随机数生成器、解析器、快照测试还是状态管理,只要您有一个简单的、易于理解的、能够进行转换的单元,就会产生令人惊奇的东西。

我们现在看到,副作用可能是另一个概念,符合这个叙述。 我们有一个相当简单的基本单元,它只是一个可以通过某些回调函数异步执行的工作单元。它支持map操作,因此我们可以轻松地将effect产生的任何值转换为另一个值。我们还可以询问effect是否支持zip操作,或者flat map操作,或者“higher-order effects”是什么样子,但即使在所有这些之前,我们可以从这个map操作中获得很多好处。

为了看到这一点,让我们重新建立秩序。我们需要做的主要是在几个位置添加一些.run,并在返回闭包时显式地指定Effect名称:

// ComposableArchitecture.swift

// effect(self.send)
effect.run(self.send)

// return { callback in
//   localEffect { localAction in
return Effect { callback in
  localEffect.run { localAction in

// { _ in
Effect { _ in

现在我们在构建秩序,我们可以开始看到effect类型将如何让我们理解一些我们的副作用工作,甚至简化。如果我们查看我们的Wolfram Alpha swift文件,我们将看到我们的副作用的实质:

public func wolframAlpha(
  query: String,
  callback: @escaping (WolframAlphaResult?) -> Void
  ) -> Void {
  var components = URLComponents(string: "https://api.wolframalpha.com/v2/query")!
  components.queryItems = [
    URLQueryItem(name: "input", value: query),
    URLQueryItem(name: "format", value: "plaintext"),
    URLQueryItem(name: "output", value: "JSON"),
    URLQueryItem(name: "appid", value: wolframAlphaApiKey),
  ]

  URLSession.shared
    .dataTask(with: components.url(relativeTo: nil)!) { data, response, error in
      callback(
        data
          .flatMap { try? JSONDecoder().decode(WolframAlphaResult.self, from: $0) }
      )
  }
  .resume()
}

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)
    )
  }
}

让我们也粘贴我们如何在reducer中使用这段代码,这样我们就可以把所有的东西放在一起:

return [
  Effect { callback in
    nthPrime(n) { prime in
      DispatchQueue.main.async {
        callback(.nthPrimeResponse(prime))
      }
    }
  }
]

我在这里看到的第一件奇怪的事情是,用于处理Wolfram Alpha的库代码仍然存在于回调世界中,而不是使用Effect类型。 我注意到的下一件事是,Wolfram库代码混合了一些与Wolfram API无关的通用内容,一些特定于Wolfram的领域,例如运行来自URLSession的请求的代码和执行JSON解码的代码。让我们来解决一些奇怪的事情。

首先,让我们让wolframAlpha查询函数返回一个effect,而不是利用回调API:

public func wolframAlpha(query: String) -> Effect<WolframAlphaResult?> {
  return Effect { callback in

这打破了nthPrime函数,但它也应该被更新以与effects一起工作,现在它非常简单,因为我们可以mapWolfram查询效果:

func nthPrime(_ n: Int) -> Effect<Int?> {
  return wolframAlpha(query: "prime \(n)").map { result in

3. Reusable effects: network requests

所以,我们特定领域的Wolfram effects是用Effect类型表示的,这很好,但现在这意味着我们可以利用map操作来分割这些单位,使它们更容易理解。例如,我们可以引入一个只负责执行URLSession请求的effect:

func dataTask(with request: URL) -> Effect<(Data?, URLResponse?, Error?)> {
  return Effect { callback in
    URLSession.shared.dataTask(with: request) { data, response, error in
      callback((data, response, error))
    }
    .resume()
  }
}

这段代码是库代码,可以与我们的应用程序及其任何模块完全分离。

然后我们可以利用这个基本的工作单元来实现我们的Wolfram API效果:

public func wolframAlpha(query: String) -> Effect<WolframAlphaResult?> {
  var components = URLComponents(string: "https://api.wolframalpha.com/v2/query")!
  components.queryItems = [
    URLQueryItem(name: "input", value: query),
    URLQueryItem(name: "format", value: "plaintext"),
    URLQueryItem(name: "output", value: "JSON"),
    URLQueryItem(name: "appid", value: wolframAlphaApiKey),
  ]
  return dataTask(with: components.url(relativeTo: nil)!)
    .map { data, _, _ in
      data
        .flatMap { try? JSONDecoder().decode(WolframAlphaResult.self, from: $0) }
  }
}

但是这里还有一个可重用代码单元:JSON解码。如果我们能简单地将JSON解码的概念与任何effect联系起来会怎样? 听起来我们应该用一个方法来扩展Effect类型来实现这种逻辑:

extension Effect where A == (Data?, URLResponse?, Error?) {
  func decode<B: Decodable>(as type: B.Type) -> Effect<B?> {
    return self.map { data, _, _ in
      data
        .flatMap { try? JSONDecoder().decode(B.self, from: $0) }
    }
  }
}

现在我们可以进一步简化我们的Wolfram API effect,这样它基本上只关心特定于Wolfram的逻辑,例如为Wolfram API构造请求,而不关心发出网络请求和做JSON解码:

return Effect.dataTask(with: components.url(relativeTo: nil)!)
  .decode(as: WolframAlphaResult.self)

请注意,我们可以通过使用适当的Result类型(而不仅仅是可选的)来增强这个功能。

现在要真正使用这个,我们需要更新如何在reducer中使用这个effect。目前它看起来像这样:

Effect { callback in
  nthPrime(n) { prime in
    DispatchQueue.main.async {
      callback(.nthPrimeResponse(prime))
    }
  }
}

既然nthPrime函数返回一个素数,我们就不需要显式地创建一个effect并在内部调用该函数了。

nthPrime(count) // Effect<Int?>

唯一奇怪的是,这个effect产生一个可选的整数,但是要从reducer返回,它必须是CounterActioneffect。 我们如何将这个可选的整数转换为一个动作? 嗯,当然用map! 事实上,我们甚至可以使用CounterAction的nthprimerresponse作为我们想要map的函数:

nthPrime(count).map { CounterAction.nthPrimeResponse($0) }

4. Reusable effects: threading

这将使我们正常编译,但我们失去了确保在发送动作时将其调度回main线程的能力。我们不能单独使用map来添加这个功能。map操作无法改变将操作传递到回调函数的方式,它只能在将值发送到回调函数之前对它们进行简单的转换。然而,我们可以在Effect上定义另一个操作,就像我们在JSON解码中做的那样,它添加了以下功能:

extension Effect {
  func receive(on queue: DispatchQueue) -> Effect {
    return Effect { callback in
      self.run { a in
        queue.async { callback(a) }
      }
    }
  }
}

现在我们的effect转化为:

nthPrime(n)
 .map(CounterAction.nthPrimeResponse)
 .receive(on: .main)
let count = state.count
return [
  nthPrime(count)
   .map(CounterAction.nthPrimeResponse)
   .receive(on: .main)

现在这个效果看起来很好,很有陈述性。nthPrime函数只专注于计算第n个质数所需做的工作(即使它被分解成小单位),然后我们将这种effect映射到我们想要送回storecounter action中,最后,我们确保action是在主线程上发送的,这是必要的,因为nthPrime是在后台线程上工作的。

更妙的是,由于我们没有在手动构造的effect的闭包中操作,我们可以删除之前为可变值所做的let dance:

return [
  nthPrime(state.count)
   .map(CounterAction.nthPrimeResponse)
   .receive(on: .main)

在所有这些更新之后,不幸的是我们破坏了一些东西,所以让我们做一些工作来让它们回到工作秩序中。

5. Getting everything building again

如果我们检查最喜欢的质数模块,它不是构建顺序,因为它的reducer仍然是单一的,同步effects。我们需要更新它以返回一个闭包的异步数组。

private func saveEffect(favoritePrimes: [Int]) -> Effect<FavoritePrimesAction> {
  return Effect { _ in
    ...
  }
}

加载效果也处于类似的状态,但我们需要使用Effect初始化器,引入callback参数,并进行传递。

private let loadEffect = Effect<FavoritePrimesAction>.sync {
  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 nil }
//  self.store.send(.loadedFavoritePrimes(favoritePrimes))
  callback(.loadedFavoritePrimes(favoritePrimes))
}

最受欢迎的质数模块现在正在构建,所以让我们试着让主应用程序与它一起构建。有相当多的错误,但是让我们试着一个一个地修正它们。

The activity feed broke ,因为它有一个详尽的switch。让我们更新签名。

func activityFeed(
  _ reducer: @escaping (inout AppState, AppAction) -> [Effect<AppAction>]
) -> (inout AppState, AppAction) -> [Effect<AppAction>] {

And make it exhaustive.

case .counterView(.counter),
     .favoritePrimes(.loadedFavoritePrimes),
     .favoritePrimes(.loadButtonTapped),
     .favoritePrimes(.saveButtonTapped):

最后,我们不仅要运行减速机,还必须返回它的effects

return reducer(&state, action)

事情越来越接近building,但state已经变了。当我们将AppState投射到CounterViewState时,我们必须沿着它探查一些新值。我们可以相应地更新AppState

struct AppState {
  ...
  var alertNthPrime: PrimeAlert? = nil
  var isNthPrimeButtonDisabled: Bool = false

引入这个顶级状态可能看起来有点失控,但我们将在未来的章节中讨论应用状态的正常化。既然这个状态在应用程序级别是可用的,我们就可以将它传递给counter视图。

extension AppState {
  var counterView: CounterViewState {
    get {
      CounterViewState(
        alertNthPrime: self.alertNthPrime,
        count: self.count,
        favoritePrimes: self.favoritePrimes,
        isNthPrimeButtonDisabled: self.isNthPrimeButtonDisabled
      )
    }
    set {
      self.alertNthPrime = newValue.alertNthPrime
      self.count = newValue.count
      self.favoritePrimes = newValue.favoritePrimes
      self.isNthPrimeButtonDisabled = newValue.isNthPrimeButtonDisabled
    }
  }
}

我们还必须公开这些属性。

现在一切都建立起来了,如果我们把事情旋转一下,它仍然和以前一样。但如果我们在计数器模块中编写的可重复使用的effect标识随处可见就好了。幸运的是,我们可以将其转移到可组合架构,而无需做太多工作。

我们需要做的就是复制、粘贴和公开。

6. Conclusion

因此,现在我们能够在基本可组合架构模块中共享非常可重用的effects。但更好的是,你可能想分享更多领域特定的effects!

这就是我们使用effects所做的所有事情的要点。我们在架构中偶然发现了另一个可组合的概念:副作用! 乍一看,这似乎很令人惊讶。我们已经习惯了在代码库中添加一些副作用来让代码发挥作用,随着时间的推移,我们越来越难以完全理解所有这些副作用是如何共同作用来完成工作的。我们不认为它们有一个定义良好的结构,可以在高水平上操纵。

关于这个体系结构中的effects还有很多可说的,因为我们刚刚开始触及为什么effects中的可组合性很重要的皮毛。但在我们进一步深入之前,让我们回顾一下我们在14集之前开始的架构的目标。

我们列出了任何架构都应该致力于解决的5个重要问题:

  • 架构的类型应该表示为简单的值类型。我们一次又一次地看到,值类型可以消除很多复杂性,所以我们应该尽可能地采用它们。
  • 突变和状态观察的基本单位应该以一种可组合的方式表达。应该有操作允许你从现有的变异和观察中获得所有新的形式。
  • 体系结构应该是模块化的,也就是说,你应该能够将体系结构的许多单元放入它们自己的模块中,这样它们就可以与其他所有单元完全分离,同时仍然能够粘在一起形成一个整体。
  • 架构应该有一个明确的副作用故事。它应该准确地描述副作用是如何执行的,以及他们的工作结果如何反馈到体系结构中。
  • 最后,架构应该描述如何测试各种组件,理想情况下,编写这些测试只需要最少的设置。

我们现在已经为这5个问题中的4个提供了非常精确的答案。下次我们将开始进行测试,并说明在这个体系结构中获得惊人的测试覆盖率是可能的。考虑到我们在本集中所做的工作,这甚至可能相当令人惊讶,因为这种效果类型现在似乎不是超级可测试的。然而,这是可能的,而且很酷。