探索Swift中KeyPath的使用

2,330 阅读7分钟

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

Swift5.2增加了另一个KeyPath<Root,Value>,这是个泛型类型,用来表示从 Root 类型到某个 Value 属性的访问路径.既然它是一个类型,你就可以在变量中存储、传递、操作这个类型。

KeyPath基础知识

实例

// 1
struct User {
    let name: String
    let email: String
    let address: Address?
    var role: Role

}

// 2
struct Address {
    let street: String
}

// 3
enum Role {
    case admin
    case member
    case guest

    var permissions: [Permission] {
        switch self {
            case .admin:
                return [.create, .read, .update, .delete]
            case .member:
                return [.create, .read]
            case .guest:
                return [.read]
        }
    }
}

// 4
enum Permission {
    case create
    case read
    case update
    case delete
}

KeyPath表达式语法

KeyPath是一种定义属性和下标引用的方法,我们可以把它用于从实例属性下标任何表达式

\typename.path

KeyPath构成:\(反斜杠)+ typename(类型名)+ .(点)+ path(路径)

let streetValue = user.address?.street

// KeyPath version referencing the same value.
let streetKeyPath = \User.address?.street

Typename具体类型的名字,包含泛型参数

//每行注释代表被推断出的类型
let stringDebugDescription = \String.debugDescription
// KeyPath<String, String>

let firstIndexInteger = \[Int][0]
// WritableKeyPath<[Int], Int>

let firstInteger = \Array.first
// KeyPath<[Int], Int?>

我们可以通过完整的类型名称(array)或缩写形式([])引用数组,但你也必须指定它的泛型类型[Int]和array 。

与Swift中的大多数内容一样,在可以推断类型的上下文中可以省略类型名。在下面的例子中,类型名可以从显式变量类型推断出来,所以我们可以将类型名留空。注意,我们仍然需要\.

// \String.debugDescription => \.debugDescription
let stringDebugDescription: KeyPath<String, String> = \.debugDescription

Path

路径可以是属性名、下标、可选链接表达式和强制展开表达式。基本上,我们通常在引用实例对象/结构时使用的所有东西。

// 1
let userName = \User.name
// KeyPath 属性名

// 2
let firstIndexInteger = \[Int][0]
// WritableKeyPath<[Int], Int> 下标

// 3
let streetAddress = \User.address?.street
// KeyPath 可选链属性

// 4
let forceStreetAddress = \User.address!.street
// KeyPath 强制解包属性

路径可以根据需要重复多次,它还可以被推断为计算的属性名。

let userRolePermissions = \User.role.permissions
// KeyPath<User, [Permission]>

let firstUserRolePermissions = \User.role.permissions[0]
// KeyPath<User, Permission>

KeyPath相关类型

Swift有五种KeyPath类型,但我们可以根据它们的功能将其分为两类:

  • 只读的KeyPath

  • 可写的KeyPath(可读可写)

三个只读KeyPath:

  • KeyPath

  • ParialKeyPath

  • AnyKeyPath

两个可写KeyPath:

  • WritableKeyPath

  • ReferenceWritableKeyPath

继承关系图

KeyPath.png

在本文中,我将只关注三种基本类型的关键路径:

  • KeyPath:对属性的只读访问。根类型可以是值/引用语义。

  • WritableKeyPath:提供对具有值语义(例如struct和enum)的可变属性的读写访问。

  • ReferenceWritableKeyPath:通过引用语义(如class)提供对可变属性的读写。

新旧KeyPath对比

Proposal: SE-0161 Implemented (Swift 4)

  • Defer way to get / set property
