Swift 与 Objective-C

【Swift 中级 #7】属性包装器原理:@State 并非魔法

属性包装器不是 SwiftUI 语法,而是 Swift 5.1 通过 SE-0258 引入的语言功能。本文梳理编译器如何翻译 @Clamped、wrappedValue 和 projectedValue($) 的本质,以及避免滥用的界限。

6 分钟阅读
【Swift 中级 #7】属性包装器原理:@State 并非魔法 封面图

使用 SwiftUI 的开发者每天会输入几十次 @State 和 @Published。但如果问这个 @ 符号到底做了什么,答案往往各不相同。“这不是 SwiftUI 语法吗?”是常见误解。不是。属性包装器(property wrapper)是 Swift Evolution 提案 SE-0258 在 Swift 5.1 中引入的语言功能,而 SwiftUI 只是它最知名的使用者。

这是中级系列第 7 篇。我们将通过亲手实现属性包装器来理解其工作原理,并梳理 wrappedValue、projectedValue($) 的本质,以及避免滥用的判断标准。

问题意识 — 每个属性都重复编写包装逻辑

在属性篇中,我们看过使用 didSet 验证值的模式。但如果多个属性都需要相同的验证,该怎么办?如果音量、亮度和进度都要限制在 0…1 范围内,就会复制三份 didSet。

var volume: Double = 0.5 {
    didSet { volume = min(max(volume, 0), 1) }
}
var brightness: Double = 0.5 {
    didSet { brightness = min(max(brightness, 0), 1) }
}
// 相同的代码不断重复出现...

同一套逻辑却要为每个属性重新编写,这是泛型篇中“重复还是安全”问题的属性版本。属性包装器为这套包装逻辑命名,并让它可以复用。

@propertyWrapper
struct Clamped {
    private var value: Double = 0
    var wrappedValue: Double {
        get { value }
        set { value = min(max(newValue, 0), 1) }
    }
}

struct Player {
    @Clamped var volume: Double
    @Clamped var brightness: Double
}

即使赋值为 player.volume = 1.5,实际保存的仍然是 1.0。验证逻辑只存在于 Clamped 中。

工作原理 — @ 符号如何完成翻译

要确认 @Clamped 并非魔法,只需查看编译器的翻译结果。@Clamped var volume: Double 大致会展开成这样。

private var _volume = Clamped()          // 实际存储:包装器实例
var volume: Double {                     // 我们使用的名称:计算属性
    get { _volume.wrappedValue }
    set { _volume.wrappedValue = newValue }
}

关键在于两行。真正存储的是带下划线的包装器实例,而我们访问的名称是一个指向包装器 wrappedValue 的计算属性。属性篇中建立的存储属性与计算属性之分,在这里直接成为基础。归根结底,包装器就是把“存储属性 + 计算属性 + 重复逻辑”封装成一个类型,再通过 @ 语法使用。

理解这种翻译后,包装器的限制也就顺理成章了。带包装器的属性实际上是计算属性,因此再添加 didSet 时行为容易混淆;过去不能用于局部变量或计算属性的限制(Swift 5.5 起允许局部变量),本质上都是“带下划线的存储空间在哪里创建”的问题。

编译器将 @Clamped 声明翻译为带下划线的存储空间和计算属性的示意图
@Clamped var volume 会被翻译为带下划线的存储空间和计算属性

projectedValue — 美元符号的本质

在 SwiftUI 中,你可能见过像 $text 这样加美元符号的写法。这也是包装器的功能。在包装器类型中声明名为 projectedValue 的属性后,编译器会提供一条名为 $이름 的第三通道。

  • volume → wrappedValue(值本身)
  • _volume → 包装器实例(只能在声明它的类型内部访问)
  • $volume → projectedValue(包装器额外提供的内容)

“额外提供的内容”由包装器设计者决定。SwiftUI 的 @State 通过 $ 提供 Binding(读写通道),而 Combine 的 @Published 则提供 Publisher(变更流)。同样的 $ 语法会产生不同的对象,因为这是各个包装器的设计选择,而不是语言规则。自己创建包装器时也是如此。如果为上面的 Clamped 添加一个用于报告“值是否被截断”的 projectedValue,就可以通过 $volume 这个 API 检查“刚才的赋值是否超出范围”。

实战配方 — 通过 UserDefaults 包装器学习设计

生产环境中最常用的自定义包装器模式之一,就是访问 UserDefaults。我们一边确认原理,一边动手实现。

@propertyWrapper
struct UserDefault<T> {
    let key: String
    let defaultValue: T

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

enum Settings {
    @UserDefault(key: "hasSeenOnboarding", defaultValue: false)
    static var hasSeenOnboarding: Bool
}

键字符串拼写错误、类型转换和默认值处理等重复逻辑都会收进包装器,使用处只剩一行:Settings.hasSeenOnboarding = true。这个示例还说明,泛型 <T> 和 init 参数(key、defaultValue)同样可以原样应用于包装器。@ 符号后的括号就是调用包装器的 init。

这些场景正是包装器的用武之地:更换存储位置(UserDefaults、钥匙串)、封装访问(线程锁、日志记录)、调整值(限制范围、去除空白)。它们的共同点是,都是与值的含义无关的存储和访问技术关注点。从关注点分离的角度看,包装器是将技术关注点从属性声明中分离出来的工具。

滥用边界 — 隐藏在 @ 后面的复杂性

包装器的风险恰恰来自它的优势:任意代码都可能隐藏在一行赋值之后。这项功能与最小惊讶原则正面冲突。

我建议用三条标准划定边界。第一,包装器内部只做可预测的事情。调整值或更换存储位置没有问题,但如果把网络请求或页面跳转之类的重副作用隐藏在赋值之后,就会变成调试地狱。第二,只使用团队熟悉的包装器。@State 这样的生态标准,或团队代码库中有文档说明的包装器,都是资产;但如果每个文件都出现新的 @,代码审查就会变成寻找包装器定义的游戏。第三,只使用一次的逻辑就保留在 didSet 中。包装器的目的在于复用,因此等第二个使用场景出现时再提升抽象层级才是正确做法。这正是 YAGNI(You Aren’t Gonna Need It — 在需要之前不要创建)的原则。

最后补充一个近期趋势。随着 Swift 和 SwiftUI 转向基于宏的开发方式,出现了像 Observation 框架中的 @Observable 这样并非包装器而是宏的 @。有了 @ 不再意味着它一定是属性包装器。宏是什么,以及它与包装器有何不同,将在进阶系列中介绍。

一座带有名称、下划线和美元符号三扇门的属性保险箱插图
名称、下划线和美元符号:一个属性出现三扇门

总结

  • 属性包装器并非 SwiftUI 专属功能,而是 Swift 5.1 的语言功能(SE-0258),用于将每个属性反复出现的包装逻辑封装成可复用的类型。
  • 其原理是转换。@Wrapper var x 会展开为“带下划线的包装器实例(存储)+ 名为 x 的计算属性(通道)”。
  • $x 是名为 projectedValue 的第三个通道,具体提供什么内容由包装器设计者决定(@State 提供 Binding,@Published 提供 Publisher)。
  • 它适合用于更改存储位置、包装访问、调整值等技术性关注点;繁重的副作用和一次性逻辑不应放入包装器。

下一篇是中级系列的最后一篇:KeyPath。本文将整理反斜杠语法\.name把属性作为值处理的原理,以及 map(.name)得以实现的背景。


参考资料

延伸阅读