访问一个Swift属性包装器的实例

482 阅读8分钟

就像它的名字所暗示的那样,Swift的属性包装器功能使我们能够将一个给定的属性值包装在一个自定义类型中,这反过来又使我们能够在该值被修改时应用转换和运行其他类型的逻辑。

默认情况下,属性包装器与它所使用的包围类型完全脱节,这在某些情况下会被证明是相当有局限性的。例如,我们不能执行方法调用或以其他方式与包装器的包围实例进行交互,因为标准API没有为我们提供这样的引用。

然而,事实证明,有一个另类的、有点隐蔽的API,它实际上可以让我们访问每个包围的实例,这可以让我们采用一些非常有趣的模式。让我们来看看!

⚠️虽然本文涉及的所有语言特性都是Swift的官方部分,但我们将利用一个带有下划线前缀的API,这一点应该始终保持谨慎,因为这样的API一定会在任何时候改变。

开始吧

默认情况下,Swift属性封装器的实现方式是用@propertyWrapper 属性注释给定类型(通常是一个结构),然后在该类型中声明一个wrappedValue 属性,作为被封装的值的底层存储。例如,像这样:

@propertyWrapper
struct MyWrapper<Value> {
    var wrappedValue: Value
}

然而,如果我们看一下Swift Evolution对属性包装器功能的建议,我们可以看到它还提到了另一种处理包装器值的方法--通过一个静态下标,看起来像这样:

@propertyWrapper
struct EnclosingTypeReferencingWrapper<Value> {
    static subscript<T>(
        _enclosingInstance instance: T,
        wrapped wrappedKeyPath: ReferenceWritableKeyPath<T, Value>,
        storage storageKeyPath: ReferenceWritableKeyPath<T, Self>
    ) -> Value {
        ...
    }
    
    ...
}

除了当前的包围实例之外,上述API让我们可以使用Swift的关键路径功能来访问我们的包装器本身,以及被包装的底层价值。唯一的要求是,包围的类型需要是一个类,因为上面的下标使用了ReferenceWritableKeyPath ,它依赖于引用语义

在实现上述API时,我们可能还想防止我们的属性使用值语义被突变(因为我们希望所有的突变都通过我们的下标),这可以通过将标准的wrappedValue 属性标记为不可用来实现--像这样:

@propertyWrapper
struct EnclosingTypeReferencingWrapper<Value> {
    static subscript<T>(
        _enclosingInstance instance: T,
        wrapped wrappedKeyPath: ReferenceWritableKeyPath<T, Value>,
        storage storageKeyPath: ReferenceWritableKeyPath<T, Self>
    ) -> Value {
        ...
    }
    
    @available(*, unavailable,
        message: "This property wrapper can only be applied to classes"
    )
    var wrappedValue: Value {
        get { fatalError() }
        set { fatalError() }
    }
}

请注意,我们必须给wrappedValue 一个getter和setter,否则编译器会将我们的属性包装器视为不可变的。

有了上述设置,现在不可能在结构中使用我们的新属性包装器,如果我们试图这样做,编译器会自动显示我们使用@available 属性定义的错误信息。

重新实现已发布的属性封装器

让我们来看看上述模式可能非常有用的一种情况,让我们看看我们是否能够真正重新实现 Combine 的Published 属性包装器,它通常与ObservableObject 协议结合使用,以将一个类连接到 SwiftUI 视图

虽然Combine是苹果公司内部开发的闭源框架(所以我没有真正看过它的源代码),但我们可以根据上面的下标,对它的Published 包装器的实现方式做一些有根据的猜测。由于每个ObservableObject 都需要有一个objectWillChange 发布器(它是自动合成的),Published 类型可能会调用该发布器,以便通知每个观察者任何变化--它可能看起来像这样:

@propertyWrapper
struct Published<Value> {
    static subscript<T: ObservableObject>(
        _enclosingInstance instance: T,
        wrapped wrappedKeyPath: ReferenceWritableKeyPath<T, Value>,
        storage storageKeyPath: ReferenceWritableKeyPath<T, Self>
    ) -> Value {
        get {
            instance[keyPath: storageKeyPath].storage
        }
        set {
            let publisher = instance.objectWillChange
            // This assumption is definitely not safe to make in
            // production code, but it's fine for this demo purpose:
            (publisher as! ObservableObjectPublisher).send()
            
            instance[keyPath: storageKeyPath].storage = newValue
        }
    }

    @available(*, unavailable,
        message: "@Published can only be applied to classes"
    )
    var wrappedValue: Value {
        get { fatalError() }
        set { fatalError() }
    }

    private var storage: Value

    init(wrappedValue: Value) {
        storage = wrappedValue
    }
}

注意我们是如何使用一个单独的storage 属性来存储我们的Published 类型的底层值的,而不是使用wrappedValue ,因为我们希望这个默认属性保持不可用。

真正有趣的是,如果我们把上面的代码放到一个SwiftUI项目中,所有的东西实际上都很有可能继续工作,除非我们正在做一些事情,比如使用它们的预测值Published 实例转换为发布者(我们还没有添加支持),而且只要我们所有的@Published 属性都定义在符合ObservableObject 的类型中。