let keyPath = \UIView.frame
view[keyPath: keyPath]
  • With type information (#keyPath( ) only return ‘Any’)
let frame = view[keyPath: path]
//frame: CGRect
  • Applicable to any type (#keyPath( ) only applicable to NSObjects)
struct User {
    var id: Int
}

//\User.id

类型如何被推断出来

let firstIndexInteger = \[Int][0]
// WritableKeyPath<[Int], Int>

let firstInteger = \Array<Int>.first
// KeyPath<[Int], Int?>

如何推断KeyPath类型?它从属性/下标和根类型进行推断。

  • 如果属性或下标是只读的(例如let或下标只有get),则推断KeyPath。

  • 如果它是可变的(例如var或带有get/set的下标)。

  • 根类型为值类型(例如struct和enum),则可以推断WritableKeyPath。

  • 通过引用类型的根(比如class),可以推断出ReferenceWritableKeyPath。

例子

我们用let声明每个属性,因此我们得到了KeyPath。

let userRole = \User.role
// KeyPath<User, Role>

let streetAddress = \User.address?.street
// KeyPath<User, String?>

first和debugDescription是只读的计算属性,所以我们也得到了KeyPath作为结果。

let stringDebugDescription = \String.debugDescription
// KeyPath<String, String>

let firstInteger = \Array<Int>.first
// KeyPath<[Int], Int?>

当引用数组下标时,我们得到WritableKeyPath,因为它是一个读写下标(get/set)。

subscript(index: Int) -> Element { get set }

let firstIndexInteger = \[Int][0]
// WritableKeyPath<[Int], Int>

如果我们将name属性更改为var,当引用\User.name时,我们将获得WritableKeyPath。

struct User {
    var name: String
}

\User.name
// WritableKeyPath<User, String>

如果我们将User改为class, var和let的键路径将分别为ReferenceWritableKeyPath和KeyPath。

class User {

    var name: String
    let email: String

    init(name: String, email: String) {
        self.name = name
        self.email = email
    }

}

\User.name
// ReferenceWritableKeyPath<User, String>

\User.email
// KeyPath<User, String>

使用

属性读写访问

要使用KeyPath访问一个值,将KeyPath传递给下标subscript(keyPath:),该下标对所有类型都可用。您可以使用它根据KeyPath和实例的类型进行读写。

var user = User(
    name: "Sarunw",
    email: "sarunw@example.com",
    address: nil,
    role: .admin)

let userRoleKeyPath = \User.role
// WritableKeyPath<User, Role>

// 1
let role = user[keyPath: userRoleKeyPath]
print(role) // admin

// 2
user[keyPath: userRoleKeyPath] = .guest
print(user.role) // guest

陷阱

使用不安全表达式构造KeyPath可能导致与在实例上使用它们相同的运行时错误。下面是一个在关键路径中使用强制展开表达式(!)和数组下标(index: Int)的例子。

let fourthIndexInteger = \[Int][3]
let integers = [0, 1, 2]
print(integers[keyPath: fourthIndexInteger])

// Fatal error: Index out of range

let user = User(
    name: "Sarunw",
    email: "sarunw@example.com",
    address: nil,
    role: .admin)

let forceStreetAddress = \User.address!.street
print(user[keyPath: forceStreetAddress])
// Fatal error: Unexpectedly found nil while unwrapping an Optional value

标识KeyPath

我们还有一个特殊的KeyPath,可以引用整个实例而不是属性。我们可以使用以下语法创建一个,\.self

标识KeyPath的结果是整个实例的WritableKeyPath,因此您可以使用它在单个步骤中访问和更改存储在变量中的所有数据。

var foo = "Foo"

// 1
let stringIdentity = \String.self
// WritableKeyPath<String, String>
foo[keyPath: stringIdentity] = "Bar"
print(foo) // Bar

struct User {
    let name: String
}
var user = User(name: "John")

// 2
let userIdentity = \User.self
// WritableKeyPath<User, User>
user[keyPath: userIdentity] = User(name: "Doe")
print(user) // User(name: "Doe")

使用场景

KeyPath看起来像是从实例中读取和写入值的另一种方式。但是事实是,我们可以以变量的形式来读写一个值,这使得用例比读写更广泛。

KeyPath替代协议

系统Identifiable协议

protocol Identifiable {
    associatedtype ID
    static var id: WritableKeyPath<Self, ID> { get }
}

在SwiftUI中,我们可以从可识别数据的集合创建视图。可识别协议的唯一要求是一个名为ID的Hashable变量。

struct User: Identifiable {
    let name: String
    
    // 1,使用“名称”唯一标识用户。这仅仅是为了演示,一般使用UUID避免重复
    var id: String {
        return name
    }

}

let users: [User] = [
    User(name: "John"),
    User(name: "Alice"),
    User(name: "Bob"),
]

struct SwiftUIView: View {
    var body: some View {
        ScrollView {
            ForEach(users) { user in
                Text(user.name)
            }
        }
    }
}

Identifiable是一个协议,唯一标识一个项目在一个列表。SwiftUI还提供了一个使用KeyPath的替代初始化器。这个替代初始化器没有强制数据类型遵循可识别协议,而是让数据类型指定到其底层数据标识的KeyPath。

// 1
struct User {
    let name: String
}

struct SwiftUIView: View {
    var body: some View {
        ScrollView {
            // 2
            ForEach(users, id: \.name) { user in
                Text(user.name)
            }
        }
    }
}

我们可以使用KeyPath来注入该值,而不是使用协议来定义获取某个值的通用接口。KeyPath提供了一种将读取访问转移到其他函数的方法。 这里使用KeyPath的引用读/写访问的能力,从而产生与Identifiable协议相同的功能。

KeyPath作为函数

函数的等效实现

KeyPath表达式\Root.value可以表示为带有以下签名的函数(Root) ->Value。让我们看看这个转换是如何工作的。

在本例中,我们试图将用户名映射到一个用户数组中。map(_:)具有以下签名。它接受一个转换参数闭包,将数组元素(element)作为参数,并返回您想要转换为(T)的类型。

func map<T>(_ transform: (Element) throws -> T) rethrows -> [T]
struct User {
    let name: String
}

let users = [
    User(name: "John"),
    User(name: "Alice"),
    User(name: "Bob")
]

let userNames = users.map { user in
    return user.name
}

//返回用户名数组
// ["John", "Alice", "Bob"]

在本例中,map(_:)接受函数(Element) -> Value的参数。根据我们的声明,我们应该能够使用一个键路径表达式\Element代替。让我们尝试创建一个采用键路径作为参数的map函数。

extension Array {
    func map<Value>(_ keyPath: KeyPath<Element, Value>) -> [Value] {
        return map { $0[keyPath: keyPath] }
    }
}

let userNames = users.map(\.name)
// ["John", "Alice", "Bob"]

正如您所看到的,我们可以为一个函数创建一个等效的实现,该函数(Root) -> Value预期的键路径为\Root.Value。在Swift 5.2中,我们甚至不需要自己做转换。根据这项提议,该功能被内置到Swift中,结果,一个键路径表达式\Root.value可以在允许使用(Root) -> value函数的地方使用。同样,键路径表明它可以做的不仅仅是访问一个值。在这种情况下,它甚至取代了一个函数调用。

更广泛的扩展

extension Sequence {
    func map<T>(_ keyPath: KeyPath<Element, T>) -> [T] {
        return map { $0[keyPath: keyPath] }
    }
}

集合类型排序

标准库可以给任意的包含可排序的元素的序列进行自动排序,但是对于其他不可排序的元素,我们必须提供自己的排序闭包。然而,使用Keypath,我们可以很简单的给任意的可比较的元素添加排序的支持。就像之前一样,我们给序列添加一个扩展,来将给定的KeyPath在排序表达闭包中进行转化:

extension Sequence {
    func sorted<T: Comparable>(by keyPath: KeyPath<Element, T>) -> [Element] {
        return sorted { a, b in
            return a[keyPath: keyPath] < b[keyPath: keyPath]
        }
    }
}

写属性

加载一系列的事项,然后在ListViewController中去渲染它们,然后当加载操作完成后,我们会简单的将加载的事项赋值给视图控制器中的属性。

class ListViewController {
    private var items = [Item]() { didSet { render() } }
    
    func loadItems() {
        loader.load { [weak self] items in
            self?.items = items
        }
    }
}

通过关键路径赋值能否让上面的语法简单一点,并且能够移除我们经常使用的weak self的语法(如果我们忘记对self的引用前加上weak关键字的话,那么就会产生循环引用)。

既然所有上面我们做的事情都是获取传递给我们闭包的值,并将它赋值给视图控制器中的属性 - 那么如果我们真的能够将属性的setter作为函数传递,会不会很酷呢?这样我们就可以直接将函数作为完成闭包传递给我们的加载方法,然后所有的事情都会正常执行。

为了实现这一目标,首先我们先定义一个函数,让任意的可写的转化为一个闭包,然后为关键路径设置属性值。为此,我们将会使用ReferenceWritableKeyPath类型,因为我们只想把它限制为引用类型(否则的话,我们只会改变本地属性的值)。给定一个对象,以及给这个对象设置关键路径,我们将会自动将捕获的对象作为弱引用类型,一旦我们的函数被调用,我们就会给匹配关键路径的属性赋值。就像这样:

func setter<Object: AnyObject, Value>(
    for object: Object,
    keyPath: ReferenceWritableKeyPath<Object, Value>) -> (Value) -> Void {
        return { [weak object] value in
                object?[keyPath: keyPath] = value
        }
}

总结

KeyPath is incredibly important in Cocoa Development. And this is they let us reason about the structure of our types apart from any specific instance in a way that’s far more constrained than a closure. —— What’s New in Foundation · WWDC 2017 · Session 212

上面这段话摘录自2017 WWDC 的 What’s New in Foundation,简单的翻译就是 KeyPath 对于 Cocoa 的使用非常重要,因为它可以通过类型的结构,去获取任意一个实例的相应属性,而且这种方式远比闭包更加简单和紧凑。

参考