Introduction

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

我们现在可以用我们的reducers和它们运行的状态来做到这一点。我们可以编写我们的reducers,让它们在最低限度的状态下运行,以完成工作,然后把它们拉回来,放入一个更大的reducers中,并在整个应用程序的状态下运行。

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

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

Focusing a reducer's actions

让我们再来看看我们的counterReducer:

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

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

  default:
    break
  }
}

这个reducers只对一个整数起作用,这很好。这对我的思维模式很有帮助,我知道reducers想要完成什么,因为我知道它真的不能做太多。 它只有一个整数可以改变。

但是,请注意,它也包含应用程序的全部操作。

我们应该知道出错了,因为在switch语句中有一个default情况。这意味着,如果我们向CounterAction枚举中添加一个新操作,我们将不会得到编译器错误,并且会在reducers中静默地忽略该操作。

解决这一缺点的一种方法是在彻底switching之前先提取counter动作:

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

    case .incrTapped:
      state += 1
    }

  default:
    break
  }
}

但这并不理想,因为这会增加嵌套和噪音。

所以,让我们重构这个reducer,让它只执行它关心的动作:

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

  case .incrTapped:
    state += 1
  }
}

这很简单,它使代码更短,我们不再需要default情况。它现在只关注动作和状态,甚至可以被提取到模块中。这是一件很强大的事情!例如,一个代码库的新手可能会在自己的模块中看到一个reducer,并知道它不可能触及应用目标中的任何东西。

Enums and key paths

然而,我们现在有一个编译错误在我们的应用程序reducer:

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

🛑 Type of expression is ambiguous without more context

尽管counterReducer在状态方面与其他reducer使用相同的语言,但它不再使用相同的actions语言。counter reducer只理解计数器动作,而其他reducers则理解整个应用动作,所以我们不能再将这些reducers组合在一起。

所以,问题是:我们如何把一个只理解局部作用的reducer转变成一个理解全局作用的reducer?

这与我们在reducer和状态上遇到的问题非常相似,因为我们想将在局部状态下工作的reducer转换为在全局状态下工作的reducer。在这种情况下,我们发现解决方案是沿着一条从全局状态到局部状态的关键路径pullback。但在action世界中,相应的解决方案是什么呢?

我们的操作是枚举,枚举没有键路径的概念,至少不是Swift定义的,也不是Swift编译器自动提供的。然而,这不应该阻止我们探索对应的枚举关键路径的概念是什么样子的,并看看这是否可能解决我们的问题。

如果我们要将关键路径的本质提取到一个包中,它可能看起来像这样:

struct _KeyPath<Root, Value> {
  let get: (Root) -> Value
  let set: (inout Root, Value) -> Void
}

这根本不是在Swift中实现关键路径的方式,我们在前面加上了下划线,以表明这只是一个思想实验。但关键路径的核心在于,它们为您提供了一种从根中“获取”值的方法,并允许您在根中设置值,从而为您提供一个已更改的新根。这两个操作基本上是您可以在结构上执行的最一般的操作。您可以从结构中提取字段,也可以在结构上的字段中设置新值。

举还有两个非常基本的操作可以在其上执行,它们非常类似于关键路径的get和set。对于某些枚举类型,您可以获取一个值并将其嵌入到枚举的一种情况中,或者您可以获取枚举的一个值并尝试在其中一种情况中提取相关的数据。

例如,从我们的AppAction枚举。我们可以从case的相关数据中获取一个值,并将其放入枚举中:

AppAction.counter(CounterAction.incrTapped)

这有点像setter:我们接受一个值并将其嵌入到AppAction enum中。

我们也有类似getter的东西:我们可以取一个枚举值并尝试从一个特定的情况中提取一个值:

let action = AppAction.favoritePrimes(.deleteFavoritePrimes([1]))
let favoritePrimesAction: FavoritePrimesAction?
switch action {
case let .favoritePrimes(action):
  favoritePrimesAction = action
default:
  favoritePrimesAction = nil
}

