关联对象的底层实现&分类为什么不能添加实例变量
一、 为什么 Category 不能加实例变量 (Ivar)?
核心原因在于 Objective-C 对象的内存布局在编译期就已经确定,而 Category 是在运行期动态加载的。
1. class_ro_t 与 内存布局
在 Objective-C 的 Runtime 源码中,类的底层结构体是 objc_class。其中最重要的部分是 class_data_bits_t,它指向 class_rw_t(Read-Write),而 class_rw_t 中包含一个指向 class_ro_t(Read-Only)的指针。
-
class_ro_t(Read-Only): 存储了编译器决定的信息,包括类的名称、方法、协议,以及最重要的ivars(实例变量列表) 和instanceSize(实例大小)。 -
编译期决议: 当你的代码被编译时,编译器计算了主类(Host Class)需要多少内存,以及每个 Ivar 相对于对象内存起始地址的 偏移量(Offset)。
2. 硬加入 Ivar 会发生什么?
如果允许 Category 添加 Ivar:
-
内存越界: 现有的对象实例已经分配了固定大小的内存。Category 加载时(通常是 App 启动的
dyld链接阶段或realizeClass阶段),如果强行增加 Ivar,意味着对象的大小变大了。已经生成的对象实例无法动态扩容,访问新 Ivar 会导致访问非法内存。 -
地址偏移错乱: 即使你能在加载 Category 时重新计算布局,那子类怎么办?如果子类已经依赖父类的 Ivar 布局编译好了,父类突然插入一个 Ivar,所有子类的 Ivar 偏移量全部失效,导致访问数据错乱(即“脆弱基类问题” Fragile Base Class,虽然 Modern Runtime 解决了部分,但仅限于主类变更,不支持 Category 动态插入)。
例外: Extension(扩展)可以添加 Ivar,因为 Extension 是在编译期与主类一起编译的,编译器能正确计算最终的布局。
二、 为什么 Category 可以加属性 (Property)?
这里需要区分 Property(属性) 和 Ivar(实例变量) 的概念。
-
Property = Ivar + Setter + Getter (通常情况)。
-
但是在 Category 中:Property = Setter声明 + Getter声明。
当你自己在 Category 中写 @property 时,编译器不会自动合成 Ivar(_variable),也不会自动合成 Getter/Setter 的实现。它仅仅是向类的 class_rw_t 的 methods 列表中添加了两个方法的声明。
因此,Category 加属性本质上是在加方法,这在 Runtime 中是完全支持的(因为方法列表存储在 class_rw_t 中,是可读写的,可以在运行时动态添加)。
为了让这个属性真正“可用”,我们需要手动实现 Getter 和 Setter,并找个地方把值存起来——这就引出了 关联对象。
三、 关联对象的底层实现 (HashMap)
关联对象(Associated Objects)并没有存储在对象本身的内存中,而是存储在一个全局的哈希表中。这解释了为什么它不会破坏对象的内存布局。
根据 Runtime 源码 (objc-references.mm),其核心架构如下:
1. 核心数据结构层级
我们可以将存储结构看作一个 双层 HashMap:
-
AssociationsManager:-
管理一个全局唯一的锁 (
spinlock),保证线程安全。 -
维护一个全局的 HashMap,即
AssociationsHashMap。
-
-
AssociationsHashMap(全局表):-
Key: 被关联对象的指针地址(经过
DISGUISE处理,通常是按位取反等操作,防止被当做指针误用)。 -
Value:
ObjectAssociationMap(属于该对象的所有关联表)。 -
这是一个
Unordered Map。
-
-
ObjectAssociationMap(对象表):-
这是属于某个特定对象的所有关联对象的集合。
-
Key: 我们调用
objc_setAssociatedObject时传入的key(通常是一个静态地址&key). -
Value:
ObjcAssociation。
-
-
ObjcAssociation(最小单元):-
存储具体的 Value (关联的值)。
-
存储 Policy (内存修饰符:
OBJC_ASSOCIATION_RETAIN_NONATOMIC,COPY,ASSIGN等)。
-
2. 存取过程 (objc_setAssociatedObject)
当你调用 objc_setAssociatedObject(obj, key, value, policy) 时:
-
加锁:
AssociationsManager获取全局自旋锁。 -
查找/创建:
-
在全局
AssociationsHashMap中,根据obj的地址查找对应的ObjectAssociationMap。 -
如果没找到,就新建一个。
-
-
存储:
-
在
ObjectAssociationMap中,根据key查找或新建ObjcAssociation。 -
如果传入的
value是nil,则移除该关联。 -
根据
policy对value进行 Retain 或 Copy 操作,并更新ObjcAssociation。
-
-
标记: 调用
obj->setHasAssociatedObjects()。这会在对象的isa指针的位域中标记“该对象有关联对象”。(这一步对性能优化至关重要,见下文)。 -
解锁: 释放自旋锁。
3. 关联对象的释放
既然数据存在全局表中,对象销毁了怎么办?会内存泄漏吗?
不会。 Runtime 监听了对象的释放流程:
-
当对象调用
dealloc时。 -
检查
isa指针中的has_assoc标记位。 -
如果标记为
true,调用_object_remove_assocations(obj)。 -
该函数会获取全局锁,去
AssociationsHashMap中找到该对象的 Map,对 Map 中所有的 Value 发送release消息,然后将该对象从全局表中移除。
四、 总结与架构师视角的思考
| 特性 | Ivar (实例变量) | Associated Object (关联对象) |
|---|---|---|
| 存储位置 | 对象实例内存内部 (class_ro_t 决定) | 全局 AssociationsHashMap |
| 访问速度 | 极快 (直接指针偏移量访问) | 较慢 (需要哈希查找 + 全局锁) |
| 内存布局 | 编译期确定,不可变 | 运行期动态管理 |
| Category支持 | 不支持 | 支持 |
技术要点总结:
-
Category 不能加 Ivar 是因为会破坏已确定的内存布局 (
class_ro_t),导致偏移量计算错误。 -
Category 能加 Property 是因为 Property 声明本质是方法声明,且我们可以通过关联对象模拟存储。
-
关联对象 实现了一个 全局的、线程安全的双层 Hash Map,通过对象地址映射数据,实现了数据与对象内存的解耦。
性能警示:
虽然关联对象很强大,但因为涉及到 全局锁 和 哈希查找,其性能低于直接访问 Ivar。在核心渲染循环或高频调用的代码路径中(例如 Flutter 的外接纹理处理或复杂的 UI 布局计算),应谨慎使用关联对象,尽量通过继承或组合来解决问题。