Runtime:OC分类的本质和底层实现
一、分类是什么、我们一般用它来做什么
二、分类的本质
三、分类的底层实现
四、分类的
+load
方法和+initialize
方法
调用时机 调用方式 调用顺序 +load
方法+load
方法是系统把类和分类载入内存时调用的+load
方法是通过内存地址直接调用的,所以分类的+load
方法不会覆盖类的+load
方法会先调用所有类的 +load
方法,然后再调用所有分类的+load
方法+initialize
方法+initialize
方法是类初始化的时候调用的+initialize
方法是通过消息发送机制调用的,所以分类的+initialize
方法会覆盖类的+initialize
方法会优先调用分类的 +initialize
方法
一、分类是什么、我们一般用它来做什么
分类是OC的一个高级特性,我们一般用它来给系统的类或三方库的类扩展方法、属性和协议,或者把一个类不同的功能分散到不同的模块里去实现。
举个简单例子:
比如我们给NSObject
类扩展一个test
方法。
// NSObject+INETest.h
#import <Foundation/Foundation.h>
@interface NSObject (INETest)
- (void)ine_test;
@end
// NSObject+INETest.m
#import "NSObject+INETest.h"
@implementation NSObject (INETest)
- (void)ine_test {
NSLog(@"%s", __func__);
}
@end
比如我们有一个Person
类,保持它的主体,然后把它“吃”、“喝”的功能分散到不同的模块里去实现。
// INEPerson.h
#import <Foundation/Foundation.h>
@interface INEPerson : NSObject
@property (nonatomic, assign) NSInteger age;
@end
// INEPerson.m
#import "INEPerson.h"
@implementation INEPerson
@end
// INEPerson+INEEat.h
#import "INEPerson.h"
@interface INEPerson (INEEat)
- (void)ine_eat;
@end
// INEPerson+INEEat.m
#import "INEPerson+INEEat.h"
@implementation INEPerson (INEEat)
- (void)ine_eat {
NSLog(@"%s", __func__);
}
@end
// INEPerson+INEDrink.h
#import "INEPerson.h"
@interface INEPerson (INEDrink)
- (void)ine_drink;
@end
// INEPerson+INEDrink.m
#import "INEPerson+INEDrink.h"
@implementation INEPerson (INEDrink)
- (void)ine_drink {
NSLog(@"%s", __func__);
}
@end
// ViewController.m
#import "INEPerson.h"
#import "INEPerson+INEEat.h"
#import "INEPerson+INEDrink.h"
- (void)viewDidLoad {
[super viewDidLoad];
INEPerson *person = [[INEPerson alloc] init];
[person ine_eat];// INEPerson (INEEat) eat
[person ine_drink];// INEPerson (INEDrink) drink
}
分类和延展的区别:
- 分类一般用来给系统的类或三方库的类扩展方法、属性和协议,或者把一个类不同的功能分散到不同的模块里去实现;而延展一般用来给我们自定义的类添加私有属性。
- 分类的数据不是在编译时就合并到类里面的,而是在运行时;而延展的数据是在编译时就合并到类里面的。
二、分类的本质
通过查看Runtime的源码(objc-runtime-new.h
文件),我们得到分类的定义如下:(伪代码)
typedef struct category_t *Category;
struct category_t {
const char *name;// 该分类所属的类的名字
struct classref *cls;
struct method_list_t *instanceMethods;
struct method_list_t *classMethods;
struct protocol_list_t *protocols;
struct property_list_t *instanceProperties;
};
可见分类的本质是一个category_t
类型的结构体,该结构体内部有若干个成员变量,其中有几个是我们重点关注的:
cls指针
:指向该分类所属的类。classMethods
:该分类为类扩展的类方法列表。instanceMethods
:该分类为类扩展的实例方法列表。instanceProperties
:该分类为类扩展的属性列表。protocols
:该分类为类扩展的协议列表。
三、分类的底层实现
系统不是在编译时就把分类的数据合并到类里面的,而是在运行时,而且分类的数据还放在类本身数据的前面,越晚编译的分类越在前面。
仅仅知道Category的本质是不够的,因为知道本质并不能解释我们在开发中遇到的很多疑惑。比如上面第一部分Person
类的例子,我们明明知道一个类所有的实例方法都存储在类里面,而对象调用方法的流程就是根据它的isa
指针找到所属的类,然后找到相应的方法来调用,那person
对象是怎么找到分类里的ine_eat
和ine_drink
方法来调用的呢?
现在我们可以大胆猜测......对象内部只有一个isa
指针,指向它所属的类,所以不可能有一套类似的方法查找机制让它专门去分类里查找方法,于是我们就大胆猜测系统把分类里的方法合并到类里面去了,那到底是编译时合并的呢,还是运行时合并的?很简单,我们只需要看看编译后类里面是否已经包含了分类的方法就行。
- 系统不是在编译时就把分类的数据合并到类里面的
接着上面第一部分Person
类的例子,我们用clang
编译器把INEPerson.m
文件转换成C/C++代码,以便窥探编译后INEPerson
类里面是否已经包含了分类的方法。
struct objc_class OBJC_CLASS_$_INEPerson = {
0, // &OBJC_METACLASS_$_INEPerson,
0, // &OBJC_CLASS_$_NSObject,
0, // (void *)&_objc_empty_cache,
// 可读可写的
["age", "setAge:"],// 所有的实例方法
["age"],// 所有的属性
[],// 所有遵循过的协议
// 只读的
"INEPerson",// 类名
["_age"],// 所有的成员变量
16,// 实例对象的实际大小
};
可见经过编译后,INEPerson
类里面的数据还是它本身拥有的那些数据,并没有分类的方法,这就表明系统不是在编译时就把分类的数据合并到类里面的。
- 而是在运行时,而且分类的数据还放在类本身数据的前面,越晚编译的分类越在前面
既然系统不是在编译时就把分类的数据合并到类里面的,那就只能是在运行时了,接下来我们就找找运行时(Runtime)的相关源码(objc-runtime-new.mm
文件),看看系统到底是怎么把分类合并到类里面的:
运行时,系统读取镜像阶段,会读取所有的类,并且如果发现有分类,也会读取所有的分类,然后遍历所有的分类,根据分类的cls指针
找到它所属的类,重新组织一下这个类的内部结构——即合并分类的数据。
// 系统读取镜像
void _read_images()
{
// 读取所有的类
// ...
// 发现有分类
// 读取所有的分类
category_t **catlist = _getObjc2CategoryList(hi, &count);
// 遍历所有的分类
for (i = 0; i < count; i++) {
// 读取某一个分类
category_t *cat = catlist[I];
// 根据分类的cls指针找到它所属的类
Class cls = cat->cls;
// 重新组织一下这个类的内部结构——即合并分类的数据
remethodizeClass(cls);
}
}
那具体怎么个合并法呢?系统会去获取这个类所有的分类,然后倒序遍历这所有的分类,把每个分类里面的实例方法列表拿出来,存进一个二维数组里(因为是倒序遍历分类的,所以越晚编译的分类的实例方法列表反而越会放在二维数组的前面),然后再把这个二维数组内所有一维数组的首地址复制进methods
成员变量指向的那块内存里(注意这个存储过程会把类本身的实例方法列表挪到最后——即高内存地址上,而把分类的实例方法列表存在前面)。
// 重新组织一下这个类的内部结构——即合并分类的数据
static void remethodizeClass(Class cls)
{
// 系统会去获取这个类所有的分类(没有合并过的)
category_list *cats = unattachedCategoriesForClass(cls);
// 把所有分类的数据合并到类里面
attachCategories(cls, cats);
free(cats);
}
/**
* 把所有分类的数据合并到类里面
*
* @param cls 当前类
* @param cats 当前类所有的分类
*/
static void attachCategories(Class cls, category_list *cats)
{
#pragma mark - 倒序遍历所有的分类,把每个分类里面的实例方法列表拿出来,存进一个二维数组里
/*
创建一个二维数组,用来存放每个分类里的实例方法列表,最终结果类似下面这样:
[
[instanceMethod1, instanceMethod2, ...] --> 分类1所有实例方法
[instanceMethod1, instanceMethod2, ...] --> 分类2所有实例方法
...
]
*/
method_list_t **mlists = (method_list_t **) malloc(cats->count * sizeof(*mlists));
// 属性
property_list_t **proplists = (property_list_t **) malloc(cats->count * sizeof(*proplists));
// 协议
protocol_list_t **protolists = (protocol_list_t **) malloc(cats->count * sizeof(*protolists));
int mcount = 0;
int propcount = 0;
int protocount = 0;
int i = cats->count;
// 注意:这里是倒序遍历所有的分类
while (i--) {
// 获取一个分类
auto cat = cats[I];
// 获取分类的实例方法列表,存进二维数组
method_list_t *mlist = cat->methods;
mlists[mcount++] = mlist;
// 属性
protocol_list_t *protolist = cat->protocols;
protolists[protocount++] = protolist;
// 协议
property_list_t *proplist = cat->properties;
proplists[propcount++] = proplist;
}
#pragma mark - 把这个二维数组内所有一维数组的首地址存进methods成员变量所指向的那块内存空间里
// 获取当前类的数据(包括实例方法列表、属性列表、协议列表等)
auto classData = cls->data();
// 给当前类的实例方法列表附加所有分类的实例方法列表
classData->methods.attachLists(mlists, mcount);
free(mlists);
// 属性
classData->properties.attachLists(proplists, propcount);
free(proplists);
// 协议
classData->protocols.attachLists(protolists, protocount);
free(protolists);
}
/**
* 给当前类的实例方法列表附加所有分类的实例方法列表
*
* @param addedLists 所有分类的实例方法列表(就是那个二维数组,但其实是那个二维数组的首地址)
* @param addedCount 分类的个数
*/
void attachLists(List* const * addedLists, unsigned int addedCount) {
#pragma mark - 重新为类的methods成员变量分配内存
// 获取类原来methods成员变量的元素个数(注意:一个类的methods成员变量是一个数组,存储着若干个指针,指向相应的方法列表,而不是直接就是个方法列表存储方法)
unsigned int oldCount = array()->count;
// 加上分类的个数,得到新的methods成员变量该有多少个元素
unsigned int newCount = oldCount + addedCount;
// 重新为methods成员变量所指向的数组分配内存,一个指针占8个字节
setArray((array_t *)realloc(array(), array_t::byteSize(newCount)));
array()->count = newCount;
#pragma mark - 为类的methods成员变量重新分配完内存后,对其内存数据进行移动和复制操作
//
/*
内存复制:
memmove(dst, src, len),从src所指向的内存空间复制len个字节的数据到dst所指向的内存空间,内部处理了内存覆盖。
memcpy(dst, src, n),从src所指向的内存空间复制n个字节的数据到dst所指向的内存空间,内部没处理内存覆盖。
*/
// 把类原来的实例方法列表复制到最后面(但其实是把类原来的实例方法列表,在methods成员变量里对应的那个指针————原来的实例方法列表的首地址————复制到最后面了)
memmove(array()->lists + addedCount, array()->lists,
oldCount * sizeof(array()->lists[0]));
// 把所有分类的实例方法列表放在前面(同理,其实是把所有分类的的实例方法列表的首地址复制到前面了,因为methods成员变量里存放的是指针————即实例方法列表的地址,不过这里二维数组的内存拷贝会拷贝它里面所有一维数组的首地址,而不仅仅这个二维数组的首地址)
memcpy(array()->lists, addedLists,
addedCount * sizeof(array()->lists[0]));
}
这样就把所有分类的实例方法列表全都合并到类里面去了,最终类的方法列表结构如下:
以上我们只是说明了分类为类扩展实例方法的底层实现,至于分类为类扩展类方法、属性、协议是同理的。
四、分类的+load
方法和+initialize
方法
苹果提供类、分类的
+load
方法和+initialize
方法,其实就是给我们开发者暴露两个接口,让我们根据这俩方法的特点来合理使用。比如我们想在某个类被载入内存时做一些事情,就可以在+load
方法里做操作,想在某个类初始化时做一些事情,就可以在+initialize
方法里做操作。
1、+load
方法
1.1 调用时机
假设有一个Person
类,并且为它创建了两个分类INEEat
和INEDrink
。
// INEPerson.m
#import "INEPerson.h"
@implementation INEPerson
+ (void)load {
NSLog(@"INEPerson +load");
}
@end
// INEPerson+INEEat.m
#import "INEPerson+INEEat.h"
@implementation INEPerson (INEEat)
+ (void)load {
NSLog(@"INEPerson (INEEat) +load");
}
@end
// INEPerson+INEDrink.m
#import "INEPerson+INEDrink.h"
@implementation INEPerson (INEDrink)
+ (void)load {
NSLog(@"INEPerson (INEDrink) +load");
}
@end
我们什么都不做,不使用Person
类,甚至连它的头文件也不导入。
// ViewController.m
#import "ViewController.h"
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
}
@end
直接运行程序,发现控制台打印如下:
INEPerson +load
INEPerson (INEEat) +load
INEPerson (INEDrink) +load
于是我们就可以得出结论:+load
方法是系统把类和分类载入内存时调用的,它和我们代码里使用不使用这个类和分类无关。并且因为+load
方法只会在类和分类被载入内存时调用,所以每个类和分类的+load
方法在程序的整个生命周期中肯定会被调用且只调用一次。
1.2 调用方式
这里先回想一下,上面第三部分我们说过分类的方法列表会合并到类本身的方法列表里,并且分类的方法列表还会在类本身方法列表的前面,因此分类的方法会覆盖掉类里同名的方法。
但不知道你注意没有,上面第1小节的例子,控制台打印了三个东西,也就是说分类的+load
方法和类的+load
方法都走了,这很奇怪啊,按理说应该只走其中某一个分类的+load
方法才对啊,怎么会三个都走呢?也就是说为什么分类的+load
方法没有覆盖掉类的+load
方法?
接下来我们就找找运行时(Runtime)的相关源码(objc-runtime-new.mm
文件),看看能不能得到答案:(伪代码)
// 系统加载镜像
void load_images()
{
call_load_methods();
}
// 调用+load方法
void call_load_methods()
{
// 1、首先调用所有类的+load方法
call_class_loads();
// 2、然后调用所有分类的+load方法
call_category_loads();
}
// 调用所有类的+load方法
static void call_class_loads()
{
// 获取到所有的类
struct loadable_class *classes = loadable_classes;
for (int i = 0; i < loadable_classes_used; i++) {
// 获取到某个类
Class cls = classes[i].cls;
// 获取到某个类+load方法的地址
load_method_t load_method = (load_method_t)classes[i].method;
// 直接调用该类的+load方法
(*load_method)(cls, SEL_load);
}
}
// 调用所有分类的+load方法
static void call_category_loads()
{
// 获取到所有的分类
struct loadable_category *cats = loadable_categories;
for (i = 0; i < loadable_categories_used; i++) {
// 获取到某个分类
Category cat = cats[i].cat;
// 获取到某个分类+load方法的地址
load_method_t load_method = (load_method_t)cats[i].method;
// 直接调用该分类的+load方法
(*load_method)(cls, SEL_load);
}
}
可见+load
方法是通过内存地址直接调用的,而不像普通方法那样走消息发送机制。因此就解释了我们留下的疑惑,虽然说分类的方法列表在类本身方法列表的前面,但是对+load
方法根本不起作用,人家不走你那一套,所以分类的+load
方法不会覆盖类的+load
方法。
1.3 调用顺序
这里就直接给出结论了,感兴趣的话,可以像第2小节那样去看源码(核心代码就集中在上面那几个方法里)并敲代码验证验证。
- 会先调用所有类的
+load
方法,先编译的类先调用;如果存在继承关系,那么在调用子类的+load
方法之前会先去调用父类的+load
方法。 - 然后再调用所有分类的
+load
方法,先编译的分类先调用。
2、+initialize
方法
2.1 调用时机
假设有一个Person
类和一个继承自Person
类的Student
类,并且为Student
类创建了两个分类INEEat
和INEDrink
。
// INEPerson.m
#import "INEPerson.h"
@implementation INEPerson
+ (void)initialize {
NSLog(@"INEPerson +initialize");
}
@end
// INEStudent.m
#import "INEStudent.h"
@implementation INEStudent
+ (void)initialize {
NSLog(@"INEStudent +initialize");
}
@end
// INEStudent+INEEat.m
#import "INEStudent+INEEat.h"
@implementation INEStudent (INEEat)
+ (void)initialize {
NSLog(@"INEStudent (INEEat) +initialize");
}
@end
// INEStudent+INEDrink.m
#import "INEStudent+INEDrink.h"
@implementation INEStudent (INEDrink)
+ (void)initialize {
NSLog(@"INEStudent (INEDrink) +initialize");
}
@end
我们什么都不做,直接运行程序,发现控制台什么都没打印。
// ViewController.m
#import "ViewController.h"
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
}
@end
此时我们调用一下Student
类的+alloc
方法。
// ViewController.m
- (void)viewDidLoad {
[super viewDidLoad];
[INEStudent alloc];
}
运行程序,发现控制台打印如下:
INEPerson +initialize
INEStudent (INEDrink) +initialize
于是我们就可以得出结论:+initialize
方法是类初始化的时候调用的,所以严格地来讲,我们不能说“+initialize
方法是第一次使用类的时候调用的”,你看上面例子中我们根本没使用Person
类嘛,但它的+initialize
方法照样被调用了。如果我们压根儿不使用这个类,它的+initialize
方法被调用0次,但是我们不能说一个类的+initialize
方法最多被调用1次,因为+initialize
方法是通过消息发送机制来调用的,如果好几个子类都继承自某一个类,而这些子类都没有实现自己的+initialize
方法,那就都会去调用这个父类的+initialize
方法,这不就是调用N次了嘛。
2.2 调用方式
上面第1小节的例子,控制台打印了一个:
INEStudent (INEDrink) +initialize
这就明显表明:+initialize
方法的调用方式不同于+load
方法,它是通过消息发送机制调用的,所以才会只走分类里面的 +initialize
方法,也就是说分类的+initialize
方法会覆盖类的+initialize
方法。
但有一点很奇怪,因为控制台还打印了:
INEPerson +initialize
这是父类的+initialize
方法呀!既然+initialize
方法是通过消息发送机制调用的,那它在自己类的内部找到某个方法后,就不应该再调用父类里面的方法了呀,怎么回事?
接下来我们就找找运行时(Runtime)的相关源码(objc-runtime-new.mm
文件),看看能不能得到答案:(伪代码)
// 查找方法的实现:类接收到消息后,会去查找这个消息的实现并调用,那我们就从查找这个消息的实现下手吧,前面的源码没有相关信息
IMP lookUpImpOrForward(Class cls, SEL sel)
{
// 在查找方法的过程中,如果发现这个类没被初始化过
if (!cls->isInitialized()) {
// 则初始化这个类
initializeNonMetaClass(cls);
}
}
// 初始化一个类
void initializeNonMetaClass(Class cls)
{
// 在初始化一个类的过程中
Class supercls = cls->superclass;
if (supercls && !supercls->isInitialized()) {// 如果发现这个类的父类没被初始化过
// 则递归,一层一层地先初始化父类,直到NSObject,直到nil
initializeNonMetaClass(supercls);
// 一层一层初始化完之后,才会一层一层自上而下地调用各个类的+initialize方法
callInitialize(cls);
} else {// 如果发现这个类的父类被初始化过了
// 则直接初始化自己
initializeNonMetaClass(cls);
// 并调用自己的+initialize方法,
// 如果自己没有实现,则会去找父类的+initialize方法调用。(因为+initialize方法是通过消息发送机制调用的嘛)
callInitialize(cls);
}
}
void callInitialize(Class cls)
{
// +initialize方法确实是通过消息发送机制调用的
((void(*)(Class, SEL))objc_msgSend)(cls, SEL_initialize);
}
可见系统在调用一个类的+initialize
方法之前,首先会看看它的父类初始化了没有,如果没有初始化,则初始化它的父类并调用它父类的+initialize
方法,然后再初始化自己并调用自己的+initialize
方法;如果它的父类初始化了,则直接初始化自己并调用自己的+initialize
方法,如果自己没有实现,则会去找父类的+initialize
方法调用。
2.3 调用顺序
这里就直接给出结论了。
- 系统在调用一个类的
+initialize
方法之前,首先会看看它的父类初始化了没有,如果没有初始化,则初始化它的父类并调用它父类的+initialize
方法,然后再初始化自己并调用自己的+initialize
方法;如果它的父类初始化了,则直接初始化自己并调用自己的+initialize
方法,如果自己没有实现,则会去找父类的+initialize
方法调用。 - 如果分类里也实现了
+initialize
方法,会优先调用分类的。