尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

iOS分类与关联对象:从runtime源码解析Category与AssociatedObject

iOS分类与关联对象:从runtime源码解析Category与AssociatedObject 分类Category和关联对象Associated Object这两块在 iOS 面试里几乎是被问烂的组合拳分类为什么能覆盖宿主类方法分类里加属性为什么不生成 ivar关联对象到底是怎么存的如果你只是背结论遇到那种喜欢一路深挖到 runtime 源码的面试官很容易露怯。这篇文章我从 objc4-818 系列源码出发把 category_t 结构、attachCategories 的合并过程、AssociationsManager 的三层存储链一条一条过一遍顺手把面试官最爱追问的几个点也掰开揉碎讲清楚。内容适合正在准备 iOS 面试的同学也适合写了好几年分类、但从没想过背后发生了什么的朋友。1. 分类的底层结构category_t 里到底存了什么1.1 从 struct category_t 说起在 runtime 源码的 objc-runtime-new.h 里分类的底层结构是 category_t核心字段大致是这样的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; // 实例属性 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 是宿主类的指针编译时会指向宿主类。第三从这个结构能看出来分类本身并不拥有真正的成员变量它只有属性描述没有 ivar 列表。很多人在面试时背分类不能添加成员变量其实根源就在这里——category_t 里根本没有 ivar 相关的字段所以无论你怎么写 property编译期都不会为它合成对应的下划线成员变量。这一点先记住后面讲关联对象时会再呼应。1.2 分类的编译期产物要理解分类最好先看编译器把它变成了什么。我平时做源码分析时常用 clang 重写来观察中间产物clang -rewrite-objc MyClassCategory.m -o MyClassCategory.cpp打开生成的 .cpp 文件会看到类似这样的结构static struct _category_t _OBJC_$_CATEGORY_MyClass_$_MyCategory __attribute__ ((used, section (__DATA,__objc_const))) { MyCategory, 0, // OBJC_CLASS_$_MyClass, (const struct _method_list_t *)_OBJC_$_INSTANCE_METHODS_MyClass_$_MyCategory, 0, 0, 0, 0, };也就是说源代码里的interface MyClass (MyCategory) ... end会被编译成一个全局的 category_t 结构体存放在 Mach-O 的__DATA,__objc_const段。这个结构体里实例方法、类方法、协议、属性分别打包成独立的列表最终还是等运行时去合并。所以可以这么理解编译期只负责生成描述真正让分类生效的是运行期。这也是分类和 extension 最大的区别——extension 在编译期就把方法直接加到类的方法列表里而分类是运行时动态合并进去的。面试如果被问分类和 extension 的区别从这个编译期/运行期的角度切入比背一堆结论要高级得多。1.3 分类的运行期合并入口分类的合并发生在 App 启动阶段。dyld 加载镜像后会走到 objc_init接着 map_images然后是 _read_images。在 _read_images 里runtime 会调用 _getObjc2CategoryList 拿到所有分类再根据分类是否已经 attach 过的状态做两件事如果宿主类还没被 realize会把分类放进类的 unattachedCategories 列表等类第一次被使用时再处理如果宿主类已经被 realize就直接调 remethodizeClass把分类合并进去。remethodizeClass 内部最终会走到 attachCategories。这里有个比较绕的调度逻辑因为要考虑分类加载顺序、并发、懒加载类等场景但核心动作是固定的把分类的方法、属性、协议插入到类原本的方法列表、属性列表、协议列表前面。2. 分类方法“覆盖”宿主类方法的前因后果2.1 attachLists 的头插法面试里最经典的问题来了分类和宿主类有同名方法为什么最终调用到的是分类的实现答案就在 attachLists 里。这个方法在 objc-runtime-new.h 的 list_array_tt 模板类中实现逻辑我贴一下核心代码void attachLists(List* const * lists, uint32_t mcount) { if (mcount 0) return; if (hasArray()) { uint32_t oldCount array()-count; uint32_t newCount oldCount mcount; setArray((array_t *)realloc(array(), array_t::byteSize(newCount))); array()-count newCount; // 把旧的方法列表整体往后移动 mcount 个位置 memmove(array()-lists mcount, array()-lists, oldCount * sizeof(array()-lists[0])); // 把新方法列表拷贝到最前面 memcpy(array()-lists, lists, mcount * sizeof(array()-lists[0])); } else { // 原来不是数组结构重新分配后直接拷贝 List* oldList list; uint32_t oldCount oldList ? 1 : 0; ... } }关键操作就两步memmove 把原有方法列表整体后移memcpy 把分类方法列表放到最前面。也就是说分类不是覆盖了宿主类方法而是插队到了宿主类方法前面。因为 objc_msgSend 查找实现时会从方法列表头部开始找找到同名的 Method 就直接返回 IMP根本不会再往后看所以表现出分类方法优先。这个细节值得反复强调因为它引出了好几个高阶考点。比如分类方法覆盖不是真正的替换宿主类的实现还在方法列表里只是查找顺序靠后了再比如如果两个不同分类都实现了同一个方法那到底谁生效取决于谁的方法列表排更前面也就是 attach 顺序。2.2 方法查找流程与“覆盖”的本质为了把覆盖这个词说透得看一眼方法查找流程。objc_msgSend 在汇编层做快速查找找不到就进入 __objc_msgSend_uncached最终走到 lookUpImpOrForward里面用getMethodNoSuper_v去类的方法列表中查找static method_t *getMethodNoSuper_v(objc_class *cls, SEL sel) { for (auto mlists cls-data()-methods.beginLists(), end cls-data()-methods.endLists(); mlists ! end; mlists) { method_t *m findMethodInList(*mlists, sel); if (m) return m; } return nil; }这里遍历的顺序就是方法列表数组的顺序。分类方法被插到数组头部之后遍历时会第一个命中于是看起来像是覆盖了宿主类实现。但注意宿主类的实现依然存在reversed 的 method swizzle 等操作也仍然能找到它们。这就引出一个很实在的坑如果宿主类某个方法很重要你随手在分类里写了个同名方法并没有真正替换宿主方法只是挡住了。一旦哪天宿主类自己也做了调整或者有其他分类也写了同名方法行为会变得很难排查。我在实际项目中见过因为两个库都扩展了同一个系统类方法导致其中一个功能莫名失效的情况后来查了半天才发现是分类顺序问题。2.3 load 与 initialize 的特殊性分类中的 load 和 initialize 也是面试高频点。这两者虽然都是自动调用但机制完全不同。load 的特点是App 启动时主类的 load 先执行然后执行分类的 load。主类与分类之间是按定义顺序执行的而且无论类有没有被使用load 都会执行。更重要的是load 不走 objc_msgSend 的动态查找而是直接通过函数指针调用。这意味着即使分类重写了 load宿主类的 load 依然会执行并且先于分类执行。initialize 则是懒加载的会在类第一次收到消息前由 runtime 调用走的还是正常消息发送流程。所以分类如果实现了 initialize宿主类的 initialize 会被覆盖——实际上是查找顺序导致只调用了分类的版本。这里有个经典坑如果有多个分类都写了 initialize只有最后编译的那个会被调用如果宿主类和分类都写了宿主类的可能永远不执行导致某些初始化逻辑缺失。我给你一个非常典型的排查场景。项目里有个基类写了 initialize 做统计上报后来某次版本更新发现上报量骤降最后定位到是某个工具库给基类加了个分类分类里也实现了 initialize把基类的实现给挡住了。所以我的建议是分类里尽量不要写 initialize如果非要写一定要调 super并且在注释里标注清楚。3. 关联对象源码全解析3.1 从 API 到全局存储结构关联对象是 runtime 给开发者开的一扇后门既然分类不能加成员变量那我们就用一张全局表把对象 key和value绑起来。三个公开 API 大家都熟void objc_setAssociatedObject(id object, const void *key, id value, objc_AssociationPolicy policy); id objc_getAssociatedObject(id object, const void *key); void objc_removeAssociatedObjects(id object);底层存储结构是很多人的知识盲区。我再强调一遍这是一个全局三维映射核心数据结构是这样的层级数据结构KeyValue第一层AssociationsManager无全局单例AssociationsHashMap第二层AssociationsHashMap对象指针disguisedObjectAssociationMap第三层ObjectAssociationMap外部传入的关联 keyObjcAssociation第四层ObjcAssociation-policy value在 runtime 源码中AssociationsManager 持有一个全局的 AssociationsHashMap同时带一把 spinlock 锁保证并发安全。AssociationsHashMap 的 key 是 disguised ptr简单理解就是对对象指针做了位运算混淆防止直接暴露裸指针value 是 ObjectAssociationMap这一步对应的是同一个对象可以关联多个不同 key 的对象。继续往下ObjectAssociationMap 的 key 是你调用 objc_setAssociatedObject 时传入的 keyvalue 是 ObjcAssociation 结构体它内部保存了 policy关联策略和 value关联对象。这种设计很容易理解第一层区分哪个对象第二层区分哪个 key第三层存具体关联值和内存管理策略。3.2 _object_set_associative_reference 的完整流程真正干活的是_object_set_associative_reference函数我把核心流程拆开讲。先看简化版源码逻辑void _object_set_associative_reference(id object, const void *key, id value, uintptr_t policy) { ObjcAssociation old_association(0, nil); { AssociationsManager manager; AssociationsHashMap associations(manager.get()); disguised_ptr_t disguised_object DISGUISE(object); if (value) { // 1. 如果 value 不为 nil做内存策略处理 ObjcAssociation association(policy, acquireValue(value, policy)); // 2. 查找对象对应的 ObjectAssociationMap auto result associations.try_emplace(disguised_object, ObjectAssociationMap{}); ObjectAssociationMap refs result.first-second; // 3. 如果之前已经有关联值先保存旧值后面统一 release auto it refs.find(key); if (it ! refs.end()) { old_association.swap(it-second); } refs[key] association; } else { // 4. value 为 nil说明是要移除关联 auto it associations.find(disguised_object); if (it ! associations.end()) { ObjectAssociationMap refs it-second; auto ref refs.find(key); if (ref ! refs.end()) { old_association.swap(ref-second); refs.erase(ref); } } } } // 5. 锁外释放旧值 if (old_association.hasValue()) { releaseValue(old_association.value(), old_association.policy()); } }这里有几个值得注意的细节。第一acquireValue 会根据 policy 对 value 做 retain 或 copy。源码里类似这样static id acquireValue(id value, uintptr_t policy) { switch (policy 0xFF) { case OBJC_ASSOCIATION_SETTER_RETAIN: return objc_retain(value); case OBJC_ASSOCIATION_SETTER_COPY: return _objc_copy(value); default: return value; } }也就是说如果是 RETAIN 系策略关联对象会持有 value 一次如果是 COPY 系策略会拷贝一份再持有。如果既不是 retain 也不是 copyASSIGN那就直接存指针不做任何内存管理。这个细节直接解释了为什么 ASSIGN 关联很容易野指针。第二整个查找和写入的过程是在 AssociationsManager 的锁内完成的。锁的粒度是全局的正因为如此频繁读写关联对象会有一定开销尤其在高频路径上性能敏感的地方要慎用。第三旧值的释放被放到了锁外。这个设计很巧妙——避免在持锁状态下触发 dealloc降低死锁风险和持锁时间也能减少因为 objc_release 导致重入关联对象 API 时的死锁概率。你在源码里能看到 old_association 被 swap 出来然后锁外统一 releaseValue目的就在这里。3.3 对象销毁时关联对象的清理关联对象的生命周期和宿主对象严格绑定。当一个对象执行 dealloc 时runtime 会走objc_destructInstance里面会调用_object_remove_assocationsvoid *objc_destructInstance(id obj) { if (obj) { bool cxx obj-hasCxxDtor(); bool assoc obj-hasAssociatedObjects(); if (cxx) object_cxxDestruct(obj); if (assoc) _object_remove_assocations(obj, /*deallocating*/ true); obj-clearDeallocating(); } return obj; }_object_remove_assocations会找到这个对象在全局表里对应的 ObjectAssociationMap然后遍历所有关联 key把每个 ObjcAssociation 的 value 拿出来在锁外根据 policy 做 releasevoid _object_remove_assocations(id object, bool deallocating) { ObjectAssociationMap refs{}; { AssociationsManager manager; AssociationsHashMap associations(manager.get()); disguised_ptr_t disguised_object DISGUISE(object); auto it associations.find(disguised_object); if (it ! associations.end()) { refs.swap(it-second); associations.erase(it); } } for (auto ref : refs) { releaseValue(ref.second.value(), ref.second.policy()); } }这里的重点也很明确对象销毁时关联对象并不自动置 nil而是根据策略执行 release 或 copy 释放。这导致一个非常常见的坑如果关联对象 value 是一个 blockblock 又持有了宿主对象就会形成 retain cycle对象 dealloc 时关联表里的 value 一直等不到释放。所以我在项目里给自定 View 挂回调 block 时会特别留意是否使用了 weak 包装。4. 面试高频追问与实战避坑4.1 分类里能添加属性吗关联对象能替代 ivar 吗这个几乎是必考题。从前面 category_t 的分析已经能得出结论分类可以声明 property但编译期不会自动生成 ivar也不会有 synthesize 帮我们生成 getter/setter。如果你只声明了 property然后直接在外面点语法读写运行时会直接 unrecognized selector 崩溃。有两种常规做法。第一用关联对象实现 getter/setter这是最常见的// UIViewProperty.h interface UIView (Property) property (nonatomic, strong) NSString *identify; end // UIViewProperty.m implementation UIView (Property) - (void)setIdentify:(NSString *)identify { objc_setAssociatedObject(self, selector(identify), identify, OBJC_ASSOCIATION_RETAIN_NONATOMIC); } - (NSString *)identify { return objc_getAssociatedObject(self, selector(identify)); } end这里有个小技巧key 可以直接用 selector(identify)或者用 static char 变量地址甚至用 SomeStaticChar 地址主要是保证 key 的唯一性。用 selector 的好处是 getter 方法名天然唯一不容易写重。但要注意关联对象实现属性只是行为上像属性底层的存储和真正的 ivar 完全不同。它不参与对象内存布局不会像 ivar 那样随对象一起分配也不能用 KVC 的 ivar 查找路径去访问。所以在高性能场景或对内存布局有要求的场景别这么做。判断是否能用关联对象替代 ivar我的看法是能替代属性存储不能替代真正成员变量的语义。如果这个值是对象的固有组成部分、生命周期和宿主严格同步、需要参与 Codable 或 KVC那还是应该用真 ivar 的类来做而不是塞进分类里。4.2 多个分类同名方法到底谁生效面试官问到这基本是想考察你是不是真的理解了 attachLists 的顺序。结论是取决于分类的 attach 顺序而这个顺序在编译链接后基本由 Build Phases 里的 Compile Sources 文件顺序决定。你可以把文件在列表里上移或下移运行时生效的方法就会变。这也解释了为什么很多大厂都明确禁止在分类中覆盖宿主类方法甚至禁止两个分类实现同名方法。因为这不是一个稳定的代码语义一旦新增文件、调整编译顺序行为就可能变化。我踩过这样的坑某个页面突然不走自己写的逻辑查下来是另一名同事给同一个类加了分类里面也实现了同名方法Build 顺序排在了前面导致我的方法完全没被调用。这种问题很难定位因为代码层面看两个方法都存在只是运行路径变了。如果确实需要给已有的类方法提供另一种实现优先考虑继承、组合或者 runtime method swizzling 并做好交换记录而不是依赖分类覆盖的隐式行为。4.3 关联对象为什么做不了 weak这个问题值得展开。很多人以为 OBJC_ASSOCIATION_ASSIGN 就是弱引用其实不是。ASSIGN 只是不 retain 不 copy直接把 value 赋值过去。它和被关联对象的 dealloc 没有联动关系被关联的对象释放后关联表里存的仍然是一个悬垂指针再访问就是野指针崩溃。而真正的 weak 引用依赖 objc 的 weak 表对象释放时会把所有 weak 指针自动置 nil。关联对象没有走这套机制所以标准 API 里没有 weak 策略。网上有不少讨论怎么实现弱关联对象的方案但都不是官方标准接口生产环境不建议依赖。如果业务上确实需要弱关联我通常用两种姿势一是用一个容器对象持有 weak 属性interface WeakWrapper : NSObject property (nonatomic, weak) id target; end objc_setAssociatedObject(obj, key, wrapper, OBJC_ASSOCIATION_RETAIN_NONATOMIC);二是明确在宿主对象的 dealloc 里手动清理关联值把依赖关系写清楚。第一种方案更通用一点但也别忘记 wrapper 本身是被强持有的它内部持有 weak 引用避免循环引用。4.4 日常开发中的取舍分类和关联对象不是银弹用多了代码反而难维护。我个人的项目约定是能用 extension 的不用分类。同文件内的私有拆分用 extension 在编译期就解决不产生运行时顺序问题。分类只做与宿主类关系不大的扩展比如给 UIView 加一个通用的圆角方法、给 UIColor 加十六进制初始化避免覆盖宿主方法或写和宿主状态强相关的逻辑。关联对象只用于解耦临时状态比如把埋点上下文挂在正在展示的 ViewController 上、给复用 cell 挂一个点击回调。但每次读关联对象都有全局锁开销高频调用路径不要用。如果发现一个类被加了大量分类就要考虑是不是该拆出去独立组件了。分类不是重构的终点只是过渡手段。面试的时候讲完这些源码细节我会顺势补一句分类和关联对象的本质都是运行时方案理解了底层存储和查找顺序很多诡异问题都能一眼看穿然后举一两个生产环境踩坑的例子。这类从源码出发、又能落到实际问题的回答通常比单纯背结论要更能打动面试官。最后分享一个我实际写代码时的习惯关于关联对象的 key我从来不用字符串字面量而是 static char 地址或者 selector保证唯一的同时还能避免字符串比较的开销。分类的 property 声明里readonly 和 readwrite 要分清能用 readonly 的尽量先只读避免还要额外处理 setter 带来的关联对象写入成本。还有一点分类的 load 里不要调用依赖其他类已加载完成的逻辑因为分类 load 加载顺序不定很容易踩到初始化时序的坑。这些细节在我自己团队审查代码时几乎一抓一个准提前注意能省掉不少线上问题。
返回列表