现在位置: 首页 > Swift 教程 > 正文

Swift 属性包装器

写业务代码时经常遇到同一类需求:给属性加范围校验、读写时自动转换格式、把值同步到 UserDefaults。

这些逻辑如果每个属性都手写一遍,既啰嗦又容易漏。

属性包装器(property wrapper,SE-0258,Swift 5.1 引入)就是为此设计的:把「读写属性时附带的那层逻辑」抽成一个可复用的类型,再用 @ 语法套到属性上。


什么是属性包装器

属性包装器本质上是一个带有 @propertyWrapper 标记的类型,它必须提供一个名为 wrappedValue 的属性。

使用方在声明属性时加上 @包装器名,之后读写这个属性,实际经过的就是包装器里的 wrappedValue 的 getter 和 setter。

换句话说,包装器把「存储 + 附加逻辑」打包成一个类型,属性声明处只留下一行注解。

角色写在哪里职责
包装器类型标了 @propertyWrapper 的 struct / class / enum实现 wrappedValue,负责真正的读写逻辑
包装器属性使用方的 @Wrapper var x: T声明被包装的属性,只写业务意图
wrappedValue包装器类型内部对外的值,getter 返回、setter 接收
projectedValue包装器类型内部(可选)额外的投影值,通过 $x 访问

@propertyWrapper 语法

先看一个最小可用的包装器,它在写入时把值限制在指定范围内。

实例

import Foundation

// 用 @propertyWrapper 标记,类型名前的 @ 只在声明属性时使用
@propertyWrapper
struct Clamped {
    private var value: Int              // 真正存值的地方
    let range: ClosedRange<Int>         // 允许的取值范围

    // wrappedValue 参数对应属性声明处的初始值
    init(wrappedValue: Int, _ range: ClosedRange<Int>) {
        self.range = range
        // 初始化时也要夹紧,否则初始值可能越界
        self.value = min(max(wrappedValue, range.lowerBound), range.upperBound)
    }

    var wrappedValue: Int {
        get { value }
        set { value = min(max(newValue, range.lowerBound), range.upperBound) }
    }
}

struct Player {
    @Clamped(0...100) var health: Int = 120   // 初始值 120 会被夹到 100
    @Clamped(1...10) var level: Int = 3
}

var player = Player()
print(player.health)
player.health = 250
print(player.health)
player.health = -5
print(player.health)
print(player.level)

运行结果:

100
100
0
3

属性声明里的 = 120 被传给 init(wrappedValue:)wrappedValue 参数,括号里的 0...100 对应 init 中剩下的参数。

编译器做了什么

加注解的属性会被编译器改写成「一个包装器存储属性 + 一个计算属性」。

下面的两段代码只是示意,沿用上面已经定义的 Clamped 类型,帮助你理解两者之间的对应关系。

实例

// 你写的代码(Clamped 定义见上文)
struct Player {
    @Clamped(0...100) var health: Int = 120
}

它等价于下面这种手写形式,只是下划线属性的名字由编译器生成:

实例

// 编译器生成的等价结构(示意,Clamped 定义见上文)
struct Player {
    private var _health = Clamped(wrappedValue: 120, 0...100)

    var health: Int {
        get { _health.wrappedValue }
        set { _health.wrappedValue = newValue }
    }
}

下划线前缀的 _health 就是包装器实例本身,它在类型外部是私有的,但在声明它的类型内部可以直接访问。

实例

import Foundation

@propertyWrapper
struct Clamped {
    private var value: Int
    let range: ClosedRange<Int>

    init(wrappedValue: Int, _ range: ClosedRange<Int>) {
        self.range = range
        self.value = min(max(wrappedValue, range.lowerBound), range.upperBound)
    }

    var wrappedValue: Int {
        get { value }
        set { value = min(max(newValue, range.lowerBound), range.upperBound) }
    }
}

struct Player {
    @Clamped(0...100) var health: Int = 120