虽然这非常冗长,但它类似于getter操作,我们可以进行模式匹配并提取可选的关联值。

这是我们可以对枚举做的两个操作,我们可以对每一个枚举做。如果Swift支持enum键路径,我们可以将这两个操作打包成一个新的类型,可能像这样:

struct EnumKeyPath<Root, Value> {
  let embed: (Value) -> Root
  let extract: (Root) -> Value?
}

也许Swift编译器可以自动为我们创建枚举键路径,每个枚举都有一个键路径,也许我们可以通过相同的语法访问它们:

// \AppAction.counter // EnumKeyPath<AppAction, CounterAction>

Enum properties

现在,即使Swift今天没有给我们这个功能,但事实证明,如果我们只做一点前期工作,我们就可以非常接近这个功能。事实上,关于这个话题,我们有一整系列的节目。

几个月前,我们花了几集的时间讨论结构体和枚举实际上只是一枚硬币的两面,也就是说,它们是基本相连的概念。这让我们看到,一个概念拥有的许多功能,另一个概念自然也会拥有。然而,有时Swift更喜欢结构体而不是枚举,因为它提供了强大的特性,在枚举世界中没有相应的版本。

特别是:属性和键路径。结构体通过点语法有非常简单的数据访问,这意味着你可以很容易地访问结构体中的字段,只需要做“.”然后是你的字段名称。枚举没有这样的功能。如果您想要获取枚举中的数据,您别无选择,只能switch枚举,在您关心的情况下进行模式匹配,然后在该情况下获取相关的数据。此外,结构上的每个属性都获得编译器生成的键路径,这就像一个小的getter/setter对,可以解锁所有类型的有趣的东西。对于枚举没有这样的东西。

这两个概念之间存在很大的不平衡。这使得Swift似乎更喜欢结构体而不是枚举,尽管其中一个并不比另一个更重要。因此,在本系列的下一集中,我们将通过引入“enum属性”概念来弥补这一缺陷。这些是在枚举上定义的计算属性,每个枚举对应一个。它基本上把我们在上面的特别方式中所做的工作打包成一个漂亮的、一致的包。

例如,对于AppAction枚举,它看起来像这样:

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

  var counter: CounterAction? {
    get {
      guard case let .counter(value) = self else { return nil }
      return value
    }
  }
  var primeModal: PrimeModalAction? {
    get { 
      guard case let .primeModal(value) = self else { return nil }
      return value
    }
  }
  var favoritePrimes: FavoritePrimesAction? {
    get {
      guard case let .favoritePrimes(value) = self else { return nil }
      return value
    }
  }
}

通过这些属性,我们可以获得AppAction枚举中任何情况下的关联数据的实例访问权,这给了应用程序actions很多与应用程序state结构体相同的人机工程学。

尽管这些枚举属性非常有用,但从头编写和维护它们是相当痛苦的。这就是为什么我们花了三集额外的时间来探索苹果的SwiftSyntax库,这样我们就可以构建一个命令行工具来为我们生成这些属性,并自动将代码插入到我们的源代码中。我们最终开放了这个工具,它很容易使用,我们可以直接将它添加到这个playground中,并自动生成所有这些enum属性。所以,让我们开始吧!

我们已经在playground当前所在的目录中添加了一个Package.swift文件,它的依赖项指向开源的swift-enum-properties repo:

// swift-tools-version:4.2
import PackageDescription

let package = Package(
  name: "StateManagement",
  dependencies: [
    .package(url: "https://github.com/pointfreeco/swift-enum-properties.git", from: "0.1.0")
  ]
)

这样我们就可以运行这个工具附带的generate-enum-properties可执行文件了:

$ swift run generate-enum-properties
Generate enum properties (version 0.1.0).

usage: generate-enum-properties [--help|-h] [--dry-run|-n] [<file>...]

    -h, --help
        Print this message.

    -n, --dry-run
        Don't update files in place. Print to stdout instead.

    --version
        Print the version.

这将打印出使用说明。

