iOS中Category的底层实现原理

4,116 阅读14分钟

1. Category的使用场景

Category也叫分类类别,是OC提供的一种扩展类的方式。不管是自定义的类还是系统的类,我们都可以通过Category给原有类扩展方法(实例方法和类方法都可以),而且扩展的方法和原有的方法的调用方式是一模一样的。比如我项目中经常需要统计一个字符串中字母的个数,但是系统没有提供这个方法,那我们就可以用CategoryNSString类扩展一个方法,然后只需引入Category的头文件就可以和调用系统方法一样来调用扩展的方法。

// 给NSString类添加一个Category,并扩展一个实例方法
@interface NSString (QJAdd)

- (NSInteger)letterCount;

@end
// 在需要使用这个扩展方法的地方引入头文件 #import "NSString+QJAdd.h",然后就可以调用这个扩展方法了
- (void)test{
    NSString *testStr = @"sdfjshdfjk.,d.889";
    NSInteger letterCount = [testStr letterCount];
}

Category除了用来给类进行扩展外,还有一种比较高级的用法,就是用来拆分模块,将一个大的模块拆分成多个小的模块,方便进行维护和管理。什么意思呢?我就举一个很多开发人员都会存在的问题,就是AppDelegate这个类。这个类是刚创建项目时自动生成的,用来管理程序生命周期的。在刚创建项目时,这个类中是没有多少代码的,但是随着项目的进行,越来越多的代码会被放在这个类里面。比如说集成极光推送、友盟、百度地图、微信SDK等各种第三方框架时,这些第三方框架的初始化工作,甚至是相关的业务逻辑代码都会放在这个类里面,这就导致随着APP的功能越来越复杂,AppDelegate中的代码就会越来越多,有的甚至有几千行,看着就让人头皮发麻。

这时我们就可以利用Category来对AppDelegate进行拆分,首先我们就需要对AppDelegate中的代码进行划分,把同一种功能的代码抽取出来放在一个分类里面。比如说我可以新建一个极光推送的分类,然后把所有和极光推送有关的代码都抽出来放入这个分类,把所有和微信相关的代码抽出来放进微信的分类中,后面又有新的功要添加的话我只需要新建分类就好了。维护的时候要改什么功能的代码就直接找相应的分类就好了。

// 把所有和极光推送有关的代码都抽出来放入这个分类
#import "AppDelegate.h"

@interface AppDelegate (JPush)

@end

2. Category的底层实现

在讲解这个问题之前,我们需要对OC方法调用的底层机制以及类对象的内存存储结构有一定了解,对于这一块不熟悉的可以先去看看我的另外一篇博客OC对象的本质

我们先来思考这样一个问题,当我们通过Category给一个类扩展了一个实例方法,我们调用这个实例方法时,它也是通过实例的isa指针找到类对象,然后在类对象的方法列表中去查找这个方法。那么Category扩展的方法是如何被添加到类对象的方法列表中去的呢?是编译的时候添加进去的还是运行的时候添加进去的呢?

首先我们来看下Category的内存存储结构,Category底层其实就是一个category_t类型的结构体,我们可以在objc4源码的objc-runtime-new.h文件中看到它的定义:

