探索底层原理,积累从点滴做起。大家好,我是Mars。
往期回顾
iOS底层原理探索—OC对象的本质
iOS底层原理探索—class的本质
iOS底层原理探索—KVO的本质
iOS底层原理探索— KVC的本质
今天带领大家探索iOS之Category的本质。
Category
首先我们声明一个Person
类
//Person.h
#import <Foundation/Foundation.h>
@interface Person : NSObject
{
int _age;
}
- (void)run;
@end
//Person.m
#import "Person.h"
@implementation Person
- (void)run
{
NSLog(@"Person:run");
}
@end
我们之前在iOS底层原理探索—OC对象的本质中讲到:实例对象的isa
指针指向类对象,类对象的isa
指针指向元类对象。我们创建一个Person
对象p
,当p
调用run
方法时,通过实例对象的isa指针找到类对象,然后在类对象中查找对象方法,如果没有找到,就通过类对象的superclass
指针找到父类对象,接着去寻找run
方法。
那么当我们调用分类的方法时,是否跟上面的调用顺序一样呢?下面我们创建分类来验证一下:
创建Person
的分类:
在New File
的iOS
文件下选择Objective-C File
File Type
选择Category
,Class
父类选择Person
//Person+test.h
#import "Person.h"
@interface Person (Test)
- (void)test;
+ (void)abc;
@property (assign, nonatomic) int age;
- (void)setAge:(int)age;
- (int)age;
@end
//Person+test.m
#import "Person+Test.h"
@implementation Person (Test)
- (void)test
{
}
+ (void)abc
{
}
- (void)setAge:(int)age
{
}
- (int)age
{
return 18;
}
- (void)run
{
NSLog(@"Person+test:run");
}
@end
以上我们就完成创建了Person
的Test
分类。
在此先告诉大家结论:分类中的对象方法是存储在类对象中的,和类对象方法在同一个地方,调用步骤也和调用对象方法一样。如果是类方法的话,同样也是存储在元类对象中
。
这一点大致可以从分类的底层结构中看出来:
分类的底层结构
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);
};
从分类的源码中可以看出Categroy
在底层是以categroy _t
的结构存在,里面包括对象方法
,类方法
,协议
,和属性
。注意分类结构体中是不存在成员变量
的,因此分类中是不允许添加成员变量。分类中添加的属性并不会帮助我们自动生成成员变量,只会生成set
、get
方法的声明,需要我们自己去实现。
至此我们可以得出结论:
分类的实现原理是将分类中的方法,属性,协议信息放在
category_t
结构体中,然后将结构体内的方法列表拷贝到类对象的方法列表中。分类中可以添加属性,但是并不会自动生成
成员变量
及set
、get
方法。因为底层的category_t
结构体中并不存在成员变量。通过之前对对象的分析我们知道成员变量是存放在实例对象中的,并且编译的那一刻就已经决定好了。而分类是在运行时才去加载的,那么我们就无法在程序运行时将分类的成员变量中添加到实例对象的结构体中。因此分类中不可以添加成员变量
由于上述结论的验证是依据底层源码,过程比较枯燥,也不能保证大家阅读一次就能弄清楚整个流程,所以将结论提前告知。不愿意阅读源码的读者也可以忽略以下内容,掌握上面的结论即可。
首先把Person+Test.m
文件通过命令行转化为c++
文件,查看底层编译过程。
xcrun -sdk iphoneos clang -arch arm64 -rewrite-objc Person+Test.m
然后将生成的.cpp
文件拖拽至Xcode中查看。在.cpp
文件中搜索category_t
,通过搜索结果我们可以看到,_category_t
结构体中,存放着类名
,对象方法列表
,类方法列表
,协议列表
,以及属性列表
:
在.cpp
文件中继续往下看,我们可以看到_method_list_t *instance_methods
结构体的内容:
通过结构体名称_OBJC_$_CATEGORY_INSTANCE_METHODS_Person_$_Test
可以看出是INSTANCE_METHODS
--对象方法。我们可以看到结构体中存储了方法占用的内存,方法数量以及方法列表。并且从上图中可以看到在分类中我们实现的test
,setAge
, age
和run
四个方法。
同样,我们继续往下阅读,查看看到_method_list_t *class_methods
结构体的内容:
同样通过结构体名称 _OBJC_$_CATEGORY_CLASS_METHODS_Person_$_Test
可以看出是CLASS_METHODS
--类方法。同样可以看到我们实现的abc
类方法。
继续往下查看时,我们可以看到属性列表结构体_prop_list_t
:
属性列表结构体_OBJC_$_PROP_LIST_Person_$_Test
即_prop_list_t
结构体,里面存储了属性的占用空间
,属性数量
以及属性列表
,从上图中可以看到我们声明的age
属性。
同时我们发现,.cpp
文件中没有protocol_list_t *protocols
协议信息列表结构体的相关信息。这是由于我们创建分类是并没有遵守任何协议,自认分类里面也就没有任何协议相关的信息。我们返回分类Person+Test
,使其遵守NSCopying
协议,再通过命令行将分类的.m
文件编译成.cpp
文件后查看:
通过上图可以看到分类底层先将协议方法通过_method_list_t
结构体存储,之后通过_protocol_t
结构体存储在_OBJC_CATEGORY_PROTOCOLS_$_Person_$_Test
中,分别为protocol_count
--协议数量以及存储协议方法
的_protocol_t
结构体。
在.cpp
文件末尾处,我们看到系统定义了_category_t
类型的_OBJC_$_CATEGORY_Person_$_Test
结构体:
将_OBJC_$_CATEGORY_Person_$_Test
结构体跟上文提到的catrgory_t
结构体对照:
不难看出,上下两图中两个结构体内容一一对应,并且我们在红框标注的方法中看到,定义的_class_t
类型的OBJC_CLASS_$_Person
结构体,最后将_OBJC_$_CATEGORY_Person_$_Test
的cls
指针指向OBJC_CLASS_$_Person
结构体地址。我们可以得出结论,cls
指针指向的应该是分类的主类类对象
的地址。
通过以上分析我们发现,分类确实是将我们定义的对象方法
,类方法
,属性
等都存放在catagory_t
结构体中。那么catagory_t
结构体又如何让将这些信息存储到类对象中呢?我们通过分析runtime
的源码来进一步了解。
runtime源码
我们通过opensource网站下载最新的源码来进一步分析。
首先来到runtime初始化函数
接着我们来到&map_images
读取模块,来到map_images_nolock
函数中找到_read_images
函数,在_read_images
函数中我们找到分类相关代码:
从上述代码中for
循环中的判断我们可以知道这段代码是用来检查有没有分类的。通过_getObjc2CategoryList
函数获取到分类列表之后,进行遍历,获取其中的方法,协议,属性等。可以看到最终都调用了remethodizeClass(cls)
函数。我们来到remethodizeClass(cls)
函数内部查看:
通过上述代码我们发现attachCategories
函数接收了类对象cls
和分类数组cats
,当然一个类可以有多个分类,分类信息存储在category_t
结构体中,那么多个分类则保存在category_list
中。
我们来到attachCategories
函数内部:
上述源码中可以看出,首先根据方法列表
,属性列表
,协议列表
通过malloc
分配内存,根据多少个分类以及每一块方法需要多少内存来分配相应的内存地址。之后从分类数组里面往三个数组里面存放分类数组里面存放的分类方法
,属性
以及协议
放入对应mlist
、proplists
、protolosts
数组中,这三个数组放着所有分类的方法
,属性
和协议
。
之后通过类对象的data()
方法,拿到类对象的class_rw_t
结构体rw
,在class
结构中我们介绍过,class_rw_t
中存放着类对象的方法
,属性
和协议
等数据,rw结构体
通过类对象的data
方法获取,所以rw
里面存放这类对象里面的数据。
之后分别通过rw
调用方法列表
、属性列表
、协议列表
的attachList
函数,将所有的分类的方法
、属性
、协议列表
数组传进去,我们大致可以猜想到在attachList
方法内部将分类和本类相应的对象方法
,属性
和协议
进行了合并。
我们来看一下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);
下面我们图示经过memmove
和memcpy
方法过后的内存变化:
首先未经过内存移动和拷贝时:
经过memmove
方法之后,内存变化为:
// 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]));
我们发现原来指针并没有改变,至始至终指向开头的位置。并且经过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);
}
我们在控制器中引入Person
类,在控制器的viewDidLoad
方法中创建Person
对象,并且调用run
方法和上面的打印所有类的所有方法名的方法:
- (void)viewDidLoad {
[super viewDidLoad];
Preson *p = [[Preson alloc] init];
[p run];
[self printMethodNamesOfClass:[Preson class]];
}
通过打印台打印内容可以发现,调用的是分类中的run
方法,并且Person
类中存储着两个run
方法。
关于Category的底层原理探索我们告一段落,如有疑问,欢迎在评论区留言。
更多技术知识请关注公众号
iOS进阶