要真正调用这个工具,我们只需要将它指向这个目录和所有子目录中的所有.swift文件。让我们首先删除AppAction中的所有属性,这样我们就可以看到它的作用了。然后运行:

$ swift run generate-enum-properties ComposableArchitecture.playground/Contents.swift
Updating ComposableArchitecture.playground/Contents.swift

就像我们的playground代码被更新为新代码,它为我们定义的每一个枚举以及该枚举中的每一个情况提供枚举属性。例如,这个属性被添加到AppAction中:

var counter: CounterAction? {
  get {
    guard case let .counter(value) = self else { return nil }
    return value
  }
  set {
    guard case .counter = self, let newValue = newValue else { return }
    self = .counter(newValue)
  }
}

这里我们有之前定义的getter,甚至还有setter,我们现在不会用到,但在后面的章节中会很方便。

工具现在为我们生成的这些属性使枚举的行为非常类似于结构体。例如,我们可以很容易地从枚举实例中提取一个值:

let someAction = AppAction.counter(.incrTapped)
someAction.counter
// Optional(incrTapped)

someAction.favoritePrimes
// nil

现在,我们还可以为枚举的每个例子获取关键路径:

\AppAction.counter
// WritableKeyPath<AppAction, CounterAction?>

Pulling back reducers along actions

现在,我们有了一种方法来为每个枚举生成属性和键路径,这给了我们什么? 结构体上的关键路径正是让我们能够沿着state拉回reducers的原因。希望enum上的关键路径给我们同样的能力,除了我们想沿着action拉回reducer。

为了理解我们试图完成的任务,让我们编造一个函数签名。我们希望能够将知道如何与本地actions合作的reducers撤回到知道如何与全球actions合作的reducers:

func pullback<Value, GlobalAction, LocalAction>(
  _ reducer: @escaping (inout Value, LocalAction) -> Void,
  ???
) -> (inout Value, GlobalAction) -> Void {

  ???
}

问题是:我们要把什么拉回来? 对于结构体,它是从全局状态到局部状态的简单关键路径。但这在这里并不完全正确,因为从全局action中提取特定的局部action并不总是可能的。这就是enum属性返回可选值的原因。所以我们需要的是一个可选的本地action的关键路径:

func pullback<Value, GlobalAction, LocalAction>(
  _ reducer: @escaping (inout Value, LocalAction) -> Void,
  action: WritableKeyPath<GlobalAction, LocalAction?>
) -> (inout Value, GlobalAction) -> Void {

  ???
}

我们应该能够实现这个函数。在这之前,让我们先想想它应该代表什么。我们从一个操作局部actionsreducers开始,也就是特定于大应用程序中的一个小屏幕的动作。我们还从一个关键路径开始,该路径可以从全局actions中提取局部actions,但有时会失败。我们想把这个起始信息变成一个reducers,在整个应用程序的全局动作上工作。它的做法是,当一个全局actions进来时,我们会尝试使用关键路径从它提取一个局部actions。如果成功了,我们就可以把它传递给reducers,如果失败了,我们就默默地让它过去,什么也不做。

这真的很简单,实现也很简单:

func pullback<Value, GlobalAction, LocalAction>(
  _ reducer: @escaping (inout Value, LocalAction) -> Void,
  action: WritableKeyPath<GlobalAction, LocalAction?>
) -> (inout Value, GlobalAction) -> Void {

  return { value, globalAction in
    guard let localAction = globalAction[keyPath: action] else { return }
    reducer(&value, localAction)
  }
}

就这些。

在我们继续之前,让我们快速地清理一下。现在我们有两个版本的拉回:一个用于state,一个用于action。让我们将它们合并成一个单独的拉回:

func pullback<GlobalValue, LocalValue, GlobalAction, LocalAction>(
  _ 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)
  }
}

我们怎么用这个呢? 现在我们的appReducer无法编译。 我们需要更新这些回调,以便它们能够与新签名一起工作,这需要指定actioncounter reducer现在可以通过将应用action投射到counteraction中的关键路径被拉回:

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

