iOS底层原理总结探寻Category本质

释放双眼,带上耳机,听听看~!

上一篇《iOS底层原理总结 – 探寻KVO的本质》

本篇学习总结:

  • 探寻Category本质
  • Category底层代码分析
  • load 和 initialize方法底层分析

好了,带着问题,我们一一开始阅读吧 ?

一.探寻Category本质

我们还是先来上一段代码,之后的分析都是基于这段代码:
MJPerson类

//MJPerson.h文件
#import <Foundation/Foundation.h>
@interface MJPerson : NSObject
- (void)run;
@end
//MJPerson.m文件
#import "MJPerson.h"
// class extension (匿名分类类扩展)
@interface MJPerson()
{
int _abc;
}
@property (nonatomic, assign) int age;
- (void)abc;
@end
@implementation MJPerson
- (void)abc
{
}
- (void)run
{
NSLog(@"MJPerson - run");
}
+ (void)run2
{
}

MJPerson+Test 分类

//MJPerson(Test) .h文件
#import "MJPerson.h"
@interface MJPerson (Test)
- (void)test;
@end
//MJPerson(Test) .m文件
#import "MJPerson+Test.h"
@implementation MJPerson (Test)
- (void)run
{
NSLog(@"MJPerson (Test) - run");
}
- (void)test
{
NSLog(@"test");
}
+ (void)test2
{
}
@end

MJPerson+Eat分类

//MJPerson(Eat).h文件
#import "MJPerson.h"
@interface MJPerson (Eat) <NSCopying, NSCoding>
- (void)eat;
@property (assign, nonatomic) int weight;
@property (assign, nonatomic) double height;
@end
//MJPerson(Eat).m文件
#import "MJPerson+Eat.h"
@implementation MJPerson (Eat)
- (void)run
{
NSLog(@"MJPerson (Eat) - run");
}
- (void)eat
{
NSLog(@"eat");
}
- (void)eat1
{
NSLog(@"eat1");
}
+ (void)eat2
{
}
+ (void)eat3
{
}

main.m文件

#import "MJPerson+Eat.h"
#import "MJPerson+Test.h"
#import "MJPerson.h"
int main(int argc, const char * argv[]) {
@autoreleasepool {
MJPerson *person = [[MJPerson alloc] init];
[person run];
}
return 0;
}

我们之前讲过实例对象的isa指针指向类对象,类对象的isa指针指向元类对象,当p调用run方法时,通过实例对象的isa指针找到类对象,然后在类对象中查找对象方法,如果没有找到,通过类对象的superclass指针找到父类的类对象,接着去寻找run方法。
那么问题来了,当我们调用分类中的run方法时,它也会按照我们前面说的方式去查找run方法吗?
小码哥直接给出结论了,先看结论,再看底层代码实现

分类中的对象方法依然存储在类对象中,同本类对象在同一个地方,调用步骤也同本类调用对象方法一样,同理,如果是类方法的话,是存储在元类对象中的。

二.Category底层代码分析

我们前面讲过了,所有的OC代码最终都会被编译成C/C++代码,我们要想进一步了解底层代码,需要将文件转化成C++文件,转化方式在《iOS底层原理-探寻OC对象本质》中讲过了,这里我们直接看.cpp文件。

转化C++文件.png
  • _category_t:分类结构体

我们点开MJPerson+Eat.cpp文件,搜索category,我们可以找到 _category_t 结构体,结构如下:

_category_t结构体信息.png

从底层代码中可以看出,_category_t 结构体包含对象方法列表,类方法列表,协议列表,属性列表等信息,但是没有找到成员变量信息,是不是可以初步肯定一下之前的结论:分类中可以添加对象方法,类方法,协议,属性,不可以添加成员变量,category可以添加属性,但是不会自动生成成员变量,只能生成setter getter方法,还需要我们手动实现一下方法。

那么我们就一个变量一个变量的分析吧

  • _method_list_t:方法列表

然后搜索 _method_list_t,结构体如下:

_method_list_t结构体信息.png

此时我们发现了两个名字比较长的变量名,分别是
_OBJC__CATEGORY_INSTANCE_METHODS_MJPerson__Eat_OBJC__CATEGORY_CLASS_METHODS_MJPerson__Eat,从名称上可以推测是类方法实例方法,下面的代码赋值跟上面的结构体成员变量一一对应,我们可以看到结构体中存储了方法占用的内存,方法数量,以及方法列表。并且从上图中找到分类中我们实现对应的对象方法。

  • _protocol_list_t:协议列表信息