因此,尽管上述情况很难说是对其内置对应物的完全准确的1:1再实现,但它绝对展示了这种类型的功能是多么强大。但现在,让我们实际使用这种功能来构建一些有用的东西。

代理属性

当使用像UIKit和AppKit这样的框架时,想要在其包围的父视图中隐藏某些子视图,并且只让这些视图通过特定的API被突变是非常常见的。例如,下面的HeaderView 有一个title 和一个image 属性,但保持用于渲染这些属性的底层视图为私有,然后手动将这些碎片连接在一起:

class HeaderView: UIView {
    var title: String? {
        get { titleLabel.text }
        set { titleLabel.text = newValue }
    }

    var image: UIImage? {
        get { imageView.image }
        set { imageView.image = newValue }
    }

    private let titleLabel = UILabel()
    private let imageView = UIImageView()
    
    ...
}

上述模式的好处是,它给每个视图一个小得多的API表面,这反过来又使我们的视图不太可能以某种方式被滥用(例如,以一种父视图没有被设计为处理的方式配置子视图)。然而,这也需要相当多的模板,因为我们目前必须手动将每个属性的getter和setter转发给用于渲染的底层视图。

这也是另一种情况,在这种情况下,一个封闭的类型引用的属性包装器可能会非常有用。由于用于访问包装器的包围实例的语言机制是基于键路径的,我们可以建立一个Proxy 包装器,自动将其包装的值与它的包围类型的键路径之一同步--像这样:

@propertyWrapper
struct Proxy<EnclosingType, Value> {
    typealias ValueKeyPath = ReferenceWritableKeyPath<EnclosingType, Value>
    typealias SelfKeyPath = ReferenceWritableKeyPath<EnclosingType, Self>

    static subscript(
        _enclosingInstance instance: EnclosingType,
        wrapped wrappedKeyPath: ValueKeyPath,
        storage storageKeyPath: SelfKeyPath
    ) -> Value {
        get {
            let keyPath = instance[keyPath: storageKeyPath].keyPath
            return instance[keyPath: keyPath]
        }
        set {
            let keyPath = instance[keyPath: storageKeyPath].keyPath
            instance[keyPath: keyPath] = newValue
        }
    }

    @available(*, unavailable,
        message: "@Proxy can only be applied to classes"
    )
    var wrappedValue: Value {
        get { fatalError() }
        set { fatalError() }
    }

    private let keyPath: ValueKeyPath

    init(_ keyPath: ValueKeyPath) {
        self.keyPath = keyPath
    }
}

有了这个新的属性包装器,我们现在可以从我们的HeaderView 中移除手动实现的getters和setters,并且简单地用@Proxy 来注释我们想要同步到底层视图的属性。

class HeaderView: UIView {
    @Proxy(\HeaderView.titleLabel.text) var title: String?
    @Proxy(\HeaderView.imageView.image) var image: UIImage?

    private let titleLabel = UILabel()
    private let imageView = UIImageView()
    
    ...
}

这已经很不错了,但有点遗憾的是,在构建我们的关键路径时,我们必须重复引用HeaderView 。如果我们能让编译器根据我们的属性所定义的包围类型来推断该类型,那就好得多了。

为了实现这一点,我们将不得不使用一点 "类型系统黑客 "技术。 首先,让我们把我们的Proxy 属性包装器重命名为AnyProxy

@propertyWrapper
struct AnyProxy<EnclosingType, Value> {
    ...
}

然后,让我们定义一个协议,使用一个类型别名,用Self 来专门化AnyProxy 。然后,我们将使用一个扩展将该协议应用于所有NSObject 类型(包括所有UIKit和AppKit视图)。

protocol ProxyContainer {
    typealias Proxy<T> = AnyProxy<Self, T>
}

extension NSObject: ProxyContainer {}

有了上面这组变化,我们现在就可以在引用我们的代理键路径时省略包围的类型,因为Proxy 现在指的是我们新的专门版本的AnyProxy 属性包装器:

class HeaderView: UIView {
    @Proxy(\.titleLabel.text) var title: String?
    @Proxy(\.imageView.image) var image: UIImage?

    private let titleLabel = UILabel()
    private let imageView = UIImageView()
    
    ...
}

我们现在有了一种非常简洁、优雅的方式来定义代理属性,不需要任何手动同步,这不仅从语法的角度来看很整洁,而且还消除了我们在编写那些手动getters和setters时犯错误的风险。真的很好!

当然,我们最终解决方案的一个权衡是,每当我们希望引用Proxy ,我们现在必须使包围的类型符合我们的ProxyContainer 协议。然而,由于该协议实际上没有任何要求,而且我们总是可以退回到直接使用AnyProxy ,所以这并不是一个大问题。

结论

虽然它可能还不是一个完全出炉的语言特性,我们可以在生产代码中依赖它(除非我们愿意承担使用下划线前缀的语言特性所涉及的风险),但事实上,Swift的属性包装器确实支持引用其包围的实例,这是令人难以置信的强大。

希望这一特性最终能被提升为我们都能放心使用的一流能力,就像@_functionBuilder 属性(用于定义函数/结果构建器)即将演变成Swift 5.4的@resultBuilder 属性一样。

谢谢你的阅读!