// 定义在objc-runtime-new.h文件中
struct category_t {
    const char *name; // 比如给Student添加分类,name就是Student的类名
    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中存储的不仅有方法列表,还有协议列表和属性列表。

我们每创建一个分类,在编译时都会生成这样一个结构体并将分类的方法列表等信息存入这个结构体。在编译阶段分类的相关信息和本类的相关信息是分开的。等到运行阶段,会通过runtime加载某个类的所有Category数据,把所有Category的方法、属性、协议数据分别合并到一个数组中,然后再将分类合并后的数据插入到本类的数据的前面。

想要了解详细的流程,可以去看源码,由于源码太多,这里就不贴出来了,这里给大家提供一下解读源码时整个流程的函数调用过程:

objc-os.mm文件的_objc_init函数开始-->map_images-->map_images_nolock-->_read_images-->remethodizeClass-->attachCategories-->attachLists-->realloc、memmove、 memcpy

下面我根据我的理解,举个例子描述一下整个流程,我这里只讲解实例方法列表的合并流程,类方法列表、属性列表、协议列表等信息的合并流程都是一样的。

首先我们声明一个Student类,然后创建2个分类:Student (aaa)Student (bbb),本来和分类中都有实现2个方法,如下代码所示:

// Student.m文件
#import "Student.h"
@implementation Student

- (void)study{
    NSLog(@"%s",__func__);
}

- (void)studentTest{
    NSLog(@"%s",__func__);
}
@end

// Student+aaa.m文件
#import "Student+aaa.h"
@implementation Student (aaa)

- (void)study{
    NSLog(@"%s",__func__);
}

- (void)studentAaaTest{
    NSLog(@"%s",__func__);
}
@end

// Student+bbb.m文件
#import "Student+bbb.h"
@implementation Student (bbb)

- (void)study{
    NSLog(@"%s",__func__);
}

- (void)studentBbbTest{
    NSLog(@"%s",__func__);
}
@end
  • 当编译完成后,2个分类aaa和bbb的信息分别存储在它们对应的结构体中,结构体的实例方法列表分别是aaa->instanceMethods = @[@"study",@"studentAaaTest"]bbb->instanceMethods = @[@"study",@"studentBbbTest"]。而此时Student类对象的方法列表是存在class_ro_t结构体的baseMethodList中。所以在编译阶段各个方法列表都是分开存储的
  • 等到运行阶段,Student类对象会初始化class_rw_t结构体,这个结构体中也有个方法列表methods,它是一个二维数组,它初始化后首先将class_ro_t中的baseMethodList拷贝过来,此时methods = @[baseMethodList]
  • 然后通过runtime加载aaa和bbb这两个分类的数据并将它们的方法列表进行合并。它们在合并后的数组(我这里给它取个名字叫categoryMethodList)中的顺序是和他们参与编译的顺序有关的,如果先编译aaa,再编译bbb,那么在合并后的数组中,bbb的方法列表在前面,aaa的方法列表在后面,所以此时categoryMethodList = @[bbb->instanceMethods,aaa->instanceMethods]
  • 然后再将categoryMethodList的数据添加到methods中。添加之前methods的容量大小是1,它会先根据categoryMethodList中方法列表的个数(也就是有几个分类,这里是2个分类)进行扩容,methods扩容后的大小是3,它先将methods中原来的数据(baseMethodList)移到最后,然后再将categoryMethodList中的数据插入进来,所以最后的结果就是methods = @[bbb->instanceMethods,aaa->instanceMethods,baseMethodList]

这样就完成了分类方法列表和本类方法列表的合并。所以合并后分类的方法在前面(最后参与编译的那个分类的方法列表在最前面),本类的方法列表在最后面。所以当分类中有和本类同名的方法时,调用这个方法执行的就是分类中的方法。从这个现象来看,就好像本类的方法被分类中同名的方法给覆盖了,实际上并没有覆盖,只是调用方法时最先查找到了分类的方法所以就执行分类的方法。比如上面的例子,本类和2个分类中都有study这个方法,如果我们打印这个类的方法列表就会发现里面有3个叫study的方法。

3. Category如何给类扩展属性

我们先来看一下普通类定义一个属性,比如当我们给一个Student类定义一个属性@property (nonatomic , assign) NSInteger score;时,编译器会自动帮我们生成一个叫_score的成员变量,并且会自动实现这个属性的setter/getter方法:

@implementation Student
{
    NSInteger _score;
}

- (void)setScore:(NSInteger)score{
    _score = score;
}

- (NSInteger)score{
    return _score;
}
@end

那我们可不可以用Category以同样的方式来给类扩展属性和成员变量呢?我们从Category的底层结构体category_t可以看出,这个结构体中有方法列表、协议列表和属性列表,但是没有成员变量列表,所以我们可以在Category中定义属性,但是不能定义成员变量,定义成员变量的话编译器会直接报错。

如果我们在Student的分类中定义一个属性@property (nonatomic , strong) NSString *name;,那编译器会为我们做什么呢?编译器只会帮我们声明- (void)setName:(NSString *)name;- (NSString *)name;这两个方法,而不会实现这两个方法,也不会定义成员变量。所以此时如果我们在外面给一个实例对象设置name属性值student.name = @"Jack",编译器并不会报错,因为setter方法是有声明的,但是一旦程序运行,就会抛出unrecognized selector的异常,因为setter方法没有实现。

那要如何做才能让我们正常的使用name这个属性呢?我们可以手动去实现setter/getter方法,实现这两个方法时关键就在于如何将属性值给保存起来,在普通的类中我们是定义一个成员变量_name来保存这个属性值,但是在分类中我们无法定义成员变量,所以需要想其他办法来保存。我们可以通过以下几种方式来实现这个需求。

3.1 以本类中已有的属性来进行存储

什么叫以本类中已有的属性来进行存储呢?我们直接举一个例子,比如我要给UIView扩展xy这两个属性,那么我们可以添加一个分类来实现:

// .h文件
@interface UIView (Add)

@property (nonatomic , assign) CGFloat x;
@property (nonatomic , assign) CGFloat y;

@end


// .m文件
#import "UIView+Add.h"
@implementation UIView (Add)

- (void)setX:(CGFloat)x{
    CGRect origionRect = self.frame;
    CGRect newRect = CGRectMake(x, origionRect.origin.y, origionRect.size.width, origionRect.size.height);
    self.frame = newRect;
}

- (CGFloat)x{
    return self.frame.origin.x;
}

- (void)setY:(CGFloat)y{
    CGRect origionRect = self.frame;
    CGRect newRect = CGRectMake(origionRect.origin.x, y, origionRect.size.width, origionRect.size.height);
    self.frame = newRect;
}

- (CGFloat)y{
    return self.frame.origin.y;
}
@end

这种方式就是通过UIView原有的属性frame来对新添加的属性xy的值进行存取操作,很显然这种方式有很大的局限性,只有在上面这种特殊情况下才能使用。

3.2 通过自定义全局字典来存储

比如给Student添加了一个分类Student (add),分类中定义了2个属性nameage,那我们就可以在分类的.m文件中定义2个全局字典nameDicageDicnameDic用来存储所有实例对象的name属性值,其中以实例对象的指针作为key,name属性值作为value。ageDic用来存储所有实例对象的age属性值。代码如下所示:

#import "Student+add.h"

// 以实例对象的指针作为key
#define QJKey [NSString stringWithFormat:@"%p",self]

@implementation Student (add)

// 定义2个全局字典用来存储2个新增的属性的值
NSMutableDictionary *nameDic;
NSMutableDictionary *ageDic;

+ (void)load{
    nameDic = [NSMutableDictionary dictionary];
    ageDic = [NSMutableDictionary dictionary];
}

//
- (void)setName:(NSString *)name{
    nameDic[QJKey] = name;
}

- (NSString *)name{
    return nameDic[QJKey];
}

- (void)setAge:(NSInteger)age{
    ageDic[QJKey] = @(age);
}

- (NSInteger)age{
    return [ageDic[QJKey] integerValue];
}

@end

这种方法虽然可以实现我们的需求,但是有个问题,每当我们实例化一个对象时都会往那两个全局字典中添加一个元素,而实例化的对象销毁时,全局字典中与之对应的元素又没有被移除,这样就会导致这两个字典占用内存会越来越大,有内存溢出的风险。

3.3 通过关联对象来存储

3.3.1 关联对象API说明

关联对象是runtime提供的一组API,所以需要引入头文件#import <objc/runtime.h>


添加关联对象API:

void objc_setAssociatedObject(id object, 
                              const void * key,
                              id value, 
                              objc_AssociationPolicy policy);

比如Student的分类中新增了一个name属性,我要给一个实例对象的stu的name属性赋值为Jack:

