1. Binding transformations

那么,我们如何解决这个问题呢?好吧,我们需要从Swift和SwiftUI停止的地方开始,这意味着我们需要创建一些工具,允许我们在只设计用于结构体的地方使用枚举。为了理解这可能会看到什么,让我们更深入地了解一下SwiftUI提供给我们的工具到底是什么,我们声称它是专门为结构体量身定制的。

很难看出SwiftUI到底有多喜欢结构体,因为编译器附带了一些奇特的特性和隐藏了大量复杂性的语法糖。但当我们写下下面这行时:

self.$item.status.quantity

一些事情正在发生。首先,我们使用以下语法访问为视图项字段提供权限的绑定:

(self.$item as Binding<Item>)

这是因为我们使用了@Binding属性包装器:

@Binding var item: Item

因此,一旦我们有了一个Item的绑定,我们就可以使用该Item的属性进一步点链到该项上,以派生另一个绑定:

(self.$item.status as Binding<Item.Status>)

Binding类型当然没有一个.status属性。我们之所以能够做到这一点,是因为“动态成员查找(dynamic member lookup)”的魔力,它允许我们对字段实际上不存在的类型使用点链接,但在引擎盖下,我们可以通过逻辑将该属性访问重新路由到其他内容。

我们甚至可以继续将点链接到此上,以派生另一个绑定:

(self.$item.status.quantity as Binding<Int>)

现在我们知道,“动态成员查找”给了我们这种神奇的能力,可以通过简单的点链接从现有绑定中不断派生新绑定,但是这种转换的实际形状是什么?

让我们暂时忘记“动态成员查找”。即使苹果从来没有从高层传下过这种神奇的功能,我们最终也肯定会自己实现它。

让我们尝试从头开始实现它,看看实际涉及到什么。“动态成员查找”的关键在于,我们对某个值有一个绑定,我们希望从中派生一个绑定,该绑定集中在值的一小部分,我们通过一个键路径来实现这一点。我们可以将此解释编码为实际的函数签名:

extension Binding {
  func transform<LocalValue>(
    _ keyPath: WritableKeyPath<Value, LocalValue>
  ) -> Binding<LocalValue> {
    ???
  }
}

这个函数表示我们有一个从ValueLocalValue的关键路径,使用它我们希望将Value的绑定转换为LocalValue的绑定。

我们知道我们需要返回一个绑定,因此我们可以从那里开始:

extension Binding {
  func transform<LocalValue>(
    _ keyPath: WritableKeyPath<Value, LocalValue>
  ) -> Binding<LocalValue> {
    Binding<LocalValue>(
      get: { ??? },
      set: { ??? }
    )
  }
}

在这个get参数中,我们需要生成LocalValue类型的东西。我们唯一可以支配的就是当前对Value的约束以及关键路径。当前绑定提供了获取底层包装值的能力,然后我们可以进一步将键路径插入该值以提取LocalValue

extension Binding {
  func transform<LocalValue>(
    _ keyPath: WritableKeyPath<Value, LocalValue>
  ) -> Binding<LocalValue> {
    Binding<LocalValue>(
      get: { self.wrappedValue[keyPath: keyPath] },
      set: { ??? }
    )
  }
}

接下来,set参数是一个闭包,它为我们提供了一个新值,我们需要对它进行某种设置。要做到这一点,我们只需再次将路径键入wrappedValue并设置:

extension Binding {
  func transform<LocalValue>(
    _ keyPath: WritableKeyPath<Value, LocalValue>
  ) -> Binding<LocalValue> {
    Binding<LocalValue>(
      get: { self.wrappedValue[keyPath: keyPath] },
      set: { localValue in self.wrappedValue[keyPath: keyPath] = localValue }
    )
  }
}

通过这种转换,我们可以重写代码,使用转换来代替“动态成员查找”:

self.$item.transform(\.status).transform(\.isOnBackOrder)

事实上,这相当于先附加关键路径,然后转换:

self.$item.transform(\.status.isOnBackOrder)

此外,使用标识键路径执行转换根本不会更改绑定:

self.$item.transform(\.self)

更简洁地说,我们在这里看到的两个属性是:

  • 关键路径追加的变换就是变换的追加。
  • identity的转换就是identity。