继续搜索 _protocol_list_t,结构体如下:

_protocol_list_t结构体信息.png

这里同样看到了一个名字比较长的变量名:_OBJC_CATEGORY_PROTOCOLS__MJPerson__Ea,下面的代码赋值跟上面的结构体成员变量一一对应。

  • _prop_list_t:属性列表信息

最后搜索 _prop_list_t,结构体如下:

_prop_list_t结构体信息.png

这里同样看到了一个名字比较长的变量名:_OBJC__PROP_LIST_MJPerson__Eat,下面的代码赋值跟上面的结构体成员变量一一对应。

最后我们再搜索一下category 发现了这么一个变量:
**_OBJC__CATEGORY_MJPerson__Eat ** 变量时属于 _category_t 结构体类型,我们再来看一下 _category_t 结构体信息。

_OBJC_$_CATEGORY_MJPerson_$_Eat结构体信息.png
_category_t结构体信息.png

上下两张图对比来看,我们发现定义了category_t类型的变量,
MJPerson:赋值给name;
OBJC_CLASS
$_MJPreson :赋值给cls指针变量;
对应的列表信息一一赋值给变量。

通过以上分析我们发现,编译时期,分类被编译成了catagory_t结构体类型的变量,分类中的对象方法,类方法,属性,协议等都存放在catagory_t结构体对应的成员变量中。

回到最初的结论,分类中的实例方法是如何放到类对象中去呢,这要从runtime源码说起,下面是通过查看runtime源码找到catagory_t存储的方法,属性,协议等是如何存储在类对象中的。

我们先记录一下看源码的顺序,源码本来就有点晦涩难懂,我们根据顺序去查看

 

runtime查看源码顺序.png

首先我们下载源码,打开objc-os.mm文件,先从runtime初始函数看起

objc_init函数.png

接着我们来到 & map_images读取模块(images这里代表模块),

map_images.png

点击 map_images_nolock 函数中找到_read_images函数,

map_images_nolock部分代码.png

在_read_images函数中我们可以找到重组类信息

_read_images重组类信息.png

从上述代码中我们可以知道这段代码是用来查找有没有分类的。通过_getObjc2CategoryList函数获取分类列表之后,进行遍历,获取其中的方法,协议,属性等,可以看到最终都调用了remethodizeClass(cls)函数,我们点进去查看一下

remethodizeClass.png

通过上述代码我们发现attachCategories函数接收了类对象cls和分类数组cats,如我们一开始写的代码所示,一个类有多少个分类,就会有多少个category_t结构体类型的变量,这些分类信息都都保存在category_list中。我们来到attachCategories函数内部

attachCategories-1.png

 

attachCategories-2.png

 

attachCategories-3.png

上述代码中可以看出,这步骤才是关键步骤了

  • 1.首先根据传进来的cats数组分别创建了mlist,proplists,protocols三个二维数组,用于存储方法每个分类的方法列表,属性列表,协议列表
  • 2.通过while(i–)倒序方式进行遍历循环,取出每一个分类中的方法数组,属性数组,协议数组,存进mlist,proplists,protocols三个二维数组中
  • 3.取出类对象的class_rw_t,我们在《iOS底层原理总结 – 探寻Class的本质》 中已经讲过了,类对象中存储的方法列表,属性信息,协议信息,成员变量信息都存储在class_rw_t
  • 4.将存放所有分类方法的二维数组 mlist 附加到类对象的方法列表中
  • 5.将存放所有属性方法的二维数组 proplists 附加到类对象的属性列表中
  • 6.将存放所有协议方法的二维数组 protocols 附加到类对象的协议列表中

我们看一下attachLists函数内部实现:

attachLists函数内部实现.png

array()->lists:类对象原来的方法列表,属性列表,协议列表。
addedLists:所有分类的方法列表,属性列表,协议列表。
attachLists函数里面最重要的两个方法为memmove(内存移动方法)和memcpy(内存拷贝方法)。
我们分别说一下这两个函数,

 

memmove和memcpy方法说明.png

 

下图是经过memmove函数之后数据在内存中分配情况
1.在原有空间上扩容addedCount大小空间

 

增大内存空间.png

2.遵循menmove方法移动原则,原有方法开始往后移动

// array()->lists 原来方法、属性、协议列表数组
// addedCount 分类数组长度
// oldCount * sizeof(array()->lists[0]) 原来数组占据的空间
memmove(array()->lists + addedCount, array()->lists,
oldCount * sizeof(array()->lists[0]));
memmove方法之后内存变化.png

