Swift 中的属性包装器

当处理代表某种状态形式的属性时,通常会在每次修改值时触发某种关联的逻辑。例如,我们可以根据一组规则验证每个新值,可以以某种方式转换分配的值,或者每当值更改时都可以通知一组观察者。

在这种情况下,Swift 5.1的属性包装器功能非常有用,因为它使我们能够将此类行为和逻辑直接附加到属性本身上,这通常为代码重用和归纳开辟了新的机会。让我们看一下属性包装器是如何工作的,并探讨一些可以在实践中使用它们的情况的示例。

透明地包装值

顾名思义,属性包装器本质上是一种类型,它包装一个给定的值,以便将附加的逻辑附加到该值上,并且可以使用结构体或类来实现,方法是使用@propertyWrapper属性对其进行注释。除此之外,唯一真正的要求是每个属性包装类型应该包含一个名为wrappedValue的存储属性,该属性告诉 Swift 要包装的是哪个底层值。

例如,假设我们想创建一个属性包装器,自动将分配给它的所有字符串值大写。可以这样实现:

@propertyWrapper struct Capitalized {
    var wrappedValue: String {
        didSet { wrappedValue = wrappedValue.capitalized }
    }

    init(wrappedValue: String) {
        self.wrappedValue = wrappedValue.capitalized
    }
}

请注意,我们需要显式地将传递到初始值中的任何字符串大写,因为属性观察器只有在值或对象完全初始化之后才会触发。

要将新的属性包装器应用于任何String属性,只需使用@Capitalized对其进行注释,Swift 就会自动将该注释与上述类型匹配。我们可以这样做,以确保用户类型的firstNamelastName属性始终大写:

struct User {
    @Capitalized var firstName: String
    @Capitalized var lastName: String
}

属性包装器的厉害之处在于,它们的行为完全透明,这意味着我们仍然可以像处理普通字符串一样处理上述两个属性——无论是在初始化用户类型,还是在修改其属性值时:

// Wei Zhy
var user = User(firstName: "wei", lastName: "zhy")

// Wei Xian
user.lastName = "xian"

类似地,只要属性包装器定义了init(wrappedValue:)初始值设定项(就像我们的Capitalized那样),那么我们甚至可以在本地为包装的属性分配默认值,如下所示:

struct Document {
    @Capitalized var name = "Untitled document"
}

因此,属性包装器使我们能够透明地包装和修改任何存储的属性——使用@propertyWrapper标记的类型和与该类型名称匹配的注释的组合。但这只是个开始。

属性的属性

属性包装器也可以有自己的属性,并且支持进一步的定制,甚至可以将依赖项注入到包装器类型中。

例如,假设我们正在开发一个消息应用程序,它使用 Foundation 的 UserDefaults API在磁盘上存储各种用户设置和其他轻量级数据。这样做通常需要编写某种形式的映射代码,以便将每个值与其底层的UserDefaults存储进行同步——通常需要为我们要存储的每个数据段进行复制。

然而,通过在通用属性包装器中实现这种逻辑,我们可以使其易于重用——因为这样做可以让我们简单地将包装器附加到任何希望由UserDefaults支持的属性。下面是这样一个包装器的样子:

@propertyWrapper struct UserDefaultsBacked<Value> {
    let key: String
    var storage: UserDefaults = .standard

    var wrappedValue: Value? {
        get { storage.value(forKey: key) as? Value }
        set { storage.setValue(newValue, forKey: key) }
    }
}

就像任何其他结构体一样,我们上面的UserDefaultsBacked类型将自动获得一个成员构造器,其中包含所有具有默认值的属性的默认参数——这意味着我们可以通过简单地指定每个属性要由哪个UserDefaults键支持来初始化它的实例:

struct SettingsViewModel {
    @UserDefaultsBacked<Bool>(key: "mark-as-read")
    var autoMarkMessagesAsRead

    @UserDefaultsBacked<Int>(key: "search-page-size")
    var numberOfSearchResultsPerPage
}

编译器将基于通用UserDefaultsBacked包装器的类型自动推断出我们每个属性的类型。