我们已经说了很多关于这对短语的观点,并且我们已经证明了,无论何时,只要这样的一个短语可以表达出你自己的一种泛型类型,那么在你的视线中就会隐藏着一个美妙的小世界。

我们提到的概念是谦逊的map函数。两年前我们第一次讨论map函数,在那一集中我们展示了map是一个非常普遍的概念,适用于许多类型。首先,Swift标准库提供了4种maps,这些maps是根据集合、词典、可选项和results定义的,而扩展的苹果生态系统则提供了更多maps,比如Combine publishers的maps。但是还有更多的maps潜伏在那里。我们可以在一个看似不同的类型世界上定义map的概念,比如随机数生成器、解析器、异步值等等。

对于每一个发现,我们总是有相同的性质:the map of a composition is the composition of the maps
例如,如果我们用一个函数f映射一个解析器,然后用一个函数g映射生成的解析器,这与我们简单地用f和g的组合映射一次是一样的。数组、可选项、结果、随机性等等也可以说是完全相同的。

我们在这里看到的东西非常相似,但在某些方面也不同。如果展开变换函数的签名,我们会看到它具有以下形状:

// (WritableKeyPath<Value, NewValue>) -> (Binding<Value>) -> Binding<NewValue>

让我们将泛型的名称简化为更抽象的名称:

// (WritableKeyPath<A, B>) -> (Binding<A>) -> Binding<B>

也就是说,transform只不过是将从A到B的关键路径转换为从BindingBinding的函数的一种方式。这也是我们之前描述map函数的方式。这是一种将函数从A提升到B的方法,将函数从A的某个容器提升到B的容器,无论是数组、可选项、结果还是其他许多东西:

// ((A) -> B) -> ([A]) -> [B]
// ((A) -> B) -> (A?) -> B?
// ((A) -> B) -> (Result<A, E>) -> Result<B, E>

因此,我们的transform在其形状和它所满足的属性上与map非常相似,但它只是略有不同,因为它在签名的左侧使用了一个键路径,而不是一个函数。

但这并不是什么大事,事实上我们以前也做过类似的事情。我们之前已经看到,有一种称为“pullback”的转换,它表达了与map非常相似的东西,只是方向相反。我们看到谓词甚至快照测试都支持此操作:

// pullback: ((A) -> B) -> (Predicate<B>) -> Predicate<A>
// pullback: ((A) -> B) -> (Snapshotting<B>) -> Snapshotting<A>

它们还满足与map类似的属性,包括标识的回调就是标识,组合的回调就是回调的组合。

但在那次探索之后的几个月里,我们面对面地看到了另一次转型,这一转型看起来与回调非常相似,但又有点不同。我们发现,reducers(为可组合体系结构提供动力的逻辑单元)支持类似于回调的东西,但它使用关键路径而不是函数:

// pullback: (WritableKeyPath<A, B>) -> (Reducer<B>) -> Reducer<A>

这意味着,如果我们有一条从A到B的关键路径,那么我们可以将B上的reducers转换为A上的reducers,这是我们将局部reducers转换为全局reducers所需的关键转换,它允许我们将大问题分解为小问题,并有助于应用程序的模块化。它还碰巧满足与其他回调相同的所有属性,包括标识键路径的回调是标识函数,而键路径组合的回调是回调的组合。

我们被授权称之为pullback,因为无论出于何种目的,它都扮演着与谓词和快照测试相同的角色。它将一个从A到B的过程转化为一个从B到A的过程,并且它满足一些属性。流程是否完全是一个函数并不重要,它可以是一个关键路径,或者其他很多东西。

这个故事现在又上演了,除了这次的map。我们现在应该有权调用转换方法映射,因为它体现了与我们从许多其他类型中了解和喜爱的映射相同的所有原则:

extension Binding {
  func map<NewValue>(
    _ keyPath: WritableKeyPath<Value, LocalValue>
  ) -> Binding<LocalValue> {
    ...
  }
}

然后我们的用法看起来像:

self.$item.map(\.status).map(\.isOnBackOrder)
self.$item.map(\.status.isOnBackOrder)
self.$item.map(\.self)

这种转换不仅很容易使用,因为它只是map,而且它也正是“动态成员查找”的含义。在这个绑定转换中发生的工作正是“动态成员查找”在幕后必须做的,事实上,我们可以在实现中调用它:

extension Binding {
  func map<LocalValue>(
    _ keyPath: WritableKeyPath<Value, LocalValue>
  ) -> Binding<LocalValue> {

    self[dynamicMember: keyPath]

    // .init(
    //   get: { self.wrappedValue[keyPath: keyPath] },
    //   set: { localValue in self.wrappedValue[keyPath: keyPath] = localValue }
    // )
  }
}

所以“动态成员查找”只不过是在绑定上定义的简单映射函数。事实上,我们甚至可以访问“动态成员查找”的签名进行绑定,以查看其形状是否完全相同:

@frozen @propertyWrapper @dynamicMemberLookup public struct Binding<Value> {
  ...
  public subscript<Subject>(dynamicMember keyPath: WritableKeyPath<Value, Subject>) -> Binding<Subject> { get }
}

这就是为什么我们觉得,不管苹果是从顶层把它交给我们的,这种有约束力的转变都会被我们发现。我们已经多次看到,在处理泛型类型时,我们应该始终注意定义像map这样的操作,因为它们解锁了将类型转换为所有新类型的轻量级方法。这正是我们想要对绑定执行的操作:我们希望通过深入绑定包装的结构,从现有绑定派生所有新绑定。


2. Bindings of optionals

因此,我们现在开始得到第一个真正的提示,为什么我们说SwiftUI为我们提供了许多专门针对结构体的工具。它最方便的转换之一,即从现有绑定派生新绑定的能力,是建立在“动态成员查找”概念的基础上的,该概念与关键路径密切相关,而关键路径在处理结构体及其属性时最常用。这就是为什么我们在尝试将此工具应用于枚举时可能会遇到一些阻力。

我们想解决这个问题,但让我们朝着更好的API迈出一小步。让我们再次看看我们添加到状态枚举中的一个有问题的属性,比如数量字段:

var quantity: Int {
  get {
    switch self {
    case let .inStock(quantity: quantity):
      return quantity
    case .outOfStock:
      return 0
    }
  }
  set {
//    switch self {
//    case .inStock:
      self = .inStock(quantity: newValue)
//    case .outOfStock:
//      break
//    }
  }
}

这是有问题的,原因有二:

  • 首先,我们必须选择从outOfStock案例中返回数量值。当然,返回0是完全合理的,但最好是不必考虑这个case,毕竟这就是为什么我们首先要使用枚举。

  • 其次,在设定数量的时候,我们也必须考虑如何做outOfStock的情况。现在我们已经决定将状态翻转为指定数量的库存,但我们也可以什么都不做。我们不确定哪一个是正确的,我们也不必回答这样的问题,因为这是我们转向enum来解决领域建模难题的全部原因。

所以,让我们来做,这样我们就不必回答这些问题了。让我们从这个computed属性返回一个可选值,这样我们就可以正确地表示有时我们不知道该做什么:

var quantity: Int? {
  get {
    switch self {
    case let .inStock(quantity: quantity):
      return quantity
    case .outOfStock:
      return nil
    }
  }
  set {
    guard let quantity = newValue else { return }
    self = .inStock(quantity: quantity)
  }
}

这甚至是我们之前考虑过的,但立即排除了它,因为这意味着以下绑定是可选的:

self.$item.status.quantity // Binding<Int?>

这种绑定不能与Stepper一起使用,因此无法编译。

然而,现在我们已经看到转换绑定是一件完全合法的事情,而且事实上SwiftUI附带了开箱即用的转换,也许我们现在可以大胆地定义所有新的转换。

现在的问题是我们有一个可选Int的绑定,而我们真正想要的是一个诚实Int的绑定。然而,我们并不总是想要一个诚实Int的绑定,因为这是不可能的。如果项目的状态为outOfStock,那么我们就无法表示此绑定,事实上我们不希望这样。因此,也许我们想要的是更多形式的转换:

// (Binding<Int?>) -> Binding<Int>?

或者,更一般地说:

// (Binding<A?>) -> Binding<A>?

也就是说,它将可选的绑定转换为绑定的可选绑定。也许这种操作的一个好名字是unwrap,因为它有点像是在绑定内部展开可选的:

// unwrap: (Binding<A?>) -> Binding<A>?

在尝试实现这样一个函数之前,让我们先了解它是如何有用的。

