尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Swift属性体系深度解析:从存储属性到属性包装器

Swift属性体系深度解析:从存储属性到属性包装器 1. 属性体系背后的设计逻辑1.1 从一个具体问题说起为什么属性不只是一块内存我刚学 Swift 的时候以为属性就是“类里那些 var 和 let 声明的变量”存个值、读个值完事。直到有一天我写了个模型层用var data: Data?存网络响应结果在某次状态切换时数据被意外清空排查了半天才发现是观察器里做了副作用操作。那一刻我才意识到Swift 的属性是一套有完整生命周期、有回调钩子、有能力注入机制的存储系统绝不只是“一块内存”。你可以把属性理解成小区里的快递柜存储属性是柜子本体计算属性是没有柜子、但能帮你把快递送到楼上的管家属性观察器是柜子上的警报器——放进包裹、取出包裹都会响一声属性包装器则是给整排柜子统一装的一种锁规则一致、无需逐个配置。理解了这层类比你就能理解为什么 Swift 会把看似简单的“属性”设计得这么复杂。这篇文章适合三类人刚入门 Swift 想搞懂属性全貌的初学者写过一段时间、想搞清楚lazy、willSet、propertyWrapper背后原理的进阶开发者以及在做架构设计、想把状态管理和副作用集中收口的团队开发者。我会从底层逻辑讲到实操代码最后分享一些排查问题的真实经验。1.2 Swift 属性家族全景图与选型原则Swift 的属性大体分五个方向存储属性、计算属性、属性观察器、属性包装器、类型属性。先看一张全景对照表。属性类型有没有存储有没有回调典型使用场景需要注意的成本let/var 存储属性有无模型字段、普通状态内存占用随实例走lazy 存储属性有首次访问才初始化无大对象、耗时初始化线程安全需自己保证计算属性无无派生值、格式化输出每次访问都重算属性观察器挂在存储属性上willSet/didSet状态变更联动 UI、日志观察器里改自己会死循环属性包装器有包装自身存储可自定义校验、防注入、偏好设置类型擦除、调试复杂度类型属性static有类型级无单例、配置表、常量池全局生命周期选型的原则其实很简单先问自己要的是什么语义再问要不要监听变化最后问要不要复用规则。举个例子你写一个用户模型nickname直接存displayName是由nickname和uid拼出来的那就别把displayName存起来用计算属性。如果你希望昵称变化时自动刷新消息列表那就给nickname加didSet。如果多个属性都要做非空校验、长度校验那就抽一个NonEmpty包装器统一处理。很多人一上来就堆观察器结果一个属性变更牵出一串副作用这是典型的“工具选型失误”。1.3 属性与存储生命周期的关系属性不是孤立的它们受制于实例的生命周期。Swift 使用 ARC 管理内存一个实例的存储属性随实例创建分配、随实例销毁释放。这个大家都有直觉但有三个细节很少有人讲透。第一let 属性必须在初始化完成前赋值否则编译不过。这不是故意为难你而是 Swift 保证“所有存储属性在 init 结束时都有一个确定的值”从源头上杜绝未初始化内存的读取。可选类型var x: Int?默认是 nil算是给了你一个初始值但你得想清楚这个默认值是不是你真正想要的。第二lazy 只在首次访问时初始化且只初始化一次。注意lazy 变量必须以 var 声明因为它的初始化发生在运行时类初始化阶段根本不知道它有没有被访问过。我见过有人把 lazy 理解成“延迟创建的单例”其实它不是单例——每个实例都会有自己的一份延迟存储。第三存储属性是值类型的组成部分。当你的 struct 被复制时所有存储属性都会被复制。如果你希望某些属性复制后是独立副本、某些属性共享同一份内存那就要自己用引用类型包装或者引入写时复制的机制。属性在这个层面的设计往往决定了一个模型层是稳定还是到处是坑。2. 核心类型逐个击破2.1 存储属性与懒加载——从内存模型的视角存储属性是 Swift 属性体系的地基。它在堆/栈上占用实际内存随实例而生随实例而死。对于 class属性存储在堆上每个实例有一份对于 struct属性内联存储在值里复制即整体拷贝。lazy 存储属性的价值在于“把初始化成本推迟到真正需要的时刻”。最经典的场景是UIImage的解码一张很大的图片如果类初始化时就去加载会拖慢启动速度用 lazy 之后用户真正滑动到那一屏时才触发加载。final class ImageFeedItem { let url: URL lazy var decodedImage: UIImage? { // 这里的闭包只会在第一次访问 decodedImage 时执行 guard let data try? Data(contentsOf: url) else { return nil } return UIImage(data: data) }() init(url: URL) { self.url url } }但 lazy 有一个很隐蔽的成本它本质上是“每次访问时检查是否已初始化的线程不安全机制”。如果你在后台队列首次访问decodedImage同时主线程也在访问同一个实例的属性理论上可能出现两次初始化的竞争问题。实际开发中我一般遵守一个规则lazy 属性只在主线程访问或者直接用let 依赖注入代替。另一个容易忽略的是lazy 闭包会捕获 self。如果你在闭包里写了self就会形成“实例持有闭包、闭包持有实例”的回环在 deinit 排查时看不出来但内存确实会晚释放。现在我用 lazy 之前都会问自己一句真的需要懒加载还是只是不想写初始化参数2.2 计算属性与属性观察器的本质区别计算属性没有存储每次访问都执行一段代码后返回结果。它看起来像一个属性实际上是一个方法。Swift 的设计哲学是“调用方无需关心背后是存储还是计算”——所以计算属性在 API 层面和存储属性完全一致。struct Circle { var radius: Double var area: Double { get { return .pi * radius * radius } set { radius sqrt(newValue / .pi) } } }注意上面这个例子计算属性可以有 getter 和 setter而且 setter 默认带一个newValue。这意味着你可以在赋值时反向推导出其他存储属性的值。这种设计让“对象坐标归一化”等操作变得非常优雅。但是我建议计算属性里不要做耗时操作因为外部无法感知“这一个属性的访问是 O(1) 还是 O(n)”。属性观察器挂在存储属性上willSet发生在赋值之前didSet发生在赋值之后。它们能拿到新值和旧值但不能改变本次赋的值——willSet 里改属性没有意义因为真正写入已经发生在 setter 内部didSet 里再赋值倒是会再次触发观察器容易死循环。观察器最常见的用途是联动刷新。比如登录状态变化后所有依赖登录状态的界面都要刷新final class SessionManager { static let shared SessionManager() var isLoggedIn: Bool false { didSet { if isLoggedIn ! oldValue { NotificationCenter.default.post(name: .authStateChanged, object: nil) } } } }有一点必须提醒观察器不负责“能不能改”只负责“改了之后做什么”。如果你需要校验并拒绝非法值应该在 setter 外部比如属性包装器处理而不是在 willSet 里“堵住”赋值。我自己早期写过一个 bug在 didSet 里判断值非法后又改回旧值结果触发了两次通知UI 闪了一下排查了好一阵子才发现。2.3 属性包装器——解决样板代码与复用问题属性包装器是 Swift 属性体系里最值得花时间研究的部分。它的本质是把“存储”和“访问”封装成一个独立的类型然后把类型实例作为属性的底层存储。来看一个最经典的例子把 UserDefaults 封装成一个属性包装器。propertyWrapper struct UserDefaultT { let key: String let defaultValue: T init(_ key: String, defaultValue: T) { self.key key self.defaultValue defaultValue } var wrappedValue: T { get { UserDefaults.standard.object(forKey: key) as? T ?? defaultValue } set { UserDefaults.standard.set(newValue, forKey: key) } } } struct Settings { UserDefault(hasSeenOnboarding, defaultValue: false) var hasSeenOnboarding: Bool UserDefault(userName, defaultValue: ) var userName: String }这段代码的价值在于你在多个地方声明属性只写一次包装器读写 UserDefaults 的逻辑就全部收口了。新同事接手时看到UserDefault就能猜到读写行为不用翻业务代码。很多初学者不理解wrappedValue和projectedValue的区别。简单说wrappedValue是属性本身的读写业务代码里settings.userName Tom触发的就是它projectedValue则暴露更多信息用$前缀访问常见做法是把配置项的 key 暴露出来或者提供校验结果。比如你写一个Trimmed包装器可以让$value返回是否发生了裁剪方便 UI 提示。属性包装器也有代价。第一包装器本身是一个额外的类型调试时堆栈层级会加深第二属性包装器不能和lazy同时使用也不能直接用在协议属性声明里第三Codable 会自动合成解码时有时会把包装器的内部存储也要求解码导致报错。这些坑我后面专门写一节。2.4 类型属性与全局共享状态设计类型属性用static声明归属于类型本身而不是某个实例。它有两种形态static var可以理解为“全局变量但限定命名空间”static let则是常量池。在一个项目里我见过三种比较合理的类型属性用法。第一种是单例。Swift 的static let shared SomeClass()天然是原子初始化、线程安全的不用加锁。final class AppConfig { static let shared AppConfig() var logLevel: LogLevel .info var apiBaseURL: URL URL(string: https://api.example.com)! }第二种是共享不可变配置比如static let cellReuseID HistoryCell放在模型类里避免散落字符串。第三种是类型级的计算属性用来映射枚举、提供默认资源等。比如ColorTheme的static var currentTheme可以根据系统外观自动切换。设计类型属性时最需要警惕的是“可变全局状态的蔓延”。如果所有人都能访问AppConfig.shared并任意改属性很快你就不知道这个配置到底被谁改过、什么时候改的。我在做大型项目时会把可变状态收敛到少数几个类中并且用私有 setter 或属性包装器加一层写保护。3. 实操从零搭建一个带属性系统的 Swift 模型层3.1 设计一个面向协议的属性模型层这一节我们完整做一个小项目用户设置中心。它要管理用户名、游客模式、消息通知开关、主题色还要在属性变化时自动持久化和 UI 联动。第一步定义一个协议来描述“用户偏好项”这样不同业务模块可以各自实现自己的偏好项而不依赖一个巨大的单例。protocol PreferenceStorable { var storageKey: String { get } func load() - Self func save(_ value: Self) }协议让属性的存储方式具有多态性有人存 UserDefaults有人存内存有人存云端。但注意协议声明属性时只能声明计算属性或遵循协议的类型属性不能像类那样要求一个static var的存储。这一点在 Swift 5.x 之后很多人踩过坑我之前想在协议里定义一个static var defaultValue结果发现协议存储属性只对实例属性开放最终只能改成协议加关联类型的方式。3.2 属性观察器实现自动持久化偏好设置类里最直接的做法是给每个属性加 didSet然后调 UserDefaults 同步。final class UserSettings { static let shared UserSettings() var nickname: String 游客 { didSet { UserDefaults.standard.set(nickname, forKey: nickname) } } var isGuest: Bool true { didSet { UserDefaults.standard.set(isGuest, forKey: isGuest) NotificationCenter.default.post(name: .guestModeChanged, object: nil) } } var enableNotification: Bool false { didSet { UserDefaults.standard.set(enableNotification, forKey: enableNotification) } } }这段代码能跑但有两个问题重复代码多且每个属性都硬编码 key。假如你有二十个设置项就会写二十遍UserDefaults.standard.set(...)看着就头大。更麻烦的是一旦 key 写错运行时才会发现编译期完全无感知。我建议把 UserDefaults 的读写收口到一个属性包装器里让每个属性只声明一次存储方式自动完成加载和保存。3.3 属性包装器实现格式化与校验接着第二节的UserDefault包装器我们做增强版支持默认值、支持值变化时通知、支持自定义存储键。propertyWrapper struct SettingT { let key: String let defaultValue: T private let notifier: ((T) - Void)? init(_ key: String, defaultValue: T, notifier: ((T) - Void)? nil) { self.key key self.defaultValue defaultValue self.notifier notifier } var wrappedValue: T { get { if let value UserDefaults.standard.object(forKey: key) as? T { return value } return defaultValue } set { UserDefaults.standard.set(newValue, forKey: key) notifier?(newValue) } } }这里有三个关键点。第一T必须是UserDefaults能直接存储的类型或者需要额外做桥接。Swift 的as? T对 Int、Bool、String、Double 这类类型有效但对自定义模型会失败。第二notifier的存在让调用方可以传一个闭包在值变化时触发 UI 刷新。我通常在里面发通知或者直接调用刷新方法。第三注意UserDefaults.standard.object(forKey:)返回的是Any?如果存进去 String 取出来也是 String一般没问题。但如果你把 Int 存进去用as? String去取会得到 nil兜底到defaultValue——这是一个很容易忽略的点。真实项目里我还会给包装器加一个projectedValue用来暴露当前值是否等于默认值、最后修改时间等元信息。extension Setting { var projectedValue: SettingInfoT { SettingInfo(key: key, modifiedAt: UserDefaults.standard.object(forKey: key _modifiedAt) as? Date) } } struct SettingInfoT { let key: String let modifiedAt: Date? }这样业务方拿到$nickname.modifiedAt就能在设置页面显示“最近修改时间”非常方便。3.4 类型属性实现全局配置设置类本身用static let shared做成单例是很自然的。但这里有个容易犯的错误单例本身有全局生命周期如果你在设置类里放了大量存储属性那些属性也会一直活在内存里。我之前遇到过一个案例某个团队在单例里保存了“上次下载的图片缓存对象”导致 App 退到后台后内存居高不下。后来改成单例只保存轻量偏好大型缓存对象移到独立缓存器里并按需加载。下面是一个完整但克制的配置类写法enum AppStyle { static let cornerRadius: CGFloat 12 static let defaultPadding: CGFloat 16 static var isDarkMode: Bool false { didSet { NotificationCenter.default.post(name: .themeChanged, object: nil) } } } final class UserSettings { static let shared UserSettings() Setting(nickname, defaultValue: 游客) var nickname: String Setting(isGuest, defaultValue: true) var isGuest: Bool Setting(notifyEnabled, defaultValue: true) var notifyEnabled: Bool private init() { // 加载默认值等 } }类型属性和存储属性、包装器组合在一起既保证了静态访问的便捷又保证了每个配置项都有独立的存储键和回调钩子。当你看到全部设置项都变成一行Setting声明时会明显感觉到代码膨胀度降下来了。4. 跨界视角其他技术栈中的“属性”4.1 Python 类属性、类方法和静态方法给 Swift 的启示热词里出现了 Python 类属性、类方法、静态方法。Swift 的static属性和 Python 的class attribute在语义上很接近——都是挂在类型上而不是实例上。但 Python 多了一个classmethod和staticmethod的区别Swift 相对统一static不能继承、不能重写class关键字可以被子类重写。用 Swift 写一个类似 Python 类方法的场景你希望子类提供自己的默认配置。class Vehicle { class var defaultWheels: Int { return 4 } var wheelCount: Int Vehicle.defaultWheels } final class Motorcycle: Vehicle { override class var defaultWheels: Int { return 2 } }这种“类计算属性配合继承”的写法和 Python 的classmethod返回默认值思路几乎一模一样。我给你一个忠告不要把static let和static var当成全局变量的避风港尤其是在并发场景下对static var的写入必须考虑数据竞争。Swift 6 的严格并发检查对全局可变状态的限制会更严趁早收敛是好事。4.2 Java/Spring 的 Bean 属性注入与生命周期Spring 热词里的“Bean 属性注入”其实是另一个维度的属性语义Java Bean 的属性是字段getter/setter 的约定Spring 通过反射机制在运行时注入依赖。这跟 Swift 属性包装器有几分相似——都希望“属性自己声明依赖/存储方式框架负责接线”。在 Swift 里做依赖注入我通常用初始化器注入而不是靠反射。原因很简单Swift 的反射能力有限对存储属性的遍历不如 Java 灵活强行模拟 Spring 会牺牲类型安全和编译期检查。类比对我是有帮助的Spring 的Value注解注入配置值类似 Swift 的Setting包装器Spring 的 Bean 生命周期回调PostConstruct类似 Swift 的didSet 初始化序号控制。但 Swift 的哲学更偏向显式、编译期确定“运行时魔法”能不用就不用。4.3 从 HDF5、EDA 看“属性”的另一种含义HDF5 是一种科学数据格式把元数据存成“属性”把二进制大块数据存成“数据集”EDA 工具里给元件引脚设置“无电气属性”标记。这些听起来和 Swift 无关但它们共同揭示了“属性”这个词的本质属性是附着在某个主体上的元信息它描述主体但不等于主体本身。把这个理解移植回 Swift你会发现一个设计原则能用计算属性描述派生信息就不要用存储属性保存冗余状态。比如一个订单的“剩余支付时间”用payDeadlineDate.timeIntervalSinceNow算出来即可不需要存一个remainingSeconds属性然后定时器去更新——那样做既浪费内存又容易产生不一致。同时“属性”和“主体生命周期”的绑定关系在任何领域都成立。HDF5 的元数据必须和他的数据集一起存储EDA 的电气属性必须跟着焊盘封装走。Swift 的存储属性也天然绑定在实例生命周期上设计时问一句“这个属性跟着谁存活”能避免很多无谓的全局状态。5. 常见陷阱与排查实录5.1 属性观察器不是万能的didSet听上去像是“值变了之后一定会触发”但有两个例外。第一个例外在 init 中给属性赋值不触发观察器。这是刻意的设计因为实例还没完全初始化完成观察器里的代码如果访问 self 的其他属性可能导致未定义行为。我见过有人想利用 init 里的赋值触发统计埋点结果统计一个都没发出来后来才发现 init 阶段根本不会走 didSet。第二个例外观察器不能阻止值的写入也不能感知“改写为相同值”的语义差别。比如var count 0 { didSet { print(count changed, old: \(oldValue)) } } count 0这行赋值也会触发 didSet打印old: 0。如果业务方用“是否触发”来判断值真的变化就会出 bug。实际开发中我会在 didSet 里再加一层if oldValue ! newValue判断。5.2 计算属性和 KVO/KVC 的冲突在 Objective-C 时代KVO 依赖运行时很多属性天然支持观察。Swift 的计算属性如果不暴露给 Objective-CKVO 就会失效。即使你写objc dynamic var可以给存储属性加 KVO但计算属性要让 KVO 生效必须用objc dynamic并手动实现setter麻烦得很。我在 iPhone 项目里遇到过一个问题想用 KVO 监听 viewModel 里的计算属性变化结果 ViewController 完全没有收到回调。原因是计算属性根本没有存储变化KVO 机制无从下手。后来我改成给底层存储属性加 didSet在 didSet 里手动调用一个“通知代理”的方法问题就解决了。如果你要兼容 KVO建议规则是存储属性用 KVO 观察派生值用didSet 重新读取计算属性来联动不要试图让 KVO 去观察计算属性除非你有非常强的兼容需求。5.3 属性包装器带来的 Codable 坑propertyWrapper配合Codable有一个经典报错使用自动合成的init(from:)解码时编译器不知道你的包装器该怎么从 JSON 中取值。我碰到过的真实情况propertyWrapper struct HealthValue { var wrappedValue: Int } struct UserModel: Codable { HealthValue var health: Int }解码的时候会报Cannot automatically synthesize Decodable because HealthValue does not conform to Decodable。解决办法有两种第一种让包装器实现Codable第二种给模型手动写CodingKeys和init(from:)。大多数情况下让包装器遵循Codable是更省事的路径因为你可以决定解码时是取wrappedValue还是取额外字段。extension HealthValue: Codable { init(from decoder: Decoder) throws { let container try decoder.singleValueContainer() wrappedValue try container.decode(Int.self) } }这个坑很难在编译期发现通常是在接口联调时第一次跑解码才爆炸。我个人建议凡是自定义属性包装器只要有可能被 Codable 模型使用就干脆直接实现 Codable免得后面踩雷。5.4 线程安全下的属性设计最后说一个最容易“看起来没问题线上崩溃”的主题多线程访问属性。Swift 的存储属性本身不保证线程安全。两个线程同时写一个var array: [Int]轻则数据错乱重则崩溃。很多人以为在 didSet 里加锁就万事大吉其实锁只能保护读写的那一瞬如果业务逻辑是先读再写中间被并发打断照样会出问题。我在 Swift 5 时代做数据层时给属性设计定了几条规矩只有不可变let属性可以跨线程自由读可变属性尽量收敛到一个隔离队列访问用DispatchQueue做同步属性如果是集合类型必须使用写时复制或加锁绝不在一行代码里连续读写同一个可变属性比如obj.arr.append本身是安全调用但obj.arr obj.arr newItem就可能被插队。Swift 6 引入了更严格的并发检查很多“碰运气”的写法编译器会直接报错。尽早把属性访问的并发模型设计清楚比事后用锁补救要稳妥得多。从实际经验来说属性设计和架构风格高度相关。如果你用的是 UIKit MVC观察器是主力如果你用的是 SwiftUI更加要重视Published、State等属性包装器和视图刷新之间的协作关系。无论哪种架构核心都是让属性的“存储位置”、“变更通知”、“派生计算”各司其职避免一个属性承担太多隐性职责。最后分享一个我经常用的小技巧写新类之前先列出这个类所有属性给每个属性标注三个标签——“存储 / 计算 / 类型”、“需要观察 / 不需要观察”、“可写 / 只读”。标完再动手写代码。这个过程看起来繁琐但能挡住绝大部分属性设计层面的坑。我踩过太多次在代码写到一半才想起来某个属性应该用 lazy、某个计算属性应该做成函数、某段重复逻辑应该抽成包装器的情况。提前在纸上标记后面代码会很顺。
返回列表