上面的设置使我们的新属性包装器易于使用,只要我们希望一个属性由用户默认值.standard,但由于我们参数化了该依赖关系,如果愿意,我们还可以选择使用自定义实例——例如,为了方便测试,或者能够在同一应用程序组中的多个应用程序之间共享值:

extension UserDefaults {
    static var shared: UserDefaults {
        let combined = UserDefaults.standard
        combined.addSuite(named: "group.johnsundell.app")
        return combined
    }
}

struct SettingsViewModel {
    @UserDefaultsBacked<Bool>(key: "mark-as-read", storage: .shared)
    var autoMarkMessagesAsRead

    @UserDefaultsBacked<Int>(key: "search-page-size", storage: .shared)
    var numberOfSearchResultsPerPage
}

但是,我们上面的实现有一个相当严重的缺陷。尽管上面两个属性都声明为非可选,但它们的实际值仍然是可选的,因为UserDefaultsBacked类型指定Value? 作为其 wrappedValue 属性的类型。

谢天谢地,这个缺陷很容易修复。我们所要做的就是将defaultValue属性添加到包装器中,然后在底层UserDefaults存储不包含属性键的值时使用它。为了使这些默认值的定义方式与通常定义属性默认值的方式相同,我们还将为包装器提供一个自定义初始值初始化器,该初始化器使用wrappedValue作为新defaultValue参数的外部参数标签:

@propertyWrapper struct UserDefaultsBacked<Value> {
    var wrappedValue: Value {
        get {
            let value = storage.value(forKey: key) as? Value
            return value ?? defaultValue
        }
        set {
            storage.setValue(newValue, forKey: key)
        }
    }

    private let key: String
    private let defaultValue: Value
    private let storage: UserDefaults

    init(wrappedValue defaultValue: Value,
         key: String,
         storage: UserDefaults = .standard) {
        self.defaultValue = defaultValue
        self.key = key
        self.storage = storage
    }
}

有了上述条件,我们现在可以将我们的两个属性变成非可选值,如下所示:

struct SettingsViewModel {
    @UserDefaultsBacked(key: "mark-as-read")
    var autoMarkMessagesAsRead = true

    @UserDefaultsBacked(key: "search-page-size")
    var numberOfSearchResultsPerPage = 20
}

这很好了。然而,我们的一些UserDefaults值实际上可能是可选的,如果我们必须不断地指定nil作为这些属性的默认值,那将是不幸的——因为这不是我们在不使用属性包装器时必须做的事情。

为了解决这个问题,我们还为包装器添加了一个方便的API,它的值类型准守ExpressibleByNilLiteral协议(Optional即准守次协议),在这个API中,我们将自动插入nil作为默认值:

extension UserDefaultsBacked where Value: ExpressibleByNilLiteral {
    init(key: String, storage: UserDefaults = .standard) {
        self.init(wrappedValue: nil, key: key, storage: storage)
    }
}

有了上述更改,我们现在可以轻松地将UserDefaultsBacked包装器与可选值和非可选值一起自由使用:

struct SettingsViewModel {
    @UserDefaultsBacked(key: "mark-as-read")
    var autoMarkMessagesAsRead = true

    @UserDefaultsBacked(key: "search-page-size")
    var numberOfSearchResultsPerPage = 20

    @UserDefaultsBacked(key: "signature")
    var messageSignature: String?
}

然而,还有一件事我们需要考虑,因为我们现在可以将nil分配给UserDefaultsBacked属性。为了避免在这种情况下发生崩溃,我们必须更新属性包装,首先检查是否有任何赋值为nil,然后再继续将其存储在当前UserDefaults实例中,如下所示:

// 因为我们的属性包装器的值类型不是可选的,但是
// 仍然可以包含`nil`值,我们必须引入这个
// 使我们能够将任何赋值转换为类型的协议
// 我们可以与`nil`相比:
private protocol AnyOptional {
    var isNil: Bool { get }
}

extension Optional: AnyOptional {
    var isNil: Bool { self == nil }
}

@propertyWrapper struct UserDefaultsBacked<Value> {
    var wrappedValue: Value {
        get { ... }
        set {
            if let optional = newValue as? AnyOptional, optional.isNil {
                storage.removeObject(forKey: key)
            } else {
                storage.setValue(newValue, forKey: key)
            }
        }
    }
    