我们还需要更新favoritePrimesReducerappReducer的回调。我们可以做的一件简单的事情是沿着identity key path将它拉回来:

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

这让代码再次编译,我们可以看到计数器工作就像以前一样,尽管它的reducers完全与应用程序state和应用程序action隔离。

Pulling back more reducers

我们现在有了第三种形式的reducers组合:我们能够沿着action的关键路径拉回,以便让我们的reducers只关注局部action,我们将其拉回全球action的世界。

在这个过程中,我们大大简化了计数器reducer

我们的其他reducer仍然在全球action中运行,所以让我们修复它!

favoritePrimesReducer正在沿着标识键路径拉回它的action,这意味着它仍然在操作完整的应用程序action enum

pullback(favoritePrimesReducer, value: \.favoritePrimesState, action: \.self)

我们可以让reducer更简单、更具体,只使用最喜欢的primes动作:

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

然后我们可以更新回拉,沿着关键路径将app action投射到favorite prime action中:

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

一切仍然像以前一样构建和运行。

最后我们有primeModalReducer,它的操作也非常一般化。

我们可以让它更具体,只工作于模态actions:

func primeModalReducer(state: inout AppState, action: PrimeModalAction) -> Void {
  switch action {
  case .removeFavoritePrimeTapped:
    state.favoritePrimes.removeAll(where: { $0 == state.count })
    state.activityFeed.append(.init(timestamp: Date(), type: .removedFavoritePrime(state.count)))

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

然后我们可以像这样更新app reducer:

let _appReducer = combine(
  pullback(counterReducer, value: \.count, action: \.counter),
  pullback(primeModalReducer, value: \.self, action: \.primeModal),
  pullback(favoritePrimesReducer, value: \.favoritePrimesState, action: \.favoritePrimes)
)

🛑 Generic parameter ‘Action’ could not be inferred

我们有一个编译器错误,因为我们已经完全从AppAction隔离了我们的reducer,所以它不能再推断。编译器不知道它回调到哪种全局操作。

我们可以通过标注reducer函数来给它一个提示。

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

编译器很高兴,一切正常!

我们已经将处理全球state和全球action的巨型reducer重构为3个更小的reducer,它们只处理它们关心的state和action。然后我们便能够将这些小型reducer进行整合,从而形成我们的大型应用reducer

我们能够用很少的库代码完成大部分工作。我们的Store类大约有15行代码,而回调则需要另外4行左右。这是我们“architecture”的核心,它描述了在应用程序中应用状态变化的一致方式。

这种架构风格的唯一缺点和成本是,我们需要进行一些代码生成,以便为我们的操作枚举的每个案例提供关键路径。 我们同意这不是理想的,但实际上我们只是在做代码生成来弥补Swift中结构体和枚举之间的不平衡。我们认为这是代码生成的一种无害的用法,因为它解决了Swift数据类型的一个严重缺陷,希望有一天Swift能够解决struct和enum之间的这种不平衡。

Till next time

既然我们已经有了架构的基础,我们就可以开始探索与之相关的东西,以解锁在旧的应用程序制作方式中甚至不可能实现的功能。有一个概念,我们已经讨论过很多次关于称为“高阶结构”。在这里,你要做一些你已经学习过的构造,并将它提升到一个更高的层次,通过考虑函数,把那个对象作为输入,然后返回那个对象作为输出。 典型的例子是“高阶函数”,即以函数作为输入,以函数作为输出的函数。

但在Point-Free中,我们也考虑了“高阶随机数生成器”,即将Gen类型作为输入并返回Gen类型作为输出的函数。我们还考虑了“高阶解析器”,这是将解析器作为输入并将解析器作为输出的函数。每当你形成这些更高阶结构之一时,你就获得了解锁新东西的能力,这是普通结构单独做不到的。

那么“higher order reducer”到底是什么样的呢? 一个函数以reducer作为输入,以reducer作为输出,这意味着什么?

我们下次再探讨这个问题!