iOS 分类(Category)加载原理、加载顺序、与类扩展区别

5 阅读16分钟

在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个层级(优先级从高到低)

分类加载顺序遵循“后加载、先执行”的原则,核心层级(优先级从高到低):

  1. 最后加载的分类 → 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:区分分类与类扩展的使用场景,不混用

问题:给系统类添加类扩展(报错,无法实现);用分类添加私有方法(方法公开,无法隐藏)。

避坑方案: 扩展系统类、拆分代码 → 用分类;添加自定义类的私有方法、属性 → 用类扩展;给第三方类扩展功能 → 用分类(无需第三方源码)。

六、总结:分类与类扩展的核心逻辑

通过本文的解析,我们可以清晰梳理出核心逻辑,帮你快速掌握两者的精髓:

  1. 分类(Category):核心是“动态扩展”,运行时通过Runtime将方法合并到原类,优先级高于原类,加载顺序决定方法覆盖规则,适合扩展系统类、拆分代码;
  2. 类扩展(Extension):核心是“静态隐藏”,编译期合并到原类,属于原类的私有部分,可添加实例变量,适合隐藏自定义类的内部实现;
  3. 两者本质区别:分类是“外部扩展”,无需原类源码;类扩展是“内部扩展”,必须有原类源码,不可混淆使用。

理解分类的加载原理和加载顺序,能帮你规避方法覆盖的风险;分清分类与类扩展的区别,能让你在合适的场景选择合适的扩展方式,写出更规范、稳健的OC代码。其实两者的使用并不复杂,核心是“看透底层逻辑,按需使用”——动态扩展用分类,私有隐藏用类扩展。