  • 第一个参数(object):关联的对象,也就是上面的stu
  • 第二个参数(key):这里传入一个void * 类型的指针作为key,这个key是自己随便设置的,后面获取关联对象也是根据这个key来获取的。后面我会列出几种常用的设置key的方式。
  • 第三个参数(value):要设置的属性值,也就是上面的Jack
  • 第四个参数(policy):要设置的属性的修饰类型,比如name的修饰类型是strong, nonatomic,那这里对应的的policy就是OBJC_ASSOCIATION_RETAIN_NONATOMIC。具体对应关系如下(注意是没有和weak对应的policy的):
objc_AssociationPolicy 对应的修饰符
OBJC_ASSOCIATION_ASSIGN assign
OBJC_ASSOCIATION_RETAIN_NONATOMIC strong, nonatomic
OBJC_ASSOCIATION_COPY_NONATOMIC copy, nonatomic
OBJC_ASSOCIATION_RETAIN strong, atomic
OBJC_ASSOCIATION_COPY copy, atomic

获取关联对象:

id objc_getAssociatedObject(id object, const void * key);

移除所有的关联对象:

void objc_removeAssociatedObjects(id object);

设置key的常用方式: key是一个void *类型的指针,原则上来说随便设置一个指针都可以,只要保证设置关联对象时和获取关联对象时的key一样就行了。但是为了提高代码的可读性,我们可以采用以下几种方式来设置key:

比如给stu实例对象的name属性赋值Jack

方式一:

对每一个属性都声明一个静态全局的void *类型的变量,这个变量中存储的值是它自己的地址,这样就可以保证这个变量值的唯一性。

// 声明全局静态变量
static void *_name = &_name;

// 设置关联对象
objc_setAssociatedObject(stu, _name, @"Jack", OBJC_ASSOCIATION_RETAIN_NONATOMIC);

// 获取关联对象
NSString *temName = objc_getAssociatedObject(stu, _name);

方式二: 这种方式和上面那种方式差不多,只不过声明全局变量后不赋值,直接将变量的地址值设置为key:

static void *_name;

objc_setAssociatedObject(stu, &_name, @"Jack", OBJC_ASSOCIATION_RETAIN_NONATOMIC);

NSString *temName = objc_getAssociatedObject(stu, &_name);

方式三:

用属性名字符串的地址作为key,注意key = @"name"这种是将字符串的地址赋值给key,而不是将字符串本身赋值给key。在iOS中,无论定义多少个指针变量指向@"name"这个字符串,这些指针指向的其实都是同一个内存空间,所以可以保证key的唯一性。

objc_setAssociatedObject(stu, @"name", @"Jack", OBJC_ASSOCIATION_RETAIN_NONATOMIC);

NSString *temName = objc_getAssociatedObject(stu, @"name");

方式四:

用属性的getter方法个的@selector作为key,因为一个类中同一个方法名对应的SEL的地址始终是同一个。比较推荐使用这种方式,因为在写代码的时候会有提示。

objc_setAssociatedObject(stu, @selector(name), @"Jack", OBJC_ASSOCIATION_RETAIN_NONATOMIC);

NSString *temName = objc_getAssociatedObject(stu, @selector(name));
3.3.2 通过关联对象来保存属性值
// .h文件
#import "Student.h"
@interface Student (Add)

@property (nonatomic , strong) NSString *name;
@property (nonatomic , assign) NSInteger age;
@end


// .m文件
#import "Student+Add.h"
#import <objc/runtime.h>
@implementation Student (Add)

- (void)setName:(NSString *)name{
    objc_setAssociatedObject(self, @selector(name), name, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (NSString *)name{
    return objc_getAssociatedObject(self, @selector(name));
}

- (void)setAge:(NSInteger)age{
    objc_setAssociatedObject(self, @selector(age), @(age), OBJC_ASSOCIATION_ASSIGN);
}

- (NSInteger)age{
    return [objc_getAssociatedObject(self, @selector(age)) integerValue];
}
@end
3.3.3 关联对象存储原理

有人可能会有疑问,关联对象底层是怎么实现的呢,它是不是通过属性生成了成员变量,然后合并到了类对象的成员属性列表中去呢?其实不是的,关联对象是另外单独存储的,底层实现关联对象技术的核心对象有4个:

  • ObjcAssociation:这个对象里面有2个成员uintptr_t _policyid _value,这两个很显然就是我们设置关联对象传入的参数policyvalue
  • ObjectAssociationMap:这是一个HashMap(以键值对方式存储,可以理解为是一个字典),以设置关联对象时传入的key值作为HashMap,以ObjcAssociation对象作为HashMap。比如一个分类添加了3个属性,那一个实例对象给这3个属性都赋值了,那么这个HashMap中就有3个元素,如果给这个实例对象的其中一个属性赋值为nil,那这个HashMap就会把这个属性对应的键值对给移除,然后HashMap中就还剩2个元素。
  • AssociationsHashMap:这也是一个HashMap,以设置关联属性时传入的参数object作为(实际是对object对象通过某个算法计算出一个值作为)。以ObjectAssociationMap作为。所以当某个类(前提是这个类的分类中有设置关联对象)每实例化一个对象,这个HashMap就会新增一个元素,当某个实例化对象被释放时,其对应的键值对也会被这个HashMap给移除。注意整个程序运行期间,AssociationsHashMap只会有一个,也就是说所有的类的关联对象信息都是存储在这个HashMap中。
  • AssociationsManager:从名字就可以看出它是一个管理者,注意整个程序运行期间它也只有一个,他就只包含一个AssociationsHashMap

这4个对象的关系图如下图所示:

关联对象存储原理.png