Introduction

我们现在已经有了一个非常基本的架构版本。我们有一个store类,它是状态类型的泛型,表示应用程序的完整状态,它是一个动作类型的泛型,该动作类型表示应用程序中可能发生的所有用户动作。

  • store类包装了一个状态值,这只是一个简单的值类型,这允许我们一次性地挂钩到观察者,以便我们可以在状态即将发生变化时通知SwiftUI

  • store类还保留了一个reducer,它是我们应用程序的大脑。 它描述了如何获取应用程序的当前状态和来自用户的传入操作,并生成应用程序的全新状态,然后将其呈现并显示给用户。

这个小小的工作已经解决了我们在本集开始时提到的5个问题中的2个。

但是,尽管这一切都很酷,我们还可以走得更远。让我们来解决在appReducer中开始发展的问题。现在它看起来相当庞大:一个巨大的reducer处理3个不同屏幕的突变。这似乎不是特别具有可扩展性。如果我们有24个屏幕我们真的需要一个开关语句来切换24个不同屏幕的每个动作吗?这是行不通的。

我们需要研究将reducer合成成更大的reducer的方法。如何将一个大的reducer分解成许多小的只做一件特定事情的reducer然后将它们粘在一起形成我们的主appReducer?让我们开始研究这个。

Combining reducers

这是我们的应用程序reducer:

func appReducer(value: inout AppState, action: AppAction) -> Void {
  switch action {
  case .counter(.decrTapped):
    state.count -= 1

  case .counter(.incrTapped):
    state.count += 1

  case .primeModal(.saveFavoritePrimeTapped):
    state.favoritePrimes.append(state.count)
    state.activityFeed.append(.init(timestamp: Date(), type: .addedFavoritePrime(state.count)))

  case .primeModal(.removeFavoritePrimeTapped):
    state.favoritePrimes.removeAll(where: { $0 == state.count })
    state.activityFeed.append(.init(timestamp: Date(), type: .removedFavoritePrime(state.count)))

  case let .favoritePrimes(.deleteFavoritePrimes(indexSet)):
    for index in indexSet {
      let prime = state.favoritePrimes[index]
      state.favoritePrimes.remove(at: index)
      state.activityFeed.append(.init(timestamp: Date(), type: .removedFavoritePrime(prime)))
    }
  }
}

它负责通过执行适当的状态更改,将整个应用程序的当前状态与任何用户操作结合起来。它处理三个不同的屏幕和五个不同的用户操作。 这个函数越来越长了! 随着屏幕越来越多,这个功能将变得越来越庞大。

在函数中表达架构基本单元的美丽之处在于,它展示了我们可以以有趣的方式组合它们的可能性。毕竟,函数是可以无限组合的。我们发现reducer函数的特征有很多不同类型的组成,它们正是我们需要的,把appReducer分解成一堆更小的可以组合在一起的reducer

让我们从最简单的组合开始。如果有两个reducers在相同的状态和相同的动作下运行,你能做什么? 有没有办法把它们组合成一个reducers,让两个reducers同时工作? 这是完全可能的,而且实际上很容易实现:

func combine<Value, Action>(
  _ first: @escaping (inout Value, Action) -> Void,
  _ second: @escaping (inout Value, Action) -> Void
) -> (inout Value, Action) -> Void {

  return { value, action in
    first(&value, action)
    second(&value, action)
  }
}

这简单地说,要组合两个reducers,您可以先在state上运行第一个,然后运行第二个。

我们如何使用它? 好吧,让我们天真地把大的reducer分解成几个小的reducers。在我看来,这里确实有3个reducers在起作用:一个用于计数,一个用于模态,一个用于最喜欢的质数屏。

让我们为每个屏幕创建一个reducer。每一个都将对他们所关心的动作子集进行操作:

func counterReducer(value: inout AppState, action: AppAction) -> Void {
  switch action {
  case .counter(.decrTapped):
    state.count -= 1

  case .counter(.incrTapped):
    state.count += 1

  default:
    break
  }
}

