Swift 中的属性包装器 - Property Wrappers

在使用 Swift 开发的过程中,经常会遇到诸如 SwiftUI 中的 @State,Combine 中的 @Published 这类用来修饰属性的东西。这些都是属性包装器(Property Wrappers)。

Property Wrappers 是什么 ?

简要的说,属性包装器是一种给属性附加逻辑的类型。从结构上看就像是给修饰的属性加了个壳子,对属性的存取都会经过属性包装器设定的逻辑。逻辑壳子可以是 Class、Struct,意味着它可以拥有自己的属性和方法。

以 SwiftUI 中的 @State 为例,它的结构如下:

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
@frozen @propertyWrapper public struct State<Value> : DynamicProperty {
    public init(wrappedValue value: Value)
    public init(initialValue value: Value)
    public var wrappedValue: Value { get nonmutating set }
    public var projectedValue: Binding<Value> { get }
}

State 是一个 Struct,其被 @propertyWrapper 修饰为属性包装器。在公开的实现中,除了初始化方法外的两个参数 wrappedValueprojectedValue前者是属性包装器必要的参数,用来提供包装器逻辑的实际实现。后者是一个可选参数,用来映射自定义的值。在任何可以访问到属性的位置都可以使用 $ 符号访问属性,使用 $.XXX 实际上获取的是projectedValue

另一个属性包装器 @Published 实现也大同小异:

@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *)
@propertyWrapper public struct Published<Value> {

    public init(wrappedValue: Value)
    public init(initialValue: Value)

    /// 定义了一个与属性相关联的 Combine 中的发布者
    public struct Publisher : Publisher {...}

    public var projectedValue: Published<Value>.Publisher { mutating get set }
}

这里可以看到, @Published 中没有公开 wrappedValueprojectedValue 是其内部定义的发布者类型,被 @Published 修饰的属性通过 $ 访问属性时,因为其访问的是projectedValue,即发布者。Combine 相关内容可以看看这篇文章

下面实现一个简单的属性包装器来加深理解。

自定义属性包装器

开发中,很多属性都具有相同的行为,例如:

  1. 表单数据属性具有范围限制(例如颜色属性 0-255)
  2. 某些字符串具有约束(长度限定、大小写限定等)
  3. 某些属性可被 Combine 订阅(具有发布者 Publisher)
  4. 存储属性的存储行为
  5. 属性的定制化的懒加载行为

属性包装器的能够应用到的地方有很多,苹果近两年推出了不少新的属性包装器。我们先实现一个最简单属性包装器,不论存储什么值,值都将其保存为大写:

@propertyWrapper struct RJUpper {
    private var value:String = ""
    var wrappedValue:String {
        get { value }
        set {
            value = newValue.uppercased()
        }
    }
    
    init(wrappedValue: String) {
        self.wrappedValue = wrappedValue
    }
}

class MyTest {
    // 使用自定义的属性包装器修饰字符串
    @RJUpper var text = ""
    init() {}
}

// 初始化 MyTest,并打印其 text 属性
let demo = MyTest()
demo.text = "123abc"
print(demo.text)    //output: 123ABC

上面的代码定义了一个名为 RJUpper 的属性包装器,实现了必要的 wrappedValue,并在其设置值时将字符串置为大写。

这个例子中的 wrappedValue 是个计算属性,并不负责值的存储,值存储在私有变量 value 中。

实现 init(wrappedValue: String) 后属性在定义初始化时将自动调用。

基于上述特点就可以根据属性实际情况自定义出各种各样属性包装器,像上面的例子将 wrappedValue 设计成一个计算属性。被包装器修饰的属性存取都会经过 wrappedValue 。又或者,将 wrappedValueset 方法省去,使其成为类似 let 修饰的常量。当然,也可以直接使用wrappedValue:

@propertyWrapper struct RJUpper {
    var wrappedValue:String = ""
    
