ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Objective-C属性(@property)详解:从原理到内存管理的完整指南

Objective-C属性(@property)详解:从原理到内存管理的完整指南 1. 属性解决了什么问题重写手写存取方法的日子很多刚接触 OC 的开发者第一眼看到的往往是property (nonatomic, strong) NSString *name;这样一行代码用了很久也不一定想过为什么要有属性这套机制在属性出现之前OC 开发是怎么写的先回到那个没有属性或者说没有property的年代。假设你要给一个类添加一个name字段让别人能读能写你需要做的事情是// Person.h interface Person : NSObject - (NSString *)name; - (void)setName:(NSString *)name; end // Person.m implementation Person { NSString *_name; } - (NSString *)name { return _name; } - (void)setName:(NSString *)name { _name [name copy]; } end这套写法本身并不复杂但问题在于一个类如果有十几个字段就得手写十几对 getter 和 setter。这些方法的结构高度重复本质上就是把成员变量暴露给外部同时加一层控制。而且这里还有更隐蔽的问题——ARC 出现之前内存管理要手动写每个 setter 里都要考虑retain、release、copy的平衡稍不留神就是泄漏或者过度释放。property本质上就是把这一整套重复劳动声明化了。你只需要声明一个属性编译器会自动帮你完成三件事生成对应的成员变量_name生成 getter 方法- (NSString *)name生成 setter 方法- (void)setName:(NSString *)name。更关键的是这套生成逻辑是按照你声明的修饰词来定制的比如你写了copy编译器生成的 setter 里自动就是拷贝语义。所以属性解决的核心问题不是少写几行代码而是把对象的内部状态如何暴露、暴露的时候做什么保护这个设计决策从散落的实现代码中抽离出来变成一个集中声明的配置项。理解了这一点再看后面的修饰词、自动合成、运行时机制就都有了一个清晰的主线。另外要提一个使用层面的感触属性不只是给外部访问用的。它同时承担了类内部对状态的管理这个职责。一个常见的初学者困惑是我能不能直接用_name访问什么时候用self.name。这个问题没有一刀切的答案但在大多数场景下外部访问用self.name走 setter/getter内部初始化场景可以直接操作_name下面内存管理那一节会展开讲。心里有这个区分代码风格会清楚很多。2. property 声明语法与自动合成的完整链路2.1 从 property 到成员变量的自动生成现代 OC 编译器LLVM 时代在绝大多数情况下你写完一行property后不需要再写synthesize属性会自动合成autosynthesis。比如你写了property (nonatomic, strong) NSString *name;编译器会默认帮你合成synthesize name _name;也就是说它自动创建了一个名为_name的成员变量并生成对应的 getter 和 setter。这条规则有两个重要的边界条件值得注意第一如果你在实现里手动实现了 getter 和 setter两个都实现编译器就不会再自动合成成员变量了。这是很多初学者遇到Use of undeclared identifier _name错误的根源——你以为是自动生成就永远生成但实际上编译器发现你两个方法都手写了就判断你不需要自动合成了于是_name也不存在了。第二如果你只手动实现了其中一个比如只实现了 getter或者只实现了 setter编译器仍然会帮你合成另外一个同时也会合成_name变量。这是 LLVM 的贴心之处但也埋了坑有些开发者只重写 getter 来做懒加载然后发现 setter 还在就误以为 getter 都自己写了setter 也是自己的代码实际上 setter 是编译器合成的行为和你声明的修饰词一致。2.2 synthesize 的手写场景和 dynamic 的非常规操作虽然自动合成是主流但有两个场景你可能需要手写synthesize场景一你想要自己的成员变量名字而不是默认的_name。比如synthesize name myName;这样生成的成员变量就变成了myName而不是_name。说实话这个需求比较小众但确实存在。比如在维护老代码时某个成员变量已经被用_xxx命名了你想让新属性和它挂钩又不想大规模改引用就可以用这种方式。场景二你手写了 getter 和 setter但又想明确告诉编译器你还是给我生成_name。这时可以写synthesize name _name;显式声明之后即使你两个方法都手动实现了编译器也会按要求创建_name成员变量。而dynamic则是另一个极端它明确告诉编译器这个属性的 getter 和 setter 我不用你管到运行时自然会有的。最常见的场景是 Core Data 里的property声明以及NSManagedObject子类。因为这些属性的 getter/setter 根本不是在编译期生成的而是 Core Data 框架在运行时动态添加的如果你不写dynamic编译器会警告属性既没有自动合成也没有被 dynamic 声明。顺便提一下dynamic也用在一些自定义的 NSObject 子类配合消息转发机制的场景属于进阶玩法普通业务开发中用得不多。2.3 点语法与消息调用的等价关系很多新手会把self.name xxx理解成给属性赋值这个理解没错但从 OC 的机制上说点语法只是调用 setter/getter 方法的语法糖。self.name Hello;在编译时和下面这一行完全等价[self setName:Hello];同理NSString *n self.name; // 等价于 [self name]; NSString *n2 _name; // 直接读成员变量不走 getter这个等价关系非常重要因为它引出了几个经典坑坑一在 setter/getter 内部使用点语法会导致无限递归。比如- (void)setName:(NSString *)name { self.name name; // 又调用了 setName:死循环 }正确写法是在 setter 内部直接操作_name。坑二点语法和成员变量访问的语义完全不同。self.name通过方法走一遍可能做了拷贝、可能做了 weak 处理、可能在 KVO 时候发送通知_name就是裸的指针赋值/读取。所以同一个名字在不同写法下行为差异可以非常大这也是属性机制的精髓所在——它把底层存储和对外行为解耦了。3. 修饰词怎么选存储语义、原子性与读写控制修饰词是 OC 属性里最需要用理解而非背的部分。一个简单的property (nonatomic, strong) NSString *name;里nonatomic和strong各管一摊事。3.1 存储语义strong、weak、copy、assign 的选择逻辑存储语义决定了 setter 方法内部如何处理传入的对象。strong在 MRC 时代叫retain表示持有这个对象setter 内部会对新值做一次 retain对旧值做一次 release。在 ARC 下你不需要写 retain/release编译器自动插入但语义是一样的只要属性还持有这个对象对象就不会被释放。weak表示不持有对象是对对象的弱引用。setter 内部不会 retain 新值。当被引用的对象释放后这个属性会被自动置为 nil从而避免悬垂指针。这是 delegate 属性几乎必定用 weak 的原因——避免循环引用后面内存管理章节会专门展开。copy的 setter 内部会对新值发送copy消息得到一个新的不可变副本然后持有这个副本。NSString、NSArray、NSDictionary 这些有可变子类的类型属性一般用 copy目的是防止外部传一个可变对象进来然后在你不知情的情况下被改掉。assign主要用于基本数据类型NSInteger、CGFloat、BOOL以及非 OC 对象的 C 类型。注意在 ARC 下如果用assign修饰一个 OC 对象类型的属性是很危险的行为对象释放后属性不会自动置 nil继续访问就是野指针崩溃。事实上很多编译器版本会对对象类型 assign直接报错逼你改用 weak。另外提一下unsafe_unretained它和weak有点像但不自动置 nil现在基本只在追求极致性能或特殊兼容场景下使用日常开发碰不到。来看一个常见的选型表格修饰词适用范围setter 行为典型场景strongOC 对象持有新值释放旧值绝大多数对象属性weakOC 对象不持有对方释放后自动置 nildelegate、父子关系中的子引用、XIB 里的 IBOutletcopy遵循 NSCopying 的 OC 对象拷贝一份副本持有NSString、NSArray、Blockassign基本数据类型直接赋值NSInteger、CGFloat 等unsafe_unretainedOC 对象不推荐直接赋值不持有不置 nil需要兼容旧代码的特殊场景3.2 原子性atomic 与 nonatomic 的真实代价atomic意味着属性是线程安全的但要注意这里的线程安全只是保证读写这个属性本身是原子的也就是不会出现两个线程同时 set 导致内存错乱这种问题。它并不保证业务逻辑的线程安全。举个例子你有两个属性A和B一个线程在读另一个线程在同时修改 A 和 B即使用了atomic你依然可能读到 A 是新的、B 是旧的这种中间状态。真正的线程安全需要更上层的同步机制比如锁或者队列。atomic的底层实现是对 setter/getter 加了锁本质上类似自旋锁或互斥锁所以它是有性能开销的。iOS 开发中几乎所有的 UI 相关属性和数据模型属性都用nonatomic原因有两个一是 UI 操作本身必须回到主线程属性层面的原子性帮助不大二是atomic带来的锁开销在频繁读写的场景下确实会拖累性能。我的建议是默认都写nonatomic只有在确认多线程直接读写这一个属性且需要原子性时才考虑atomic并且即使那样也要想清楚这个原子性到底够不够用。3.3 读写控制readonly 与 readwritereadwrite是默认值表示既生成 getter 也生成 setter。readonly则只生成 getter外部只能读不能写。一个常见需求是对外只读对内可写。做法是在 .h 里声明为readonly然后在 .m 的文件扩展名class extension里重新声明为readwrite。注意重新声明为 readwrite不是你改一行代码就行的需要在 .m 文件的匿名 category 里再写一遍property比如// ViewController.h interface ViewController : UIViewController property (nonatomic, strong, readonly) NSString *status; end // ViewController.m interface ViewController () property (nonatomic, strong, readwrite) NSString *status; end这种写法非常常用既保证了封装性又让内部能自由赋值。这里有个小细节编译期只会看到一个 property 的声明组合两个声明必须兼容不能冲突比如在 .h 里你写了copy.m 里就不能改成strong。3.4 自定义 getter/setter 名字OC 的property还允许自定义 getter 和 setter 的方法名两个常见场景第一布尔类型的属性。比如property (nonatomic, getterisFinished) BOOL finished;这样外部可以写if (objc.isFinished)语义上更自然。第二通过setter:自定义 setter 名字比如property (nonatomic, settersetNickName:) NSString *name;用得不多但有些框架里要求你提供特定名字的 setter 时会用到。4. 属性底层的运行时机制合成、调用与拦截4.1 自动合成到底做了什么在 clang 编译器里property的自动合成发生在编译阶段。可以试着用 clang 的命令把 OC 代码导出成中间表示能看到编译器为属性生成了类似下面的代码static NSString * _I_Person_name(Person * self, SEL _cmd) { return *(NSString **)((char *)self offsetof(Person, _name)); } static void _I_Person_setName_(Person * self, SEL _cmd, NSString *name) { objc_setProperty(self, selector(setName:), name, strong, atomic); }当然这只是一个概念示意实际生成逻辑要复杂一些但核心思路是明确的成员变量的读写被包装成了 C 语言层面的函数setter 里会根据修饰词插入对应的内存管理操作retain/release/copy以及原子性控制。4.2 KVO 之所以能工作是因为 setter 有固定的调用路径KVOKey-Value Observing是 OC 属性机制的一个典型受益者。KVO 能实现观察的前提是你监听某个 keyPath 时系统通过 runtime 动态生成一个子类比如NSKVONotifying_Person重写这个属性的 setter。当你调用setName:时实际上走的是被重写后的 setter它会在赋值前后调用willChangeValueForKey:和didChangeValueForKey:从而通知观察者。这个机制能成立恰恰是因为 OC 属性的 setter 走的是方法调用路径而不是直接内存写。如果你在对象内部直接操作_nameKVO 是感知不到的。这也解释了为什么某个属性明明被赋了新值KVO 却不触发这种问题出现时八成是因为代码里用了_name ...而不是self.name ...。KVO 在技术上不仅支持自动触发也支持手动触发但手动触发的场景比较少见。理解setter 是 KVO 的触发入口这一点对排查问题已经足够了。4.3 关联对象给已有的类动态挂属性OC 的 runtime 还提供了一套关联对象机制从 API 层面模拟属性static char kAssociatedKey; // 存 objc_setAssociatedObject(obj, kAssociatedKey, value, OBJC_ASSOCIATION_RETAIN_NONATOMIC); // 取 id value objc_getAssociatedObject(obj, kAssociatedKey);这套机制在分类category中很常用因为分类本身不能直接添加成员变量但可以借助关联对象实现给这个类加一个属性的效果。需要注意的是关联对象的内存管理语义通过OBJC_ASSOCIATION_*宏来控制取值对应着强引用、弱引用、拷贝等几种策略选错了同样会有循环引用问题。要区分清楚property是编译期特性自动合成是在编译期完成运行期只是不知情地使用关联对象是纯粹的运行期特性完全由 runtime 函数操作编译期看不到任何成员变量。两种机制各有适用场景。4.4 属性 VS 直接成员变量的设计考量说到底属性是一个受控的访问接口成员变量是原始存储空间。把这两者混为一谈是很多潜在 bug 的开端。举一个设计层面的例子一个类的属性声明为copy手动的 getter 返回_name时直接返回内部对象。外部拿到了这个对象后如果它不是不可变的话依然能修改内部状态这其实破坏了 copy 的语义。所以严谨的做法是当属性值是一个可变对象的副本时getter 可以考虑返回不可变版本比如NSArray返回时封装成NSArray或者至少让调用方清楚你拿到的不保证后续不受影响。再从职责角度看属性定义的是外部契约成员变量才是内部实现。同一个属性内部可以随时换存储方式从直接_name改成懒加载等等而对外的访问方式不变。这种封装思想是属性机制最有价值的部分。5. 实战中避不开的内存管理细节循环引用与拷贝陷阱5.1 delegate 为什么必须用 weakiOS 开发里最常见的循环引用场景大概就是 delegate 了。假设一个控制器A强持有UITableView而UITableView的 delegate 是A。如果 delegate 是强引用那么A- table强持有table - delegate(A)强持有两个对象互相强持有谁也释放不了内存泄漏。所以 delegate 几乎永远是weak这样 table 对 A 只是弱引用不阻碍 A 的释放。block 里的循环引用也是热门话题。block 如果被一个对象强持有比如 GCD 的全局 block 会有特殊处理但属性持有的 block 一般会block 内部又强引用了 self那就形成了 self - block - self 的环。常见解法是把 self 转成 weak 再在 block 里使用__weak typeof(self) weakSelf self; self.handler ^{ [weakSelf doSomething]; };在 Swift 时代有更方便的 capture list但 OC 里这是标配写法的原因就是属性机制下 strong 默认的持有语义。5.2 copy 属性到底要不要担心性能copy一个字符串的成本通常不高如果你 copy 的是一个很大的数组确实会有性能开销。但性能不是第一考量语义安全才是。想象这样一个场景NSMutableString *mStr [NSMutableString stringWithString:hello]; self.name mStr; // 如果 name 是 strong [mStr appendString: world]; // self.name 变成了 hello world外部改一下你的属性跟着变如果self.name是strong上面的行为就是外部可变对象被改了你持有的不可变字符串也变了这往往不是期望的语义。使用copy后setter 会拷贝一份不可变的副本外部再怎么改也不影响内部值。所以对 NSString、NSArray、NSDictionary、NSData 这些类型首选copy这不是性能洁癖而是一种语义约定。与之对应的一个点如果你声明的是 NSMutableString 类型的属性就不能再用 copy 了因为 copy 出来的是不可变对象赋值后你调appendString:直接崩溃。这时应该用strong同时这个属性本身也可以被外部修改你要在业务层承担这份风险。5.3 在 init 和 dealloc 里访问属性的分歧点这是一个非常典型的踩坑点。行业社区里争论过很多轮结论也基本统一在init和dealloc中优先直接操作成员变量_name而不是self.name。原因有两条第一setter 通常是给对象状态已经就绪的阶段用的在 init 阶段调用可能触发不必要的副作用。比如一个属性在 setter 里做了通知、做了 KVO 触发、或者做了某种联动逻辑但 init 阶段对象还没完全构造完成这些行为可能不安全。第二在 dealloc 中对象已经进入释放流程再调用 setter里面的 weak 处理、KVO 移除逻辑可能会引发问题甚至访问到已经释放的关联对象。当然这并不是绝对禁令。某些场景下你确实需要在 init 里调用self.x ...比如这个属性是IBOutlet且你在代码里赋值不涉及副作用就还好。但默认习惯应该是init 里直接给_name赋初始值dealloc 里直接清理_name指向的资源不绕圈。6. 高频踩坑现场与最后的几个小经验6.1 一个典型的崩溃排查过程assign 修饰对象类型我遇过这类问题某次线上崩溃崩溃堆栈指向一个对象的某个字符串属性访问时野指针。排查到后面发现是历史代码里写了property (nonatomic, assign) NSString *title;在 ARC 下这基本就是定时炸弹。assign不持有对象等赋值来源释放后属性里存的地址就悬空了再访问就是野指针。处理方式很简单改成copy或strong。排查过程的价值在于不要只看到崩溃就急着加空判断先看属性修饰词、看这个属性是否在对象释放后被继续使用往往几分钟就能定位。6.2 一个深坑atomic 属性的并发读写并没有想象的那么保险接前面 atomic 的讨论。有的团队会把所有属性都写成atomic觉得省心。但说句实在话atomic在移动端这种主线程操作 UI的开发模式里收益非常有限。它顶多保证单次读写的原子性和一定程度的内存安全却让所有 setter/getter 都背上锁的开销。如果你真的有多线程读写的需求更现实的方案是子线程处理好数据然后以不可变的结构一次性返回或者用 dispatch_queue 做串行化。把线程安全完全寄托在atomic上是设计层面的偷懒。6.3 懒加载 getter 的注意点重写 getter 做懒加载是常见操作- (NSArray *)list { if (!_list) { _list [[NSArray alloc] initWithObjects:1, 2, nil]; } return _list; }这里有几个细节容易忽略。首先你在 getter 里访问的是_list不是self.list因为self.list又会调用 getter造成死循环。其次如果这个属性同时被声明为atomic懒加载的写法其实绕过了原子性保护因为这时代码手动管理而非编译器生成的 setter/getter。再次懒加载和多线程同时访问时可能创建多次虽然不是每次都会出事但最好在锁定场景下避免依赖懒加载。6.4 属性在分类中的实现技巧如前所述分类不能直接添加成员变量。如果你需要给一个既有类加个属性并且让它有真实存储那就绕不开关联对象。但关联对象写多了有个小问题代码里到处是objc_getAssociatedObject的调用可读性差。建议封装成属性样式的存取方法比如interface UIView (Utils) property (nonatomic, strong) UIColor *borderColor; end implementation UIView (Utils) - (void)setBorderColor:(UIColor *)borderColor { objc_setAssociatedObject(self, selector(borderColor), borderColor, OBJC_ASSOCIATION_RETAIN_NONATOMIC); } - (UIColor *)borderColor { return objc_getAssociatedObject(self, selector(borderColor)); } end这样对外使用还是点语法内部才看到关联对象细节。注意 key 的地址要稳定用selector作为 key 是一个简洁可靠的做法。6.5 在调试中验证属性的行为真要透彻理解一个属性的行为除了看文档还可以直接做实验。我常用的一个方法是临时在 setter 里加日志或者断点运行时看一下赋值路径。或者用class_getInstanceMethod查看一个类有没有真的自动生成对应方法Method m class_getInstanceMethod([Person class], selector(setName:));如果m不为空说明 setter 存在至于是编译器自动合成的还是你手写的也可以通过方法实现的指针进一步判断。再分享一个比较实用的调试经验当你怀疑某个属性被 KVO 监听导致赋值异常时可以先在断点里用observationInfo看对象被谁观察了配合NSKeyValueObserving相关方法排查。属性这套机制表面上是语法糖底层是编译器加运行时的高度协同。学习它的价值在于你以后写的每一行property都清楚它意味着什么、会不会带来隐患。把这些基础打牢你再去看 ARC 下的内存管理、看 KVO、看 Core Data 的dynamic、看各种网络库里的属性声明都会有一种豁然开朗的感觉。我用 OC 写了几年代码最大的体会是很多项目的 bug 其实不是算法问题而是状态访问边界没控制好。属性把状态访问收拢到一个明确的语法入口认真理解并规范使用它本身就是一种降低项目复杂度的方式。
返回列表