func primeModalReducer(state: inout AppState, action: AppAction) -> Void {
  switch action {
  case .primeModal(.addFavoritePrime):
    state.favoritePrimes.append(state.count)
    state.activityFeed.append(.init(timestamp: Date(), type: .addedFavoritePrime(state.count)))

  case .primeModal(.removeFavoritePrime):
    state.favoritePrimes.removeAll(where: { $0 == state.count })
    state.activityFeed.append(.init(timestamp: Date(), type: .removedFavoritePrime(state.count)))

  default:
    break
  }
}

func favoritePrimesReducer(state: inout AppState, action: AppAction) -> Void {
  switch action {
  case let .favoritePrimes(.removeFavoritePrimes(indexSet)):
    for index in indexSet {
      state.activityFeed.append(.init(timestamp: Date(), type: .removedFavoritePrime(state.favoritePrimes[index])))
      state.favoritePrimes.remove(at: index)
    }

  default:
    break
  }
}

然而,我们仍然需要将它们粘合到主reducer中,以便为整个应用程序运行逻辑。现在我们可以使用combine函数重新创建一个和之前完全一样的应用程序reducer:

let appReducer = combine(combine(counterReducer, primeModalReducer), favoritePrimesReducer)

因为combine一次只能使用两个reducer,所以使用嵌套有点尴尬。在我们的应用程序中,我们想要在很多屏幕上结合很多很多的reducers,所以这不会有很好的伸缩性。

我们可以通过使用可变变量来修复这个问题,允许combine函数与任意数量的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)
    }
  }
}

现在我们可以简单地编写app reducer:

let appReducer = combine(
  counterReducer,
  primeModalReducer,
  favoritePrimesReducer
)

这将我们所有的小reducers组合成一个大reducer,我们的应用程序仍然像以前一样工作。

这是我们谈到reducers时的第一种组合。它是最简单的,但它已经允许我们将大的appReducer(大约30行代码)分解成3个独立的reducer,每一个大约有10行代码。

Focusing a reducer's state

现在,尽管我们已经把我们的大appReducer分解成一些更小的部分,每一部分都有自己的一些问题,这给了我们一些提示,它们还有更多的组成形式有待发现。

例如,考虑计数器reducer:

func counterReducer(state: inout AppState, action: AppAction) -> Void {
  switch action {
  case .counter(.decrTapped):
    state.count -= 1

  case .counter(.incrTapped):
    state.count += 1

  default:
    break
  }
}

这个reducer想要完成一些非常简单的事情,只是增加和减少一个整数,但它接受的是完整的应用状态,包括喜爱的质数,活动feed和更多。我们真正想要的是让reducer对这样一个简单的整数起作用:

//func counterReducer(state: inout AppState, action: AppAction) -> Void {
func counterReducer(state: inout Int, action: AppAction) -> Void {
  switch action {
  case .counter(.decrTapped):
    // state.count -= 1
    state -= 1

  case .counter(.incrTapped):
    // state.count += 1
    state += 1

  default:
    break
  }
}

尽管逻辑是相同的,代码行数也是相同的,但是这个reducer在本质上比前一个reducer更简单,因为它操作的状态集更小。 不熟悉这段代码的人可以查看这个函数的签名,并知道这个reducer只处理一个简单的整数,而不是整个AppState模型。

然而,改变这个签名却破坏了一些东西:

let appReducer = combine(
  counterReducer,
  primeModalReducer,
  favoritePrimesReducer
)

🛑 Cannot convert value of type ‘(inout Int, AppAction) -> ()’ to expected argument

我们不能再把所有这些reducers结合在一起,因为它们说的不是同一个状态。counterReducer只希望在整数上工作,而其他两个reducer在完整的应用程序状态下工作。对此该怎么办?

这就把我们带到关于reducer的下一个组成形式:pullback!

