快两年没碰ObjC的运行时源码了结果面试官一上来就问Category我心里咯噔一下。别看平时写分类挺顺手像UIImage扩展个圆角方法、NSString加个MD5加密真问到Category底层怎么实现的方法是怎么加载进去的脑子里那点底子还真不太够用。后来回去把源码翻了一遍又对照了别人写的源码分析这次彻底捋顺了。先说结论Category在编译期只是生成了一个category_t结构体里面存的还是待处理的方法列表、协议列表和属性列表真正把这些内容合并进类主结构是在运行时由objc运行时加载镜像文件时完成的。这个机制决定了分类能做什么、不能做什么也解释了那些看起来很诡异的面试题——比如为什么分类会覆盖主类的方法、为什么同名方法不警告、为什么load先执行主类后执行分类。这篇文章我按面试的节奏来写从结构体定义、运行时合并流程、与Extension的区别到关联对象的底层实现每一环都结合源码和实操经验拆开讲。去某大厂面试前把这一套吃透顶得上翻十篇零散的文章。1. Category到底是个什么东西1.1 面试官第一句话你说说Category是怎么实现的这个问题看起来简单但绝大多数人第一反应是Category就是给类扩展方法用的这个回答最多值2分。面试官真正想听的是你知道Category在编译期和运行时的表现是分开的吗你知不知道它其实没有真正修改原类先说编译期。我们写一个分类比如在Person类上定义一个PersonEat的分类里面有一个eat方法。当你用clang编译这个文件时编译器会生成一个_category_t类型的结构体这个结构体里记录的是分类的名字字符串分类所属的类Class对象引用分类里实例方法的列表分类里类方法的列表分类遵循的协议列表分类里声明和实现的属性信息重点来了这只是一个存放信息的容器编译完成后它跟原类本身没有任何关系。原类的结构体(类的bits数据、方法列表、属性列表)并没有因为存在这个分类而发生任何变化。所有合并动作都被推迟到了运行时。1.2 Category能做什么不能做什么从使用层面Category提供了几个看起来很理所当然的能力给现有类添加实例方法、类方法不需要继承给现有类添加遵循的协议让这个类看起来实现了新协议给现有类添加属性声明但注意这里添加的属性不会自动生成成员变量也不会自动生成getter/setter你可以通过关联对象来模拟存储但关联对象本质并不属于分类的结构体它不能做的也很明确不能给类添加成员变量ivar这是和Extension最大的区别也是面试官接下来最爱追问的点不能在分类里覆写已有的系统方法然后指望不触发任何副作用——其实能覆写但会破坏原有逻辑且多个分类同时覆写同一个方法执行哪个完全取决于编译顺序属性只有声明没有实现直接赋值和取值会崩我当年踩过一个坑在分类里写了一个属性想要做缓存结果一运行就崩了报错是unrecognized selector。后来才明白分类里写property不会自动给你生成setter/getter也不会给你生成那个成员变量。要真的存数据必须手动实现setter/getter再用关联对象API把值存到运行时的一个全局哈希表里。2. 底层结构category_t、method_list_t、protocol_list_t2.1 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; // 实例属性列表 // 新版本还有 classProperties这里不展开 };name字段存的是分类字符串名cls字段存的是这个分类指向的宿主类后面四个字段就是分类里所有成员内容的入口。method_list_t本质上是一个数组容器数组里每个元素是一个method_tmethod_t又包含三件套方法名SEL、方法实现IMP、方法类型编码type encoding。在源码里直接能看到的是一个methods数组编译器会把你在分类中写的每个方法转换成对应的method_t。protocol_list_t同理存的是protocol_t指针的数组。如果你在分类里声明遵守某个协议但协议方法实现要么在分类里实现要么在宿主类里实现运行时合并时会把协议信息一并挂到类上让typeWithProtocol这样的查询能正常工作。property_list_t存的是分类中property生成的属性描述。需要注意这个列表只包含属性名、类型、attribute等信息不包含带下划线的成员变量。合并进主类后class_getProperty能查到这个属性但class_copyIvarList查不到对应的成员变量。2.2 为什么分类没有成员变量的位置前面专门提到category_t结构体里没有ivars字段。这不是巧合而是刻意的设计。分类存在的意义是在不修改类的源码、不重新编译原类的前提下给类补充能力。如果允许添加成员变量就意味着要改变类的内存布局因为每个实例的Ivar Offset成员变量偏移量是在编译期固定的。一旦改变布局所有编译好的代码里通过偏移量访问成员变量的逻辑就全乱了而且还会破坏子类继承时的字节对齐等一大堆问题。所以关联对象几乎是唯一能在分类里存实例级数据的方式。关联对象并没有存在实例里它是放在一个全局的AssociationsManager里维护的哈希表中根据对象地址和关联key做了一层映射。这一点下面专门展开。2.3 一个分类在编译时生成几个结构体不少同学会误以为一个分类对应一个category_t其实还分情况。如果你写了一个同时包含实例方法、类方法、协议、属性的分类编译后会生成一个category_t。但如果你用Objective-C 2.0的某些特性比如分类里有类属性那么还有单独的类属性列表。另外如果宿主类是Swift类情况会更特殊因为Swift类的方法表结构和OC不一样Category对它一般不直接生效。实际开发中我们在Swift里使用extension做扩展跟OC的Category虽然思想类似但底层机制完全不同面试官如果问到这里建议如实说明自己熟悉的是ObjC侧的实现。3. 运行时加载从image读到方法合入的完整链路3.1 起点-read_images主类方法和分类方法的整合发生在objc运行时初始化阶段。当程序启动、动态库被加载时dyld会调用objc运行时中的_read_images函数这个函数会遍历镜像中的各种结构进行初始化。在这个过程里会专门有一个loop处理所有分类大概逻辑是拿到一个分类之后先找到它的宿主类然后标记这个类需要做方法重配(methodize)再把分类放进一个全局数组或者直接做attach操作。不同版本实现细节有差异但处理的几个核心步骤是稳定的。简单说就是分类内容不会马上合并而是先把信息收集起来等待后续统一的合并时机。3.2 合并时机attachCategories真正干活的函数是attachCategories它负责把某个类关联的所有分类的内容拼接进这个类本身的数据结构。调用时机通常有两个类的首次初始化之后也就是类结构被methodize方法化的时候会把已注册到该类的分类合并进去某些版本里懒加载类和非懒加载类的处理顺序不完全一样但最终都会走到合并这一步有些版本的实现中会先收集该类的所有分类到一个数组然后做分类排序最后逐个把方法列表、协议列表、属性列表拼接到主列表的前端。3.3 方法列表拼接为什么分类方法会覆盖主类方法这是最核心、也是面试必问的一环。当多个方法来源要合并进同一个类时运行时不是做去重对比而是采用一个新的方法数组容量是主类原有方法数加上所有分类方法数的总和然后把新内容放到数组的最前面再把主类的原有方法拼接在后面。具体落表顺序会因为实现版本而有所不同但是关键点在于分类的方法排在了主类方法的前面。因为方法查找时objc_msgSend会线性遍历方法列表一旦按顺序找到第一个匹配的SEL就直接调用对应的IMP后面的实现不再检查。这就导致了如果主类里有一个- (void)run分类里也有一个- (void)run实际运行时调用的会是分类里的实现。这一现象被通俗地说成分类覆盖了主类的方法但严格讲并不是覆盖而是排序靠前、优先命中。主类的方法还在列表里只是永远轮不到它。3.4 同名分类方法之间是谁赢面试官到这里通常会加一道题那如果两个分类都实现了同一个方法谁生效答案是不确定取决于谁先被编译、谁先参与合并。因为运行时收集分类时可能依赖节点顺序、链接顺序、加载顺序。不同项目、不同镜像加载次序结果可能不一样。这一点在真实开发里非常危险。我在一个项目里遇到过这样的情况一个系统类两个基础库都实现了同名的方法一个库期望的行为是A另一个库期望的是B最后线上表现时而被A覆盖、时而被B覆盖追查成本极高。后面团队定下的规矩是分类里不覆写系统方法除非有极其明确的需求并采用swizzling做统一管理分类里重名方法一律改命名。3.5 load与initialize在分类中的执行顺序整个合并过程中还有一个容易乱的细节load的调用时机是镜像加载时initialize是第一次使用这个类时。分类里的load方法也会被调用而且调用顺序是宿主类优先子类优先于父类这里不用过度展开重点记忆宿主类的load先执行然后是宿主类要遵守的各个分类分类之间依然依赖编译顺序。initialize则不同如果子类没有实现initialize分类里实现了不会因为分类方法论而影响它——因为initialize的查找走的是另一条正常方法查找路径分类的方法如果排序在前面也可能代替实现但这一点和主类方法被覆盖的规则一致。很多开发者在load里做Method Swizzling那必然要理解这个顺序。我自己的习惯是只在主类的load里做swizzle避免在分类里做因为多个分类都做了swizzle之后一旦顺序变化整套流程容易出隐蔽bug。4. Category与Class Extension的差异4.1 编译期的Extension一个隐藏的主类碎片面试官经常拿Category和Extension做对比问有什么区别。这个问题答好了能体现出你清楚编译期和运行时的本质差异。Extension在源码里长得也很像分类比如interface Person () property (nonatomic, copy) NSString *secret; - (void)privateMethod; end它的内容会直接编译进主类的类结构里。也就是说如果你在主类的实现文件里写了一个Extension它相当于在编译期把属性、方法、成员变量追加到主类本身了。尽管它写在单独的.m文件里但编译器视角里它就是主类的一部分。所以Extension里声明的属性主类中可以直接自动生成setter/getter和成员变量不存在关联对象问题。4.2 运行时的Category一个补丁包Category则是独立、预生成的结构体它和主类的融合被推迟到运行时。所以它可以添加方法、协议、属性声明但不能添加成员变量。用项目管理打比方Extension是在蓝图阶段直接修改设计图Category是在交付之后贴一张补丁。这个区别是面试官非常看重的回答时尽量把这个对比说出来。很多人知道结论但说不清原因其实只要记得这两个关键字就行编译期、运行时。4.3 实战中怎么选开发实践里什么时候用Extension什么时候用Category如果这个类型是你自己负责的并且是在同一个模块、同一个类中补充私有能力优先用Extension。例如在一个类的.m文件里定义私有属性、私有方法全部写在Extension里是最清爽的既有代码补全又天然隐藏到实现细节。如果是给不可控的类、系统框架、第三方类增加通用能力或者把一个大的实现拆成多个功能模块就用Category。比如给UIImage加一个统一圆角裁剪方法、给UIView加一个loading指示器包装这些场景用Category非常合适因为不需要修改原类的内存布局也不影响其他人对这个类的使用。5. 关联对象分类里唯一能存实例属性的路子5.1 三个API与底层结构关联对象的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);policy参数就是内存管理语义assign、retain_nonatomic、copy_nonatomic、retain、copy这些对应我们写property时的一些修饰词但要注意它是全局关联表里管理的值。底层核心数据结构是AssociationsManager它内部维护了一个全局的HashMapKey是对象指针Value是另一个HashMap内层Map的Key是关联的key也就是我们传入的那个const void*Value是一个ObjcAssociation对象里面存具体的value和policy。所以关联对象本质上就是在对象的外部用一张大哈希表记录这个对象的一些额外值。每个对象只是有一个地址作为索引真正持有值时靠这张哈希表的生命周期管理。5.2 什么时候释放关联对象的释放时机和对象本身的dealloc是有绑定关系的。当对象被释放时运行时在dealloc流程中会调用object_dispose相关的逻辑清理它的关联表内容。具体来说每个对象在销毁时会调用_objc_object的dealloc方法内部会处理关联表中的所有条目根据policy对value做release或者不做操作。这个设计决定了关联对象生命周期天然跟随宿主对象不需要手动释放除非要提前清理。但也带来一个容易忽视的问题如果关联对象的值是一个大的对象比如一个数组、一张大图宿主对象一直存活它会一直在哈希表里占内存。如果你在合适的时间不再需要它可以用objc_setAssociatedObject传入nil来清除对应条目。5.3 关联key的常见坑很多初学者喜欢用字符串做key。比如objc_setAssociatedObject(self, myKey, value, OBJC_ASSOCIATION_RETAIN_NONATOMIC);这样写能用但有个隐患如果多个地方用了同一个字符串可能互相覆盖。而且字符串比较是按指针相等来判断的编译器优化时不同字符串字面量可能相同也可能不相同这就很不可靠。更稳妥的做法是声明一个静态指针作为key比如static void *kMyKey kMyKey; objc_setAssociatedObject(self, kMyKey, value, OBJC_ASSOCIATION_RETAIN_NONATOMIC);这样key的地址全局唯一不会和别的key冲突也更符合官方文档的推荐。稍微还可以用selector作为key比如selector(myKey)它的本质也是取SEL的唯一标识。5.4 关联对象的安全注意点还有一个经验不要在dealloc里用关联对象做复杂的事。因为在dealloc期间对象已经处于销毁状态外部关联对象表也在清理中如果这时又做关联、读取很容易出现不稳定行为。我当年是踩过类似坑的表现为间歇性crash堆栈在objc_setAssociatedObject里。后来把关联对象的清理逻辑提前到了ViewController的viewDidDisappear避免了问题。另外多线程环境里对同一个对象反复设置关联对象需要保证key管理是线程安全的。虽然底层AssociationsManager加了锁但你自己的业务逻辑里如果依赖get之后再set并不保证整体原子性。6. 面试追问高频考点与回答口径6.1 主类和分类都实现同一个方法最终生效的是谁回答要点分类方法合入后在方法列表里排在主类方法之前消息查找是逐方法List线性扫描的先找到就先调用所以分类实现会被优先命中。注意用词这不是真正的覆写原方法仍然存在于方法列表中可以通过特定手段访问比如直接找imp或者通过super的方式在某些场景下绕开。如果面试官追问怎么访问到被覆盖的主类实现答案有几种Method Swizzling交换IMP之后调用时走的顺序相反拿到method list里的具体method_t手动调用IMP指向的函数如果宿主类是子类的方法super调用链处理方式不同分类下调用super可能仍然走主类实现我这里不想给死答案因为不同系统版本行为略有差异。更推荐回答思路是虽然分类方法排在前面但runtime并没有提供公开API直接获取被覆盖的实现。常见做法是Method Swizzling在分类方法里调用原IMP。6.2 分类和Extension同时存在同名方法当在Extension里与分类里同时声明并实现了相同的方法名Extension就是主类的一部分它的方法排位和分类的方法排位有所不同。具体的排位依据是合并顺序。面试里更关键的是考察面试者是否理解扩展是编译期拼接分类是运行时拼接。6.3 分类中声明属性为什么不能直接赋值取值因为属性声明不会自动生成ivar也不会自动生成getter/setter实现。而分类也没有能力去修改类的实例布局。如果要增加可存储属性只能用关联对象。这个点前面已经展开这里不再重复。6.4 多个分类加载顺序和编译顺序的影响编译顺序影响分类在最终方法列表中的位置。某个平台、某个产品线下因为引入的动态库不同编译顺序可能不同所以结果不确定。在面试中不要只背结论要表达出对工程隐患的认知尽量避免在分类里覆写方法。7. 实战复盘我亲测过的几种排查和分析方式7.1 用clang查看编译产物如果想亲眼看到分类编译后的结构体可以在终端写clang -rewrite-objc PersonEat.m然后生成一个PersonEat.cpp打开它搜索category_t关键词就可以看到编译期生成的category结构体里的信息。这个方式非常直观适合困惑于编译期到底生成了什么的初学者。注意脚本编辑环境如果直接重写整个工程文件会生成巨大文件一般建议单独准备一个只有分类的测试文件减少干扰。7.2 用runtime动态打印方法列表在运行时打印类的方法列表观察分类方法和主类方法的排列顺序是验证底层结论最直观的手段。unsigned int count 0; Method *methods class_copyMethodList([Person class], count); for (unsigned int i 0; i count; i) { NSLog(method: %, NSStringFromSelector(method_getName(methods[i]))); } free(methods);运行后会看到分类里的方法排在最前方主类的方法在后面。这个方法有助于面试前自己验证也适合讲给面试官听。7.3 对系统性问题的定位思路如果开发中发现某个分类方法没生效先不要怀疑runtime先检查这个分类有没有被编译进target宿主类有没有加载方法名是否真的匹配是不是有多个分类互相干扰是否有重复Symbollinker是否警告很多分类方法不生效是因为压根没把分类的文件加进编译列表或者文件名被工程引用方式改了。8. 个人经验总结Category这一块看起来简单但深挖起来涉及运行时、编译链接、内存布局、消息发送等多个扎扎实实的知识点。如果只是背结论面试时候追问两三个细节就会露馅。我自己的复习路线是先把category_t结构体看懂再去理解attachCategories的合并顺序然后关掉源码自己画一遍流程最后写一个测试Demo用来验证主类和分类的调用顺序以及关联对象的使用。这套流程走下来再去面试基本不会被问倒。最后分享一个小技巧面试被问到Category时可以挑一个自己踩过的真实坑来收尾。比如我上面说的属性崩溃、多个分类同名方法互相覆盖那次。因为技术面试不只考察记忆力更考察你是否真正做过、是否理解边界条件。一个真实的踩坑故事比十句完美的八股结论都有说服力。