    // 类型内部可以直接访问 _health,拿到包装器实例
    func describe() {
        print("取值范围:\(_health.range),当前值:\(health)")
    }
}

var player = Player()
player.describe()
player.health = 999
player.describe()

运行结果:

取值范围:0...100,当前值:100
取值范围:0...100,当前值:100

如果你只写 @Clamped(0...100) var health: Int 而不给初始值,编译器就没有东西传给 wrappedValue,此时包装器必须另外提供一个不带 wrappedValue 参数的初始化器,否则编译报错。


wrappedValue 与 projectedValue

wrappedValue 是包装器的核心,对外的读写全部走它。

projectedValue 是可选的第二个出口,它给属性提供一个「额外的视角」,通过 $ 前缀访问。

成员访问方式是否必须典型用途
wrappedValue直接写属性名,如 player.health必须校验、转换、持久化后的实际值
projectedValue$,如 player.$health可选暴露包装器状态、返回绑定或发布者

下面这个包装器在写入时把首字母变成大写,同时用投影值返回全大写的版本。

实例

import Foundation

@propertyWrapper
struct Capitalized {
    private var value: String = ""

    // 有初始值的属性会调用这个初始化器
    init(wrappedValue: String) {
        self.wrappedValue = wrappedValue
    }

    var wrappedValue: String {
        get { value }
        set { value = Capitalized.transform(newValue) }
    }

    // projectedValue 通过 $ 访问
    var projectedValue: String {
        return wrappedValue.uppercased()
    }

    private static func transform(_ text: String) -> String {
        guard let first = text.first else { return text }
        return String(first).uppercased() + text.dropFirst()
    }
}

struct Site {
    @Capitalized var name: String = "runoob"
}

var site = Site()
print(site.name)
site.name = "www.runoob.com"
print(site.name)
print(site.$name)

运行结果:

Runoob
Www.runoob.com
WWW.RUNOOB.COM

可以看到 site.name 只把首字母大写,而 site.$name 走的是投影值,返回全大写。

projectedValue 的类型由你决定,不必和 wrappedValue 相同。SwiftUI 里的 @State 就是用投影值返回一个 Binding


实用示例

属性包装器的价值在于复用。下面三个例子覆盖了校验、转换和持久化三类常见需求。

@Clamped 限制取值范围

这个包装器前面已经出现过,它把越界的值夹回区间内,适合血量、进度、音量这类有上下限的属性。

实例

import Foundation

@propertyWrapper
struct Clamped {
    private var value: Int
    let range: ClosedRange<Int>

    init(wrappedValue: Int, _ range: ClosedRange<Int>) {
        self.range = range
        self.value = min(max(wrappedValue, range.lowerBound), range.upperBound)
    }

    var wrappedValue: Int {
        get { value }
        set { value = min(max(newValue, range.lowerBound), range.upperBound) }
    }
}

struct Progress {
    @Clamped(0...100) var percent: Int = 0
}

var task = Progress()
task.percent = 30
print(task.percent)
task.percent = 150
print(task.percent)

运行结果:

30
100

@UserDefault 自动读写 UserDefaults

把「读取时带默认值、写入时同步到 UserDefaults」的逻辑封装进包装器,业务属性就不用再写一堆 object(forKey:)set(_:forKey:)

实例

import Foundation

@propertyWrapper
struct UserDefault<T> {
    let key: String            // UserDefaults 中使用的键
    let defaultValue: T        // 键不存在时返回的默认值

    var wrappedValue: T {
        get { UserDefaults.standard.object(forKey: key) as? T ?? defaultValue }
        set { UserDefaults.standard.set(newValue, forKey: key) }
    }
}

struct Settings {
    @UserDefault(key: "runoob_nickname", defaultValue: "游客")
    var nickname: String

    @UserDefault(key: "runoob_score", defaultValue: 0)
    var score: Int
}

