前阵子有位朋友跳槽面试,回来跟我复盘时提到,二面面试官问了一道他认为“稳了”的题:说说 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 Person+Test.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有两个分类:
// Person+CategoryA.m @implementation Person (CategoryA) - (void)sayHello { NSLog(@"A"); } @end // Person+CategoryB.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_ASSIGN | assign | 弱引用,不保留值,对象释放后指针不会自动置 nil |
| OBJC_ASSOCIATION_RETAIN_NONATOMIC | nonatomic, retain | 强引用,非原子性 |
| OBJC_ASSOCIATION_COPY_NONATOMIC | nonatomic, copy | 拷贝值,非原子性 |
| OBJC_ASSOCIATION_RETAIN | retain, atomic | 强引用,原子性 |
| OBJC_ASSOCIATION_COPY | copy, 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 两者对比速查表
| 对比项 | +load | +initialize |
|---|---|---|
| 调用时机 | 类/分类加载进内存时,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 不会。一旦把底层机制搞通,面试时候无论怎么换问法,都只是同一件事的不同表述,不会再被表面问题难住。