《Swift进阶》第五章(属性)知识点梳理、重点与难点总结

5 阅读6分钟

一、核心知识点罗列

(一)属性的核心分类与基础特性

  1. 存储属性(Stored Property)

    • 本质:直接存储实际值,占用实例内存,分为变量存储属性(var)和常量存储属性(let)。
    • 访问控制:支持private(set)/fileprivate(set)等修饰符,实现“外部只读、内部可写”(如private(set) var record: [(CLLocation, Date)] = [])。
    • 适用类型:类、结构体(枚举无存储属性,因枚举无实例存储),不能定义在扩展中。
    • 自动合成:结构体默认生成成员初始化器(Memberwise Initializer),自动初始化所有存储属性。
  2. 计算属性(Computed Property)

    • 本质:无独立存储空间,本质是“无参数方法”,通过get(必需)和set(可选)方法实现值的获取与修改。
    • 只读计算属性:省略set方法或直接写var prop: Type { 计算逻辑 }(如var timestamps: [Date] { record.map { $0.1 } })。
    • 复杂度要求:非O(1)复杂度的计算属性需在文档中注明,避免调用者忽视性能损耗。
    • 适用类型:类、结构体、枚举,可定义在类型本体或扩展中。

(二)变更观察者(WillSet & DidSet)

  1. 核心作用:监控属性值变化(即使值未改变也会触发),分别在“值设置前”(willSet)和“值设置后”(didSet)执行。
  2. 关键特性
    • willSet:参数newValue表示新值,可自定义名称(如willSet(newVal) { ... })。
    • didSet:参数oldValue表示旧值,常用于触发副作用(如didSet { setNeedsLayout() })。
    • 限制:必须在属性声明时定义,不能在扩展中添加;子类可重写父类属性以追加观察者。
    • 与KVO的区别:Swift变更观察者是编译时特性,仅作用于类型内部;Objective-C KVO是运行时特性,支持外部观察。

(三)延迟存储属性(Lazy Property)

  1. 本质:延迟初始化,首次访问时才执行初始化逻辑,初始化后值被缓存。
  2. 语法与限制
    • 必须用var声明(let需初始化完成前赋值,无法延迟)。
    • 初始化逻辑:支持闭包表达式(如lazy var preview: UIImage = { /* 耗时计算 */ }()),闭包后需加()触发执行。
    • 内存特性:结构体中访问延迟属性属于mutating操作,需将结构体实例声明为var(因访问时可能修改实例内部状态)。
    • 线程安全:无内置线程同步,多线程同时访问可能导致初始化逻辑执行多次。
  3. 注意事项:延迟属性的值不会随依赖属性变化自动更新(如distanceFromOrigin依赖x/yx修改后需手动重新计算)。

(四)属性包装(Property Wrapper)

  1. 核心目标:将属性的重复逻辑(如存储、验证、副作用)封装为可复用组件,简化代码(替代重复的willSet/didSet或计算属性逻辑)。
  2. 基础实现
    • 关键字@propertyWrapper标记封装类型,必须实现wrappedValueget必需,set可选)。
    • 编译器转换:为被包装属性生成“带下划线的私有存储属性”(如@Box var isOn: Bool生成private var _isOn: Box<Bool>),通过wrappedValue访问实际值。
    • 初始化:支持init(wrappedValue:)默认初始化(如Boxinit(wrappedValue: A)),允许直接赋值初始值(@Box var isOn: Bool = false)。
  3. 核心特性
    • 投影值(Projected Value):通过projectedValue属性暴露额外功能,访问时加$前缀(如$person对应person.projectedValue),常用于绑定或引用传递。
    • Self封装:部分属性包装需访问“封装实例”(如@Invalidating需调用view.setNeedsLayout()),通过静态下标static subscript(_enclosingInstance: ...)实现。
    • 适用场景:状态管理(SwiftUI的@State/@Binding)、依赖注入(@Inject)、副作用触发(@Invalidating(.layout))、存储适配(@UserDefaults)。
  4. 使用限制
    • 不能用于枚举(枚举无实例存储)、全局变量;可用于类、结构体、局部变量、函数参数。
    • 不可与weak/unowned/lazy/@NSCopying同时修饰。
    • 嵌套使用需注意:仅外层包装支持“封装实例访问”(如SwiftUI的@State需作为最外层包装)。