    ...
}

属性包装器作为实际类型实现的事实给了我们很大的力量——我们可以给它们属性、初始值设定项甚至扩展——这反过来又使我们能够使我们的调用站点真正整洁干净,并充分利用Swift强大的类型系统。

解码和重写

尽管为了利用值语义,大多数属性包装器可能会实现为结构体,但有时我们可能希望通过使用类来选择引用语义。

例如,假设我们正在进行一个项目,该项目使用特性标志来支持新特性和实验的测试和逐步展开,并且我们希望构建一个属性包装器,让我们以不同的方式指定这些标志。因为我们希望在代码库中共享这些值,所以我们将把包装器实现为一个类:

@propertyWrapper final class Flag<Value> {
    var wrappedValue: Value
    let name: String

    fileprivate init(wrappedValue: Value, name: String) {
        self.wrappedValue = wrappedValue
        self.name = name
    }
}

有了新的包装器类型,我们现在可以开始将标志定义为封装的FeatureFlags类型中的属性——这将作为我们应用程序所有功能标志的唯一真实来源:

struct FeatureFlags {
    @Flag(name: "feature-search")
    var isSearchEnabled = false

    @Flag(name: "experiment-note-limit")
    var maximumNumberOfNotes = 999
}

在这一点上,上面的Flag属性包装可能看起来有点多余,因为它实际上除了存储其wrappedValue之外什么都不做——但这将很快改变。

使用功能标志的一种非常常见的方法是通过网络下载它们的值,例如每次应用程序启动时,或者根据特定的时间间隔。然而,即使在使用Codable时,实现这一点通常也会涉及到相当多的样板文件,因为对于那些可能尚未添加到后端的标志(或者在测试或回滚完成后已删除的标志),我们很可能希望返回到应用程序的默认值。

因此,让我们使用Flag属性包装器来实现这种形式的解码。因为我们想使用每个标志的name作为其编码键,所以我们要做的第一件事是定义一个新的CodingKey类型,它允许我们这样做:

private struct FlagCodingKey: CodingKey {
    var stringValue: String
    var intValue: Int?

    init(name: String) {
        stringValue = name
    }
    
    // CodingKey协议需要这些初始化器:

    init?(stringValue: String) {
        self.stringValue = stringValue
    }

    init?(intValue: Int) {
        self.intValue = intValue
        self.stringValue = String(intValue)
    }
}

接下来,我们需要一种方法来引用我们的每个标志,而不知道它们的泛型类型——但我们不需要诉诸于完全类型擦除,而是要添加一个名为DecodableFlag的协议,该协议将使每个标志能够根据其Value类型解码自己的值:

private protocol DecodableFlag {
    typealias Container = KeyedDecodingContainer<FlagCodingKey>
    func decodeValue(from container: Container) throws
}

有了上述内容,我们现在就可以编写解码代码了,只要Flag类型的泛型值类型是可解码的,我们就可以使标志类型条件性符合新的DecodableFlag协议:

extension Flag: DecodableFlag where Value: Decodable {
    fileprivate func decodeValue(from container: Container) throws {
        let key = FlagCodingKey(name: name)

         // 我们只想尝试解码存在的值,如果后端数据中缺少标志,使我们的应用程序返回其默认值:
        if let value = try container.decodeIfPresent(Value.self, forKey: key) {
            wrappedValue = value
        }
    }
}

最后,让我们通过使FeatureFlags符合Decodable协议来完成我们的解码实现。在这里,我们将使用反射对每个标志属性进行动态迭代,然后要求每个标志尝试使用当前解码容器对其值进行解码,如下所示:

extension FeatureFlags: Decodable {
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: FlagCodingKey.self)

        for child in Mirror(reflecting: self).children {
            guard let flag = child.value as? DecodableFlag else {
                continue
            }

            try flag.decodeValue(from: container)
        }
    }
}

虽然我们确实需要实现一些底层的基础设施,但我们现在有了一个非常灵活的特性标志系统——能够在服务器端和客户端指定标志值,并且只需向FeatureFlags类型添加@flag注释属性,就可以定义新的标志。