    init(wrappedValue:String) {
        self.wrappedValue = wrappedValue.uppercased()
    }
}

转换为大小写这样的需求只是为了举例,介绍下属性包装器的基本结构,在实际开发中,常见的有限制属性的取值范围,接下来将使用泛型让限制范围的属性包装器更加通用。

@propertyWrapper struct RJRange<Type:Comparable> {
    private var value: Type
    private var min :Type
    private var max :Type
    var wrappedValue:Type {
        get { value }
        set {
            value = (min < newValue) ? (max > newValue ? newValue : max) : min
        }
    }
    
    init(wrappedValue:Type, min:Type, max:Type) {
        assert((min <= wrappedValue && max >= wrappedValue), "\n\(wrappedValue)不在范围应\(min)-\(max)内")
        self.min = min
        self.max = max
        value = wrappedValue
    }
}

代码定义了名为 RJRange的属性包装器, 其适合用于修饰所有符合 Comparable 协议的属性。minmax 为区间的最值,通过我们自定义的 init 方法配置。前面提到 init 方法会在定义属性初始化的时候调用,在此处加入了对初始化时传入数据的断言(assert),用来提示开发者,初始化的值是否正常。

具体调用结果如下:

class MyTest {
    @RJRange(min: 0, max: 255) var color = 255
    @RJRange(min: "A", max: "C") var text = "C"
    @RJRange(wrappedValue: 0.5, min: 0.0, max: 1.0) var percent
}

let demo = MyTest()
print(demo.color)           // output: 255
print(demo.text)            // output: "C"
print(demo.percent)         // output: 0.5
demo.percent = 1.1          // 调用 wrappedValue 的 set 方法 
print(demo.percent)         // output: 1.0

在修饰属性时,属性包装器的 wrappedValue 的入参是可选的,不显示传入 wrappedValue 就需要给予属性初始值,效果上是相同的,都是属性的初始化,推荐不显示传入,与其他代码统一规范。minmax 则需要显示传入。

如果属性包装器指定了具体类型,则可以设定默认值,例如:

@propertyWrapper struct RJRange {
    ...
    init(wrappedValue:Int, min:Int = 0, max:Int = 100) {...}
}

// 使用时就可以不用传入 min 和 max
@RJRange var color = 255

此处的 color 会触发 assert,因为初始化值 255 不处于 属性包装器默认值的0到100之间。可以修改默认参数,也可以显示传入指定的范围:

@RJRange(min:0, max:255) var color = 255

探索-属性包装器的组合

当需要使用多个属性包装器修饰同一个属性时,需要注意属性修饰器的顺序,属性修饰器的执行顺序是 从内到外 的,以两个简单的属性包装器为例:

// 仅打印新值的包装器
@propertyWrapper struct RJPrint<Value> {
    var wrappedValue:Value {
        didSet { print(wrappedValue) }
    }
    
    init(wrappedValue: Value) { self.wrappedValue = wrappedValue }
}

// 本地存储的包装器
@propertyWrapper struct RJStore<Value> {
    let key:String
    private var value:Value?
    var wrappedValue:Value? {
        get { value }
        set {
            value = newValue
            saveDataToDB()
        }
    }
    
    init(wrappedValue:Value? = nil, key:String) {
        value = wrappedValue
        self.key = key
        if wrappedValue == nil {
            loadFromLocalDB()
        }
    }
    
    private mutating func loadFromLocalDB() {
        if let obj = UserDefaults.standard.value(forKey: key) as? Value {
            self.value = obj
        }
    }
    
    private func saveDataToDB() {
        UserDefaults.standard.setValue(value, forKey: key)
    }
}

class MyTest {
    @RJPrint var text = "123"
    @RJStore<String>(key: "TestKey") var file = "abc"
    @RJPrint @RJStore(key: "FuckerKey") var trump = "999"
    @RJPrint @RJRange(min: 0, max: 255) var value = 123
}

