在iOS开发中,分类(Category)是OC语言中非常核心的特性,常用于给已有类(系统类、自定义类)添加方法、协议,无需修改原类源码,极大提升了代码的灵活性和复用性。但很多开发者只停留在“会用”的层面,对其底层加载原理、加载顺序一知半解,也容易混淆分类与类扩展(Extension)的区别,进而引发“方法覆盖”“分类方法找不到”等隐藏问题。
本文将从OC Runtime源码出发,拆解分类的加载原理、明确加载顺序的核心规则,再通过多维度对比,讲清分类与类扩展的本质区别,结合实战避坑案例,帮你彻底吃透这两个易混淆的OC特性,写出更稳健、高效的OC代码。
核心要点:分类的核心是“动态扩展类的方法”,加载依赖Runtime的动态绑定;加载顺序决定方法优先级;类扩展是“类的隐藏扩展”,与分类本质不同,不可混淆。
一、前置基础:分类(Category)的核心定义与作用
在拆解底层之前,先明确分类的基础概念,避免陷入细节误区,这是理解后续加载原理的前提。
1. 分类的定义
分类(Category)是OC中用于扩展类功能的语法,允许我们在不修改原类、不继承原类的前提下,给类添加实例方法、类方法、协议、属性(仅声明,需手动实现setter/getter)。
基本语法(示例:给NSString添加分类):
// 声明
@interface NSString (StringExtension)
// 实例方法
- (NSString *)reverseString;
// 类方法
+ (NSString *)randomStringWithLength:(NSInteger)length;
// 声明属性(仅声明,无默认实现)
@property (nonatomic, copy) NSString *customDesc;
@end
// 实现
@implementation NSString (StringExtension)
- (NSString *)reverseString {
// 实现逻辑
NSMutableString *reverseStr = [NSMutableString string];
for (NSInteger i = self.length - 1; i >= 0; i--) {
[reverseStr appendFormat:@"%c", [self characterAtIndex:i]];
}
return reverseStr;
}
+ (NSString *)randomStringWithLength:(NSInteger)length {
// 实现逻辑
NSString *chars = @"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";
NSMutableString *randomStr = [NSMutableString stringWithCapacity:length];
for (NSInteger i = 0; i < length; i++) {
NSInteger index = arc4random_uniform((uint32_t)chars.length);
[randomStr appendFormat:@"%c", [chars characterAtIndex:index]];
}
return randomStr;
}
// 手动实现属性的setter/getter(否则会崩溃)
- (void)setCustomDesc:(NSString *)customDesc {
objc_setAssociatedObject(self, @selector(customDesc), customDesc, OBJC_ASSOCIATION_COPY_NONATOMIC);
}
- (NSString *)customDesc {
return objc_getAssociatedObject(self, @selector(customDesc));
}
@end
2. 分类的核心作用
- 扩展系统类功能:无需修改系统类源码(如给NSString、UIView添加常用方法),避免破坏系统类的原有逻辑;
- 拆分自定义类代码:将一个庞大的类,按功能拆分为多个分类(如UIViewController的网络请求分类、UI布局分类),提升代码可读性和可维护性;
- 实现协议分离:将类需要遵守的协议,在分类中实现,使协议实现与类的核心逻辑分离;
- 临时扩展功能:在不修改原类的前提下,临时给类添加测试、调试相关的方法,不影响正式代码。
3. 分类的局限性
分类虽灵活,但存在明显局限性,这与其底层实现密切相关:
- 不能添加实例变量(ivar):只能声明属性,无法直接添加实例变量,需通过关联对象(objc_setAssociatedObject)间接实现;
- 方法覆盖风险:若分类中定义了与原类、其他分类同名的方法,会覆盖原方法(具体覆盖规则由加载顺序决定);
- 无法直接访问原类的私有方法和私有实例变量(需通过Runtime反射间接访问,不推荐)。
二、深度解析:分类(Category)的加载原理(Runtime源码级)
分类的加载并非编译期完成,而是在程序启动时(dyld加载镜像后) ,由OC Runtime动态处理的,核心是“将分类的方法、协议等信息,合并到原类的方法列表、协议列表中”。
以下结合Runtime源码(objc4源码),拆解分类加载的完整流程,核心围绕3个关键结构体和1个核心函数展开。
1. 核心结构体:分类的底层存储
在Runtime中,分类的信息被存储在category_t结构体中(源码精简版),每个分类对应一个category_t实例:
// 分类的底层结构体(objc4源码精简)
struct category_t {
const char *name; // 分类名称(如StringExtension)
classref_t cls; // 分类所属的类(如NSString)
struct method_list_t *instanceMethods; // 分类的实例方法列表
struct method_list_t *classMethods; // 分类的类方法列表
struct protocol_list_t *protocols; // 分类遵守的协议列表
struct property_list_t *instanceProperties; // 分类的实例属性列表(仅声明)
};
// 方法列表结构体(存储分类的方法)
struct method_list_t {
uint32_t entsize; // 每个方法的大小
uint32_t count; // 方法数量
method_t first[1]; // 方法数组(存储具体的method_t)
};
关键说明:category_t中仅存储分类的方法、协议、属性声明,不存储方法实现(方法实现存储在代码段,由编译器关联);属性仅声明,无默认的setter/getter实现,需手动实现或通过关联对象实现。
2. 核心流程:分类加载的4个步骤
程序启动时,dyld(动态链接器)会加载所有镜像(.app中的可执行文件、系统库等),当加载到包含分类的镜像时,Runtime会触发_objc_init函数,进而执行分类的加载逻辑,完整流程如下:
步骤1:镜像加载,收集分类信息
dyld加载镜像后,Runtime会遍历镜像中的所有分类(通过_getObjc2CategoryList函数),收集每个分类对应的category_t结构体,区分分类所属的类(cls字段),将同一类的所有分类暂存到一个列表中。
步骤2:关联分类到原类
Runtime通过category_t中的cls字段,找到分类所属的原类(如NSString),将该类的所有分类,添加到原类的category_list(类的分类列表)中,完成分类与原类的关联。
步骤3:合并分类信息到原类
这是分类加载的核心步骤,Runtime通过attachCategories函数,将分类的方法列表、协议列表、属性列表,合并到原类的对应列表中,具体规则:
- 方法列表:将分类的实例方法列表,添加到原类的实例方法列表最前面;将分类的类方法列表,添加到原类的类方法列表(实际是原类的元类的实例方法列表)最前面;
- 协议列表:将分类的协议列表,添加到原类的协议列表中(顺序不影响协议遵守);
- 属性列表:将分类的属性列表,添加到原类的属性列表中(仅添加声明,不添加实现)。
源码关键逻辑(attachCategories函数精简):
// 合并分类信息到原类
static void attachCategories(Class cls, category_list *cats, bool flush_caches) {
if (!cats) return;
if (PrintReplacedMethods) printReplacements(cls, cats);
bool isMeta = cls->isMetaClass();
// 1. 收集所有分类的方法、协议、属性
method_list_t **mlists = (method_list_t **)malloc(cats->count * sizeof(*mlists));
int mcount = 0;
protocol_list_t **protos = (protocol_list_t **)malloc(cats->count * sizeof(*protos));
int pcount = 0;
property_list_t **proplists = (property_list_t **)malloc(cats->count * sizeof(*proplists));
int propcount = 0;
// 遍历所有分类,收集信息
for (uint32_t i = 0; i < cats->count; i++) {
category_t *cat = cats->list[i];
// 实例方法(原类)/类方法(元类)
method_list_t *mlist = isMeta ? cat->classMethods : cat->instanceMethods;
if (mlist) mlists[mcount++] = mlist;
// 协议
if (cat->protocols) protos[pcount++] = cat->protocols;
// 属性
if (cat->instanceProperties) proplists[propcount++] = cat->instanceProperties;
}
// 2. 合并到原类
Class meta = cls->ISA();
// 合并方法列表(添加到原类方法列表最前面)
attachMethodLists(cls, mlists, mcount, flush_caches);
// 合并协议列表
attachProtocolLists(cls, protos, pcount);
// 合并属性列表
attachPropertyLists(cls, proplists, propcount);
// 释放内存
free(mlists);
free(protos);
free(proplists);
}
步骤4:刷新缓存,完成加载
方法列表合并后,原类的方法缓存(cache_t,参考之前OC Runtime源码解析)会失效,Runtime会调用flushCaches函数,清空原类和元类的方法缓存,确保后续方法查找时,能找到合并后的方法列表,避免缓存命中错误。
加载原理核心总结
分类加载的本质:程序启动时,Runtime将分类的方法、协议、属性,合并到原类的对应列表中,且分类方法会被添加到原类方法列表的最前面,这也是“分类方法会覆盖原类同名方法”的根本原因——方法查找时,会优先找到列表前面的分类方法。
三、关键剖析:分类(Category)的加载顺序
分类的加载顺序,直接决定了“同名方法的优先级”——当多个分类给同一个类添加同名方法,或分类与原类有同名方法时,哪个方法会被执行,完全由加载顺序决定。
以下分2种场景,明确加载顺序规则,结合实战案例说明,帮你避坑。
1. 核心规则:加载顺序的3个层级(优先级从高到低)
分类加载顺序遵循“后加载、先执行”的原则,核心层级(优先级从高到低):
- 最后加载的分类 → 2. 之前加载的分类(按加载顺序逆序) → 3. 原类
原理:由于分类方法会被添加到原类方法列表的最前面,后加载的分类,其方法会被添加到所有已加载分类方法的前面,因此查找方法时,会优先找到后加载的分类的方法,进而覆盖前面的分类和原类的同名方法。
2. 场景1:多个分类给同一个类添加同名方法
示例:给NSString添加两个分类(StringExtension1、StringExtension2),都实现reverseString方法:
// StringExtension1
@implementation NSString (StringExtension1)
- (NSString *)reverseString {
return @"我是Extension1的reverse方法";
}
@end
// StringExtension2
@implementation NSString (StringExtension2)
- (NSString *)reverseString {
return @"我是Extension2的reverse方法";
}
@end
加载顺序影响:
- 若StringExtension2后加载(编译顺序靠后),则调用[NSString stringWithString:@"abc"].reverseString,返回“我是Extension2的reverse方法”;
- 若StringExtension1后加载,则返回“我是Extension1的reverse方法”;
- 原类(NSString)若有reverseString方法(实际没有),会被所有分类的同名方法覆盖。
3. 场景2:分类与原类有同名方法
示例:自定义类Person,原类实现eat方法,分类也实现eat方法:
// 原类
@interface Person : NSObject
- (void)eat;
@end
@implementation Person
- (void)eat {
NSLog(@"原类:吃饭");
}
@end
// 分类
@implementation Person (EatExtension)
- (void)eat {
NSLog(@"分类:吃大餐");
}
@end
结果:调用[Person new].eat,输出“分类:吃大餐”。
原因:分类加载时,其eat方法被添加到原类方法列表的最前面,方法查找时优先找到分类的方法,覆盖原类的同名方法。
4. 影响加载顺序的因素(实战重点)
日常开发中,分类的加载顺序主要由以下2个因素决定,需重点注意:
- 编译顺序:Xcode中,“Build Phases → Compile Sources”中,分类文件的顺序越靠后,编译优先级越高,加载顺序越靠后,方法优先级越高;
- 镜像加载顺序:动态库中的分类,会随动态库的加载而加载,动态库加载顺序优先于主工程,因此动态库中的分类方法,会优先于主工程中同名的分类方法。
避坑技巧:开发中尽量避免给同一个类的不同分类添加同名方法;若必须添加,需通过调整编译顺序,明确方法优先级,避免出现不可预期的行为。
四、核心对比:分类(Category)与类扩展(Extension)的区别
很多开发者会混淆分类(Category)和类扩展(Extension),甚至认为“两者是一样的”——其实两者的本质、作用、底层实现完全不同,类扩展更像是“类的隐藏接口”,而分类是“类的动态扩展”。
以下从7个核心维度,结合源码和示例,清晰区分两者的区别,同时说明各自的适用场景。
1. 本质区别(核心)
- 分类(Category):动态扩展,运行时通过Runtime将方法合并到原类,无需原类源码,可给系统类、自定义类扩展功能;
- 类扩展(Extension):静态扩展,编译期就被合并到原类中,属于原类的一部分,必须有原类源码(无法给系统类添加类扩展),相当于“给原类添加私有方法、私有属性的接口”。
2. 语法区别
// 分类(Category):有分类名称,可单独声明和实现(.h和.m分离)
@interface 类名 (分类名称)
// 方法、属性、协议声明
@end
@implementation 类名 (分类名称)
// 方法实现
@end
// 类扩展(Extension):无分类名称,必须嵌套在原类的.m文件中(或与原类声明同文件),无单独实现
@interface 类名 ()
// 私有方法、私有属性、协议声明
@end
// 类扩展的方法实现,直接在原类的@implementation中实现
@implementation 类名
// 类扩展中声明的方法的实现
@end
3. 能否添加实例变量(ivar)
- 分类(Category):不能直接添加实例变量,只能声明属性,需通过关联对象间接实现;
- 类扩展(Extension):可以添加实例变量(私有实例变量),声明的属性会自动生成setter/getter实现和实例变量(无需手动实现)。
示例(类扩展添加私有属性和实例变量):
// Person.h(公开接口)
@interface Person : NSObject
@property (nonatomic, copy) NSString *name; // 公开属性
- (void)publicMethod; // 公开方法
@end
// Person.m(类扩展,添加私有内容)
@interface Person ()
@property (nonatomic, assign) NSInteger age; // 私有属性(自动生成setter/getter和_age实例变量)
- (void)privateMethod; // 私有方法
@end
@implementation Person
- (void)publicMethod {
// 实现
}
- (void)privateMethod {
// 类扩展中声明的私有方法,在原类中实现
}
@end
4. 加载时机区别
- 分类(Category):运行时加载(程序启动时,由Runtime合并到原类);
- 类扩展(Extension):编译期加载,与原类一起编译,属于原类的一部分,编译后就已合并到原类的方法、属性列表中。
5. 访问权限区别
- 分类(Category):声明的方法、属性,默认是公开的(可被外部访问);
- 类扩展(Extension):声明的方法、属性、实例变量,默认是私有的(仅能在原类内部访问,外部无法访问,除非通过Runtime反射)。
6. 适用场景区别
-
分类(Category):
- 给系统类扩展功能(如给NSString添加字符串处理方法、给UIView添加布局方法);
- 拆分自定义类的代码(按功能拆分,提升可维护性);
- 扩展第三方框架的类(无需修改第三方源码)。
-
类扩展(Extension):
- 给自定义类添加私有方法、私有属性、私有实例变量;
- 隐藏类的内部实现细节,仅暴露公开接口(.h文件);
- 在原类中声明需要实现的私有协议方法。
7. 方法覆盖区别
- 分类(Category):可以覆盖原类的同名方法(加载顺序决定优先级),但不推荐(会破坏原类的原有逻辑);
- 类扩展(Extension):不能覆盖原类的同名方法(编译期会报错),因为类扩展是原类的一部分,同名方法会被视为重复定义。
总结对比表
| 对比维度 | 分类(Category) | 类扩展(Extension) |
|---|---|---|
| 本质 | 动态扩展,运行时合并 | 静态扩展,编译期合并,属于原类一部分 |
| 语法 | 有分类名称,.h和.m分离 | 无分类名称,嵌套在原类.m中,无单独实现 |
| 添加实例变量 | 不能,需通过关联对象间接实现 | 可以,自动生成实例变量 |
| 加载时机 | 运行时(程序启动) | 编译期 |
| 访问权限 | 默认公开 | 默认私有 |
| 适用场景 | 扩展系统类、拆分自定义类、扩展第三方类 | 添加自定义类的私有方法、属性 |
| 方法覆盖 | 可以覆盖原类方法(不推荐) | 不能覆盖,会报错 |
| 是否需要原类源码 | 不需要(可扩展系统类) | 需要(无法扩展系统类) |
五、实战避坑:分类使用的3个注意事项
结合前面的原理和顺序规则,总结3个实战中最容易踩坑的场景,给出避坑方案,帮你规避隐藏问题。
避坑1:避免分类覆盖原类/其他分类的同名方法
问题:分类覆盖原类的同名方法后,会破坏原类的原有逻辑,导致依赖原类方法的代码出现异常;多个分类同名方法,会因加载顺序不确定,导致行为不可预期。
避坑方案: 给分类方法添加前缀(如“ext_”),避免同名(如ext_reverseString);尽量不要给系统类添加与系统方法同名的分类方法;多个分类给同一个类扩展方法时,明确分工,避免方法名重复。
避坑2:分类声明的属性,必须手动实现setter/getter
问题:分类中声明属性后,若未手动实现setter/getter,也未使用关联对象,调用该属性时会崩溃(unrecognized selector sent to instance)。
避坑方案: 若属性仅用于存储简单数据,使用关联对象实现setter/getter(如前面NSString分类的customDesc属性);若属性需要复杂逻辑,考虑将属性放在类扩展中(仅自定义类可用),避免分类属性的繁琐实现。
避坑3:区分分类与类扩展的使用场景,不混用
问题:给系统类添加类扩展(报错,无法实现);用分类添加私有方法(方法公开,无法隐藏)。
避坑方案: 扩展系统类、拆分代码 → 用分类;添加自定义类的私有方法、属性 → 用类扩展;给第三方类扩展功能 → 用分类(无需第三方源码)。
六、总结:分类与类扩展的核心逻辑
通过本文的解析,我们可以清晰梳理出核心逻辑,帮你快速掌握两者的精髓:
- 分类(Category):核心是“动态扩展”,运行时通过Runtime将方法合并到原类,优先级高于原类,加载顺序决定方法覆盖规则,适合扩展系统类、拆分代码;
- 类扩展(Extension):核心是“静态隐藏”,编译期合并到原类,属于原类的私有部分,可添加实例变量,适合隐藏自定义类的内部实现;
- 两者本质区别:分类是“外部扩展”,无需原类源码;类扩展是“内部扩展”,必须有原类源码,不可混淆使用。
理解分类的加载原理和加载顺序,能帮你规避方法覆盖的风险;分清分类与类扩展的区别,能让你在合适的场景选择合适的扩展方式,写出更规范、稳健的OC代码。其实两者的使用并不复杂,核心是“看透底层逻辑,按需使用”——动态扩展用分类,私有隐藏用类扩展。