☰
iOS Category底层原理:编译期结构、运行期合并与关联对象
2026/10/15 17:29:11 网站建设 项目流程

前阵子有位朋友跳槽面试,回来跟我复盘时提到,二面面试官问了一道他认为“稳了”的题:说说 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"); } @end

Build 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弱引用,不保留值,对象释放后指针不会自动置 nil
OBJC_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 两者对比速查表

对比项+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 不会。一旦把底层机制搞通,面试时候无论怎么换问法,都只是同一件事的不同表述,不会再被表面问题难住。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询