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 语法
先看一个最小可用的包装器,它在写入时把值限制在指定范围内。
实例
// 用 @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 类型,帮助你理解两者之间的对应关系。
实例
struct Player {
@Clamped(0...100) var health: Int = 120
}
它等价于下面这种手写形式,只是下划线属性的名字由编译器生成:
实例
struct Player {
private var _health = Clamped(wrappedValue: 120, 0...100)
var health: Int {
get { _health.wrappedValue }
set { _health.wrappedValue = newValue }
}
}
下划线前缀的 _health 就是包装器实例本身,它在类型外部是私有的,但在声明它的类型内部可以直接访问。
实例
@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 | 可选 | 暴露包装器状态、返回绑定或发布者 |
下面这个包装器在写入时把首字母变成大写,同时用投影值返回全大写的版本。
实例
@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 限制取值范围
这个包装器前面已经出现过,它把越界的值夹回区间内,适合血量、进度、音量这类有上下限的属性。
实例
@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:)。
实例
@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 自动首字母大写
转换类包装器适合统一处理展示格式,例如姓名、标题、标签。
实例
@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 是「第一次访问时初始化一次」,属性包装器是「把这段逻辑做成可复用的类型」。
实例
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 时,$属性名 会编译报错,但普通的读写不受影响。