投影值

正如我们在本文中所探讨的,属性包装器的一个主要好处是,它们使我们能够以一种完全不影响我们的调用站点的方式向属性添加逻辑和行为——因为无论属性是否包装,值都是以完全相同的方式读写的。

但是,有时我们实际上可能希望访问属性包装器本身,而不是其包装的值。在使用Apple的新SwiftUI框架构建UI时,这种情况尤为常见,该框架大量使用属性包装器来实现其各种数据绑定API。

例如,这里我们正在构建一个QuantityView,该视图允许使用Stepper视图指定某种形式的数量。为了将该状态绑定到我们的视图,我们用@State对其进行了注释,然后通过以前缀$传递给步进器,使其直接访问该包装状态(而不仅仅是其当前的Int值) - 像这样:

struct QuantityView: View {
    ...
    @State private var quantity = 1

    var body: some View {
        // 以“ $”为前缀的包装属性传递的是属性包装器本身,而不是其值:
        Stepper("Quantity: \(quantity)",
            value: $quantity,
            in: 1...99
        )
    }
}

上面的功能似乎是为SwiftUI量身定制的功能,但实际上它是可以添加到任何属性包装程序中的功能,例如前面的Flag类型。我们上述属性的“美元前缀”版本称为其包装器的投影值,是通过向任何包装器类型添加projectedValue属性来实现的:

@propertyWrapper final class Flag<Value> {
    var projectedValue: Flag { self }
    ...
}

这样,任何带有Flag注释的属性现在也可以作为投影值传递,即作为对其包装器自身的引用。同样,这与SwiftUI无关,实际上,在使用UIKit时,我们也可以采用相同的模式——例如,通过让UIViewController在初始化时接受Flag的实例。

这是一个示例,说明了如何实现视图控制器,以便在使用我们的应用程序的调试版本时打开或关闭给定的基于Bool的功能标志:

class FlagToggleViewController: UIViewController {
    private let flag: Flag<Bool>
    private lazy var label = UILabel()
    private lazy var toggle = UISwitch()

    init(flag: Flag<Bool>) {
        self.flag = flag
        super.init(nibName: nil, bundle: nil)
    }
    
    ...

    override func viewDidLoad() {
        super.viewDidLoad()

        label.text = flag.name
        toggle.isOn = flag.wrappedValue

        toggle.addTarget(self,
            action: #selector(toggleFlag),
            for: .valueChanged
        )
        
        ...
    }

    @objc private func toggleFlag() {
        flag.wrappedValue = toggle.isOn
    }
}

要初始化上述视图控制器,我们将使用与使用SwiftUI传递@State引用时相同的基于$前缀的语法:

let flags: FeatureFlags = ...

let searchToggleVC = FlagToggleViewController(
    flag: flags.$isSearchEnabled
)

毫无疑问,我们将在以后的文章中进一步探讨以上对属性包装器的使用——因为它可以使我们的代码更具声明性,实现基于属性的观察API,执行相当复杂的数据绑定等等。

结论

属性包装器无疑是Swift 5.1中最令人兴奋的新功能之一,因为它为代码重用和可定制性打开了许多门,并启用了功能强大的新方法来实现属性级功能。即使在诸如SwiftUI这样的声明性框架之外,属性包装器也有大量潜在的用例,其中许多不需要我们对整体代码进行任何大的更改——因为属性包装器大部分都是完全透明地运行。

但是,这种透明度既可以是优势,也可以是责任。一方面,它使我们能够以与未包装的属性完全相同的方式访问和分配包装的属性———但另一方面,存在的风险是,我们最终将在太多的抽象后面隐藏太多的非显而易见的功能。

Thanks for reading! 🚀

译自 John Sundell 的 Property wrappers in Swift

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,117评论 4 360
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 66,963评论 1 290
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 107,897评论 0 240
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,805评论 0 203
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,208评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,535评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,797评论 2 311
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,493评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,215评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,477评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,988评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,325评论 2 252
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,971评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,055评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,807评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,544评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,455评论 2 266

推荐阅读更多精彩内容