平时写 Swift 代码,十个操作里至少有八个都在碰属性。无论你是 UIKit 还是 SwiftUI 出身,var、let、didSet、@State这些东西早就刻进了肌肉记忆。可一旦代码写出问题——界面上某个值怎么改都不刷新,或者一个懒加载在并发时初始化了两次——你才会意识到,Swift 属性远不是“存个值、取个值”这么简单。
这篇文章想聊聊我对 Swift 属性的完整理解。它不是什么语法速查手册,而是把属性的各种形态拆开来讲清楚:存储属性怎么分配内存,计算属性为什么不该背缓存,属性观察器在哪个阶段触发,懒加载有哪些并发坑,static 在全局状态里的角色,属性包装器如何改变写法,以及键路径这种“把属性当函数用”的机制。内容会尽量贴近实际项目里的选择和取舍,适合刚学 Swift 的初学者,也适合写了两三年却对内存和线程行为说不清的中级开发者。
1. 存储属性:值的落脚点决定了变化的边界
1.1 引用类型与值类型的属性行为差异
存储属性是最直觉的一种属性:它是实例身上真正占内存的数据。struct的存储属性直接躺在结构实例的内存里,class的存储属性也是实例的一部分,但类和结构体在“赋值”时的表现完全不同。
看这段代码:
struct Point { var x: Double var y: Double } let origin = Point(x: 0, y: 0) var moving = origin moving.x = 10 print(origin.x) // 0moving = origin是一次值拷贝,之后修改moving.x不会影响origin.x。这是结构体的核心语义。换成class就不一样:
final class Particle { var x: Double var speed: Double init(x: Double, speed: Double) { self.x = x self.speed = speed } }如果把Particle的实例赋值给两个变量,它们指向同一个堆上的对象,任何一个变量修改x,另一个也会看到。这个差异在属性层面带来的启示是:当你用一个struct承载多个属性时,拷贝成本要心里有数。Swift 对一些标准库容器做了写时复制优化,但你自定义的结构体并不会自动获得 COW,它只会在赋值或传参时拷贝整个内存块。如果结构体里塞了大量字段,并且经常被复制,性能吃亏的地方往往就是你一个个精心设计的存储属性。
class还有一重容易忽略的语义:let属性只固定引用,不冻结对象内容。比如你写:
final class Manager { let cache = Cache() }cache这个引用不能再指向别的对象,但cache内部的var字段是可以随便改的。所以如果你真的想表达“这个属性不可变”,struct + let比class + let严格得多。
1.2 初始化、默认值和枚举的边界
存储属性在初始化这件事上有一个铁律:在实例完成初始化之前,所有存储属性必须有值。struct会生成成员初始化器,如果你给属性写了默认值,调用方甚至可以省略它:
struct User { var name: String = "未登录" var age: Int } // 可以只传 age,name 使用默认值 let user = User(age: 18)class就没有这么方便,默认情况下不会获得成员初始化器,你必须显式写init。除非你给所有存储属性都加了默认值,编译器才会提供一个无参初始化器。很多新手栽在这里,其实不是语法难,而是要理解编译器为什么这么设计:结构体是值,拷贝时必须整块可用;类是引用,构造过程需要你自己保证完整性。
还有一个小众但重要的边界:枚举不能加存储实例属性。枚举实例没有为字段预留存储空间,它的“值”就是自身那个 case。所以你在枚举里只能写计算属性或类型属性。这个限制有时候会逼着你重新设计数据结构,但反过来想,它也让枚举天然适合表达“状态集合”而不是“状态加数据”。
2. 计算属性:把派生数据从存储里解放出来
2.1 只读计算属性是状态的投影
计算属性不占存储空间,每次访问都执行一次逻辑。它最适合的场景是:一个值可以由其他属性推算出来,而且你希望它永远跟源数据保持同步。
举一个最常见的模型:
struct Rectangle { var width: Double var height: Double var area: Double { width * height } var isSquare: Bool { width == height } }area不需要单独存储。如果当初用存储属性保存“面积”,你会面临一个很尴尬的维护问题:宽或者高变了,面积要不要跟着变?每次都要记得同步,不出 bug 才是怪事。
计算属性在界面开发里更有价值。很多 UI 状态本质上都是模型数据的投影。比如一个订单模型有“原价”和“折扣”,展示给用户的“实付金额”就应该写成计算属性,而不是在业务代码里到处手动计算后塞进一个可变字段。这样无论原价还是折扣在哪个流程里被修改,页面上拿到的永远是最新的投影。
2.2 有 setter 的计算属性与性能边界
计算属性不一定是只读的。只要语义清晰,你完全可以给它写 setter,让它把值“反写”回源属性:
struct Temperature { var celsius: Double var fahrenheit: Double { get { celsius * 9 / 5 + 32 } set { celsius = (newValue - 32) * 5 / 9 } } } var temp = Temperature(celsius: 25) temp.fahrenheit = 212 print(temp.celsius) // 100这种写法把单位换算收敛到一个属性上,外部使用者不用关心底层到底存的是摄氏还是华氏。类似的还有坐标转换、日期格式化、金额分转元等等。
计算属性有三个硬限制:必须声明成var、不能加属性观察器、不能是lazy。这三个限制不是随便定的——没有存储空间,就没有“赋值前后”的钩子可以挂,也没有内存可以延迟分配。理解这一点,你在编译器报错时就不会觉得莫名其妙了。
关于性能和方法的取舍,我的原则是:如果一个值可以由其他属性无参数、无副作用地推导出来,用计算属性;如果推导过程需要传参,或者可能会产生副作用,那就做成方法。这也是 Swift 官方 API 设计规范里的建议。不要为了那点性能差去缓存一个本就十微秒内算完的值,你真正需要担心的是那种大计算量且高频访问的属性,这种情况我会显式做一个缓存方案,而不是靠计算属性硬扛:
final class Report { var rows: [Double] = [] { didSet { cachedTotal = nil } } private var cachedTotal: Double? var total: Double { get { if let cache = cachedTotal { return cache } let value = rows.reduce(0, +) cachedTotal = value return value } } }注意这个缓存必须在rows改变时清空,否则你会看到“改了数据但 total 不变”的幽灵 bug。如果缓存计算代价高且访问频繁,甚至要考虑多线程同步,这些就是存储属性和计算属性混合设计时要承担的复杂度。
3. 属性观察器:赋值背后的钩子不是万能的
3.1 willSet 与 didSet 的触发时机
属性观察器做的是同一件事:在赋值的前后插入你自定义的逻辑。
var balance: Int = 0 { willSet { print("balance 马上从 \(balance) 变成 \(newValue)") } didSet { print("balance 已经从 \(oldValue) 变成 \(balance)") } } balance = 100willSet里读取balance还是旧值,newValue是即将写入的新值;didSet里读取balance已经是新值,oldValue是之前的旧值。这两个参数名也可以自定义成更语义化的名字。
观察器在一次balance += 1里会触发两次逻辑:先读旧值,再写新值。这个“看似是赋值运算符、其实是读改写三步”的细节经常被忽略。如果观察器里做了重量级操作,你的代码可能在一行+=上卡很久。
注意,观察器在实例初始化阶段不会触发。也就是说在init里给一个带观察器的属性赋值,willSet和didSet都不跑。这个行为的底层原因并不复杂:观察器的语义是“属性在生命周期内发生变化”,而初始化阶段属性还没有真正对外可见,谈不上“变化”。很多人在这个点上先入为主地踩坑,调试半天发现didSet没执行,还以为写错了。
3.2 三个高频陷阱:重复赋值、引用类型和递归
观察器有一个特性经常被忽略:即使新值和旧值相同,它也会触发。你写balance = balance,或者给一个本来就是 0 的变量赋 0,观察器照样走一遍。如果你在didSet里做了“只有值变化才处理”的业务,记得自己加一层比较:
didSet { if oldValue != balance { // 真正变化时才处理 } }第二个陷阱和引用类型有关。如果属性的类型是某个class,你修改这个对象内部的字段,观察器不会触发。因为观察器监听的是“属性这个槽位的值是否被重新赋值”,而不是“属性指向的对象内部是否变化”。换成结构体就不一样,比如一个[Int]类型的属性,你调用append,这本质上是对属性存储位置的写操作,观察器会被触发。
第三个陷阱比较隐蔽:在didSet里再次给同一个属性赋值,观察器会再次触发,然后会再次进入didSet……如果你写了一个没想清楚的修正逻辑,这就会变成递归。
var count: Int = 0 { didSet { if count < 0 { count = 0 // 危险,会再次触发 didSet } } }这段代码虽然最终能把count拉回 0,但中间会多出若干次不必要调用,如果count正负反复横跳,可能会卡死。稳妥的做法是不要在观察器里直接修正自身属性,改用临时变量或单独的方法来完成修正,并且严格控制重入条件。
4. lazy 懒加载:延迟的收益与并发、内存的代价
4.1 Lazy 的工作方式与 UI 场景
lazy var意味着属性第一次被访问时才执行初始化闭包,之后结果会被缓存,再访问就直接返回缓存值。
final class ProfileViewController: UIViewController { private lazy var avatarView: UIImageView = { let view = UIImageView() view.translatesAutoresizingMaskIntoConstraints = false view.layer.cornerRadius = 32 return view }() override func viewDidLoad() { super.viewDidLoad() view.addSubview(avatarView) } }这种写法把视图创建的时机推迟到真正添加它的时候。如果这个页面在某些分支下根本不会显示头像,那这个UIImageView就完全没必要提前创建。它是一次性创建,之后访问到的永远是同一个实例。
因为初始化要等到实例已经存在之后才能进行,lazy属性必须声明为var,不能是let。原因很直白:let要求属性在初始化完成前有值,而懒加载恰恰把这个时间点推迟到了首次访问。
4.2 线程安全与闭包捕获 self 的隐患
lazy在单线程场景下很舒服,但你别把它当成线程安全的。Swift 对实例的懒存储属性并没有提供原子性保证。如果两个线程同时第一次访问同一个lazy var,初始化闭包可能被执行两次,导致你拿到两个不同的对象,或者更糟,出现数据竞争。这里要特别区分一点:全局变量和静态属性是默认懒加载并且线程安全初始化的,但实例的lazy属性没有这个保证。所以如果你在多线程环境里必须用懒加载,要么加锁,要么通过其他方式保证只在一个线程上触发首次访问:
final class Manager { private let lock = NSLock() private var _cache: Cache? var cache: Cache { lock.lock() defer { lock.unlock() } if let cache = _cache { return cache } let new = Cache() _cache = new return new } }还有一个隐藏很深的内存问题:懒加载属性通常是用闭包初始化的,如果这个闭包里引用了self,那么在这个属性被首次访问之前,会形成一个强引用环。举个例子:
lazy var sendButton: UIButton = { let button = UIButton() button.addTarget(self, action: #selector(send), for: .touchUpInside) return button }()sendButton持有闭包,闭包捕获self,而self又持有sendButton。如果这个按钮一直没被访问,这个环就一直在,对象永远释放不了。等到属性真的被访问、闭包执行完,闭包才会被释放,环才算解开。但“永远不访问”在真实代码里并不少见,尤其是那些存在于父容器里但长时间没被渲染的视图,这种循环就会变成实实在在的内存泄漏。所以写懒加载闭包时,一定要先问自己:这个闭包捕获了谁?
5. static 与 class:类型属性是全局态的最后防线
5.1 static 存储属性与 class 计算属性的区别
类型属性属于类型本身,而不是某个实例。用static声明的存储属性,在内核里就是一个和类型绑定的全局存储。
struct AppConstants { static let maxRetryCount = 3 static let defaultTimeout: TimeInterval = 30 }访问时直接用AppConstants.maxRetryCount,不需要先创建实例。这些常量在编译期确定或首次访问时确定,非常适合放配置项、枚举值的展示文案、颜色、字体这种和业务实例无关的数据。
Swift 里的全局变量和静态属性天生就是懒加载的,并且初始化过程是线程安全的,所以你不必用dispatch_once之类的方案去保护它们的初始化。这一点和实例的lazy var是相反的,我经常在代码评审时看到有人给静态属性套锁,其实初始化这个环节 Swift 已经管住了。
如果你写的是class类型,class关键字还能声明计算属性,并允许子类重写:
class Animal { class var species: String { "动物" } } class Dog: Animal { override class var species: String { "狗" } }但注意,class不能用于存储属性,你不可能写class var count = 0这样的代码。想要可以在子类里重写的类型属性,只能用计算属性的形式。
5.2 static let 单例与 static var 全局态的双刃剑
static let最常见的用途是单例:
final class APIClient { static let shared = APIClient() private init() {} }这段代码的好处是简洁且线程安全。坏处是它把对象变成了全局可达状态。测试的时候你很难替换内部依赖,因为shared已经写死;多个测试用例共享同一个单例,上一个用例留下的状态会污染下一个用例。我见过不少 CI 上偶发失败的测试,最后查出来不是业务 bug,而是某个static var缓存了上一个测试的 mock 数据。
所以我的建议是:static let适合放不可变常量,适合注册表和服务定位器,但不要逢对象就单例。如果确实需要一个全局可变的共享状态,先想清楚并发访问由谁负责。Swift 不保证static var的读写线程安全,你不能因为它是static就默认它原子。
6. 属性包装器:把分散的校验和存储逻辑收进声明里
6.1 自定义包装器的基本结构
属性包装器把一个属性的读写逻辑收拢到一个单独类型里。语法形式是@propertyWrapper,但它的本质是一个结构体或类,负责管理真正的存储。
最常见的用途之一是做范围校验。比如进度条需要一个 0 到 100 的数,你不想每次赋值都在业务代码里写min(max(...)),就可以定义:
@propertyWrapper struct Clamped<Value: Comparable> { private var storedValue: Value let range: ClosedRange<Value> init(wrappedValue: Value, range: ClosedRange<Value>) { self.range = range self.storedValue = min(max(wrappedValue, range.lowerBound), range.upperBound) } var wrappedValue: Value { get { storedValue } set { storedValue = min(max(newValue, range.lowerBound), range.upperBound) } } }使用的时候非常清爽:
@Clamped(range: 0...100) var progress: Double = 50 progress = 120 print(progress) // 10050会作为wrappedValue传入初始化器,0...100作为额外的range参数。赋值 120 之后,包装器自动把它压回 100。以后任何地方给progress赋值,都不用担心越界。
另一个高频场景是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) } } } final class Settings { @UserDefault(key: "has_seen_onboarding", defaultValue: false) var hasSeenOnboarding: Bool }这种写法把“读 UserDefaults、转型、给默认值、写回”四件事全部收敛到声明处。但也要注意代价:每次读取都会去访问UserDefaults,如果在一个很热的路由里频繁读这个属性,开销会比普通存储属性大不少。
6.2 projectedValue、内置包装器与初始化细节
属性包装器还有一个强大的能力:projectedValue。它通过$前缀让你的属性额外暴露一层视图。比如 SwiftUI 里的@State,你不仅能用state读写值,还能通过$state拿到一个Binding,把读写权交给子视图。这正是projectedValue的功劳。
如果你自己定义包装器,可以这样写:
@propertyWrapper struct Flag { var wrappedValue: Bool var projectedValue: Flag { self } } @Flag var isEnabled = false let binding = $isEnabled // 拿到包装器本身,可以在外部继续包装实际项目中,projectedValue常用来暴露“是否被修改过”、发送事件、或者给 UI 绑定操作。
内置包装器里,@State、@Binding、@Published、@AppStorage都是极好的例子。特别是@Published,它在 Combine 里把一个普通属性升级成发布源,任何赋值都会通知订阅者。如果项目使用了 Swift 5.9 以后的@Observable,你会发现很多原本需要@Published加手动观察的样板逻辑被大幅简化,但背后的设计思路仍然是“给属性附加行为”。
用包装器时最容易困惑的是初始化。编译器会为包装属性生成和包装器init(wrappedValue:)匹配的初始化逻辑,一旦你自己定义了额外参数,顺序必须正确。常见报错是类型推断失败,提示找不到合适的初始化器,这时候先检查一下属性声明后面的初始值是作为wrappedValue传入的,而额外参数放在前面的括号里。包装器很好用,但别滥用:断点栈会变深,代码阅读者要跳进包装器类型才能明白赋值行为,团队里最好统一使用范围。
7. 键路径与 dynamicMemberLookup:属性也可以被当作一等值传递
7.1 KeyPath 的类型安全价值
键路径是一种把“属性的名字”转成“可传递的值”的机制。最常见的写法是:
struct User { var id: Int var name: String } let users: [User] = [...] let ids = users.map(\.id) let names = users.map(\.name)\.id在编译期就能确认User真的有id属性,类型是Int。如果你重命名了属性,编译器会立刻报错,而不是等到运行时静默返回 nil。这和 Objective-C 时代的 KVC 字符串写法完全不同,后者一旦写错,运行时不报错但拿到的数据是错的,非常难查。
键路径本身是一个值,你可以把它当作参数传递:
func readField<T, V>(_ keyPath: KeyPath<T, V>, from value: T) -> V { value[keyPath: keyPath] } let name = readField(\.name, from: user)这种写法在批量处理、数据绑定、排序和 SwiftUI 里都很常见。ForEach里指定id: \.id,Picker里绑定tag: \.id,本质上都是把属性作为稳定标识或绑定来源。
需要区别的是,纯 Swift 的键路径和NSObject的#keyPath不是一回事。#keyPath(User.name)依赖 Objective-C 运行时,只有继承NSObject的类才能用 KVC 方式访问。普通struct和final class用\.name就够了,这是更类型安全、更 Swift 原生的路线。
7.2 dynamicMemberLookup 与属性式下标
@dynamicMemberLookup让类型可以接受“看起来像属性访问”的写法,实际上内部是下标调用。这个机制主要用于桥接字典、JSON、数据库行这类本没有静态属性的数据源:
@dynamicMemberLookup struct KVStore { private var dict: [String: String] subscript(dynamicMember member: String) -> String? { get { dict[member] } set { dict[member] = newValue } } } var store = KVStore(dict: [:]) store.username = "小明" print(store.username ?? "")store.username在编译时并不存在,编译器会转去调用subscript(dynamicMember:)。它的优点是调用点很简洁,可以造出类似动态语言的体验;缺点是失去了静态类型检查,属性名拼错时只能靠运行时 nil 兜底。
从存储属性到键路径,整个过程其实是一个递进:先有真正占内存的字段,再到不占内存的投影,再到把“字段名”本身变成可传递的值。理解这条线,你会更明白 Swift 的属性不是一个简单概念,而是一整套围绕数据访问的语言设施。
8. 属性设计与我的踩坑清单
8.1 加属性之前先问三个问题
我现在写代码,每增加一个属性都会先问自己三句话。
第一句:这个值是独立的真相,还是可以由其他属性推导?如果是推导值,就写计算属性,不让它有机会和源数据打架。
第二句:这个值的变化需要让谁感知?如果只是内部联动,属性观察器或者didSet缓存重置是够用的;如果要跨对象、跨模块通知,就该考虑 Combine、@Observable或显式事件机制,而不是在观察器里偷偷做全局副作用。
第三句:这个值在并下发下是否安全?尤其涉及lazy var和static var时,线程安全不会自动来。属性本身的写法越简单,越不容易在并发场景埋雷。
8.2 我自己翻过的一个车
有一段经历让我印象很深。当时我在一个详情页控制器里写了这样的代码:
final class DetailViewController: UIViewController { var itemID: Int = -1 { didSet { loadData() } } }推入页面之前,外层代码会设置itemID,我以为这样就能自动触发数据加载。结果loadData()在viewDidLoad之前就开始执行了,界面还在初始化阶段,数据回来了也没有地方渲染,页面体验就是一片空白或者永远少一块。
后来我把副作用移出了属性观察器,改成显式方法:
func configure(itemID: Int) { self.itemID = itemID loadData() }虽然少了一点“自动触发”的魔法感,但调用顺序完全可控。属性观察器适合做轻量联动,比如清理缓存、更新派生值、记录日志;它不应该承担业务调度职责,尤其是涉及 UI 生命周期和网络请求的时候。
8.3 一张简短的属性选型清单
最后整理一下我在项目里沉淀出来的选型习惯:
- 能用
let就不用var,不可变属性让并发分析简单一大截。 - 能推导就用计算属性,数据只有一个真相源。
- 一次性的昂贵初始化用
lazy var,但要锁好线程,闭包里少碰self。 - 常量、配置、单例用
static let;可变全局共享状态先想锁和测试隔离。 - 校验、持久化、UI 绑定用属性包装器收拢逻辑。
- 需要批量读取、排序、绑定时,用键路径而不是字符串。
- 需要监听属性变化时,先判断是不是该把业务挪到显式方法里。
这套习惯不是凭空想出来的,是从一次又一次线上事故和测试失败里反推出来的。属性是 Swift 代码里最普通的语法元素,但它的每一次选择都在悄悄决定你项目的稳定性和可维护性。下次写var xxx = ...之前,多花十秒想一下它的生命周期,代码会感谢你。