SwiftUI 和 Swift 5.1 新特性(3) Key Path Member Lookup

5,992 阅读6分钟

SwiftUI 应用了许多 Swift 5.1 的新特性。在上一篇中,我们聊了Swift UI 中修饰 View 状态的属性的 @Binding@State 的本质是属性代理。在本文中,我们将了解Swift @Binding@State 类型背后包含的另一个特性:Key Path Member Lookup。我们首先要复习一下 Swift 中两个日常开发中不太常用的特性:KeyPath 和 Dynamic Member Lookup。

1.KeyPath 而不是 #keyPath

Swift 中两个叫做 "Key Path" 的特性,一个是 #keyPath(Person.name),它返回的是String类型,通常用在传统 KVO 的调用中,addObserver:forKeyPath: 中,如果该类型中不存在这个属性,则会在编译的时候提示你。

我们今天着重聊的是另一个 Swift Smart Key Path KeyPath<Root,Value>,这是个泛型类型,用来表示从 Root 类型到 某个 Value 属性的访问路径,我们来看一个例子

struct Person {
  let name: String
  let age: Int
}

let keyPath = \Person.name
let p = Person(name: "Leon", age: 32)
print(p[keyPath: keyPath]) // 打印出 Leon

我们看到获取 KeyPath 的实例需要用到一个特殊的语法 \Person.name,它的类型是 KeyPath<Person, String>,在使用 KeyPath 的地方,可以使用下标操作符来使用这个 keyPath。上面是个简单的例子,而它的实际用途需要跟泛型结合起来:

// Before
print(people.map{ $0.name })

// 给 Sequence 添加 KeyPath 版本的方法
extension Sequence {
  func map<Value>(keyPath: KeyPath<Element,Value>) -> [Value] {
    return self.map{ $0[keyPath: keyPath]}
  }
}

// After
print(people.map(keyPath:\.name))

在没有添加新的map方法的时候,获取[Person]中所有 name 的方式是提供一个闭包,在这个闭包中我们需要详细写出如何获取的访问方式。而在提供了 KeyPath 版本的实现后,只需要提供个强类型的 KeyPath 实例即可,如何获取这件事情已经包含在了这个 KeyPath 实例中了。

同一类型的 KeyPath<Root,Value> 可以代表多种获取路径。例如:KeyPath<Person, Int> 既可以表示 \Person.age 也可以表示 \Person.name.count

两个 KeyPath 可以拼接成一个新的 KeyPath:

let keyPath = \Person.name
let keyPath2 = keyPath.appending(path: \String.count)

keyPath 的类型是 KeyPath<Person,String>\String.count 的类型是 KeyPath<String,Int>,调用 appending 函数,变成一个 KeyPath<Person, Int> 的类型。

我们再来看一下继承关系:KeyPathWriteableKeyPath 的父类,WriteableKeyPathReferenceWritableKeyPath 的父类。从继承关系我们可以推断出,要满足 is a 的原则,KeyPath 的能力是最弱的:只能以只读的方式访问属性; WriteableKeyPath 可以对可变的值类型的可变属性进行写入:将第一个代码示例中的 let 都改成 var,那么 Person.name 的类型就变成了 WriteableKeyPath,那么可以写 p[keyPath: keyPath] = "Bill" 了;ReferenceWritableKeyPath 可以对引用类型的可变属性进行写入:将第一个代码示例中的 struct 改成 classPerson.name 的类型变成了 ReferenceWritableKeyPath

此外,还有两种不常用的 KeyPath:PartialKeyPath<Root>KeyPath 的父类,它擦除了 Value 的类型,以及PartialKeyPath<Root>的父类 AnyKeyPath 则将所有的类型都擦除了。这五种 KeyPath 类型使用了 OOP 保持了继承的关系,因此可以使用 as? 进行父类到子类的动态转换。

KeyPath 机制常被用来对属性做类型安全访问的地方:在增删改查的 ORM 框架里很常见,另一个例子就是SwiftUI了。

2. 动态成员查找 Dynamic Member Lookup

Dynamic Member Lookup 是 Swift 4.2 中引入的特性,目的是使用静态的语法做动态的查找,示例如下:

@dynamicMemberLookup
struct Person {
  subscript(dynamicMember member: String) -> String {
    let properties = ["name": "Leon", "city": "Shanghai"]
    return properties[member, default: "null"]
  }

  subscript(dynamicMember member: String) -> Int {
    return 32
  }
}

let p = Person()
let age: Int = p.hello // 32
let name: String = p.name // Leon

支持 Dynamic Member Lookup 的类型首先需要用 @dynamicMemberLookup 来修饰,动态查找方法需要命名为 subscript(dynamicMember member: String),可以根据不同的返回类型重载。

使用方面,可以直接使用.propertyname的语法来貌似静态实则动态地访问属性,实际上调用的是上面的方法。如果如上例有重载,为了消除二义性,得通过返回值类型,明确调用方法。