(五)键路径(Key Path)

  1. 本质与语法
    • 定义:指向属性的“未调用引用”,语法以\开头(如\Person.address.street),支持类型推断(上下文明确时可简写为\.count)。
    • 核心价值:类型安全、无拼写错误风险,区别于Objective-C的字符串键路径(如"address.street")。
  2. 键路径类型层级(从基础到复杂)
    • AnyKeyPath:最基础类型,类似(Any) -> Any?,无类型信息。
    • PartialKeyPath<Source>:绑定源类型,值类型为Any?(如PartialKeyPath<Person>)。
    • KeyPath<Source, Target>:强类型键路径,对应(Source) -> Target(如\Person.nameKeyPath<Person, String>)。
    • WritableKeyPath<Source, Target>:可写键路径,支持修改属性值,要求所有嵌套属性均为var(如\Person.address.street)。
    • ReferenceWritableKeyPath<Source, Target>:针对引用类型的可写键路径,无需inout参数即可修改(如UIView的属性键路径)。
  3. 核心操作与应用
    • 访问属性:通过instance[keyPath: keyPath]访问(如lisa[keyPath: \.name])。
    • 键路径拼接:通过append(path:)组合键路径(如\Person.name + \String.count = KeyPath<Person, Int>)。
    • 支持下标:可包含数组、字典下标(如\.[1].name访问数组第二个元素的name属性)。
    • 与函数的转换:编译器自动将键路径转为函数(如people.map(\.name)等价于people.map { $0.name })。
  4. 与Objective-C键路径的区别
    • Swift键路径:编译时类型检查、无拼写错误、支持Hashable(可作为字典键)、无状态。
    • Objective-C键路径:字符串形式、运行时解析、可能崩溃、无类型安全。

(六)属性的其他关键细节

  1. 局部变量与全局变量:局部变量支持属性包装,全局变量不支持;变量与属性遵循相同的可变性、观察者规则。
  2. 函数参数中的属性包装:Swift 5.5+支持函数参数使用属性包装(如func takesBox(@Box foo: String)),编译器自动生成包装逻辑。
  3. 属性包装的弃用与错误处理:可通过@available(*, unavailable, message: ...)标记不支持的类型,提供清晰编译错误。

二、重点知识点总结

(一)属性的本质区别与使用场景

  • 存储属性vs计算属性
    • 存储属性:适用于需长期保存数据、直接访问的场景(如var record: [(CLLocation, Date)]),注意访问控制与可变性控制。
    • 计算属性:适用于“动态计算值”“无独立存储”的场景(如var timestamps: [Date]),只读计算属性优先省略set方法。
  • 核心原则:避免将复杂计算逻辑放入存储属性,通过计算属性或延迟存储属性拆分;非O(1)复杂度的计算属性必须在文档中注明。

(二)属性包装的核心价值与实用范式

  • 简化重复逻辑:将“值存储+副作用”(如属性变更触发布局失效)封装为属性包装(如@Invalidating(.layout)),替代冗余的didSet
  • 关键语法糖应用
    • wrappedValue:对外暴露的“透明接口”,使用时与普通属性无差异。
    • projectedValue:通过$前缀访问,适用于绑定(如SwiftUI的$isOn)、引用传递等场景。
  • 常见应用场景
    • UI开发:@Invalidating触发布局/约束失效。
    • 状态管理:SwiftUI的@State/@Binding/@Published
    • 存储适配:@UserDefaults包装偏好设置存储。

(三)键路径的核心优势与使用技巧

  • 简洁性与类型安全:相比闭包,键路径语法更简洁(如\Person.name vs { $0.name }),且编译时检查类型,避免运行时错误。
  • 关键操作
    • 可写键路径:WritableKeyPath用于值类型属性修改,ReferenceWritableKeyPath用于引用类型,无需inout参数。
    • 键路径拼接:通过append(path:)组合多层属性,灵活构建复杂路径。
  • 与函数的区别:键路径是Hashable、无状态的,可存储、比较;函数不可比较,可能捕获状态,两者不能双向转换。

