关联对象的底层实现&分类为什么不能添加实例变量

一、 为什么 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:

  1. 内存越界: 现有的对象实例已经分配了固定大小的内存。Category 加载时(通常是 App 启动的 dyld 链接阶段或 realizeClass 阶段),如果强行增加 Ivar,意味着对象的大小变大了。已经生成的对象实例无法动态扩容,访问新 Ivar 会导致访问非法内存。

  2. 地址偏移错乱: 即使你能在加载 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_tmethods 列表中添加了两个方法的声明

因此,Category 加属性本质上是在加方法,这在 Runtime 中是完全支持的(因为方法列表存储在 class_rw_t 中,是可读写的,可以在运行时动态添加)。

为了让这个属性真正“可用”,我们需要手动实现 Getter 和 Setter,并找个地方把值存起来——这就引出了 关联对象


三、 关联对象的底层实现 (HashMap)

关联对象(Associated Objects)并没有存储在对象本身的内存中,而是存储在一个全局的哈希表中。这解释了为什么它不会破坏对象的内存布局。

根据 Runtime 源码 (objc-references.mm),其核心架构如下:

1. 核心数据结构层级

我们可以将存储结构看作一个 双层 HashMap

  1. AssociationsManager:

    • 管理一个全局唯一的锁 (spinlock),保证线程安全。

    • 维护一个全局的 HashMap,即 AssociationsHashMap

  2. AssociationsHashMap (全局表):

    • Key: 被关联对象的指针地址(经过 DISGUISE 处理,通常是按位取反等操作,防止被当做指针误用)。

    • Value: ObjectAssociationMap (属于该对象的所有关联表)。

    • 这是一个 Unordered Map

  3. ObjectAssociationMap (对象表):

    • 这是属于某个特定对象的所有关联对象的集合。

    • Key: 我们调用 objc_setAssociatedObject 时传入的 key (通常是一个静态地址 &key).

    • Value: ObjcAssociation

  4. ObjcAssociation (最小单元):

    • 存储具体的 Value (关联的值)。

    • 存储 Policy (内存修饰符:OBJC_ASSOCIATION_RETAIN_NONATOMIC, COPY, ASSIGN 等)。

2. 存取过程 (objc_setAssociatedObject)

当你调用 objc_setAssociatedObject(obj, key, value, policy) 时:

  1. 加锁: AssociationsManager 获取全局自旋锁。

  2. 查找/创建:

    • 在全局 AssociationsHashMap 中,根据 obj 的地址查找对应的 ObjectAssociationMap

    • 如果没找到,就新建一个。

  3. 存储:

    • ObjectAssociationMap 中,根据 key 查找或新建 ObjcAssociation

    • 如果传入的 valuenil,则移除该关联。

    • 根据 policyvalue 进行 Retain 或 Copy 操作,并更新 ObjcAssociation

  4. 标记: 调用 obj->setHasAssociatedObjects()。这会在对象的 isa 指针的位域中标记“该对象有关联对象”。(这一步对性能优化至关重要,见下文)。

  5. 解锁: 释放自旋锁。

3. 关联对象的释放

既然数据存在全局表中,对象销毁了怎么办?会内存泄漏吗?

不会。 Runtime 监听了对象的释放流程:

  1. 当对象调用 dealloc 时。

  2. 检查 isa 指针中的 has_assoc 标记位。

  3. 如果标记为 true,调用 _object_remove_assocations(obj)

  4. 该函数会获取全局锁,去 AssociationsHashMap 中找到该对象的 Map,对 Map 中所有的 Value 发送 release 消息,然后将该对象从全局表中移除。


四、 总结与架构师视角的思考

特性Ivar (实例变量)Associated Object (关联对象)
存储位置对象实例内存内部 (class_ro_t 决定)全局 AssociationsHashMap
访问速度极快 (直接指针偏移量访问)较慢 (需要哈希查找 + 全局锁)
内存布局编译期确定,不可变运行期动态管理
Category支持不支持支持

技术要点总结:

  1. Category 不能加 Ivar 是因为会破坏已确定的内存布局 (class_ro_t),导致偏移量计算错误。

  2. Category 能加 Property 是因为 Property 声明本质是方法声明,且我们可以通过关联对象模拟存储。

  3. 关联对象 实现了一个 全局的、线程安全的双层 Hash Map,通过对象地址映射数据,实现了数据与对象内存的解耦。

性能警示:

虽然关联对象很强大,但因为涉及到 全局锁 和 哈希查找,其性能低于直接访问 Ivar。在核心渲染循环或高频调用的代码路径中(例如 Flutter 的外接纹理处理或复杂的 UI 布局计算),应谨慎使用关联对象,尽量通过继承或组合来解决问题。