3. Key Path Member Lookup 成员查找

复习完了 KeyPath 和 Dynamic Member Lookup,我们来到了 Swift 5.1 中引入的 Key Path Member Lookup。这里我们先引入一个类型 Lens,它封装了对值存取的功能:

struct Lens<T> {
  let getter: () -> T
  let setter: (T) -> Void
  
  var value: T {
    get {
      return getter()
    }
    nonmutating set {
      setter(newValue)
    }
  }
}

这时候,我们希望结合之前复习的 KeyPath 提供一个方法:将对这个值的存取,结合入参 KeyPath,转换成对于 KeyPath 指定的属性类型的存取。

extension Lens {
  func project<U>(_ keyPath: WritableKeyPath<T, U>) -> Lens<U> {
    return Lens<U>(
      getter: { self.value[keyPath: keyPath] },
      setter: { self.value[keyPath: keyPath] = $0 })
  }
}

// 使用 project 方法
func projections(lens: Lens<Person>) {
  let lens = lens.project(\.name)   // Lens<String>
}

为了让一个 Lens 更美观地转换成另一种 Lens,需要语法上的突破,这时候框架和语言作者又想到了 Dynamic Member Lookup 了,由于 4.2 中只支持 String 作为参数的调用,调用.property 的语法。如果把它扩展到 支持 KeyPath 为入参,调用的时候再施加编译器的魔法,变成lens.name岂不美哉?

@dynamicMemberLookup
struct Lens<T> {
  let getter: () -> T
  let setter: (T) -> Void

  var value: T {
    get {
      return getter()
    }
    nonmutating set {
      setter(newValue)
    }
  }

  subscript<U>(dynamicMember keyPath: WritableKeyPath<T, U>) -> Lens<U> {
    return Lens<U>(
        getter: { self.value[keyPath: keyPath] },
        setter: { self.value[keyPath: keyPath] = $0 })
  }
}

上面是应用 Swift 5.1 Key Path Member Lookup 后的最终版本。我们可以用 .property 的语法得到了一个新的可以对值存取的实例。lens.name 这时候等价于 lens[dynamicMember:\.name],我们可以用更精简的语法完成转换。

4. @Binding 如同 Lens

上一篇文章我们提到:StateBinding 类型定义处有 @propertyDelegate 的修饰。这篇我们看到的是Binding还有 @dynamicMemberLookup的修饰,证据是有这个方法:subscript<Subject>(dynamicMember: WritableKeyPath<Value, Subject>) -> Binding<Subject>,为的是对于值的存取可以优雅地结合 KeyPath 进行转换。 我们来看以下代码:

struct SlideViewer: View {
  @State private var isEditing = false
  @Binding var slide: Slide

  var body: some View {
    VStack {
      Text("Slide #\(slide.number)")
      if isEditing {
        TextFiled($slide.title)
      }
    }
  }
}

上面是一段 SwiftUI 的代码,在 SwiftUI 中 View 的具体类型是值类型,它代表了对 View 的描述。当此处的 isEditing 或者 Slide 发生修改的时候,SwiftUI 会重新生成这个 Viewbody

有关 slide 属性的绑定我们看到了两个用法:在读取的时候直接用 propertyWrapper 给到你的 slide.number 就可以了,而当这个绑定需要向下传给另一个子控件的时候,则使用上次和今天介绍的两个特性:先使用 $slide 获取到 slide 被代理到的 Binding 实例,然后使用 .title的语法获取到新的 Binding<String>TextFiled 的初始化函数的第一个参数,正是Binding<String>

Binding<T> 设计非常重要,它贯穿了 SwiftUI 的控件设计,我们可以想像如果设计一个 ToggleButton,则初始化函数一定有一个 Binding<Bool>。从抽象层面上来说,Binding 它抽象了对某个值的存取,但并不需要知道这个值究竟是如何存取的,十分巧妙。

StateBinding稍有不同,它首先代表了一个实实在在的 View 的内部状态(由SwiftUI 管理)苹果称之为 Source of Truth 的一种,而 Binding 明显表达了一种引用的关系,多用于作为控件API的初始化参数。当然 State 也能使用投影属性的方式(例如$isEditing),变成 Binding

结语

在本文中,我们结合上篇内容,详细讲述了 StateBinding 背后的另一个新语言特性 Key Path Member Lookup,并且了解了这样设计的精妙之处。

我们已经花了 3 篇文章来聊 SwiftUI 和 Swift 5.1 的新特性,但是这还没有结束,上面代码示例中的 VStack 的内部是合法的 Swift 代码吗?我们下次再聊。

相关文章:

SwiftUI 和 Swift 5.1 新特性(1) 不透明返回类型 Opaque Result Type

SwiftUI 和 Swift 5.1 新特性(2) 属性代理Property Delegates

扫描下方二维码,关注“面试官小健”