Objective-C Runtime底层:分类方法覆盖与关联对象原理

发布时间:2026/8/30 23:04:16
Objective-C Runtime底层:分类方法覆盖与关联对象原理 开头面试问到你“分类为什么能覆盖宿主类方法”的时候你是不是也只会说“方法列表前插”然后面试官再追问一句“那关联对象存在哪里对象销毁时它什么时候释放”很多人当场就卡住了。这两个问题表面上是 iOS 面试八股文里最常背的两段实际考的全是 runtime 源码理解。这篇文章从 Objective-C runtime 源码出发把分类Category和关联对象Associated Object这两块彻底拆开。我会带你过一遍分类从编译产物到运行时合并的全过程看清楚它为什么能“覆盖”方法、为什么不能添加实例变量再把关联对象的三层哈希结构、设置取值流程、销毁释放时机一条条捋清楚。适合正在准备 iOS 面试的开发者也适合平时写代码但没深究过 runtime 底层的人。读完你能回答的不只是“能/不能”而是每一句结论背后源码里的依据。文章不短但值得耐心看完。1. 分类的底层结构category_t 到底长什么样1.1 category_t 结构体字段逐个拆解先看 runtime 源码里分类的真实定义这段代码在 runtime.h 或者 objc-runtime-new.h 里不同版本略有出入但核心字段是一致的struct category_t { const char *name; classref_t cls; struct method_list_t *instanceMethods; struct method_list_t *classMethods; struct protocol_list_t *protocols; struct property_list_t *instanceProperties; // Fields below this point are not always present on disk. struct property_list_t *_classProperties; method_list_t *methodsForMeta(bool isMeta) { if (isMeta) return classMethods; else return instanceMethods; } property_list_t *propertiesForMeta(bool isMeta) { if (isMeta) return _classProperties; else return instanceProperties; } };逐个看字段name分类的名字注意它不是宿主类的名字而是“这个分类叫什么”。cls分类所依附的宿主类。编译期这个字段通常是空指针运行时才被回填成真正的类对象。instanceMethods实例方法列表。你在分类里写的实例方法全部编译到这里。classMethods类方法列表。以 开头的方法全部编译到这里。protocols分类遵循的协议列表。instanceProperties实例属性列表。你在分类里写 property 会生成属性的声明但注意它不会自动生成 ivar也不会自动生成 getter/setter。_classProperties类属性列表对应 property(class) 这种用法平时用得非常少。这里有个初学者最容易犯迷糊的点分类里的 property 声明了属性系统也确实把属性信息记录到了 instanceProperties 里但属性对应的实例变量、getter、setter 实现统统没有。也就是说分类的属性只是“名义上的属性”真正想让它能存值必须借助关联对象或者手动维护一个全局容器。1.2 为什么分类不能添加实例变量内存布局的硬限制把“分类不能添加实例变量”这句话放到编译器和内存布局的语境里理解就完全不一样了。一个对象在运行时它占用的内存大小由 isa、实例变量、对齐等决定。这个内存布局在编译期就已经定死了编译器会计算好每个 ivar 的偏移量。比如一个类有 a、b、c 三个实例变量编译器会按顺序排好偏移然后生成访问代码访问实例变量其实就是通过对象地址 偏移量来定位内存。而分类是在运行时才被加载进进程的它真正的合并动作发生在 app 启动后。假如分类可以添加实例变量那就意味着已经创建出来的对象内存里突然要多出一块区域这根本无法做到——就像盖好的大楼合同已经签完你突然要求开发商在每套房子里多开一个房间结构设计不允许。或许你会问那能不能在类第一次初始化之前就合并分类让所有对象都包含分类的变量实际上也不行因为编译器生成的类布局中根本没有预留这个空间而 iOS 上类的元数据基本是不可变的class_ro_t 是不可写区域。所以 runtime 只能允许分类往方法列表、协议列表里加内容但没法动实例变量布局。理解了这一层后面再看到面试题“分类能不能添加成员变量”时你就可以从内存布局不可变这个角度去答而不是只背一句“不能”。2. 分类方法的加载与合并一份代码走完完整流程2.1 从编译产物到运行时分类对象是怎么被找到的分类在编译后不是直接被塞进类里而是被存放在 Mach-O 文件的专门段中一般是__DATA段的__objc_catlist这个 section。你可以用MachOView或者nm命令查看里面能列出编译出的所有分类。App 启动时dyld 负责加载所有镜像包括主二进制和动态库加载完成后会回调 runtime 的_objc_init注册好的 map_images 函数。map_images 里面做的大量工作之一就是调用_read_images在这个函数里runtime 会遍历所有分类找到它们对应的宿主类然后进行合并。整体链路是这样的dyld 加载镜像 - objc_init 注册回调 - map_images - _read_images - 遍历 category_t 列表 - 找到对应的类调用 remethodizeClass - attachCategories / attachLists值得提一下懒加载类和非懒加载类的区别。如果一个类实现了 load 方法它就是非懒加载类runtime 在启动阶段就必须把它初始化如果没实现 load它就是懒加载类等第一次给它发消息时才会执行realizeClass完成初始化。分类的合并发生在 realize 过程里所以分类的加载时机跟宿主类是否懒惰也有关系。这一点通常面试不太深挖但对理解“为什么有时候分类方法没生效”会有帮助。2.2 attachLists 的核心操作为什么分类方法能覆盖宿主类方法分类真正被合并到类里的关键方法是 attachLists我把它精简后的核心代码贴出来以某一版本 runtime 为例void attachLists(List* const * addedLists, uint32_t addedCount) { if (addedCount 0) return; if (hasArray()) { // 原有列表已经是一个数组 uint32_t oldCount array()-count; uint32_t newCount oldCount addedCount; setArray((array_t *)realloc(array(), array_t::byteSize(newCount))); array()-count newCount; // 把旧列表整体往后挪 memmove(array()-lists addedCount, array()-lists, oldCount * sizeof(array()-lists[0])); // 把新分类的方法列表放到数组最前面 memcpy(array()-lists, addedLists, addedCount * sizeof(array()-lists[0])); } else { // 原来只有一个列表重新分配成数组 ... } }你注意看关键两行memmove把原来的方法列表整个往后移腾出头部空间memcpy把新增的分类方法列表拷贝到头部。也就是说分类方法在合并后排在原有方法列表的前面。那为什么这就实现了“覆盖”因为 Objective-C 方法的查找方式是从方法列表数组下标 0 开始逐个匹配selector一旦找到就返回对应的 IMP。分类方法被放到了数组最前面意味着它比宿主类方法更早被遍历到所以同名的宿主类方法根本没机会被命中调用结果自然就是分类的实现。注意这只是一种“伪覆盖”更准确地说应该是“优先命中”。原始方法其实还在数组后面并没有被删除。这一点很关键因为通过 runtime API 或者 method_exchangeImplementations 仍然还能找到宿主类的原始方法。如果你在代码里验证会发现class_copyMethodList([SomeClass class], count)打印出的方法列表顺序里分类方法排在前面宿主类方法排在后面。顺序就是源码里 memmove / memcpy 操作后的直接结果。2.3 多个分类同名方法到底谁生效面试里还有一个进阶问题如果同一个类有多个分类而且这些分类都实现了同一个方法最终调用的到底是哪个答案取决于分类的加载顺序而加载顺序又和链接顺序有关。核心原因是 attachLists 每次处理一批分类时也是按照列表顺序逐个把分类的方法插入数组头部的所以后处理的分类方法会排得更靠前最终被优先命中。实际操作中这个顺序受 Build Phases 里 Compile Sources 的编译顺序影响有时很难直观控制。所以我的经验是永远不要依赖多个分类同名方法之间的优先级这是一个隐形的坑。代码里一旦出现这种情况不管哪个分类生效都会给后续维护者带来困扰。可以在分类方法里打印日志确认但最好的做法是尽量避免这种写法。2.4 协议和属性的合并逻辑分类不仅仅能合并方法还能合并协议和属性。协议合并走的是 attachProtocols最终把分类里的协议追加到类遵守的协议列表中。这也意味着如果你在分类里声明遵守了某个协议外界通过conformsToProtocol:检查是能查到的。属性的合并则走 attachProperties 流程。分类里的属性会合并到类的属性列表中所以通过class_copyPropertyList确实能列出分类声明的属性。但重要的事情再说一遍这个合并只把属性的“元信息”合并进去了不会生成对应的实例变量和 getter/setter 实现。这也是为什么分类属性必须靠关联对象才能正常工作的根本原因。3. 关联对象源码深度解析三层哈希的存储逻辑3.1 关联对象的数据结构AssociationsManager 管理的三层映射关联对象的实现核心是全局的 AssociationsManager 管理的一张哈希表。源码中的定义大概是class AssociationsManager { static AssociationsHashMap *map; // 全局唯一的哈希表 public: AssociationsManager() { ... 加锁 ... } ~AssociationsManager() { ... 解锁 ... } AssociationsHashMap associations() { if (_objc_associations nullptr) { _objc_associations new AssociationsHashMap; } return *_objc_associations; } };AssociationsHashMap 是一个以对象地址为 key 的 unordered_mapvalue 是一个 ObjectAssociationMap。class AssociationsHashMap : public unordered_mapdisguised_ptr_t, ObjectAssociationMap * { ... }; class ObjectAssociationMap : public std::mapconst void *, ObjcAssociation { ... }; class ObjcAssociation { uintptr_t _policy; // 内存管理策略 id _value; // 关联的值 };所以整体结构是第一层AssociationsManager 持有全局唯一的 AssociationsHashMap。第二层AssociationsHashMap 以对象的指针为 keyvalue 是 ObjectAssociationMap。第三层ObjectAssociationMap 以关联时传的 key 为 keyvalue 是 ObjcAssociation里面存着策略和真正的 value。第三层的 key 就是你在代码里调用 objc_setAssociatedObject 时传的那个 key它只是 const void * 类型不要求是对象。这也是为什么业界最常用的做法是传一个 static char 变量的地址因为它唯一且稳定。用生活类比来说AssociationsManager 相当于整个小区物业的租户档案室AssociationsHashMap 相当于每个楼栋的档案本ObjectAssociationMap 相当于每个房间里租户的登记卡ObjcAssociation 就是登记卡上写的“这房子里放了什么东西、怎么管理”。3.2 objc_setAssociatedObject 完整流程函数原型void objc_setAssociatedObject(id object, const void *key, id value, objc_AssociationPolicy policy)源码流程简化如下进入 AssociationsManager 作用域构造函数加锁。把传入的 object 转成 disguised_ptr_t这是一个对指针做了位变换处理的值用来作为哈希表的 key避免直接暴露原始指针。在 AssociationsHashMap 中查找这个 object 对应的 ObjectAssociationMap。如果没找到就创建一张新的 ObjectAssociationMap 插入进去。在 ObjectAssociationMap 中以传入的 key 查找 ObjcAssociation。如果 key 已经存在就把旧 value 按 policy 做 release 或 copy 释放如果 key 不存在创建新的 ObjcAssociation。把新 value 和新 policy 写入 ObjcAssociation。作用域结束解锁。源码里真正执行设置的核心是一个 acquireValue releaseValue 的过程大致长这样void objc_setAssociatedObject(id object, const void *key, id value, objc_AssociationPolicy policy) { ... ObjcAssociation association(policy, acquireValue(value, policy)); // 获取或创建 ObjectAssociationMap // 用 association 替换原来的 ObjcAssociation // 如果旧关联存在会对旧值执行 releaseValue }这里 acquireValue 是根据 policy 来决定要不要做 objc_retain 或 objc_copy 的。3.3 内存管理策略policy 的五种取值关联对象一共五种 policy可以简单对应到日常写代码时的修饰符关联策略对应修饰符说明OBJC_ASSOCIATION_ASSIGNassign不 retain 不 copy值只是简单赋值对象销毁后关联值可能悬空OBJC_ASSOCIATION_RETAIN_NONATOMICstrong, nonatomic持有对象非原子操作OBJC_ASSOCIATION_COPY_NONATOMICcopy, nonatomic拷贝对象非原子操作OBJC_ASSOCIATION_RETAINstrong, atomic持有对象原子操作OBJC_ASSOCIATION_COPYcopy, atomic拷贝对象原子操作日常开发里最常用的是 RETAIN_NONATOMIC 和 COPY_NONATOMIC。给分类的 block 类型属性做关联的时候我看到很多项目直接写 RETAIN_NONATOMIC这也没什么大问题但严格来说 block 属性用 copy 更符合 Objective-C 时代的规范现在编译器对 block 的 copy 处理已经很成熟两者表现差异不大。重点提醒下 ASSIGN 策略如果你给一个对象关联了一个 assign 的另一个对象当被关联的对象 dealloc 之后关联出取出的指针就是野指针继续访问极易崩溃。所以除非你能确保生命周期可控否则看到有人用 ASSIGN 做关联对象多半是个坑。3.4 对象销毁时关联对象怎么清理这是面试容易追问的细节。对象在释放时runtime 会调用 objc_destructInstance这个函数里除了处理 C 析构函数、弱引用清理之外也会执行关联对象的清理逻辑。对应源码中会调用类似 _object_remove_assocations 的方法把该对象对应的 ObjectAssociationMap 从全局哈希表中摘除并按照每个 ObjcAssociation 的 policy 对 value 执行 release。关键点对象 dealloc 时关联对象会被 runtime 自动清理。清理是“按策略释放 value”即 RETAIN 策略的关联值会被 releaseASSIGN 策略的值不会调 release因为当初就没持有。清理仅是移除 object 对应的所有 key-value 映射不会对 key 本身做什么。所以你不需要在 dealloc 里手动移除关联对象这个动作是 runtime 自动完成的。但注意如果是你自己用 retain 等强引用的方式把 value 保持在别处那 value 的生命周期就不完全由关联对象决定了。3.5 objc_getAssociatedObject 和 objc_removeAssociatedObjectsget 的流程就比较简单了加锁。用 object 找到 ObjectAssociationMap。再用 key 找到 ObjcAssociation。取出 _value 返回。解锁。源码里取出 value 后返回时如果 policy 带 RETAIN/COPY会对外层做一次 retain 吗实际上 get 的实现里并没有额外持有一次给调用方返回值是由调用方自己的局部变量持有。如果调用方不持有值本身可能随时被释放这一点和普通属性读取一致。objc_removeAssociatedObjects 则会把某个对象所有关联的东西一次性清空。这个 API 用得少因为对一个大对象来说清空可能牵扯到多个资源释放而且如果你在类内部还依赖某些关联属性清空之后再次访问时可能返回 nil导致一些预期之外的状态。所以一般只会看到它在 dealloc 场景里被 runtime 内部调用正常业务代码不建议手动调它。4. 分类 关联对象实战给分类添加属性的标准姿势4.1 标准实现方案与代码模板假设我们要给 UIView 添加一个点击回调的属性。分类和关联对象的组合实现如下// UIViewTapAction.h #import UIKit/UIKit.h interface UIView (TapAction) property (nonatomic, copy) void (^tapAction)(void); end // UIViewTapAction.m #import UIViewTapAction.h #import objc/runtime.h static char kTapActionKey; implementation UIView (TapAction) - (void)setTapAction:(void (^)(void))tapAction { objc_setAssociatedObject(self, kTapActionKey, tapAction, OBJC_ASSOCIATION_COPY_NONATOMIC); } - (void (^)(void))tapAction { return objc_getAssociatedObject(self, kTapActionKey); } end这里面有几处细节值得单独说第一key 用 static char 变量的地址而不是直接传字符串。因为 objc_setAssociatedObject 的 key 只做指针比较传字符串字面量也是可以的但如果你在不同文件里写了相同内容的字符串常量编译器可能会把它们合并成同一个地址也可能不会存在不确定性。而 static char 的地址是编译期生成的唯一地址稳定性是最好的。第二policy 用 COPY_NONATOMIC 而不是 RETAIN_NONATOMIC。虽然现代编译器对 block 的内存管理已经很智能但稳妥起见遵循 block 属性用 copy 的老规矩没有坏处。第三getter/setter 的命名要和属性声明一致否则外部调用view.tapAction时找不到正确的方法实现。编译器会警告但不会报错容易埋坑。4.2 分类、扩展和关联对象的区别一张表理清面试题里总是把“分类、扩展、关联对象”混在一起问我用表格列一下它们的本质区别对比项Category 分类Extension 类扩展Associated Object 关联对象编译时机运行时合并编译期合并运行时动态存储能否添加方法能能间接支持通过方法实现读取能否添加实例变量不能能不能但可以存取关联值能否添加属性只有声明不会自动生成 ivar 和 setter能正常生成可以用关联实现 setter是否需要实现文件是只能写在 .m 里不独立存在典型使用场景给系统类加方法、代码按模块拆分私有属性和方法给分类属性补上存储容易混淆的点是扩展和分类很像但扩展是在编译期就合入类的它出现在 .m 文件顶部没有名字可以添加实例变量因为编译器在生成类布局时已经把扩展里的变量算进去了。分类是在运行时合入的无法参与编译期的内存布局计算。所以扩展更像是在类内部补充实现分类更像是从外部扩展能力。4.3 高频面试题速查再整理一份面试时可以直接背诵的清单每一题背后都有源码依据分类能不能覆盖宿主类方法 能。分类方法合并后被插入方法列表头部查找时优先命中。但原始方法还在不是真正的覆盖。分类里能不能添加实例变量 不能。实例变量布局在编译期确定分类运行时参与无法修改内存布局。多个分类有同名方法调用哪一个 取决于分类的加载顺序后加载后 attach的分类方法排在前面优先被调用。分类属性如果不手动实现 getter/setter 会怎样 编译能通过但运行时会崩溃因为分类不自动生成 ivar 和实现。必须用关联对象手动实现。关联对象的 key 用什么比较 指针比较。传入的 key 直接作为 ObjectAssociationMap 里的 key因此需要保证每次传入地址一致。关联对象什么时候释放 关联对象所属对象 dealloc 时runtime 调用 objc_destructInstance会自动清理所有关联对象并释放 value。关联对象线程安全吗 AssociationsManager 内部使用 spinlockset/get 单次操作是线程安全的但整段逻辑不是事务性的多线程连续 set 多个 key 仍有中间状态。category 能添加协议吗 能。协议的合并走 attachProtocols分类声明的协议会被加入类的协议列表。分类能写属性并希望外部正常访问怎么办 声明属性 手动实现 getter/setter 关联对象存储值这就是标准方案。关联对象会参与对象的 copy 吗 不会。关联对象存放在全局哈希表中和对象本身的内存无关copy 只复制实例变量和结构。如果有人用了关联对象存数据后做了 copy副本拿不到这些数据。5. 实战坑点与调试技巧5.1 关联对象引发的循环引用关联对象虽然做存储很方便但它是一个隐形的强引用。如果 value 是一个 blockblock 内部又捕获了 self而这个关联对象正是挂在 self 上的那么 self 和 block 之间就形成了引用环。举个具体现象我在一个 UIViewController 的分类里用关联对象保存了一个回调 blockblock 里用了 self 去刷新 UI。当时没有注意 block 的捕获结果控制器 pop 之后 dealloc 一直不触发内存涨得很厉害。用 Instruments 的 Leaks 工具查了半天才发现是关联对象强持有了 blockblock 又强持有了 self。解决办法block 内部用 __weak 修饰 self做成 weak-strong dance。或者在合适时机手动调用 objc_setAssociatedObject(self, key, nil, policy) 解绑关联但这样容易遗漏分支不推荐依赖。如果确实需要长期持有考虑不用关联对象改成持有者管理。5.2 分类出现同名 load 方法和 initialize 方法的坑分类里是可以写 load 和 initialize 的。很多人以为分类只能添加普通方法其实 load 和 initialize 在分类里也会执行。load 方法宿主类和所有分类的 load 都会被调用调用顺序是宿主类先于分类多个分类之间按编译顺序。因为它们走的是直接函数调用不是消息发送。initialize 方法分类的实现会覆盖宿主类的实现并且只调用一次。如果宿主类和分类都写了 initialize最终只会执行分类里的版本。如果你在分类里写了 initialize 但忘了一开始就调 super宿主类原来的初始化逻辑就丢了。这属于典型的“无意识覆盖”排查起来非常隐蔽。5.3 用 lldb 验证方法列表顺序想自己实证分类方法覆盖的原理不需要去翻源码直接在运行时打印方法列表就行。写一段临时代码#import objc/runtime.h unsigned int count 0; Method *methods class_copyMethodList([SomeClass class], count); for (unsigned int i 0; i count; i) { NSLog(%, NSStringFromSelector(method_getName(methods[i]))); } free(methods);你会发现分类里定义的方法排在前面。这个顺序正是 attachLists 里 memmove/memcpy 的结果比单纯看文档要有说服力得多。5.4 关联对象与 KVO 的交互问题关联对象存的值是直接放在哈希表里的没有走属性 setter所以自然也不会触发 KVO。如果你把一个关联对象的值当成普通属性来观察结果 KVO 一直不回调不要惊讶这是设计如此。如果业务上确实需要 KVO 监听某个“看起来像属性”的值建议改用类扩展 真正实例变量的方式或者手动在 setter 里调用 willChangeValueForKey/didChangeValueForKey。不过分类没有 ivar要做到这一点就比较别扭了这也是很多人在分类属性上踩坑之后回归类扩展的原因。6. 写在最后的个人体会源码读到这里你会发现分类和关联对象其实是一对互补的设计分类解决的是“给既有类扩充能力”的问题但受限于运行时合并无法改变内存布局关联对象解决的是“分类属性没有存储载体”的问题通过全局哈希表绕开内存布局限制。两者结合起来才让分类能像原生代码一样使用属性。我在实际项目里给系统类加能力时最常用的就是这两种手段的组合。但我个人的建议是关联对象能用但要克制。它毕竟存在一个全局哈希表里出了 bug 不像普通属性那样容易追踪尤其是在团队协作项目里别人看到分类里的属性第一反应是找声明的 property而不是去 objc_getAssociatedObject 里翻实现。每次使用前想清楚这个问题是不是真的必须用分类关联对象解决是不是用继承或组件化方案更清晰如果每次被别人问到底层原理你都能从“编译期布局”“运行时合并”“全局哈希存储”这三个角度组织答案那这类面试题基本就稳了。希望这篇文章不只是帮你背题而是让你真正理解这些机制背后的设计思路。