Swift 与 Objective-C

Objective-C Associated Objects:为分类添加属性

分类无法添加 ivar,但可以通过 Associated Objects 关联值。本文总结值存储在对象旁边的侧表中的原理、分类属性的完整实现,以及只需注意 assign 的内存策略。

4 分钟阅读
Objective-C Associated Objects:为分类添加属性 封面图

在“分类 vs extension”一文中,我们总结过“分类不能添加存储属性”。原因是类的内存布局在编译时就已确定,后加载的分类没有位置插入 ivar。

但在实际代码中,我们经常会遇到分类中确实存在属性的情况。秘密就是 Associated Objects:一种无需 ivar 即可将值挂到对象上的运行时功能。

这是解决分类存储属性问题的官方变通方案,也是 Objective-C 运行时隐藏在对象外部的辅助存储。


原理:附着在对象旁边的侧表

Associated Objects 的核心函数有两个。

objc_setAssociatedObject(目标对象、键、值、内存策略);
objc_getAssociatedObject(目标对象、键);

值不会存储在对象的 ivar 区域中。运行时会在全局管理的 **关联表(侧表)**中,以“对象地址 → {键: 值}”的形式记录。

对象本体的内存布局一个字节也不会改变,因此不会与编译时确定的布局限制冲突。

目标对象释放后,运行时会在关联表中找到对应项,并根据策略一并清理。生命周期由系统自动管理,适合实际使用。

objc_setAssociatedObject 将值写入运行时全局关联表的结构图
本体布局保持不变,值存放在旁边的表中

分类属性的完整实现

在分类中声明属性时,编译器只会生成 getter/setter 声明,不会创建存储空间。用 Associated Objects 填上这个空缺即可。

#import <objc/runtime.h>

@interface UIView (BadgeCount)
@property (nonatomic, strong) NSNumber *badgeCount;
@end

@implementation UIView (BadgeCount)

- (NSNumber *)badgeCount {
    return objc_getAssociatedObject(self, @selector(badgeCount));
}

- (void)setBadgeCount:(NSNumber *)badgeCount {
    objc_setAssociatedObject(self, @selector(badgeCount),
                             badgeCount,
                             OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

@end

使用 @selector(badgeCount)作为键是惯例。键不是按值比较,而是按指针地址比较,因此只要是整个应用中唯一的地址即可。

选择器的唯一性由运行时保证,也不需要额外声明 static 变量,因此最简洁。像 static void *kBadgeKey = &kBadgeKey;这样的经典方法仍然有效。

使用字符串字面量作为键是个陷阱。即使内容相同,只要编译单元不同,地址就可能不同。

“明明保存了却得到 nil”的谜团就由此产生。


内存策略:只需小心 assign

第四个参数对应属性特性。

策略 对应特性
OBJC_ASSOCIATION_RETAIN_NONATOMIC strong, nonatomic
OBJC_ASSOCIATION_COPY_NONATOMIC copy, nonatomic
OBJC_ASSOCIATION_RETAIN / COPY 与上者相同,但为 atomic
OBJC_ASSOCIATION_ASSIGN assign — 不是 weak

唯一需要注意的是 ASSIGN。虽然名字看起来像 weak,但它是不会自动变成 nil 的 unsafe_unretained

如果关联对象先被释放,就会留下悬空指针。一旦访问就会崩溃。

如果需要 weak 语义,就要将用 weak 属性包装值的包装对象以 RETAIN 方式关联起来。

要删除特定的关联值,设置为 nil 即可。objc_removeAssociatedObjects会清除该对象的全部关联值,威力过强,实际上不应使用。


实际应用与边界

实际项目中常见的组合如下。

  • UIKit 类扩展:给 UIView 添加徽章数量,给 UIButton 添加基于闭包的动作处理器,给 UIViewController 添加用于分析的页面名称
  • 代理到 block 的转换包装器:将代理对象关联到原对象,以绑定两者的生命周期(Alamofire 之前的网络分类经常使用这种模式)
  • 与 swizzling 配套:当通过 swizzling 插入的逻辑需要保存状态时使用。这就是运行时系列中的两种技术经常一起出现的原因。

不过仍然要守住边界。Associated Objects 终究只是存放辅助数据的通道。

如果对象的核心状态分散在关联表中,读代码的人就很难掌握整体设计。如果可以创建子类,使用 ivar 才是正确答案。

只有在需要为不拥有的类附加额外信息时,才应该使用这个方案。

绕过 Swift extension 的 stored property 限制时也可以使用相同函数(仅限 NSObject 系列)。但在 Swift 中,使用协议加专用存储类型,或通过 composition 解决,通常更自然。

用登山扣将收纳包挂在旅行包上的 Associated Objects 类比插图
这就是把收纳包挂在包外的方法

总结

  • Associated Objects 将值存储在运行时的侧表中,而不是对象本体中,因此分类也能使用。
  • 键按指针地址比较。复用 @selector是惯例,字符串字面量则是陷阱。
  • 在内存策略中,ASSIGN不是 weak,而是 unsafe_unretained。请注意悬空指针导致的崩溃。
  • 目标对象释放后,关联值也会自动清理。
  • 用途仅限于为不拥有的类附加辅助数据。把核心状态放在这里,就是设计上的红灯信号。

运行时系列的下一篇是 NSProxy。我们将深入分析消息转发一文中预告的另一个非 NSObject 根类,它如何作为“纯粹的代理”运行。

延伸阅读