经过memmove方法之后,我们发现,虽然本类的方法,属性,协议列表会分别后移,但是本类的对应数组的指针依然指向原始位置。
memcpy方法之后,内存变化

// array()->lists 原来方法、属性、协议列表数组
// addedLists 分类方法、属性、协议列表数组
// addedCount * sizeof(array()->lists[0]) 原来数组占据的空间
memcpy(array()->lists, addedLists,
addedCount * sizeof(array()->lists[0]));
memmove方法之后,内存变化.png

 

我们发现原来指针并没有改变,至始至终指向开头的位置。并且经过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 run];
[self printMethodNamesOfClass:[Preson class]];
}

那么为什么要将分类方法的列表追加到本来的对象方法前面呢,这样做的目的是为了保证分类方法优先调用,我们知道当分类重写本类的方法时,会覆盖本类的方法。
其实经过上面的分析我们知道本质上并不是覆盖,而是优先调用。本类的方法依然在内存中的。我们可以通过打印所有类的所有方法名来查看

- (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 run];
[self printMethodNamesOfClass:[Preson class]];
}

通过下图中打印内容可以发现,调用的是Test2中的run方法,并且Person类中存储着两个run方法

 

打印所有方法.png
三.load 和 initialize

load 方法会在程序启动,加载类,分类信息的时候调用,只调用一次,调用方法是指针直接调用,一般不主动调用。
先看代码

load方法调用顺序.png

再来看一下调用load方法的具体实现

load方法内部实现.png

通过源码我们发现load方法调用顺序是优先调用类的load方法,如有继承关系的类,调用子类的时候会有限调用父类的load方法,之后调用分类的load方法,分类按照编译顺序调用

我们看到load方法中直接拿到load方法的内存地址直接调用方法,不在是通过消息发送机制调用。

 

分类load方法调用源码.png

代码验证如下:
我们添加Student继承Presen类,并添加Student+Test分类,分别重写只+load方法,其他什么都不做通过打印发现:

 

load方法打印.png

最后用一张图总结load方法

 

load方法总结及源码查看顺序.png

** initialize** 方法当类第一次接收到消息时,优先调用父类的initialize方法,在调用子类的initialize方法。
之后我们为Preson、Student 、Student+Test 添加initialize方法。
第一次使用类的时候就会调用initialize方法。调用子类的initialize之前,会先保证调用父类的initialize方法。如果之前已经调用过initialize,就不会再调用initialize方法了。当分类重写initialize方法时会先调用分类的方法。但是load方法并不会被覆盖,首先我们来看一下initialize的源码。

initialize调用机制.png

最后用一张图总结:

 

initialize方法总结及源码顺序.png

总结本篇面试题:

  • 1.Category的实现原理,或者加载处理过程是什么样的?

1>编译时期将所有categor转化成category_t的结构体变量,并赋值
2>通过runtime加载某个类的所有category数据
3>把所有category的方法,属性,协议数组,合并到一个大数组中
a)后面参与编译的category数据,会在数组的前面
4》将合并后的分类数据(方法,属性,协议),合并到类原来数据的前面

  • 2.Category为什么只能加方法不能加属性?

category可以添加属性,但是并不会主动生成成员变量及setter/getter方法,因为category_t结构体中并不存在成员变量,通过之前对对象的分析我们知道成员变量是存放在实例对象中,并且编译的那一刻都已经决定好了,而分类是在运行时才去加载的,那么我们就无法运行时将分类的成员变量添加到实例对象的结构体中,因此category中可以添加属性,不可添加成员变量。

  • 3.load initialize方法的区别是什么?

a.调用方式:
1>load 是根据函数地址直接调用;
2>initialize是通过objc_msgSend方法调用;
b>调用时刻
1>load 是runtime加载类,分类的时候调用(只会调用一次);
2>initialize 是类第一次接收到消息的时候调用,每一个类只会initialize一次(父类的initialize方法可能会被调用多次,具体例子是当多个子类都没有实现initialize方法,却是第一次给类发送消息)

  • 4.load initialize 方法的调用顺序?

1.load
1>先调用类的load
a)先编译的类,优先调用load
b)调用子类的load之前,会先调用父类的load
2>在调用分类的load
a)先编译的分类,优先调用load
2.initialize
1>先初始化父类
2>在初始化子类(可能最终调用的是父类的initialize方法)

 

人已赞赏
iOS文章

iOS底层原理总结探寻block本质(二)

2020-5-11 5:48:13

iOS文章

iOS底层原理总结探寻关联对象本质

2020-5-11 7:03:02

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索