(四)变更观察者与延迟属性的实用场景

  • 变更观察者:适用于“属性变更需触发副作用”的场景(如pageSize变更触发setNeedsLayout),注意不能在扩展中添加,仅能在属性声明时定义。
  • 延迟属性:适用于“初始化耗时”“按需加载”的场景(如预览图生成),避免初始化时浪费资源;结构体中使用需注意var声明要求。

三、难点知识点总结

(一)属性包装的底层原理与限制

  • 编译器转换逻辑:被包装属性会生成“带下划线的私有存储属性”(如@Box var isOn生成_isOn: Box<Bool>),wrappedValue是访问该存储属性的计算属性,容易忽略底层包装逻辑导致误用。
  • 投影值的混淆点$前缀对应的projectedValue需手动实现,默认无投影值;部分系统包装(如@State)的投影值是Binding类型,需区分“属性值”与“绑定值”。
  • 使用限制的坑
    • 不能用于枚举、全局变量,函数参数中的属性包装会暴露包装类型(如init(projectedValue:)存在时)。
    • 嵌套包装仅外层支持“封装实例访问”,内层包装无法获取_enclosingInstance

(二)键路径的类型区分与使用陷阱

  • 可写键路径的两种类型
    • WritableKeyPath:适用于值类型,修改需实例为var(如结构体属性)。
    • ReferenceWritableKeyPath:适用于引用类型,修改无需实例为var(如类属性),容易混淆两者的适用场景。
  • 与函数的差异:键路径是Hashable、无状态的,可作为字典键;函数不可比较、可能捕获状态,不能直接替代键路径(如数据库查询中的属性引用)。
  • 性能问题:相比直接访问属性,键路径访问速度较慢,避免在性能敏感场景(如密集循环)中频繁使用。

(三)延迟存储属性的特殊行为

  • 结构体中的限制:结构体的延迟属性访问属于mutating操作,即使属性本身是let,也需将结构体实例声明为var,否则编译报错(容易忽视“访问即修改”的隐式行为)。
  • 线程安全风险:无内置线程同步,多线程同时首次访问可能导致初始化逻辑执行多次,需手动添加锁或避免多线程并发访问。
  • 值缓存问题:延迟属性初始化后值被缓存,依赖属性变更不会触发重新计算(如distanceFromOrigin依赖x/yx修改后仍返回旧值)。

(四)属性相关的编译器特性与隐式行为

  • 变更观察者的触发时机:即使属性值未改变(如x = x),willSetdidSet仍会触发;inout参数传递会强制触发观察者(即使未修改值)。
  • 属性包装的自动初始化init(wrappedValue:)是可选的,若未实现则无法直接赋值初始值(如@Reference var street: String需通过init(projectedValue:)初始化)。
  • 键路径的类型推断歧义\Person.name可推断为KeyPath<Person, String>(Person) -> String,编译器优先推断为键路径类型,需显式标注类型解决歧义。

四、总结

本章核心围绕“属性的本质与高效使用”展开,核心逻辑是“区分存储与计算的本质差异,利用属性包装和键路径简化重复逻辑、提升代码安全性”。重点在于掌握属性的分类特性、属性包装的实用范式、键路径的类型安全优势;难点集中在属性包装的底层转换、键路径的类型区分、延迟属性的特殊限制,以及属性相关的隐式编译器行为。

实际开发中,应根据场景选择属性类型:存储属性用于保存数据,计算属性用于动态计算,属性包装用于封装重复逻辑(如状态管理、副作用触发),键路径用于简洁、类型安全的属性引用。同时需规避常见陷阱(如结构体中延迟属性的var要求、属性包装的使用限制),平衡代码简洁性与可维护性。

如果需要,我可以帮你整理属性包装核心API的对比表,或针对某个难点(如键路径与函数的转换、属性包装的自定义实现)提供详细代码示例。当前文件内容过长,豆包只阅读了前 16%。