let demo = MyTest()       
demo.text = "321"  // output: 321
print(demo.file)   // output: Optional("abc")
print(demo.trump)  // output: Optional("999")

两个包装器一个负责新值输出,一个负责本地存储,MyTest 中第三个和第四个属性属性均结合了两个包装器,这样的写法称为组合包装器,属性同时具有了多个包装器的行为。

组合包装器的运行顺序是 从内向外的 ,或者说是从离属性最近的到最远的,例如上面两个组合包装器都是最后运行最左边的 @RJPrint

有运行顺序,那么不同逻辑的属性包装器组合在一起,就需要开发者对内部逻辑是否行得通有明确的认识,上面两个属性的包装器换个位置就会报错,因为后者无法识别出类型。

而很多情况下的包装器都会引入了泛型,编译器还不一定报错。

有什么其他的方案么?

@RJStore@RJRange 单独运行都没啥问题,仅为了结合就改动内部逻辑是在是有点不合适,那么新建一个组合包装器可以达到效果,

@propertyWrapper struct RJPrintStore<Value> {
    private var storage: RJPrint<RJStore<Value>>
    var wrappedValue: Value? {
        get { storage.wrappedValue.wrappedValue }
        set { storage.wrappedValue.wrappedValue = newValue }
    }
    
    init(wrappedValue:Value, key:String) {
        storage = RJPrint(wrappedValue: RJStore(wrappedValue: wrappedValue, key:key))
    }
}

// 修饰属性:
@RJPrintStore(key: "TestKey") var trump = 123

// 使用:
let test = MyTest()
print(test.trump)  // output: Optional(123)

RJPrintStore 将两个包装器行为组合到了一起,并约束了执行顺序 RJPrint<RJStore<Value>> 。但是这样的方式只对单一情形有效,当包装器行为改变时,组合包装器也会跟着改变,哪怕只改个顺序就可能很复杂。可见这样新生成一个组合包装器只对单一情形有效,并不能达到有效的“样板化”。

更多对包装器的组合尝试,可以查看 SE-0258

目前为止,除非包装器全部都像@RJPrint 这样仅打印的行为那就可以无所谓组合顺序,像下面这样:

@propertyWrapper struct RJPrint1<Value> {
    var wrappedValue:Value { didSet { print(wrappedValue) }}
    init(wrappedValue: Value) { self.wrappedValue = wrappedValue}
}

@propertyWrapper struct RJPrint2<Value> {
    var wrappedValue:Value { willSet {print(wrappedValue)}}
    init(wrappedValue: Value) { self.wrappedValue = wrappedValue}
}

可这样的属性包装器在开发中有何用?

根据上面众多例子可以看出,属性包装器不仅具有行为,还会持有值的副本,加之可能引入泛型,使包装器组合起来很复杂。几乎不可能达到针对所有情况的“完全样板化”。

因为属性包装器本身也是类型,会有自己的属性和方法,组合包装器将会将这些统统继承下来,不仅需要开发者对组合抽象背后的逻辑有清晰的认识,也增大了对属性操作的复杂性,甚至影响性能。使得属性包装器不能广泛应用于所有情况,也没必要进行完全的样板化。


总结

属性包装器将一类属性相同的行为抽象出来,简化了开发中的重复工作。其本身作为”壳“也是一种类型,意味着其能够给属性附加逻辑,这是优势,同时也意味着开发者必须需要明确属性包装器背后的逻辑。

属性包装器中加入大量的逻辑行为时,也就增大了风险,尤其是在组合多个属性包装器时,组合的内部逻辑将会非常复杂。

所以,在开发中不要什么东西都往壳子上加,尽量保持属性包装器的行为单一以便于调试阅读。

从使用苹果提供的属性包装器效果上看,与其说是给属性加了个逻辑壳子,不如说是通过属性包装器,让特定行为持有了属性

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