我们之前在Point-Free遇到过两次pullback。首先,我们发现了一种奇怪的组合形式,这种形式出现在研究一个名为“Contravariance”的主题时。这是一种与函数箭头方向相反的组合形式。一个例子是谓词,它是从某种类型到布尔类型的函数,比如(A) -> Bool。 我们知道如果我们有一个从A到B的函数那么我们可以把它变成一个从B上的谓词到A上的谓词的函数。注意,方向颠倒了:我们从A到B,最后从B的谓词到A的谓词。 这与数组形成了鲜明的对比,在数组中,如果你有一个从A到B的函数你可以把它变成一个从A数组到B数组的函数。注意,函数箭头方向被保留了。

我们称此操作为pullback操作,因为它有助于将小型特定数据上的谓词转换为大型通用数据上的谓词。例如,给定一个整数上的谓词,我们可以通过投射到用户的id字段,将其拉回用户模型上的谓词。

所有这些发现都很酷也很有力量,但随后发生了一些令人惊讶的事情。在逆方差事件发生6个多月后,我们在一个完全不相关的领域再次遭遇回调:快照测试(snapshot testing)!我们展示了我们设计的快照库具有回调的概念。也就是说,如果我们有一个从A到B的函数我们可以把它变成一个从B的快照策略到A的快照策略的函数。再次注意,方向颠倒了。这个操作使我们能够从特定的快照策略派生出更通用的快照策略。例如,我们可以在UIImages上采取快照策略,并将其拉回CALayer上,通过将CALayer渲染成UIImage。然后我们可以将CALayers上的快照策略拉回UIView只需要拉出视图中的层。最后,我们可以将UIView上的快照策略拉回到UIViewControllers只需要在控制器中取出视图。 只需要很少的工作,我们就能从这个简单的回拉操作中得到3个新的快照策略。

现在希望我描述这个操作的方式能让你们有所感触,因为这听起来像是一个回拉操作对我们的reducers非常有用。对于谓词和快照策略来说,回调对于将处理一小段数据的内容转换为处理大数据的内容都很有用,只要我们有从大数据投射到小数据的方法。这正是我们想要用reducers做的:我们想要在一小块substate上使用reducers,并将其转换为在全局状态下工作的reducers,而substate嵌入其中。

Pulling back reducers along state

让我们探讨一下如何定义reducer的回调。在它的核心,我们想要一个函数,可以将本地状态的reducer转换为全局状态的reducer ,所以我们可以从这个签名开始,即使它目前是不完整的:

func pullback<LocalValue, GlobalValue, Action>(
  _ reducer: @escaping (inout LocalValue, Action) -> Void
) -> (inout GlobalValue, Action) -> Void {

}

这是我们想要完成的核心。当然,我们现在还不能实现这个函数因为LocalValue和GlobalValue泛型目前还没有连接。在梦想实现之前,我们需要一些方法将它们联系起来。

在我们关于逆变性和快照测试的章节中,只用一个简单的函数就足够将这两个泛型联系起来了:

func pullback<LocalValue, GlobalValue, Action>(
  _ reducer: @escaping (inout LocalValue, Action) -> Void,
  _ f: @escaping (GlobalValue) -> LocalValue
) -> (inout GlobalValue, Action) -> Void {

}

也就是说,如果您提供了一种从全局值到局部值的方法,您可以将局部值的reducer转换为全局值的reducer

然而,这还不足以应对此次回调。要知道为什么,让我们尝试实现:

func pullback<LocalValue, GlobalValue, Action>(
  _ reducer: @escaping (inout LocalValue, Action) -> Void,
  _ f: @escaping (GlobalValue) -> LocalValue
) -> (inout GlobalValue, Action) -> Void {

  return  { globalValue, action in
    var localValue = f(globalValue)
    reducer(&localValue, action)
  }
}

