去年面一家头部大厂的时候面试官问了一个让我印象很深的问题“你平时写分类写得很溜那你说说Category 的底层是怎么把方法加到类上去的为什么它能‘覆盖’类里面原来的方法”当时我虽然背过不少面经但被连续追问两次“到底放在哪”、“什么时候放的”就直接卡住了。回去之后我把 categordy 相关的源码从头到尾翻了一遍又结合项目里踩过的几个坑重新整理了这个主题。这篇文章就把整条链路完整地拆开讲清楚从数据结构、合并时机、加载顺序到面试时怎么答才能让面试官觉得你是真懂而不是纯背题。同时也适合那些想彻底搞懂 runtime 机制、或者项目里重度使用分类但心里没底的同学。1. 面试现场还原一个分类底层问题其实是在问三层东西我第一次被问到“Category 底层原理”是在模拟面试阶段当时我觉得很简单就说“分类就是给类扩展方法用的系统类和自定义类都能加不需要改原类”。结果对面直接追问“方法加在哪里什么时候加为什么同名方法分类优先”这几个问题一出我就知道光背结论是不够的。后来真正到大厂面试时同样的追问又出现了一遍只是角度换成了“分类能不能添加属性”“load 和 initialize 在分类里有什么区别”。这类问题看着是一个点实际上面试官在考察三层东西。第一层是基础用法。你真的会用分类吗你知不知道分类能加实例方法、类方法、协议、属性声明但不能加实例变量有没有在实际项目里大规模使用过分类来拆分代码这一层基本是送分题只要写过一段时间 OC 都能说几句。第二层是底层原理。分类的方法在编译后变成一个什么结构体运行时是怎么把这些方法合入类的方法列表的合入的时机是在 main 之前还是首次发消息之后它和原有的类方法在查找顺序上是什么关系这些就是区分“背过面经”和“真懂 runtime”的分水岭。第三层是工程素养。面试官会问你在真实项目里有没有因为分类踩过坑、怎么规避的比如分类重写了主类方法导致的问题、多个分类同名方法的冲突、关联对象的内存管理。这一层不是知识性的是经验性的只能靠实际写代码和排查问题的积累来回答。我后来复盘发现凡是能把这三层都答顺的人基本都能过这轮面试。所以这篇文章我也按照这三层来组织先把底层结构拆开再走一遍完整加载流程最后聊几个实战里高频踩中的坑。2. 从编译产物说起category_t 结构体里到底躺着哪些东西2.1 一个分类编译后不是“类”是结构体很多人以为分类在编译后会变成某种特殊的类其实不是。分类在编译后就是一个普通的 C 结构体runtime 源码里叫category_t。它并不属于类对象也不属于元类只是一段存放在二进制文件特定段里的数据。源码结构大概是这样的不同 objc4 版本会有些字段包装差异但核心字段没变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;// 实例属性列表 };先看name它存的是分类名比如UIView (Layout)里的Layout。cls才是关键它编译时指向这个分类所扩展的那个类。编译期拿到这个结构体后编译器不会立即把里面的方法和类合并因为分类和类的编译产物是分开存放的真正合并要等运行时。2.2 实例方法和类方法分别去哪如果你在分类里写了一个实例方法它会进instanceMethods。写开头的类方法会进classMethods。这里有个容易漏掉的知识点类的实例方法存放在类对象上而类方法存放在元类上。所以分类里的实例方法最终会被合并到类对象的方法列表类方法则被合并到元类的方法列表。protocols存放分类遵守的协议合并时会追加到类的协议列表。instanceProperties存放分类里声明的属性但这里只记录属性名、类型、内存管理语义这些描述信息不会自动生成成员变量也不会自动生成 setter/getter。这也是“分类不能添加成员变量”这句话的根源——成员变量的偏移在编译期已经定死了运行时不能再插入因为一插入整个对象的内存布局就乱了。2.3 用便利贴来理解分类我自己喜欢把分类类比成便利贴你给一本已经装订好的书的某一页贴上便利贴便利贴上有补充内容。书本身没变还是那些页便利贴也没有重新装订进页面里只是贴在上面。阅读时你先看到便利贴上的内容再看到原书内容。这就是分类“覆盖”原方法的最直观解释——原方法还在只不过查找顺序上分类方法更靠前。理解了这个结构之后面试里的第一个层次基本就过关了。接下来要讲的是这些便利贴是什么时候、以什么顺序被贴到书页上的。3. 运行时合并流程分类方法如何“插队”到方法列表最前面3.1 加载时机main 函数之前发生了什么分类的合并发生在运行时而且不是等到第一次调用方法时才开始。App 启动时dyld 先把所有动态库和可执行文件加载进内存然后触发 objc 初始化流程runtime 通过map_images进入镜像处理流程最后走到_read_images。分类的读取和合并就是在这一阶段处理的也就是说早在 main 函数执行之前分类数据就已经被 runtime 读入内存了。这里要区分“懒加载类”和“非懒加载类”。如果一个类本身没有实现load也没有分类实现load那它的类对象不会在启动时立刻被初始化而是等到第一次收到消息时才完成 realize这叫懒加载类。但如果类或者它的分类里有load那这个类就会被标记为非懒加载启动阶段就必须立刻完成 realize否则没法调load。你平时写分类时如果顺手实现了load比如为了做方法交换实际上会把整个类的初始化提前这类启动性能的副作用很多人没意识到。3.2 attachCategories把分类拆开再重新拼装一旦类完成 realizeruntime 就会处理它名下的分类。核心调用链路是_read_images-remethodizeClass-attachCategories。整个过程可以简化为几个关键步骤遍历这个类的所有分类收集各自的实例方法列表、类方法列表、协议列表、属性列表把这些列表重新排序注意顺序是“后编译的分类在前先编译的分类在后”给类的方法列表重新分配内存把新收集到的分类方法放在前面原有的类方法整体往后移动协议列表、属性列表也类似地插入。attachLists这个核心操作在源码里大致长这样简化版void attachLists(List* const * addedLists, uint32_t addedCount) { if (addedCount 0) return; uint32_t oldCount array()-count; uint32_t newCount oldCount addedCount; // 重新分配一个更大的数组 array()-lists realloc(array()-lists, sizeof(array()-lists[0]) * newCount); // 把原来的方法列表整体往后移 memmove(array()-lists addedCount, array()-lists, sizeof(array()-lists[0]) * oldCount); // 把分类的方法列表拷贝到数组头部 memcpy(array()-lists, addedLists, sizeof(array()-lists[0]) * addedCount); }看到这段代码你应该就明白为什么分类方法优先级更高了。不是原方法被删掉或覆盖了而是原方法被往后挪了位置分类方法直接插到了数组最前面。方法查找时 runtime 按顺序遍历方法列表找到第一个带对应名字的 method 就返回所以最先被找到的一定是分类方法。这个设计带来的一个直接后果是如果你通过class_copyMethodList打印某个类的方法列表会看到靠前的很多是分类方法原类实现反而排在后面。如果你用method_exchangeImplementations交换原方法实现也要注意交换得到的是“查找顺序上的第一个实现”有可能本来就是分类的实现。3.3 多个分类同名方法决定权在编译顺序同一类有两个分类都实现了同一个方法谁会生效答案是编译顺序靠后的分类。因为合并的时候分类列表会做一次反转排序后编译的排前面先编译的排后面运行时按顺序查到时先拿到谁就是谁。这里的坑在于Xcode 里“编译顺序”不全由你在 Build Phases 里看到的文件顺序决定。如果分类是写在静态库里、或者来自 CocoaPods 等第三方依赖链接顺序可能变化。所以同一个类上多个分类出现同名方法时结果是不稳定的今天跑可能是 A 分类生效改一次依赖配置就变成 B 分类生效这种问题是线上排查时最恶心的情况之一。4. load、initialize 与分类面试追问的重灾区4.1 load 的调用时机与顺序load是所有分类考点的起点。它有一个非常特殊的性质不走消息发送机制而是 runtime 拿到函数指针后直接调用。这意味着什么意味着即使某个类没有实现load只要它的分类实现了load分类的这个load依旧会被调用而且每个分类的load都会被完整调到。调用顺序也很有规律先是父类的load再是子类的load最后才轮到分类的load。同一层级里又按照编译顺序来。这个顺序确保了你在分类的load里访问类本身时类的初始化工作已经完成不会出现“分类代码执行了但类还没准备好”的荒唐局面。由于load不经过消息机制它也不会因为你在分类里写了同名方法而只调用一个。网上有说法“分类里的 load 会覆盖类的 load”这是错的。正确的说法是类的load和每个分类的load都是独立调用彼此不影响分类无法通过同名方法“覆盖”掉类的load。4.2 initialize 的分类覆盖逻辑initialize就不一样了它走的是常规消息发送机制。第一次给类发消息时runtime 会检查这个类是否已经初始化没有的话就触发initialize。既然是走消息机制那分类里的initialize就会在查找时被优先找到从而覆盖类本身实现的initialize。这两个方法经常被拿来对比我一般这样记对比项loadinitialize调用时机启动阶段类加载到 runtime 时第一次向类发送消息时调用方式直接通过函数指针调用通过消息发送机制调用次数每个类的 load 都会调一次包括每个分类一个类有效实现只会调一次覆盖规则分类不能覆盖类的 load分类可以覆盖类的 initialize父类调用父类先于子类子类未实现时会调父类实现这里有个特别容易在面试中被追问的细节如果子类没有实现initialize第一次给子类发消息时runtime 会调用父类的initialize。如果后面父类自己也收到了第一条消息不会再调一次。所以父类initialize可能被调用多次但不是因为“被覆盖”而是因为多个子类都没有自己的实现。分类里实现initialize的时间要谨慎它可能会影响父类或者其他子类的初始化流程。4.3 关联对象为什么分类不能加成员变量但能加属性“分类不能添加实例变量”是面试里几乎必问的结论背后的原因我在第二节说过实例变量的内存布局在编译期就确定了类一旦完成 realize对象的 isa 和实例变量偏移就不能再变。如果允许运行时添加成员变量所有已经创建出来的对象内存都不够用了这完全不可行。那分类里声明property还能用吗能但只是声明。编译器不会自动合成成员变量和 setter/getter你要自己实现。最标准的做法就是关联对象// 头文件里声明 interface UIView (Layout) property (nonatomic, strong) NSString *layoutKey; end // 实现文件里用关联对象实现存取方法 static char kLayoutKey; implementation UIView (Layout) - (void)setLayoutKey:(NSString *)layoutKey { objc_setAssociatedObject(self, kLayoutKey, layoutKey, OBJC_ASSOCIATION_RETAIN_NONATOMIC); } - (NSString *)layoutKey { return objc_getAssociatedObject(self, kLayoutKey); } end关联对象的底层是 runtime 里的一个全局 AssociationsManager它维护着一张哈希表以对象指针为 keyvalue 是这个对象所有关联键值对的集合。在多线程环境下这张表有全局锁保护所以频繁设置关联对象是有一定开销的不适合放高频读写的数据。对象释放时runtime 会在dealloc流程里统一清理关联对象。但要注意如果你用了OBJC_ASSOCIATION_RETAIN且关联值又持有这个对象本身就可能形成循环引用这时候需要手动把关联值置空来打破循环。这些都是分类“伪属性”里最容易被忽视的内存管理细节。5. 高分的答题框架从源码阅读到面试表达5.1 一个可以直接复用的口头回答顺序以前我面试的时候容易想到哪说到哪后来总结出一个比较稳的表达顺序按照下面五步说面试官会觉得逻辑是完整的一句话回答是什么Category 是 OC 提供的一种在运行时扩展已有类的机制不需要继承也能给类添加方法常用于给系统类扩展功能、拆解大模块、整理私有方法。说底层存储分类在编译后是一个category_t结构体里面保存着实例方法、类方法、协议、属性这些描述信息它只是个数据载体不是类对象。说合并时机分类数据的真正合并发生在运行时。启动阶段dyld加载镜像后runtime 在_read_images里读取所有分类等到类完成 realize 后调用attachCategories将分类内容合入类的数据区。说查找顺序合入时分类的方法会被放在原有方法列表的前面方法查找按顺序找第一个匹配项所以分类方法“看起来覆盖了”类的方法其实原实现还在列表里只是排后面了。补充边界分类不能添加实例变量但可以通过关联对象模拟属性分类里的load独立调用无法覆盖分类里的initialize因为是走消息机制所以可以覆盖类的实现。这五步走完一个零基础面试官也能跟着你的思路走。不要一上来就抛结构体源码要像讲故事一样从外层往内层递进。5.2 追问环节的高频分支点面试官听完上面的回答后通常会在几个分支细节里继续深挖我整理过一份“追问地图”第一个分支是“分类里可以添加属性吗”。此时不能只说“可以但不会自动 getter/setter”要主动引出关联对象把objc_setAssociatedObject的底层哈希表和循环引用问题讲明白。第二个分支是“分类和 Extension 的区别”。Extension 在编译期决议可以添加实例变量方法只能在本类实现文件里实现而且它没有独立的名字一般叫“类扩展”分类则是运行时决议不能加实例变量可以有多个。第三个分支是“分类同名方法覆盖后还能调用原方法吗”。原实现还在只是查找顺序靠后。如果你在分类里用 C 函数指针或者通过 method 查找拿到原实现还是可以调用的只是一般业务代码不会这样去调。这也能顺势引出 method swizzling 的话题。第四个分支是“多个分类的 load 顺序和 initialize 覆盖”。这就要把顺序规则说清楚特别是父类、子类、分类之间的顺序以及“initialize 可能因为子类未实现而被调多次”这个容易被忽略的点。5.3 结合项目经验的价值在于说不清为什么面试官通常还会问一句“你的项目里怎么用分类的”这时候别只回答“给 UIColor 加了一个 hex 转颜色方法”。更好的回答是把分类当作代码组织和模块隔离的工具举个例子把某个页面里一大坨私有方法按“网络请求”、“视图构建”、“事件处理”拆成多个分类文件让主类只承担核心状态管理。但也要主动说明什么情况下不适合用分类比如需要保存状态的属性、需要被多个模块复用的重逻辑、或者需要严格控制生命周期的方法这类场景用继承或者组合更合适。我自己的体会是能主动说出分类的适用边界比单纯地列用法要加分得多。面试官想确认的不仅是你会不会用而是你有没有在真实项目里权衡过这个方案知道它的代价在哪里。6. 我在项目里踩过的 Category 的坑建议你绕开6.1 分类“覆盖”主类方法差点变成线上事故有一次我们某个基础组件在版本迭代后突然有业务线反馈说日期格式化输出跟之前不一致。一开始以为是时区问题排查了大半天最后才发现是一个同事给 NSDate 写了个分类方法名恰好和原有方法重名。因为分类方法排在原方法前面所有原本走旧逻辑的地方都悄无声息地走成了新实现而且新实现只有在特定语言环境下才表现异常平时根本测不出来。这件事给我的教训是分类方法一定要加前缀最好用公司缩写或者模块缩写加下划线比如xmxx_stringForDate:永远不要觉得自己不会和其他人重名。在大型项目的共享代码库里你也不知道哪个同事、哪个第三方库会在同一个类上挂多少个分类。6.2 分类声明属性但不实现存取方法KVC 闪退另一个很常见的坑是为了图方便在分类里这么写interface UIButton (Config) property (nonatomic, copy) NSString *trackId; end然后直接在外面button.trackId xxx或者更隐蔽的是通过 KVC 去赋值[button setValue:xxx forKey:trackId];结果运行时直接抛异常unrecognized selector或者走到setValue:forUndefinedKey:。原因就是分类不会自动合成 setter/getter也不会生成对应的成员变量。KVC 在 setter 和 getter 都不存在时会去找_trackId这个成员变量而分类压根没有成员变量所以必然崩溃。后面我把这些属性全部改成关联对象实现问题才彻底消失。这类问题的隐蔽之处在于很多同学觉得property写在谁那里都一样会自动生成方法。在分类里不是这样它只是描述了一个属性实现完全落在你头上。6.3 多个分类同名方法导致的小概率故障还有一种更隐蔽的情况两个毫无关系的业务组件各自给同一个类添加了同名分类方法。因为两个分类来自不同的编译单元编译顺序不稳定某次构建后行为就变了。我们当时遇到的是两个工具库都给 NSString 加了md5String方法其中一个实现返回的是大写另一个返回的是小写。某次依赖升级后链接顺序变化跑出来的 md5 从小写变成大写导致一个加密相关的接口签名验证失败。这种问题定位极其痛苦因为报错点离分类很远。后来我们定了两条硬规矩第一所有分类方法必须带明确前缀全局排查一遍重命名第二任何人要给公共类写分类都要在代码评审时检查方法名是否可能在全局范围内冲突。看似简单却是用线上故障换来的。6.4 动态库里的分类可能根本没被执行到如果你项目里用了动态库做组件化还要注意一个与分类加载相关的问题动态库里的分类只有在这个分类所扩展的类被加载到 runtime 后才会真正挂载上去。如果主工程里某个类没有被引用分类就可能一直躺在那儿不生效。我们在做某项动态化方案时就遇到过动态库里给主工程的某个类写了分类方法结果类启动时没被触达分类也迟迟不生效调用时直接unrecognized selector。碰到这种情况不要继续在分类上死磕要么确保主工程在启动早期就引用一次那个类要么放弃分类方案改成在动态库初始化时手动注册方法。后者更可靠因为不依赖类的加载时机。6.5 排查分类是否生效的调试方法最后分享一个排查分类是否生效的小技巧。你可以在断点里用 lldb 输出某个类的方法列表看看分类方法有没有排到前面来po [SomeClass instanceMethodForSelector:selector(someMethod)]或者用 runtime 函数遍历方法列表unsigned int count 0; Method *methods class_copyMethodList([SomeClass class], count); for (unsigned int i 0; i count; i) { NSLog(%, NSStringFromSelector(method_getName(methods[i]))); }如果发现分类方法没有出现在顶部那就要检查分类是否被链接进来、编译产物是否正常以及最容易被忽略的分类文件有没有被加进对应的 target。做了这么多年 iOS我越来越觉得分类这种基础特性恰恰是最能考验功底的。它看着简单但每一个“顺手”在底层都有一套精确的规则在约束。你把 Category 的原理吃透之后再看 runtime 方法查找、load/initialize、关联对象甚至方法交换都会觉得思路突然通顺了。希望这篇分享能帮你把这块硬骨头啃下来无论是应付面试还是排查线上问题都能心里有底。