
1. 分类不是语法糖那么简单从一次线上崩溃说起很多人对 Objective-C 分类Category的理解停留在“可以给已有类加方法”这个层面觉得它就是个语法糖用起来方便而已。我一开始也是这么想的直到有一次线上版本出现了一个诡异的崩溃某个系统类的方法被我们自己的分类覆盖了导致另一个模块的逻辑直接挂掉。排查了大半天才定位到问题——两个分类同时给同一个类的同一个方法写了实现谁生效完全取决于编译顺序。那次之后我才认真去翻 Runtime 源码把分类的实现机制从头到尾捋了一遍。这篇文章就是那次排查的完整沉淀。我会从 Runtime 源码的角度把分类的底层数据结构、加载流程、方法合并规则、以及那些“看起来能用但实际有坑”的用法全部拆开讲。适合已经会用分类、但对其内部机制一知半解的 iOS 开发者也适合准备面试、想把 Runtime 这块讲清楚的同学。读完你应该能回答这几个问题分类的方法到底存在哪里多个分类方法冲突时谁说了算为什么分类不能添加实例变量load和initialize在分类里的表现为什么和主类不一样这些问题的答案都藏在 Runtime 的源码里我们一个一个来。2. 分类在编译期长什么样从 .m 文件到 category_t 结构体2.1 编译器如何把分类代码“打包”你写一个分类大概是这样// NSStringMyAdditions.h interface NSString (MyAdditions) - (NSString *)my_trimmedString; end // NSStringMyAdditions.m implementation NSString (MyAdditions) - (NSString *)my_trimmedString { return [self stringByTrimmingCharactersInSet:[NSCharacterSet whitespaceCharacterSet]]; } end编译器看到这段代码不会把它合并进NSString的类对象里。它做的事情是生成一个独立的category_t结构体把分类里所有的方法、属性、协议信息都塞进去然后把这个结构体放到一个特殊的段section里等着 Runtime 在启动时来处理。在 Runtime 源码比如 objc4-818 版本里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; // 实例属性列表 struct property_list_t *_classProperties; // 类属性列表 };注意这里有个关键点category_t里根本没有ivar_list_t实例变量列表这个字段。这就是“分类不能添加实例变量”的根本原因——不是编译器不让你加而是数据结构里压根没地方放。你如果在分类里写property编译器只会帮你生成 getter 和 setter 的方法声明但不会生成对应的_ivar也不会做内存管理。你调用self.xxx value的时候实际上是在调用一个没有 backing storage 的 setter运行时要么崩溃要么行为未定义。2.2 分类的“属性”为什么是个陷阱很多人喜欢在分类里加属性来“扩展”类的状态比如interface UIView (MyFrame) property (nonatomic, assign) CGFloat my_x; end这段代码能编译通过但如果你不手动实现 getter 和 setter运行时就会报unrecognized selector sent to instance。因为编译器只帮你声明了方法没有生成实现也没有生成_my_x这个实例变量。正确的做法是用关联对象Associated Object来模拟#import objc/runtime.h implementation UIView (MyFrame) - (void)setMy_x:(CGFloat)my_x { objc_setAssociatedObject(self, selector(my_x), (my_x), OBJC_ASSOCIATION_RETAIN_NONATOMIC); } - (CGFloat)my_x { return [objc_getAssociatedObject(self, selector(my_x)) floatValue]; } end关联对象的本质是 Runtime 维护了一张全局的哈希表key 是对象的地址value 是一个包含关联对象和策略的链表。每次objc_setAssociatedObject都会往这张表里插入或更新数据。这里有个性能细节关联对象的读写比普通 ivar 慢不少因为要经过哈希查找和锁保护。所以如果你只是想在分类里加一个临时状态能用 ivar 就用 ivar实在不行再用关联对象。注意关联对象在对象销毁时会自动释放根据你设置的策略但如果你用的是OBJC_ASSOCIATION_ASSIGN那就跟assign一样不会持有对象容易产生悬垂指针。3. Runtime 加载分类的完整链路从 _read_images 到 attachCategories3.1 分类是什么时候被加载的Runtime 启动时会调用_objc_init然后经过map_images、_read_images等一系列函数把 Mach-O 文件里的各种段包括分类所在的__objc_catlist段读进内存。分类的加载发生在_read_images函数里核心逻辑大概是遍历__objc_catlist段里的所有category_t结构体。对每个分类通过name找到对应的类对象如果类还没加载就先记下来等类加载完再处理。调用addUnattachedCategoryForClass把分类暂存到一个哈希表里key 是类对象value 是分类列表。等所有类都加载完之后调用remethodizeClass把分类的方法合并到类对象的方法列表里。这里有个容易忽略的点分类的加载顺序是不确定的。__objc_catlist段里的顺序取决于编译顺序和链接顺序而 Runtime 遍历这个段的时候是按顺序处理的。所以如果你有两个分类给同一个类加了同一个方法谁生效完全看谁在__objc_catlist里排在后面——因为后合并的会覆盖先合并的。3.2 attachCategories 里的方法合并细节remethodizeClass最终会调用attachCategories这个函数是分类机制的核心。它的逻辑大致是static void attachCategories(Class cls, category_list *cats, bool flush_caches) { if (!cats) return; method_list_t **mlists ...; // 准备存放所有分类的方法列表 property_list_t **proplists ...; protocol_list_t **protolists ...; int i cats-count; while (i--) { // 注意这里是倒序遍历 category_t *cat cats-list[i]; if (cat-instanceMethods) { mlists[mcount] cat-instanceMethods; } // ... 处理属性、协议 } // 把收集到的方法列表合并到类的方法列表里 attachMethodLists(cls, mlists, mcount, ...); }注意那个while (i--)它是倒序遍历分类列表的。这意味着在__objc_catlist里排在前面的分类反而会先被处理然后被后面的分类覆盖。最终生效的是__objc_catlist里排在最前面的那个分类的方法。这个顺序跟很多人直觉相反面试的时候经常被问到。attachMethodLists做的事情是把分类的方法列表插入到类原有方法列表的前面。也就是说分类的方法会“覆盖”类本身的方法——因为 Runtime 在查找方法时是从前往后遍历方法列表的找到第一个匹配的就返回。分类的方法被插到了前面所以优先被找到。3.3 为什么分类方法会“覆盖”主类方法很多人以为分类方法是“替换”了主类方法其实不是。主类的方法还在方法列表里只是排在分类方法后面永远不会被查找到而已。你可以通过class_getInstanceMethod拿到方法实现但如果你用method_getImplementation去对比会发现拿到的是分类的实现。这个机制带来一个后果如果你在分类里覆盖了主类的方法然后又在代码里想调用主类的原始实现是没有直接办法的。你只能用method_exchangeImplementations做方法交换或者用class_getInstanceMethod拿到主类方法再手动调用——但前提是你能拿到主类方法的 IMP而 Runtime 的方法查找只会返回第一个匹配的。提示如果你确实需要在分类里“增强”而不是“覆盖”某个方法正确的做法是用 Method Swizzling在交换后的实现里调用原始实现。但要注意线程安全和继承链的问题。4. 分类与 load、initialize 的交互那些反直觉的行为4.1 load 在分类里的执行顺序load是个特殊的类方法Runtime 会在类加载时直接调用它不走消息发送机制。对于分类来说load的行为有几个特点主类的load先执行然后才是分类的load。多个分类的load执行顺序取决于编译顺序不确定。分类的load不会覆盖主类的load它们都会被执行。这是因为 Runtime 在处理load时会单独遍历所有类和分类的load方法逐个调用。源码里load_images函数会先收集所有load方法然后按“父类优先、主类优先于分类”的顺序调用。这个特性有时候会被用来做“自动注册”之类的事情比如在分类的load里把某个类注册到一个全局表里。但要注意load的执行时机非常早很多类可能还没加载完你在load里调用其他类的方法可能会出问题。4.2 initialize 在分类里的表现initialize跟load完全不同。它是在类第一次收到消息之前被调用的走的是正常的消息发送机制。这意味着分类的initialize会覆盖主类的initialize因为方法查找优先找到分类的实现。如果你有多个分类都实现了initialize只有一个会生效取决于加载顺序。父类的initialize和子类的initialize是分别调用的Runtime 会保证父类的先调用。这里有个经典的坑如果你在分类里写了initialize主类的initialize就永远不会被调用了。很多人用initialize来做一些初始化工作结果被分类覆盖了导致初始化逻辑丢失。排查这种问题非常痛苦因为没有任何编译警告。注意initialize是懒加载的只有类第一次收到消息时才会调用。如果你只是引用了类但没有发消息initialize不会执行。而load是启动时就执行不管有没有被用到。4.3 分类里的 load 和 initialize 对比特性loadinitialize调用时机Runtime 启动时类第一次收到消息前调用方式直接调用 IMP消息发送分类是否覆盖主类不覆盖都执行覆盖只执行一个执行顺序父类→子类→分类父类→子类分类覆盖主类是否懒加载否是线程安全Runtime 保证串行Runtime 用锁保证只执行一次这张表建议背下来面试被问到分类和这两个方法的交互时能直接答出来。5. 分类方法冲突的排查实战一次线上崩溃的完整复盘5.1 崩溃现象与初步定位回到开头说的那次崩溃。现象是某个页面在调用-[NSMutableArray addObject:]的时候偶尔会崩溃堆栈显示是NSInvalidArgumentException提示*** -[__NSArrayM addObject:]: object cannot be nil。但我们代码里明明做了 nil 判断而且这个崩溃不是必现的只在某些用户设备上出现。一开始怀疑是多线程问题但加了锁之后还是偶发。后来用image list命令看了下加载的镜像发现有个第三方 SDK 也用了分类而且给NSMutableArray加了addObject:方法。我们的代码里也有一个分类给NSMutableArray加了addObject:做 nil 保护。两个分类冲突了。5.2 用 Runtime 接口验证分类冲突要确认是不是分类冲突可以用 Runtime 的几个接口#import objc/runtime.h // 获取类的方法列表 unsigned int methodCount 0; Method *methods class_copyMethodList([NSMutableArray class], methodCount); for (unsigned int i 0; i methodCount; i) { Method method methods[i]; SEL sel method_getName(method); IMP imp method_getImplementation(method); NSLog(SEL: %, IMP: %p, NSStringFromSelector(sel), imp); } free(methods);如果同一个 SEL 出现了多次说明有分类冲突。但class_copyMethodList只会返回去重后的方法列表Runtime 在合并时会做去重所以你看到的是一个 SEL 对应一个 IMP。要看到所有分类的方法需要遍历__objc_catlist段这个比较麻烦一般用nm或者otool看 Mach-O 文件更直接。更实用的方法是用method_getImplementation对比不同时机拿到的 IMPIMP originalIMP method_getImplementation(class_getInstanceMethod([NSMutableArray class], selector(addObject:))); // 在某个时机之后再次获取 IMP currentIMP method_getImplementation(class_getInstanceMethod([NSMutableArray class], selector(addObject:))); if (originalIMP ! currentIMP) { NSLog(方法被替换了); }5.3 解决分类冲突的几种方案确认是分类冲突后解决方案有几种改方法名给分类的方法加前缀比如my_addObject:避免跟系统方法或其他分类冲突。这是最推荐的做法成本低风险小。用 Method Swizzling 替代分类覆盖在load里做方法交换这样可以在调用原始实现的基础上做增强而不是直接覆盖。调整编译顺序让我们的分类排在第三方 SDK 后面这样我们的方法会生效。但这种方法不可靠因为编译顺序可能变化而且第三方 SDK 也可能调整。移除冲突的分类如果第三方 SDK 的分类不是必须的可以考虑移除或者换一个版本。我们最终选择了方案 1把分类方法全部加了前缀。虽然改动量有点大但一劳永逸。提示给分类方法加前缀是个好习惯尤其是给系统类加分类的时候。前缀可以用公司名缩写或者项目名缩写比如abc_addObject:。6. 分类的进阶用法与性能考量6.1 分类与类扩展的区别很多人分不清分类Category和类扩展Class Extension。简单说类扩展是写在.m文件里的匿名分类可以添加实例变量、属性、方法编译器会把它合并进主类。分类是独立的.h/.m文件不能添加实例变量方法在 Runtime 时合并。类扩展里的方法是在编译期就合并进主类的不存在“覆盖”问题。而分类的方法是在 Runtime 时合并的有覆盖风险。所以如果你只是想给类加私有方法和属性用类扩展更安全。6.2 分类加载对启动时间的影响每个分类都会在 Runtime 启动时被处理包括查找类、合并方法列表、刷新方法缓存等。如果你的项目里有大量分类比如几百个启动时间会受到影响。我实测过一个项目减少 200 个分类后启动时间快了大概 30-50ms。虽然不多但在启动时间敏感的场景下还是值得注意的。优化建议合并功能相似的分类减少分类数量。避免给系统类加大量分类尤其是那些启动时就会用到的类比如NSObject、NSString。如果分类只是用来做方法交换考虑用load里的dispatch_once来延迟执行。6.3 分类里的方法列表合并性能attachMethodLists在合并方法列表时会创建一个新的方法列表把分类的方法和类原有的方法都拷进去。如果分类很多这个操作的时间复杂度是 O(n)n 是方法总数。对于大多数项目来说这个开销可以忽略但如果你有极端情况比如一个类有上千个方法就需要留意了。另外分类合并后会刷新方法缓存flush_caches这意味着之前缓存的方法查找结果都会失效下次调用需要重新查找。这个操作在启动时集中发生可能会导致启动时的性能抖动。7. 面试中关于分类的高频问题与回答思路7.1 分类为什么不能添加实例变量这个问题看似简单但很多人答不到点子上。标准回答是category_t结构体里没有ivar_list_t字段Runtime 在加载分类时不会为分类创建实例变量。更深层的原因是实例变量的内存布局是在编译期确定的类的instance_size在编译时就固定了。如果分类能添加实例变量就会改变类的内存布局导致已经编译好的代码出现内存错位。所以 Runtime 从设计上就不支持分类添加实例变量。7.2 分类的方法和主类方法同名时调用哪个调用分类的方法。因为 Runtime 在合并方法列表时会把分类的方法插入到主类方法的前面方法查找是从前往后遍历的所以先找到分类的方法。但要注意如果有多个分类同名生效的是__objc_catlist里排在最前面的分类的方法因为attachCategories是倒序遍历的。7.3 分类能添加属性吗能声明属性但不会自动生成实例变量和存取方法。你需要手动用关联对象实现 getter 和 setter。如果只声明不实现调用时会崩溃。7.4 分类的 load 和主类的 load 谁先执行主类的load先执行然后才是分类的load。多个分类的load执行顺序不确定。load不会互相覆盖都会被执行。7.5 如何判断一个方法是不是来自分类可以用class_copyMethodList遍历方法列表然后对比方法的 IMP 和主类的 IMP。如果 IMP 不同说明方法被分类覆盖了。更直接的方法是用otool -ov查看 Mach-O 文件里的分类信息。8. 我在实际项目中使用分类的几条经验分类用得好是利器用不好是灾难。我总结了几条经验都是踩过坑之后才明白的。第一条给系统类加分类一定要加前缀。不管是方法名还是属性名都加上项目或公司前缀。你永远不知道哪个第三方 SDK 也在给同一个类加分类冲突了排查起来非常痛苦。第二条不要在分类里覆盖主类的方法。如果你需要增强某个方法用 Method Swizzling在交换后的实现里调用原始实现。直接覆盖会导致原始逻辑丢失而且没有任何警告。第三条分类里慎用 initialize。如果你在分类里写了initialize主类的initialize就不会执行了。如果主类的initialize里有重要的初始化逻辑就会出问题。用load更安全因为load不会覆盖。第四条分类的方法列表在 Runtime 时合并会影响启动时间。如果你的项目启动时间很敏感尽量减少分类的数量尤其是给系统类加的分类。第五条用关联对象实现分类属性时注意内存管理策略。OBJC_ASSOCIATION_RETAIN_NONATOMIC是最常用的但如果你需要线程安全用OBJC_ASSOCIATION_RETAIN带锁。OBJC_ASSOCIATION_ASSIGN要慎用容易产生悬垂指针。最后再分享一个调试技巧如果你怀疑某个方法被分类覆盖了可以在load里打印方法的 IMP然后在调用处再打印一次对比是否一致。如果不一致说明被覆盖了。这个方法虽然笨但很有效尤其是在排查线上问题时。