尽管这样实现了函数,编译器似乎也很高兴,但它不可能是正确的。请注意,我们正在创建局部值的局部可变副本,然后使用reducer进行突变,但随后我们不会对该局部值的副本进行任何操作。这意味着全局值根本不会改变,因此reducer的每一次运行都不会改变任何东西。

为了演示这一点,我们可以满足编译器的要求:

pullback(counterReducer) { $0.count },

现在我们打破了计数器屏幕:所有的计数器动作都没有任何作用。

我们缺少的部分是获取新局部值并将其插入到全局值的能力。 除了我们的函数可以从全局值中获取局部值之外,我们还需要一个函数可以在全局值中设置局部值。

Something like:

func pullback<LocalValue, GlobalValue, Action>(
  _ reducer: @escaping (inout LocalValue, Action) -> Void,
  get: @escaping (GlobalValue) -> LocalValue,
  set: @escaping (inout GlobalValue, LocalValue) -> Void
) -> (inout GlobalValue, Action) -> Void {

  return  { globalValue, action in
    var localValue = get(globalValue)
    reducer(&localValue, action)
    set(&globalValue, localValue)
  }
}

现在我们可以介绍我们的计数器reducer拉回的setter组件:

pullback(counterReducer, get: { $0.count }, set: { $0.count = $1 }),

一切都像以前一样工作!
幸运的是,Swift有一个特性,将这对getter和setter捆绑到一个概念中,这个概念有一些非常好的人机工程学和编译器支持:它叫做关键路径(key path)!我们可以用一个可写的键路径来替换这两个get和set参数:

func pullback<LocalValue, GlobalValue, Action>(
  _ reducer: @escaping (inout LocalValue, Action) -> Void,
  value: WritableKeyPath<GlobalValue, LocalValue>
) -> (inout GlobalValue, Action) -> Void {
  return { globalValue, action in
    reducer(&globalValue[keyPath: value], action)
  }
}

实现变得相当短,我们可以将回拉简化为:

pullback(counterReducer, value: \.count)

Key path pullbacks

现在让我们花一分钟来回顾一下我们所做的。把这种操作称为“pullback”是否公平?之前我们的回调是沿着功能的。也就是说,我们可以沿着一个从大数据到小数据的函数拉回一个谓词,或者沿着一个从大数据结构到小数据结构的函数拉回一个快照策略。这里我们甚至没有使用函数,而是使用键路径。这样做可以吗?

当然,它确实是,我们将在未来的一集里讨论它,但我们今天被迫直面它。回拉操作的定义特征不一定是它专门使用函数来执行回拉操作。更确切地说,我们有一些以A开始,以B结束的过程。现在,函数当然符合要求,它们实际上是我们用来将A转换为B的实际过程,但是还有其他类型的过程我们可以使用,关键路径就是其中之一。

这个pullback仍然满足我们遇到的其他pullback的所有相同的属性。 例如,我们以前看到过如果你用恒等函数做回拉你只得到你开始的东西。就像沿着恒等函数对字符串使用快照策略只会得到对字符串使用相同的快照策略。这也适用于我们的新reducer回调。

如果你沿着标识键路径拉回一个reducer,你会得到相同的reducer,我们可以看到这样做:

let _appReducer = combine(
  counterReducer,
  primeModalReducer,
  favoritePrimesReducer
)
let appReducer = pullback(_appReducer, value: \.self)

这里.self是通过标识从AppState到AppState的关键路径。

这使我们可以将回调的概念一般化,而不仅仅是函数,现在我们可以对使用关键路径进行回调的想法感到舒服。

Pulling back more reducers

现在我们已经习惯了,让我们用这个技巧来处理其他的reducers。

我们可以从最受欢迎的reducer开始:

func favoritePrimesReducer(value: inout AppState, action: AppAction) -> Void {
  switch action {
  case let .favoritePrimes(.removeFavoritePrimes(indexSet)):
    for index in indexSet {
      state.activityFeed.append(.init(timestamp: Date(), type: .removedFavoritePrime(state.favoritePrimes[index])))
      state.favoritePrimes.remove(at: index)
    }

  default:
    break
  }
}