如果我们可以“展开”可选整数的数量绑定,那么我们将得到一个诚实整数的可选绑定。因此,如果我们map到该可选项,我们将获得一个诚实Int的诚实绑定:

self.$item.status.quantity.unwrap().map { (quantity: Binding<Int>) in
  ...
}

因为我们有一个诚实Int的诚实绑定,所以我们可以把它传递给Stepper,没问题:

self.$item.status.quantity.unwrap().map { quantity in
  Section(header: Text("In stock")) {
    Stepper("Quantity: \(quantity.wrappedValue)", value: quantity, in: 1...Int.max)
    Button("Sold out") { self.item.status = .outOfStock(isOnBackOrder: false) }
  }
}

您可能不知道这一点,但是通过在这里使用optional的map,我们在技术上返回了一个optional视图,函数构建器通过在视图为nil时忽略该视图来处理这种情况。因此,它的行为与if语句相同,在if语句中,仅当quantity为非nil时才会呈现视图。

事实上,在支持if-let-in函数构建器的Xcode 12中,我们将能够做到:

if let quantity = self.$item.status.quantity.unwrap() {
  ...
}

但与此同时,map是一种很好的处理方法。

因此,在我们实现这个绑定操作符之前,它不会编译。让我们试一试。我们先签个名。由于我们希望该运算符只处理optional的绑定,因此我们希望在其值generic为optional的情况下扩展绑定。这并不是非常简单,因为首先我们不能简单地做到这一点:

extension Binding where Value == Optional {
}

因为我们需要为optional指定泛型:optional<???>。我们也不能为此扩展引入新的泛型,以便我们可以通过这种方式固定泛型:

extension <Wrapped> Binding where Value == Optional<Wrapped> {
}

希望有一天Swift会支持这种参数化扩展,但不幸的是这还不可能。

在此之前,我们可以通过在运算符函数中引入泛型来解决此缺点,然后在该函数中约束泛型:

//extension <Wrapped> Binding where Value == Optional<Wrapped> {
extension Binding {
  func unwrap<Wrapped>() -> Binding<Wrapped>? where Value == Wrapped? {
    ???
  }
}

这是一个非常机械的解决方法,您只需将泛型从扩展移到函数,并将where约束移到函数,但是,如果我们可以用Swift编写一个参数化扩展,那将非常好。

为了实现这个方法,我们需要返回一个可选的绑定。所以有时候我们会返回一个诚实值的诚实绑定,有时候我们会返回nil。我们唯一可以访问的是self,它是一个可选的绑定,因此我们可以从尝试打开该值开始:

extension Binding {
  func unwrap<Wrapped>() -> Binding<Wrapped>? where Value == Wrapped? {
    guard if let value = self.wrappedValue else { ??? }
    ???
  }
}

如果我们无法打开该值,我们可以返回一个nil绑定:

extension Binding {
  func unwrap<Wrapped>() -> Binding<Wrapped>? where Value == Wrapped? {
    guard let value = self.wrappedValue else { return nil }
    ???
  }
}

如果我们成功了,那么我们就有了一个诚实的值,这意味着我们可以返回一个包装这个诚实值的绑定:

extension Binding {
  func unwrap<Wrapped>() -> Binding<Wrapped>? where Value == Wrapped? {
    guard let value = self.wrappedValue else { return nil }
    return Binding<Wrapped>(
      get: { value },
      set: { self.wrappedValue = $0 }
    )
  }
}

与此类似,我们的视图正在编译,因为“展开”(unwrap)操作符执行的正是我们需要它执行的操作:

self.$item.status.quantity.unwrap().map { quantity in
  Section(header: Text("In stock")) {
    Stepper("Quantity: \(quantity.wrappedValue)", value: quantity, in: 1...Int.max)
    Button("Sold out") { self.item.status = .outOfStock(isOnBackOrder: false) }
  }
}

这很酷。这个非常简单的绑定转换允许我们将可选绑定转换为诚实值的可选绑定,这是显示或隐藏需要访问诚实值绑定的特定UI组件的完美工具。

我们可以通过对isBackOrdered布尔进行相同的处理来完成对视图的修复。正如我们之前所说,该属性存在问题:

var isBackOrdered: Bool {
  get {
    guard case let .outOfStock(isOnBackOrder) = self else {
      return false
//      return true
    }
    return isOnBackOrder
  }
  set {
//    switch self {
//    case .inStock:
//      break
//    case .outOfStock:
      self = .outOfStock(isOnBackOrder: newValue)
//    }
  }
}

