Category的本质
Category编译之后的底层结构是struct category_t ,里面存储着分类的对象方法、类方法、属性、协议信息,在程序运行的时候,runtime会将Category的数据合并到类对象和元类对象中去。
首先我们写一段简单的代码,基于这段代码来进行以下的分析。
Person类
#import
@interface Person : NSObject
@property (nonatomic, assign) double weight;
- (void)eat;
- (void)run;
@end
#import "Person.h"
@implementation Person
- (void)eat{
NSLog(@"Person - eat");
}
- (void)run{
NSLog(@"Person - run");
}
@end
Person的类扩展 Person+Eat
#import "Person.h"
@interface Person (Eat)
@property (nonatomic, assign) int age;
- (void)eat;
- (void)eat1;
+ (void)eat;
@end
#import "Person+Eat.h"
@implementation Person (Eat)
- (void)eat{
NSLog(@"Person(Eat) - eat");
}
- (void)eat1{
NSLog(@"Person(Eat) - eat1");
}
+ (void)eat{
NSLog(@"Person(Eat) + eat");
}
@end
Person的类扩展Person+Run
#import "Person.h"
@interface Person (Run)
- (void)run;
@end
#import "Person+Run.h"
@implementation Person (Run)
- (void)run{
NSLog(@"Person(Run) - run");
}
@end
外部调用
Person *p = [[Person alloc] init];
[p eat];
[p run];
[p eat1];
打印结果
2019-07-09 17:42:58.550703+0800 category底层原理[6834:2245351] Person(Eat) - eat
2019-07-09 17:42:58.550861+0800 category底层原理[6834:2245351] Person(Run) - run
2019-07-09 17:42:58.550871+0800 category底层原理[6834:2245351] Person(Eat) - eat1
2019-07-09 17:42:58.550871+0800 category底层原理[6834:2245351] Person(Eat) + eat
面试题1.Category中能不能添成员变量?为什么?
在上述Person+Eat.h文件中添加_age成员变量,Xcode会直接报错。说明分类中是不能添加成员变量的。为什么呢?
通过runtime的源码,搜索category_t,我们可以找到分类的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; // 属性
// Fields below this point are not always present on disk.
struct property_list_t *_classProperties;
method_list_t *methodsForMeta(bool isMeta) {
if (isMeta) return classMethods;
else return instanceMethods;
}
property_list_t *propertiesForMeta(bool isMeta, struct header_info *hi);
};
通过源码我们可以看到,分类结构体中存储了对象方法,类方法,协议和属性等。同时分类结构体中是不存储成员变量的。
之前在OC对象的本质中我也说过,通过runtime源码可发现类对象的底层数据结构如下:
可以看出方法列表,属性列表,协议列表都是可读可写的,但是成员变量列表是只读的。这也说明一个类生成之后,编译时就已经把成员列表信息放在class_ro_t中,不允许再动态的修改。以上都证明不能在分类中添加成员变量。
虽然不能添加成员变量,但是是可以在分类中添加属性。添加的属性系统并没有自动生成成员变量,也没有实现set和get方法,只是生成了set和get方法的声明。这就是为什么在分类中扩展了属性,在外部并没有办法调用。在外部调用点语法设值和取值,本质其实就是调用属性的set和get方法,现在系统并没有实现这两个方法,所以外部就没法调用分类中扩展的属性。
基于最开头的代码,在外部调用一下代码
Person *p = [[Person alloc] init];
p.age = 20;
NSLog(@"%d",p.age);
运行报错
-[Person setAge:]: unrecognized selector sent to instance 0x100709220
-[Person age]: unrecognized selector sent to instance 0x1005639e0
首先能调用p.page=20,和p.age两句,说明系统已经生成了set和get方法的声明;运行时,又会报找不到setAge:和age方法而报错,说明系统没有实现set和get方法。直接调用_age也会报错,说明没有生成成员变量。这样得以证明以上关于分类中属性的结论。
面试题2.Category中添加的方法为什么会覆盖原来类中的方法?解释原理?
通过以上源码我们发现,分类的方法、协议、属性等都是存放在category_t结构体中。那这些信息具体怎么存放,运行时又是如何将这些信息同步到类中的?我们通过底层源码来一一揭秘。
首先我们通过命令行将Person+Eat.m文件转化成c++代码,查看其编译过程。
clang -rewrite-objc Person+Eat.m
在分类转化为c++文件中可以看出_category_t结构体中,存放着类名,对象方法列表,类方法列表,协议列表,以及属性列表。
struct _category_t {
const char *name;
struct _class_t *cls;
const struct _method_list_t *instance_methods;
const struct _method_list_t *class_methods;
const struct _protocol_list_t *protocols;
const struct _prop_list_t *properties;
};
接着我们找到_method_list_t结构体(对象方法列表)
static struct /*_method_list_t*/ {
unsigned int entsize; // sizeof(struct _objc_method)
unsigned int method_count;
struct _objc_method method_list[2];
} _OBJC_$_CATEGORY_INSTANCE_METHODS_Person_$_Eat __attribute__ ((used, section ("__DATA,__objc_const"))) = {
sizeof(_objc_method),
2,
{{(struct objc_selector *)"eat", "v16@0:8", (void *)_I_Person_Eat_eat},
{(struct objc_selector *)"eat1", "v16@0:8", (void *)_I_Person_Eat_eat1}}
};
_OBJC_$_CATEGORY_INSTANCE_METHODS_Person_$_Eat
从名称可以看出是INSTANCE_METHODS对象方法,结构体中存储了方法占用的内存,方法数量,以及分类中实现的eat,eat1两个对象方法。
再接着我们找到_method_list_t结构体(类方法列表)
static struct /*_method_list_t*/ {
unsigned int entsize; // sizeof(struct _objc_method)
unsigned int method_count;
struct _objc_method method_list[1];
} _OBJC_$_CATEGORY_CLASS_METHODS_Person_$_Eat __attribute__ ((used, section ("__DATA,__objc_const"))) = {
sizeof(_objc_method),
1,
{{(struct objc_selector *)"eat", "v16@0:8", (void *)_C_Person_Eat_eat}}
};
_OBJC_$_CATEGORY_CLASS_METHODS_Person_$_Eat
从名称可以看出是CLASS_METHODS类方法,结构体中存储了方法占用的内存,方法数量,以及分类中实现的eat类方法。
再接着我们找到_protocol_list_t结构体(协议列表)
static struct /*_protocol_list_t*/ {
long protocol_count; // Note, this is 32/64 bit
struct _protocol_t *super_protocols[1];
} _OBJC_CATEGORY_PROTOCOLS_$_Person_$_Eat __attribute__ ((used, section ("__DATA,__objc_const"))) = {
1,
&_OBJC_PROTOCOL_NSCopying
};
_OBJC_CATEGORY_PROTOCOLS_$_Person_$_Eat
结构体中存储了协议的数量以及分类遵守的NSCoping协议。
_prop_list_t结构体(属性列表)
static struct /*_prop_list_t*/ {
unsigned int entsize; // sizeof(struct _prop_t)
unsigned int count_of_properties;
struct _prop_t prop_list[1];
} _OBJC_$_PROP_LIST_Person_$_Eat __attribute__ ((used, section ("__DATA,__objc_const"))) = {
sizeof(_prop_t),
1,
{{"age","Ti,N"}}
};
_OBJC_$_PROP_LIST_Person_$_Eat
结构体中存储了属性所占的内存,属性数量以及分类中声明的属性age。
最后我们看到了_OBJC_$_CATEGORY_Person_$_Eat
结构体,我们将上面分析的结构体一一赋值,把两段代码做一下对照。
struct _category_t {
const char *name;
struct _class_t *cls;
const struct _method_list_t *instance_methods;
const struct _method_list_t *class_methods;
const struct _protocol_list_t *protocols;
const struct _prop_list_t *properties;
};
static struct _category_t _OBJC_$_CATEGORY_Person_$_Eat __attribute__ ((used, section ("__DATA,__objc_const"))) =
{
"Person",
0, // &OBJC_CLASS_$_Person,
(const struct _method_list_t *)&_OBJC_$_CATEGORY_INSTANCE_METHODS_Person_$_Eat,
(const struct _method_list_t *)&_OBJC_$_CATEGORY_CLASS_METHODS_Person_$_Eat,
(const struct _protocol_list_t *)&_OBJC_CATEGORY_PROTOCOLS_$_Person_$_Eat,
(const struct _prop_list_t *)&_OBJC_$_PROP_LIST_Person_$_Eat,
};
接下来我们来到runtime源码,看看运行时又是如何将这些信息同步到类中的。
首先来到runtime的初始化函数,在objc-os.mm文件文件中搜索_objc_init
。
接着我们来到&map_images
(images代表镜像或者模块),这个函数又会调用map_images_nolock
,接着会调用_read_images
,runtime加载模块的函数,我们找到其中加载category的逻辑代码。
这段代码是用来查找项目中的分类的。通过_getObjc2CategoryList函数获取到项目中每个类的分类列表,进行遍历,获取分类中的方法,协议,属性等信息。最后调用了remethodizeClass
函数,进行类和元类中的重新组织方法。我们进到函数内部查看。
接下来进入到attachCategories
函数内部。
这段代码就是取出分类中方法,属性,协议,然后分别拼接到原有类中。拼接时有个小特点,最后加载的分类,即项目中最后编译的分类中的数据,会放在新的数据数组的最前面。具体流程截图中的注释写的很清楚。方法,属性,协议的拼接都是调用的attachLists
函数,接下来我们进入到函数中。
上述源码中有两个重要的数组
array()->lists: 类对象原来的方法列表,属性列表,协议列表。
addedLists:传入所有分类的方法列表,属性列表,协议列表。
attachLists函数中最重要的两个方法为memmove内存移动和memcpy内存拷贝。我们先来分别看一下这两个函数
// memmove :内存移动。
/* __dst : 移动内存的目的地
* __src : 被移动的内存首地址
* __len : 被移动的内存长度
* 将__src的内存移动__len块内存到__dst中
*/
void *memmove(void *__dst, const void *__src, size_t __len);
// memcpy :内存拷贝。
/* __dst : 拷贝内存的拷贝目的地
* __src : 被拷贝的内存首地址
* __n : 被移动的内存长度
* 将__src的内存移动__n块内存到__dst中
*/
void *memcpy(void *__dst, const void *__src, size_t __n);
经过mommove后,内存变化为
// array()->lists 原来方法、属性、协议列表数组
// addedCount 分类数组长度
// oldCount * sizeof(array()->lists[0]) 原来数组占据的空间
memmove(array()->lists + addedCount, array()->lists,
oldCount * sizeof(array()->lists[0]));
经过memmove操作之后,我们发现,虽然本类的方法,属性,协议列表会分别后移,但是本类方法、属性、协议数组对应的指针依然指向原始位置。
memcpy方法之后,内存变化为
// array()->lists 原来方法、属性、协议列表数组
// addedLists 分类方法、属性、协议列表数组
// addedCount * sizeof(array()->lists[0]) 原来数组占据的空间
memcpy(array()->lists, addedLists,
addedCount * sizeof(array()->lists[0]));
经过memcpy操作后,指向本类方法、属性、协议的指针至始至终指向开头的位置。并且经过memmove和memcpy方法之后,分类的方法,属性,协议列表被放在了类对象中原本存储的方法、属性、协议列表前面。
那么为什么要将分类方法的列表追加到本来的对象方法前面呢,这样做的目的是为了保证分类方法优先调用,我们知道当分类重写本类的方法时,会覆盖本类的方法。其实经过上面的分析我们知道本质上并不是覆盖,而是方法顺序发生了变化,系统会优先调用分类中的方法。
本类中的方法依然还是保存在内存中的,我们可以通过打印类中所有的方法名来查看。
- (void)printMethodNamesOfClass:(Class)cls
{
unsigned int count;
// 获得方法数组
Method *methodList = class_copyMethodList(cls, &count);
// 存储方法名
NSMutableString *methodNames = [NSMutableString string];
// 遍历所有的方法
for (int i = 0; i < count; i++) {
// 获得方法
Method method = methodList[I];
// 获得方法名
NSString *methodName = NSStringFromSelector(method_getName(method));
// 拼接方法名
[methodNames appendString:methodName];
[methodNames appendString:@", "];
}
// 释放
free(methodList);
// 打印方法名
NSLog(@"%@ - %@", cls, methodNames);
}
- (void)viewDidLoad {
[super viewDidLoad];
Preson *p = [[Preson alloc] init];
[p eat];
[self printMethodNamesOfClass:[Preson class]];
}
打印结果
2019-07-10 15:25:34.427523+0800 category底层原理[8734:3239719] Person(Eat) - eat
2019-07-10 15:22:47.617090+0800 category底层原理[8708:3228107] Person - eat, eat,eat1, run, run, setWeight:, weight,
可以看出执行[p eat]时,调用的是Person+Eat分类中的方法。而打印Person类中的所有方法,可以看出Person+Eat分类中的eat,eat1方法,Person+Run分类中的run方法,Person类中的eat,run方法都存在,只是分类中的方法排在了原有类中方法的前面。
多个分类中方法调用也是有顺序的,通过上面的的分析,其结论就是最后编译的分类,最先调用其中方法。我们也可以手动控制编译的顺序,从而控制调用的方法顺序。下面来实践一下。
文章最开头的代码,在Person+Run分类中也实现eat方法。那么Person类中,Person+Eat分类中,Person+Run分类中,都实现了eat方法。由以上结论我们知道,一定会是调用分类中的eat方法,但是先调用那个分类中的方法,如何手动去控制编译顺序呢?
上面两图,在Xcode->Build Phases中可以看到项目文件的编译顺序,最后编译的分类,会调用其中的方法。编译顺序在xcode中是可以手动拖动的,从而可以自己控制编译的顺序。
总结一下Category的加载处理过程
1.通过runtime加载某各类的所有Category数据
2.把所有Category的方法、属性、协议数据,合并到一个大数组中(后面参与编译的Category数据会在数组的前面)
3.将合并后的分类数据(方法、属性、协议),插入到原来数据的前面
面试题总结
1.Category中能不能添成员变量?为什么?
答:Category中不能添加成员变量。因为objc_class结构体中ivars成员变量信息是放在只读的class_ro_t结构体中,类一旦生成,就不能动态的添加成员变量。Category本身的底层结构category_t中也只保存了方法、属性和协议等信息,并没有保存成员变量信息。综合来说是Category中不能添加成员变量。
但是分类中可以添加属性,系统不会生成对应的成员变量以及set和get方法实现,只会生成set和get方法的声明。2.Category中添加的方法为什么会覆盖原来类中的方法?解释原理?
分类的实现原理是将category中的方法,属性,协议数据放在category_t结构体中,然后将结构体内的方法、属性,协议数据拷贝到类对象的方法列表中。
runtime首先加载某个类的所有Category数据,然后把所有Category的方法、属性、协议数据,合并到一个大数组中(后面参与编译的Category数据会在数组的前面),最后将合并后的分类数据(方法、属性、协议),插入到原来数据的前面。所以调用方法时会优先到调用Category中的方法,当父类中有同样的方法就不会调用。