// 先清掉旧值,保证本次演示从默认值开始
UserDefaults.standard.removeObject(forKey: "runoob_nickname")

var settings = Settings()
print(settings.nickname)
settings.nickname = "RUNOOB 用户"
print(settings.nickname)
settings.score = 99
print(settings.score)

运行结果:

游客
RUNOOB 用户
99

由于泛型 T 无法保证能被 UserDefaults 直接存储,这个简化版本只适合 String、Int、Bool、Double 等基本类型。

@Capitalized 自动首字母大写

转换类包装器适合统一处理展示格式,例如姓名、标题、标签。

实例

import Foundation

@propertyWrapper
struct Capitalized {
    private var value: String = ""

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

    var wrappedValue: String {
        get { value }
        set {
            guard let first = newValue.first else {
                value = newValue
                return
            }
            value = String(first).uppercased() + newValue.dropFirst()
        }
    }
}

struct Article {
    @Capitalized var title: String = "swift 属性包装器"
}

var article = Article()
print(article.title)
article.title = "runoob 教程"
print(article.title)

运行结果:

Swift 属性包装器
Runoob 教程

与 lazy、计算属性的区别

属性包装器、计算属性和 lazy 都能在读写属性时插入逻辑,但它们的定位完全不同。

计算属性是「每次访问都重新计算」,lazy 是「第一次访问时初始化一次」,属性包装器是「把这段逻辑做成可复用的类型」。

实例

import Foundation

struct Counter {
    var raw: Int = 0

    // 计算属性:每次读取都会执行
    var double: Int {
        print("计算属性被访问")
        return raw * 2
    }

    // lazy 存储属性:只在第一次访问时初始化一次
    lazy var heavy: String = {
        print("lazy 初始化只执行一次")
        return "RUNOOB 数据"
    }()
}

var counter = Counter()
print(counter.double)
print(counter.double)
print(counter.heavy)
print(counter.heavy)

运行结果:

计算属性被访问
0
计算属性被访问
0
lazy 初始化只执行一次
RUNOOB 数据
RUNOOB 数据
对比项计算属性lazy 存储属性属性包装器
是否存储值不存储,每次计算存储,只初始化一次由包装器内部决定如何存储
执行时机每次读写都执行第一次访问时执行一次每次读写都经过 wrappedValue
能否复用不能,每个属性单独写不能能,一份逻辑套用到任意多个属性
能否带参数不能不能能,通过初始化器传入范围、键名等
典型用途由其他属性派生出的值开销大的对象延迟创建统一校验、转换、持久化、线程保护

选择时先看需求:只是派生值就用计算属性;只想延迟创建一次就用 lazy;同一套逻辑要在多个属性上重复使用,才值得写成属性包装器。

属性包装器不是免费的。它多了一层类型和一次方法调用,对性能极端敏感的场合可以先测再决定。多数业务代码里,可读性和复用性的收益远大于开销。


常见问题

下面整理属性包装器使用中最常见的几个疑问。

包装器可以是 class 或 enum 吗

可以。三种类型都能标 @propertyWrapper,只要提供 wrappedValue

但实践中绝大多数包装器用结构体,因为语义简单、没有引用共享问题。

wrappedValue 可以是只读的吗

可以。只提供 getter 的 wrappedValue 会让被包装的属性变成只读,写入会编译报错。

一个属性可以叠加多个包装器吗

可以,从外到内依次生效。

但叠加多层会让读写路径变得难以追踪,除非确有需要,一般不超过两层。

为什么我的包装器提示缺少 init(wrappedValue:)

因为属性声明处给了初始值,而包装器没有能接收这个初始值的初始化器。

解决办法是补上 init(wrappedValue:),或者去掉属性上的初始值改在别处赋值。

属性包装器能用在 let 上吗

不能。被包装的属性必须是 var,因为它需要 setter 才能写入。

projectedValue 可以省略吗

可以。不实现 projectedValue 时,$属性名 会编译报错,但普通的读写不受影响。