iOS Category底层原理:编译期结构、运行期合并与关联对象

发布时间:2026/10/15 17:29:17
iOS Category底层原理:编译期结构、运行期合并与关联对象 前阵子有位朋友跳槽面试回来跟我复盘时提到二面面试官问了一道他认为“稳了”的题说说 Category 的底层原理。他答了分类能扩展方法、分类可以添加属性要用关联对象然后……面试官继续追问“分类方法是怎么覆盖主类方法的”“多个分类同名方法谁先执行”他就卡住了。这道题在大厂 iOS 面试里出现频率极高但大多数人都停在“会用法、不会原理”的层面。今天我把 Category 从编译期结构、运行期合并、关联对象、load/initialize、与类扩展对比这几个维度完整拆一遍思路直接对标面试答题的层次感打算冲刺大厂面试或想补 runtime 源码基础的都可以参考。1. 面试官问“Category底层原理”其实在问什么1.1 为什么这道题是面试分水岭Category 在 iOS 开发里太常见了给系统类加方法、拆解大文件、做功能模块隔离几乎每个项目都在用。但也正因为常见很多人只是“会用”没有想过它背后发生了什么。面试官问底层原理其实不是在考你记不记得住结构体字段而是想看两件事第一你是否清楚 Objective-C 的类在运行期是动态可变的。分类方法能“覆盖”主类方法、运行时能动态交换方法这些都建立在类的方法列表可以被改写的机制上。如果你连分类是怎么合并进类的都说不清后面谈 Method Swizzling、动态方法解析都会站不住。第二你是否具备“编译期形态”和“运行期行为”分层思考的能力。Category 在编译期只是一个独立的结构体不影响主类到了运行期才由 runtime 把方法列表、协议列表、属性列表合并进主类。能主动说出这一层区分面试官基本就会认为你有源码阅读习惯。我见过不少候选人把“分类可以覆盖方法”挂在嘴边但问到“覆盖的原理是什么”答案只有“因为分类的方法优先级高”。这不算错但太表层。真正的底层答案要落到方法列表的插入顺序上。1.2 面试官常见追问清单这道题的追问方向其实非常固定提前准备好就能稳。我把常见的追问列成清单Category 能不能添加属性它和成员变量有什么区别Category 能不能添加成员变量Category 方法“覆盖”主类方法之后主类方法还在吗多个 Category 存在同名方法最终调用哪个load 和 initialize 的调用顺序分别是什么Category 和 Extension类扩展的区别是什么关联对象的底层存储结构是什么样的分类能作用于系统类吗类扩展能吗这些追问基本覆盖了 Category 的全部核心考点。接下来我按“编译期结构 - 运行期合并 - 关联对象 - load/initialize - 对比类扩展 - 答题框架”的顺序依次拆解。2. 编译期真相分类在底层其实是一个结构体2.1 category_t 结构体长什么样很多人以为 Category 在编译后会被“合并”进主类代码里其实不是。分类在编译期会被编译器生成一个独立的结构体这个结构体在 objc 源码里叫category_t。不同版本源码字段略有差异核心形态大致如下typedef 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;// 属性列表 } category_t;注意看这个结构体里没有任何成员变量列表字段。这一点直接回答了面试追问里“分类能不能添加成员变量”的问题不能。分类从数据结构层面就没有 ivar 的空间它只能携带方法、协议和属性声明。这里还藏着另一个关键点cls在编译期是空的。也就是说编译产物里的分类是一个“游离”的独立存在它只声明了“我要扩展哪个类”但还没有真正挂到那个类身上。真正把两者联系起来的工作发生在运行期。2.2 编译期和运行期谁负责合并我们可以把分类的整个生命周期分成两个阶段编译期编译器把每个分类的.m文件翻译成一个category_t类型的结构体放进 Mach-O 文件的__OBJC段更准确地说是__DATA段的分类相关 section 里。这个阶段主类的class_ro_t只包含主类自己的方法列表、属性列表和协议列表完全不受分类影响。所以从内存布局上看编译期分类和主类就是两份互不相干的数据。运行期dyld 加载可执行文件后runtime 在初始化过程中会发现这些category_t然后通过methodizeClass、attachCategories、remethodizeClass这一系列函数把分类里的内容合并到主类的class_rw_t里。合并完成之后分类内容和主类内容才真正出现在同一个类对象的方法列表中。这个“编译期独立、运行期合并”的模型是理解 Category 的基石。你可以这样记分类就像一张写好的便签先贴在桌上等果盘真正端上来的时候才动手把便签内容抄到对应的区域里。2.3 用 clang 验证一下分类的真实形态如果不想直接读源码可以用 clang 把分类代码重写成 cpp 文件看看编译产物形状。比如有一个Person Test分类执行clang -rewrite-objc PersonTest.m生成的文件里能看到类似这样的内容static struct _category_t _OBJC_$_CATEGORY_Person_$_Test __attribute__ ((used, section (__DATA,__objc_const))) { Person, 0, _OBJC_$_INSTANCE_METHODS_Person_$_Test, 0, 0, 0, };这段代码验证了前面说的两点分类名段里标记了目标类名但类指针位置是 0方法列表被单独存放在一个独立结构里没有直接写进主类的数据结构。这对面试是很好的证据支撑不是“我觉得是这样”而是“编译产物就是这样”。3. 运行期合并分类方法是怎么“挤”进主类方法列表的3.1 方法列表合并的源头是 attachLists分类在运行期的核心处理函数是attachCategories它会把分类中的方法列表、属性列表、协议列表取出来准备合并进主类。实际执行最终数据插入的是方法数组的attachLists方法。attachLists的简化源码逻辑是void attachLists(List* const * lists, uint32_t mcount) { if (mcount 0) return; // 容量不足时重新分配内存 if (this-count mcount capacity) { // realloc 并扩容 } // 把旧的已有列表全部向后移动 memmove(array() mcount, array(), count * sizeof(array()[0])); // 把新的列表复制到最前面 memcpy(array(), lists, mcount * sizeof(array()[0])); count mcount; }看到memmove和memcpy这两步就明白了新加入的方法列表被整体插到了旧列表的前面。也就是说分类方法会被放到主类方法列表的头部区域。这个“头插法”就是分类方法能覆盖主类方法的底层起点。3.2 为什么分类方法能“覆盖”主类方法Objective-C 的方法调用最终走的是objc_msgSend。消息发送的核心动作是在类的方法列表中按 selector 查找对应的实现。查找过程是从方法列表头部开始遍历的先找到哪个同名的 selector就调用哪个实现。主类方法原本在列表前面分类方法通过attachLists插到主类方法的前面于是同名方法被优先命中。这就是“覆盖”的真相主类方法并没有被删除或替换它仍然存在只是分类方法挡住了它导致消息发送流程根本走不到主类方法那一格。如果用class_copyMethodList打印方法列表你会在前面看到分类方法后面才轮到主类方法。不过有个需要注意的细节直接调用class_getInstanceMethod拿到的是“查找到的第一个方法”也就是分类方法想拿主类的原始 IMP需要遍历完整方法列表做筛选不能指望class_getInstanceMethod直接返回主类的实现。3.3 多个分类同名方法谁说了算面试里还有一个经典变体同一个类有 A、B 两个分类两个分类都实现了同一个方法最终调用哪一个结论先说跟编译顺序强相关。Xcode 工程里 Compile Sources 中靠后编译的那个分类它的方法列表在合并时会插得更靠前所以通常后编译的分类胜出。原因还是头插法后处理的列表一次次被插到最前面最后插入的自然排在最前面。举个例子假设Person有两个分类// PersonCategoryA.m implementation Person (CategoryA) - (void)sayHello { NSLog(A); } end // PersonCategoryB.m implementation Person (CategoryB) - (void)sayHello { NSLog(B); } endBuild Phases 里如果 B 在 A 下面编译顺序更靠后最终调用sayHello打印的通常是 B。但在这里我要多说一句不同 Xcode 版本对源文件编译顺序的处理会有差异我见过个别工程里 Compile Sources 的显示顺序和实际编译顺序不完全一致。所以面试时讲清楚“后编译的分类优先由合并时头插顺序决定”这个原理比单纯背结论更稳妥。实际项目里遇到依赖分类同名方法的场景最好自己跑一遍打印确认不要想当然。3.4 现场验证打印方法列表不习惯只看源码的话可以直接用代码验证合并顺序unsigned int count 0; Method *methods class_copyMethodList([Person class], count); for (unsigned int i 0; i count; i) { NSLog(%, NSStringFromSelector(method_getName(methods[i]))); } free(methods);你会看到输出结果中分类方法排在最前面主类方法跟在后面。如果存在多个分类后编译分类的方法会排在更靠前的位置。这个实验很简单但做完之后对 Category 的记忆会非常牢固因为它是你自己亲眼看到的现象。4. 分类能不能添加属性关联对象这一题怎么答4.1 property 在分类里到底发生了什么先说结论分类里写property是合法的但和普通类里的property行为不一样。在普通类里property会自动生成 getter/setter 的声明和实现还会自动合成一个下划线开头的成员变量。但在分类里编译器只会生成 getter 和 setter 的声明不会自动生成实现也不会分配 ivar。如果你在分类里声明了属性但没有实现 getter 和 setter别人调用这个属性时会在运行期直接崩溃提示找不到方法。所以常见做法是用关联对象手动实现这两个方法。这也是面试里最容易混淆的点分类不是“不能添加属性”而是“声明属性不会自动生成成员变量和存取方法的实现”。要让它真正可用得用objc_setAssociatedObject和objc_getAssociatedObject。下面是一个标准写法#import objc/runtime.h interface Person (Associate) property (nonatomic, copy) NSString *nickname; end implementation Person (Associate) - (void)setNickname:(NSString *)nickname { objc_setAssociatedObject(self, selector(nickname), nickname, OBJC_ASSOCIATION_COPY_NONATOMIC); } - (NSString *)nickname { return objc_getAssociatedObject(self, selector(nickname)); } end这里用selector(nickname)作为关联 key好处是 key 的值和属性名绑定不易冲突也不需要额外声明静态常量。4.2 关联对象的实现原理关联对象并不是直接存在类对象里的。runtime 底层维护了一张全局的关联对象表核心结构大致是AssociationsManager全局管理者内部持有AssociationsHashMap。AssociationsHashMap以对象的指针为 key映射到该对象对应的ObjectAssociationMap。ObjectAssociationMap以关联的 key 为键存放ObjcAssociation。ObjcAssociation保存具体的关联值和内存管理策略。你可以理解为每个人对象身上都挂了一个背包ObjectAssociationMap背包里有一个个小口袋每个口袋有一个标签关联 key口袋里装的才是真正的关联值。背包统一存放在一个巨大的寄存处AssociationsManager里。这个设计决定了关联对象是跟着对象本身的生命周期走的。对象销毁时runtime 会通过objc_destructInstance清理这个对象的所有关联对象不需要你手动移除。这是它和成员变量最大的区别之一成员变量直接分配在对象内存里关联对象存在全局表中。4.3 关联对象的内存管理细节objc_setAssociatedObject的最后一个参数是关联策略它和属性修饰符的对应关系如下关联策略等价修饰符说明OBJC_ASSOCIATION_ASSIGNassign弱引用不保留值对象释放后指针不会自动置 nilOBJC_ASSOCIATION_RETAIN_NONATOMICnonatomic, retain强引用非原子性OBJC_ASSOCIATION_COPY_NONATOMICnonatomic, copy拷贝值非原子性OBJC_ASSOCIATION_RETAINretain, atomic强引用原子性OBJC_ASSOCIATION_COPYcopy, atomic拷贝值原子性这里有一个容易踩的坑如果关联的是一个 block 或者可变对象却选了OBJC_ASSOCIATION_ASSIGN后续访问时很可能拿到已经被释放的对象造成野指针崩溃。我自己就在早期犯过这个错把关联对象当普通 assign 属性处理。记住一个原则关联对象的策略应该尽量和你平时写属性修饰符时的习惯保持一致该 copy 就 copy该 retain 就 retain不要因为关联对象写法特殊就放松。另外atomic 关联策略在底层会有加锁开销绝大多数业务场景用 Nonatomic 系列就足够了。面试追问到这个细节时能说出“atomic 对应 OBJC_ASSOCIATION_RETAIN/COPY会加锁”是明显的加分项。5. load 和 initialize分类最容易翻车的两个方法5.1 load 的调用节奏load是很多 iOS 面试都会顺带追问的方法因为它和分类的关系特别深。load在类或分类被加载进内存时调用时机非常早早于 main 函数。它最大的特点是不走objc_msgSend消息发送流程而是直接通过函数指针地址调用。这一点决定了它和initialize在行为上有本质区别。调用顺序可以用一句话概括主类先于分类父类先于子类。更具体一点runtime 在处理 load 时会先加载所有已实现 load 的类按“父类 - 子类”的顺序依次调用然后处理分类的 load。分类之间也有先后通常和编译顺序相关后编译的会先执行。由于 load 不走消息发送即使子类没有实现 load也不会自动通过继承链调用父类的 load父类会因为自己被加载而单独调用一次。分类里的 load 非常常用尤其是做 Method Swizzling 的场合。因为分类的 load 在其宿主类的 load 之后调用此时类的布局已经完整做方法交换比较安全。但要注意不要在 load 里做重活它是启动阶段执行的方法塞太多逻辑会拖慢启动时间我见过一个项目在分类 load 里做了大量文件 IO冷启动直接变慢几百毫秒。5.2 initialize 的消息发送逻辑initialize和 load 完全不同。它是在类第一次收到消息时才会触发走的是正常的objc_msgSend流程所以它遵循继承规则。有一个很经典的场景如果子类没有实现initialize给子类发第一条消息时runtime 会沿着继承链找到父类的实现并调用。父类第一次被触发初始化后会记录状态后续不会因为其他子类再次触发而重复执行。当分类实现了initialize时情况就变得有趣了。因为分类方法排在主类方法前面消息发送会优先命中分类版本所以主类的initialize会被“覆盖”不会执行。如果有多个分类都实现了initialize同样遵循“后编译的分类优先”的规则最终只有排在最前面的那个分类方法会执行。这一点很多人没意识到会导致在分类里写了初始化逻辑结果别的分类插到前面后自己的实现压根没跑。5.3 两者对比速查表对比项loadinitialize调用时机类/分类加载进内存时main 之前类收到第一条消息时调用方式直接函数指针调用不走消息发送objc_msgSend 消息发送继承行为父类先于子类子类未实现不会继承调用子类未实现会沿继承链调父类父类只初始化一次分类影响主类和分类的 load 都会调用分类方法会覆盖主类实现多个分类同名方法都会调用顺序看编译顺序后编译的分类优先只有一个生效常见用途Method Swizzling单例、懒配置如果你在面试时能把这张表的逻辑清楚讲出来面试官基本不会再追问这两个方法的问题。这里我再多说一句initialize里做复杂操作也要小心它触发时机不可控有的类可能在很早期就被某些系统机制触发初始化如果你在里面依赖了其他还没初始化好的全局资源容易出隐性崩溃。6. 分类 vs 类扩展面试中的第二个高频对比6.1 编译期与运行期的本质差异类扩展Extension和分类Category名字接近底层差别却非常大。类扩展在编译期就直接参与类的构造它声明的方法、属性会直接编译进类的完整布局中。所以类扩展可以添加成员变量property也会自动生成 getter/setter 和 ivar。分类则是运行期合并没有 ivar 能力也不能自动生成存取方法。用大白话概括类扩展是“你还没出生就规划好的特征”分类是“出生之后贴上去的标签”。类扩展的内容在编译阶段就是类的一部分对类来说是内建的分类则是在类已经存在后由 runtime 动态贴上去的。6.2 使用场景与代码示例类扩展最常见的场景是“私有成员”声明。在.m文件顶部写一个匿名扩展里面放一些不想暴露给外部的属性或方法interface Person () property (nonatomic, strong) NSString *privateToken; - (void)resetPrivateData; end这段代码写在.m文件里外部完全看不到而且privateToken的 ivar 编译器会自动生成可以直接在implementation Person里使用。分类则更适合把功能按照模块拆分或者给无法修改源码的系统类加能力implementation UIView (RoundedCorners) - (void)setCornerRadius:(CGFloat)radius { self.layer.cornerRadius radius; self.layer.masksToBounds YES; } end另一个差异是作用范围分类可以对系统类或第三方库的类加方法类扩展不行。因为类扩展需要拿到类的原始实现文件才能参与编译期布局系统类源码不可能给你改。6.3 对比表格对比项Category分类Extension类扩展合并时机运行期动态合并编译期直接参与类构造能否添加成员变量不能能property 能否自动生成 getter/setter不能需关联对象能能否作用于系统类能不能是否必须有实现文件需要通常是匿名扩展写在 .m 里常见用途模块拆分、扩展类能力隐藏私有属性、私有方法7. 面试回答框架与避坑心得7.1 一套四层递进的回答话术如果面试官让你“讲讲 Category 的底层原理”不要只背几个零散结论按下面这个四层框架递进层次感会非常清楚第一层说清楚分类是什么。分类是给现有类扩展方法、协议、属性声明的语法机制编译期生成category_t结构体。第二层讲清楚编译期和运行期的分工。编译期分类是独立数据运行期由 runtime 的attachCategories和attachLists把方法列表等合并进主类合并方式是新列表插到旧列表前面。第三层点出关键结论分类能“覆盖”主类方法但不是删除同名方法查找时会先命中分类方法多个分类同名方法时后编译的分类优先分类不能添加成员变量属性要通过关联对象实现。第四层延伸到相关机制关联对象的全局表结构、load 和 initialize 的差异、Category 与 Extension 的区别。这一层是拉开候选人差距的关键能讲到这里面试官基本会认为你是有源码阅读能力和体系化思维的候选人。整个过程控制在 3 到 5 分钟注意不要一开始就跳到 load 和 initialize面试官还没问你就把底层全抖完了反而显得没有交流节奏。先给结论再展开最后等对方追问细节。7.2 我在面试和实际开发中踩过的坑第一个坑和关联对象有关。我早期给一个对象关联 block 时用了OBJC_ASSOCIATION_ASSIGN结果数据来回跑了几次之后block 已经被释放调用时直接异常崩溃。原因是 assign 策略不会 retain 关联值对象释放后它就成了悬挂指针。后来我统一用OBJC_ASSOCIATION_COPY_NONATOMIC存 block再也没出过这个毛病。第二个坑是在分类的 load 里做方法交换但工程里同时存在多个调用同一方法交换的分类导致交换顺序乱了出现方法调用链异常。排查了很久才定位到原来是两个分类都对同一个viewWillAppear:做了 swizzle后加载的分类把前一个分类的 IMP 链打断了。现在我的建议是不要把 swizzle 分散在多个分类里尽量收敛到同一个工具类并且给被交换的方法名加上统一前缀避免和系统方法或别人的方法重名。第三个坑是面试答题时只顾着背结论结果被追问“为什么分类方法能排在前面”就卡住了。后来我在本地用clang -rewrite-objc和class_copyMethodList实际验证了一遍再被追问时心态就稳了。很多原理题看着难其实花一个小时自己跑一遍源码实验比背十篇面经都管用。7.3 后续还可以深挖的方向如果看完这篇觉得还不过瘾想继续深入建议按下面几个方向扩展第一精读 objc 源码里methodizeClass和attachCategories的完整实现看看新版源码里对分类列表做了哪些处理比如非懒类、懒类对分类合并时机的影响。第二研究 Method Swizzling 和class_addMethod结合理解运行时动态添加方法的边界。第三读一下关联对象相关的objc_setAssociatedObject实现看看全局表的缓存策略和线程安全是怎么保证的。我个人在实际操作中的体会是分类并不是一个需要“背”的知识点而是一个可以直接做实验验证的机制。你只要亲手打印一次方法列表亲眼看到分类方法排在主类方法前面你就会彻底理解为什么分类能覆盖主类方法为什么后编译的分类优先为什么 initialize 会被分类拦截而 load 不会。一旦把底层机制搞通面试时候无论怎么换问法都只是同一件事的不同表述不会再被表面问题难住。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询