目前还不清楚在商品有库存的情况下我们应该返回哪种布尔值,当在商品有库存的情况下设置此布尔值时,我们也不清楚应该做什么。因此,与其试图回答这些问题,并将一些笨拙的逻辑塞进这个属性中,不如使用一个可选选项来拒绝回答这个问题:

var isBackOrdered: Bool? {
  get {
    guard case let .outOfStock(isOnBackOrder) = self else {
      return nil
    }
    return isOnBackOrder
  }
  set {
    guard let newValue = newValue else { return }
    self = .outOfStock(isOnBackOrder: newValue)
  }
}

现在我们的视图没有编译,因为我们的绑定现在是可选的,但是我们可以简单地应用unwrap()操作符将其转换为诚实值的绑定,并使用map转换为视图:

self.$item.status.isBackOrdered.unwrap().map { isBackOrdered in
  Section(header: Text("Out of stock")) {
    Toggle(isOn: isBackOrdered) { Text("Is on back order?") }
    Button("Back in stock") { self.item.status = .inStock(quantity: 1) }
  }
}

现在视图已经编译,如果我们在SwiftUI预览中运行它,我们将看到它的行为与以前完全相同。当我们四处点击时,UI中的数据正在发生变化,这证明了所有绑定都正常工作,并且用户操作被正确地导入到我们正在查看的项目的变体中。

但是,我们的视图不仅与以前的视图工作相同,甚至代码也很好且简洁,而且我们的域现在已正确建模,没有用于以无效或不安全的方式访问其数据的转义窗口。我们现在拥有的计算属性适用于域。如果你问一个库存商品,如果它是延期订单,它将返回零,因为问这个问题是不理智的。我们不再为了回答没有一个正确答案的问题而弄脏我们的领域,通过正确处理这些问题,我们可以确保使用我们项目的任何人都在处理非常精确的指定数据类型。

我们以精确的方式显示枚举中每种情况的UI控件,当状态更改为特定情况时,相应的UI控件立即显示。

这看起来真的很强大。


3. Bindings of enums

但这只是开始。

到目前为止,我们所采取的方法有一些地方可以大大改进。首先,我们需要在枚举上定义可选属性以提取它们的关联数据,这是一个样板文件,如果我们必须对每个枚举的每一个实例都这样做,那么这将是一件很烦人的事情。

其次,我们积极探索绑定转换,因为我们声称SwiftUI偏向于结构体,因为它最强大的工具直接处理关键路径,这使得我们的应用程序处于劣势,因为我们决定将状态建模为枚举。然而,我们提出的解决方案只适用于optionals,这是一个枚举,但我们希望它适用于所有枚举,而不仅仅是一个枚举。

因此,我们想概括一下我们迄今为止所做的工作。为了理解这意味着什么,让我们回到我们的第一个绑定转换助手:

func unwrap<Wrapped>() -> Binding<Wrapped>? where Value == Wrapped? {
  if let value = self.wrappedValue {
    return .init(get: { value }, set: { self.wrappedValue = $0 })
  } else {
    return nil
  }
}

这表达了我们希望将可选值的绑定转换为诚实值的可选绑定的想法。它允许我们安全地打开绑定中的值,这样我们就可以将绑定传递给只处理诚实值绑定而不是可选值绑定的UI组件。

我们需要这样一个助手,除了它应该与任何枚举一起工作,而不仅仅是可选项,并且它应该能够隔离枚举的任何情况。让我们尝试实现这样一个泛化,看看需要什么来实现这一点。

我们将调用函数matching,因为它尝试模式匹配绑定枚举中的一个案例。它将对我们要从绑定的值中提取的案例类型是通用的,并且最终将返回一个诚实案例值的可选绑定:

func matching<Case>() -> Binding<Case>? {
}

与我们对“unwrap”所做的类似,我们首先需要尝试从绑定的值中提取Case值,但我们如何才能做到这一点?不管谁调用这个方法,都需要告诉我们如何提取,这是一个可以将Value转换为Case的可失败函数:

func matching<Case>(
  extract: @escaping (Value) -> Case?
) -> Binding<Case>? {

}