这个减速器也在完整的AppState模型上运行,尽管它看起来只需要访问收藏包的质数数组和活动提要。它根本不关心当前的计数值或登录的用户,那么为什么它应该有访问权限呢?

让我们通过创建一个新的中间结构模型来重构它,它只保存我们关心的数据:

struct FavoritePrimesState {
  var favoritePrimes: [Int]
  var activityFeed: [AppState.Activity]
}

我们可以更新reducer来使用这个结构体。

func favoritePrimesReducer(value: inout FavoritePrimesState, action: AppAction) -> Void {
  switch action {
  case let .favoritePrimes(.removeFavoritePrimes(indexSet)):
    for index in indexSet {
      state.activityFeed.append(.init(timestamp: Date(), type: .removedFavoritePrime(state.favoritePrimes[index])))
      state.favoritePrimes.remove(at: index)
    }

  default:
    break
  }
}

reducer的内部甚至不需要任何改变。现在当人们来到这个reducer时,他们会感到更舒服,因为他们将能够完整地理解它, 因为他们可以看到,它只能改变应用程序中非常小的一组状态,只有最喜欢的prime和活动feed。

现在reducer看起来不错,但我们再次破坏了应用reducer:

let appReducer = combine(
  pullback(counterReducer, value: \.count),
  primeModalReducer,
  favoritePrimesReducer
)

🛑 Type of expression is ambiguous without more context

我们需要通过拉回favoritePrimesReducer来解决这个问题。这个将与前一个略有不同,因为favoritePrimesReducer的状态只是来自AppState上的一个字段,而不是两个字段。

幸运的是,Swift会为计算属性创建关键路径,所以我们可以在AppState上引入一个计算属性,手动完成这项工作:

extension AppState {
  var favoritePrimesState: FavoritePrimesState {
    get {
      return FavoritePrimesState(
        favoritePrimes: self.favoritePrimes,
        activityFeed: self.activityFeed
      )
    }
    set {
      self.activityFeed = newValue.activityFeed
      self.favoritePrimes = newValue.favoritePrimes
    }
  }
}

有了这个自定义的get-set computed属性,我们就得到了一个由编译器自动生成的可写键路径,我们可以用它来拉回reducer,这样它就能与其他reducer配合:

let appReducer = combine(
  pullback(counterReducer, value: \.count),
  primeModalReducer,
  pullback(favoritePrimesReducer, value: \.favoritePrimesState)
)

我们的应用程序现在可以像以前一样编译和运行,但现在我们又有了另一个reducer,它高度关注它所关心的状态。

还有第三个reducer primeModalReducer,但它实际上需要相当多的应用程序状态。它需要当前计数器、最喜欢的质数和活动提要。在这种情况下,可能没有必要关注这个reducer ,因为它需要这么多的状态,它可以保持原样。

Till next time

因此,我们现在已经非常接近完成另一个我们在这一系列剧集开始时就开始着手解决的架构问题。我们说过,我们希望能够用简单的、可组合的单元构建大型复杂的应用程序。

我们现在可以用我们的reducer和它们运行的状态来做到这一点。我们可以编写reducer,使它们只在完成工作所需的最低状态下运行,然后将它们拉回reducer,使其适合于更大的reducer,并在完整的应用程序状态下运行。

理想情况下,我们甚至希望这些简单的、可组合的单元能够被隔离,我们甚至可以将它们放在自己的模块中,这样它们就可以轻松地与其他模块和应用程序共享。

这真是太令人兴奋了! 但是,还有一个问题。尽管我们的reducer运行于更小的数据块,但它们仍然对嵌入其中的更大的reducer了解得太多,特别是它们可以监听每一个应用程序的操作。

听起来好像我们需要在下次的行动中重复同样的故事。