然后我们可以尝试从绑定中提取case:

func matching<Case>(extract: (Value) -> Case?) -> Binding<Case>? {
  guard let `case` = extract(self.wrappedValue) else { return nil }

}

如果我们成功了,那么我们可以构造一个Binding,该绑定使用我们诚实的、非可选的case值作为getter:

func matching<Case>(extract: @escaping (Value) -> Case?) -> Binding<Case>? {
  guard let `case` = extract(self.wrappedValue) else { return nil }
  return Binding<Case>(
    get: { `case` },
    set: { `case` in }
  )
}

从技术上讲,这是可以编译的,但这显然是不对的,因为我们在set参数中没有做任何事情。这意味着对绑定所做的任何更改都不会出现在我们的模型中。这个闭包有一个诚实的case值,我们需要以某种方式使用它来覆盖绑定的值。听起来此函数的调用方需要向我们提供另一条信息,特别是一种将Case值嵌入此绑定包装的Value的方法:

func matching<Case>(
  extract: @escaping (Value) -> Case?,
  embed: @escaping (Case) -> Value,
) -> Binding<Case>? {
  guard let `case` = extract(self.wrappedValue) else { return nil }
  return Binding<Case>(
    get: { `case` },
    set: { `case` in self.wrappedValue = embed(`case`) }
  )
}

现在我们可以编译了,我们完全可以利用它,但这里有一些有趣的东西。

我们自然而然地被引导向这个助手引入这些提取和嵌入参数,因为这正是我们在函数中完成工作所需要的。我们需要一种从绑定的值中提取案例的方法,这样我们就可以得到一个诚实的、非可选的值来处理。然后,当绑定需要进行一些设置时,我们别无选择,只能要求提供一种将case值嵌入绑定值的方法。

所以,这里没有太多的选择。我们只是偶然发现了这些要求。有一个很好的理由可以解释为什么这些需求看起来如此自然和普遍,而不仅仅是我们强迫的选择。这是因为这对提取和嵌入函数构成了一种精确的方法,可以抽象地分离枚举以隔离特定情况。

事实上,这是我们之前在引入“案例路径”概念时深入探讨的问题,案例路径是关键路径的天然伴侣,只是它们经过了微调,可以使用枚举而不是结构体。简而言之,关键路径是类型的getter和setter概念的抽象:

struct User {
  var id: Int
  var name: String
}

\User.id // WriteableKeyPath<User, Int>

键路径可用于任何类型的每个属性。如果该属性只是一个getter,那么您将获得一个KeyPath,如果该属性还具有一个setter,那么您将获得一个writeablekeypath。您可以使用键路径访问任意值的属性:

var user = User(id: 42, name: "Blob")

user[keyPath: \User.id] = 100
let id = user[keyPath: \User.id]

现在,您永远不会以这种方式使用键路径,因为直接使用点语法要容易得多:

user.id
user.id = 100

但是,除了简单的数据访问之外,关键路径还可用于其他用途。它们允许您通过将特定属性与其他属性隔离来抽象数据类型的形状,这样您就可以在不考虑其嵌入的整个结构的情况下获取和转换它。

即使在这一系列事件中,我们已经看到了关键路径如何允许我们抽象地转换绑定,这本质上就是“动态成员查找”所做的。如果没有关键路径,我们必须将单独的getter和setter函数交给transform函数来完成工作。

许多Apple API和许多开源项目中也使用了关键路径。例如,关键路径允许我们将处理少量状态的reducers转换为处理所有应用程序状态的reducers,从而允许我们对应用程序进行模块化。


4. Transforming with case paths

然而,我们在几个月前发现,关键路径只占一半。它们非常适合通过隔离属性来抽象我们的类型(通常是结构体)的形状,但是还有另外一个枚举和案例的世界。难道不应该有一个相应的故事,让我们抽象地从枚举中分离出一个案例,这样我们就可以像编写键路径绑定转换那样编写通用算法吗?

嗯,幸运的是,Enum有一个故事,但对我们来说不太幸运的是,Swift没有提供给我们开箱即用的故事。这取决于我们自己开发这个工具,而这正是我们所做的。我们引入了一种称为CasePath的类型,它与键路径类似,只是经过了微调,可以使用枚举而不是结构。

事实上,甚至开放了一个库,让我们能够访问案例路径的功能。让我们快速将该库添加到我们的项目中:

CasePath

现在我们可以导入库:

import CasePaths

案例路径与关键路径非常相似。枚举的每一个案例都自然地指向一个案例路径。例如,我们可以创建一个案例路径,重点关注状态枚举的inStock案例:

let inStockCase: CasePath<Item.Status, Int>

为了构建其中一个,我们需要提供extractembed函数,这些函数描述如何从枚举中提取案例(可能会失败),以及如何将一些数据嵌入到枚举的案例中。我们可以手动这样做:

let inStockCase = CasePath<Item.Status, Int>(
  embed: Item.Status.inStock(quantity:),
  extract: {
    guard case let .inStock(quantity) = status else { return nil }
    return quantity
})

请注意,extract正是我们用于获取Status上的quantity属性的内容。

如果我们每次都必须从头开始手动构造这些case路径,那将是一个麻烦,就像在状态枚举上构造getter和setter属性有点麻烦一样。幸运的是,CasePaths库提供了一种为任何枚举的任何案例自动生成案例路径的方法,您只需执行以下操作:

let inStockCase: CasePath<Item.Status, Int> = .case(Item.Status.inStock)

这使用Swift的反射功能自动派生给定嵌入函数的提取函数,而嵌入函数是免费的,因为枚举的每个实例都是从其关联数据到枚举的函数。

此外,如果您对运营商有兴趣,我们甚至可以通过以下方式为您提供速记:

let inStockCase: CasePath<Item.State, Int> = /Item.Status.inStock

我们这样做是因为它模拟了如何从结构体属性生成关键路径:

\User.id

您可以像使用键路径一样使用案例路径,只是允许您从枚举中提取数据并将数据嵌入枚举中:

let status = Item.Status.inStock(quantity: 100)
let quantity = inStockCase.extract(status) // Optional(100)
let newStatus = inStockCase.embed(100) // Item.State.inStock(quantity: 100)

但是,正如关键路径一样,当您编写允许使用案例路径对枚举形状进行抽象的通用算法时,真正的威力就来了。事实上,我们这里有一个函数:

func matching<Case>(
  extract: @escaping (Value) -> Case?,
  embed: @escaping (Case) -> Value
) -> Binding<Case>? {
  if let `case` = extract(value) {
    return .init(get: { `case` }, set: { self.wrappedValue = embed($0) })
  } else {
    return nil
  }
}

我们可以只传入一个case路径,而不是分别传入extract和embed函数:

func matching<Case>(
  _ casePath: CasePath<Value, Case>
) -> Binding<Case>? {
  guard let `case` = casePath.extract(from: self.wrappedValue) else { return nil }
  return Binding<Case>(
    get: { `case` },
    set: { `case` in self.wrappedValue = casePath.embed(`case`) }
  )
}

这个小助手已经强大到足以支持我们的UI控件:

self.$item.status.matching(/Item.Status.inStock).map { quantity in
  Section(header: Text("In stock")) {
    Stepper("Quantity: \(quantity.wrappedValue)", value: quantity)
    Button("Sold out") { self.item.status = .outOfStock(isOnBackOrder: false) }
  }
}

self.$item.status.matching(/Item.Status.outOfStock).map { isOnBackOrder in
  Section(header: Text("Out of stock")) {
    Toggle(isOn: isOnBackOrder) { Text("Is on back order?") }
    Button("Back in stock") { self.item.status = .inStock(quantity: 1) }
  }
}

记住,一旦我们能够使用Xcode 12和Swift 5.3,我们将能够进一步简化这一点,只使用普通if-let语句:

if let quantity = self.$item.status.matching(/Item.Status.inStock) {
  Section(header: Text("In stock")) {
    Stepper("Quantity: \(quantity.wrappedValue)", value: quantity)
    Button("Sold out") { self.item.status = .outOfStock(isOnBackOrder: false) }
  }
}

if let isOnBackOrder = self.$item.status.matching(/Item.Status.outOfStock) {
  Section(header: Text("Out of stock")) {
    Toggle(isOn: isOnBackOrder) { Text("Is on back order?") }
    Button("Back in stock") { self.item.status = .inStock(quantity: 1) }
  }
}

但最重要的是,因为我们正在使用案例路径,而且案例路径是自动为我们生成的,所以我们可以删除所有样本,因为不再需要它:

//var isInStock: Bool {
//  guard case .inStock = self else { return false }
//  return true
//}
//
//var quantity: Int? {
//  get {
//    switch self {
//    case let .inStock(quantity: quantity):
//      return quantity
//    case .outOfStock:
//      return nil
//    }
//  }
//  set {
//    guard let quantity = newValue else { return }
//    self = .inStock(quantity: quantity)
//  }
//}
//
//var isBackOrdered: Bool? {
//  get {
//    guard case let .outOfStock(isOnBackOrder) = self
//      else { return nil /* or true?*/ }
//    return isOnBackOrder
//  }
//  set {
//    guard let newValue = newValue else { return }
//    self = .outOfStock(isOnBackOrder: newValue)
//  }
//}

对于枚举,我们永远不必编写另一个属性来简单地模拟getter和setter,因为case路径为我们提供了这一点,并且在一个更符合人体工程学的包中。

但是,我们可以使新的matching助手更加简洁,并更好地适应其他SwiftUI工具的工作方式。请记住,Swift附带的绑定组合类型由键路径和动态成员查找提供支持。如果我们的matching转换也可以这样做呢?

特别是,让我们在绑定上定义一个自定义下标,它允许我们使用case路径有选择地深入研究绑定的值:

extension Binding {
  subscript<Case>(
    _ casePath: CasePath<Value, Case>
  ) -> Binding<Case>? {
    self.matching(casePath)
  }
}

在内部调用matching,但也许它应该完全取代matching

现在,我们可以用以下简洁的语法来表达深入到项目状态的inStock案例的想法:

self.$item.status[/Item.Status.inStock]

这将Item的绑定转换为整数的可选绑定,表示Item的库存量,全部在一行中。我们可以进一步映射此绑定,将其转换为视图:

self.$item.status[/Item.Status.inStock].map { quantity in
  Section(header: Text("In stock")) {
    Stepper("Quantity: \(quantity.wrappedValue)", value: quantity)
    Button("Sold out") { self.item.status = .outOfStock(isOnBackOrder: false) }
  }
}
self.$item.status[/Item.Status.outOfStock].map { isOnBackOrder in
  Section(header: Text("Out of stock")) {
    Toggle(isOn: isOnBackOrder) { Text("Is on back order?") }
    Button("Back in stock") { self.item.status = .inStock(quantity: 1) }
  }
}

一旦我们有了Swift 5.3,我们就可以使用它,如果:

if let quantity = self.$item.status[/Item.Status.inStock] {
  Section(header: Text("In stock")) {
    Stepper("Quantity: \(quantity.wrappedValue)", value: quantity)
    Button("Sold out") { self.item.status = .outOfStock(isOnBackOrder: false) }
  }
}
if let isOnBackOrder = self.$item.status[/Item.Status.outOfStock] {
  Section(header: Text("Out of stock")) {
    Toggle(isOn: isOnBackOrder) { Text("Is on back order?") }
    Button("Back in stock") { self.item.status = .inStock(quantity: 1) }
  }
}

因此,我们现在正在以一种更通用的方式完成使用“unwrap”辅助对象所做的一切。我们可以立即对任何枚举的任何案例进行抽象,以显示特定于案例的UI控件。一旦状态从一个枚举情况切换到另一个枚举,我们的UI将立即更新,我们将获得该情况下数据的绑定,以便子视图可以对数据进行更改,并使其立即反映在我们的模型中。

他的力量令人难以置信。我们已经在SwiftUI中真正解锁了一些新功能,这是以前苹果提供给我们的工具无法看到的。苹果并没有让我们在SwiftUI中使用状态枚举变得容易,相反,所有的工具都是面向结构体的。

但是,在这里,我们知道将结构体和枚举放在同等地位的重要性。一旦我们有了一个为结构体设计的工具,我们应该立即开始寻找等效工具对于枚举的外观,一旦我们有了一个为枚举设计的工具,我们应该立即开始寻找等效工具对于结构体的外观。
这就是为什么我们发现了一种转换绑定的新方法,这也让我们找到了构建视图层次结构的更好方法。我们现在可以完全按照我们的需要对域进行建模,并且我们可以让视图层次结构自然地脱离该域表达式,而不是创建一堆转义图案填充,以不精确的方式将信息投影到域之外。