OC底层原理 学习大纲
上一节,我们了解了map_images的整体结构 & 非懒加载类,了解了APP启动
时,所有类
都已记录
在哈希表
中(仅类名字
和地址
)。
- 实现
类+load方法
的非懒加载类
,会在启动时
,实现类的加载,从macho
中读取原始数据
存放到rw
? - 而
懒加载类
则是在被第一次调用
时,通过消息机制
触发类的实现
。
两种类的加载方式,最终都是调用realizeClassWithoutSwift
完成实现。
上节回顾:
我们上一节留下了2个问题:rwe
何时加载?分类
如何加载?
- 现在不急着回答,本节结束后,我相信你就完全懂了。
本节尽可能讲得详细一些:
- sel注册
- 分类的本质
- 分类的数据加载
- attachCategories详解
- attachCategories的调用
准备工作:
- 可编译的
objc4-781
源码: https://www.jianshu.com/p/45dc31d91000dyld-750.6
: https://opensource.apple.com/tarballs/dyld/
1. sel注册
我们在前面学习msgSend消息机制
时,慢速查找阶段
中,在类的函数列表
查找方法时,是使用二分查找
(流程图)。
Q: 二分查找
必须是有序的
,那排序依据
是什么,如何排序
?
- 上一节我们分析
map_images
流程时,在第2步 修复预编译阶段的SEL的混乱问题
时,就需要将SEL
插入到nameSelectors
哈希表中。
- 其中
_getObjc2SelectorRefs
是macho
的__objc_selrefs
,存储的内容是SEL
:
- 遍历从
macho
的__objc_selrefs
读取SEL
,其中的sels
包含的是带地址
的sel
(后面证明)。 - 循环注册sel,
检查sel地址
,如果不同,就重新赋值sel地址
进入sel_registerNameNoLock:
- 进入
__sel_registerName
:
- 一般是可以通过
name
搜索到result
,直接返回result
。 - 但如果特殊情况
name
搜索不到,就重新创建,再返回sel
。
我们进入search_builtins
来了解查询路径:
- 发现
_dyld_get_objc_selector
是extern
申明在dyld
中:
// Called only by objc to see if dyld has uniqued this selector.
// Returns the value if dyld has uniqued it, or nullptr if it has not.
// Note, this function must be called after _dyld_objc_notify_register.
//
// Exists in Mac OS X 10.15 and later
// Exists in iOS 13.0 and later
extern const char* _dyld_get_objc_selector(const char* selName);
- 打开
dyld源码
,搜索_dyld_get_objc_selector(const
:
- 进入
getObjCSelector
:
- 发现是调用
getString
方法在读取内容,所以我们反向搜索getString(const
,检查函数的实现:
- 通过这里,我们就明确知道了:
sel
虽然是函数名(字符串)
,但同时它是有地址值
的。
拓展:
函数地址
完全随机
,是由它所在的段基础地址
和偏移值
确定的。程序每次运行,函数地址
都可能变化
。- 判断两个
函数是否相等
,是通过地址值
进行判断
两个不同类
有相同名称
的函数
,但函数地址不同,是两个独立的函数
。- 函数列表排序,是依据
SEL地址
进行排序
。所以排序后,可使用二分查找。
2.分类的本质
-
main.m
文件加入测试代码
:
// 本类
@interface HTPerson : NSObject
@property (nonatomic, copy) NSString *name;
@property (nonatomic, assign) int age;
- (void)func1;
- (void)func3;
- (void)func2;
+ (void)classFunc;
@end
@implementation HTPerson
+ (void)load { NSLog(@"%s",__func__); };
- (void)func1 { NSLog(@"%s",__func__); };
- (void)func3 { NSLog(@"%s",__func__); };
- (void)func2 { NSLog(@"%s",__func__); };
+ (void)classFunc { NSLog(@"%s",__func__); };
@end
// 分类 CatA
@interface HTPerson (CatA)
@property (nonatomic, copy) NSString *catA_name;
@property (nonatomic, assign) int catA_age;
- (void)func1;
- (void)func3;
- (void)func2;
+ (void)classFunc;
@end
@implementation HTPerson (CatA)
+ (void)load { NSLog(@"%s",__func__); };
- (void)func1 { NSLog(@"%s",__func__); };
- (void)func3 { NSLog(@"%s",__func__); };
- (void)func2 { NSLog(@"%s",__func__); };
+ (void)classFunc { NSLog(@"%s",__func__); };
@end
int main(int argc, const char * argv[]) {
return 0;
}
检查格式的方式:1. clang 2. 官方帮助文档
2.1 clang
cd
到main.m
所在文件夹,输入clang -rewrite-objc main.m -o main.cpp
,打开main.cpp
文件,搜索分类_CatA
:-
分类的
实例方法
和类方法
:
-
分类的
属性
:
-
分类的
结构
:
- 我们搜索
struct _category_t
,可看到分类的完整格式
:
发现
编译期
的HTPerson(CatA)
:name
是HTPerson
,cls
也是HTPerosn类
- 分类的
实现
:
本类属性
和分类属性
的区别:
本类属性:在
clang
编译环节,会自动生成并实现
对应的set和get方法分类属性:会存在set、get方法,但是
没有实现
(需要runtime设置关联属性
)。易混淆点: 分类属性存在
set
、get
方法,但没有实现
。
检验方式: 使用person对象
可以快捷访问到catA_age
,并可以赋值
。但是程序运行时
会crash
。 这是因为方法存在,但找不到
对应的imp实现
。
Q: 1.分类属性
为何存在set
、get方法
? 2.如何
让它不crash
(关联属性的动态实现)第1个问题在本节后续探索中,会得到很清晰的答案。 第2个问题,我们下一节专门讲解
关联属性
。
- 2.2 官方帮助文档
打开官方文档 (快捷键:shift + command + 0
),搜索Categor
:
-
切换语言为
Objective-C
:
发现类型是
objc_category
,在objc4源码
中搜索:
-
格式不一样?name呢?cls呢?
- 注意看后面的声明:
OBJC2_UNAVAILABLE
, objc2不可用。文档
是已过期
的。这个时候,我们要以真实运行的代码为准
。
了解了分类
的数据格式
,那分类的数据
是如何加到HTPerson
的呢?
3. 分类的加载
如何研究呢?
- 从
已知
的信息出发
,先找
到一条
抵达目的地的路径
,找到核心方法
,再反向搜索
核心方法被调用的地方
,进行全面推理
。
我们上一节分析_read_images
结构时,第9步 实现非懒加载类
->methodizeClass
内部有对分类的处理
。
- 在
methodizeClass
中加入测试代码:
// >>>> 测试代码
const char *mangledName = cls->mangledName();
const char * HTPersonName = "HTPerson";
if (strcmp(HTPersonName, mangledName) == 0 ) {
if (!isMeta) {
printf("%s - 精准定位: %s\n", __func__, mangledName);
}
}
// <<<< 测试代码
- 在
printf
打印处加入断点,运行程序
- 发现进入了
HTPerosn
类,查看ro
信息,发现其中baseMethods
只有8个
,分别打印查看,都是HTPerosn本类
的实例函数
。 从信息栏可以看rwe
此时为Null
ro的读取:
- 单步往下运行,发现最终会到达
attachToClass
处:
methodizeClass
的内容是:
- 读取
函数(已排序)
存到list
-> 读取属性
存到proplist
-> 读取协议
存到protolist
->分类添加到类
中attachToClass
有个细节,我们发现
initialize
在这里被添加到根元类
的函数列表
了。根元类
拥有initialize
方法,所有继承
自NSObject
的类,都将拥有initialize
方法。我们知道
+load
方法会将懒加载类
转变为非懒加载类
,在app启动前
就完成
了所有非懒加载类
的加载
。但是app启动环节加载过多内容,会影响
app的启动时长
。
- Q:有些准备必须在
类初始化之前
就完成,如果不写
在+load
方法内,怎么
做到提前准备
呢?- A:写在
initialize
内,因为每个类都继承自NSObject
,所以都自带了initialize
函数,而initialize
函数是在类第一次发送消息
时,就触发
。 所以可以做到提前准备
。
- 进入
attachToClass
,加入测试代码:
看到了关键的attachCategories
函数:绑定分类。
- 如果是
元类
,需要分别绑定对象
和类方法
。否则,只需要绑定对象
方法。
注意,此时测试代码中
HTPerson
和HTPerson(CatA)
都必须实现+load
方法,才会进入attachCategories
代码区域) 具体原因,后面第5部分 本类与分类的+load区别
会详细讲解。
下面,我们详细分析一下attachCategories
:
4. attachCategories详解
进入attachCategories
,加入定位测试代码
:
开辟了64个
空间大小的mlists
、proplists
、protolists
容器,分别用于存储函数
、属性
、协议
。
attachCategories
流程:
- 首先,
开辟空间
,对rwe
进行初始化
。 - 然后,
遍历
所有的分类
:
entry
记录当前分类
,entry.cat是category_t
结构,存储了分类所有数据。
从分类中读取函数
、属性
、协议
信息,存放
到指定容器
内。 - 最后,将
容器内数据
,分别添加
到rwe
指定属性中。
此处分为3小部分讲解:
- rwe的初始化
- 数据读取
- prepareMethodLists函数排序
- attachLists 绑定数据
4.1 rwe的初始化
哈哈哈 走过千山万水,终于找到你,我的rwe
- 进入
extAllocIfNeeded
:
- 进入
extAlloc
:
此时,rwe
才完成了初始化工作
。各项属性完备
。(关于attachLists
赋值操作,在4.3小部分
进行讲解)
关于rwe何时加载的问题:
我们现在知道分类加载
会进行rwe初始化
和加载数据
。那还有其他地方
会触发rwe
的加载吗?
-
rwe
的加载,是执行了extAlloc
方法,所以我们反向搜索
,查看谁调用
了extAlloc
方法:
只有extAllocIfNeeded
和deepCopy
调用了。
deepCopy深拷贝
: 搜索deepCopy(
,发现只被objc_duplicateClass
调用,而是objc_duplicateClass
开放使用的API
接口,并没自动调用
的地方。 所以此处不做考虑。-
extAllocIfNeeded
: 搜索extAllocIfNeeded(
,发现有以下7处
调用了它:
发现都是
动态添加(函数、属性、协议、分类等)
时,才会创建rwe
。
还记得上面ro的读取
吗?
- 当
rwe存在
时:表示这个类有数据被修改
了,所以需要从rwe返回数据
。 - 而如果
rwe不存在
,表明这个类的数据没有
被动态修改
过,所以可以直接从macho
中拷贝
一份ro
返回即可。
附上
WWDC2020视频
Advancements in the Objective-C runtime,回顾官方对于rwe
的解释,会理解得更深刻。
4.2 数据读取和prepareMethodLists
函数排序
初始化rwe
后,我们读取分类数据
:
- 查看
entry.cat
结构:
- 查看
category_t
结构,发现存储了分类所有数据。
所以分类的数据都是从entry.cat
进行读取。
- 我们在上面
定位测试代码
的打印处
加上断点,运行代码
,到达断点后,往下进入循环
内:
- 发现此时name已从编译时的
HTPerson
变成了CatA
,而我们的cls
仍旧是HTPerson
:
(类地址在内存中是唯一的,地址相同表示是一个类)
- 下面以
函数
的读取
为例,(属性、协议的读取和赋值方式一样):
将分类的methods
函数列表读取到mlist
,如果存在:
- 如果
数组
是否已满(64)
,将mlist
内部排序
后,调用attachLists
存到rwe
的methods
中,并将mcount归零。 - 将
mlist
倒序插入到mlists
中
属性
和协议
也是相同的操作方式,只是读取的内容
和存入的容器
不同而已。
- 至此,已遍历分类,将分类的
函数、属性、协议
都分别存储到mlists
、proplists
、protolists
中了。
接下来,是将他们赋值给rwe
对应属性
:
4.3 prepareMethodLists
函数排序
函数
在插入前,都会预先
进行一轮排序
,进入prepareMethodLists
:
- 进入
fixupMethodList
:
- 执行完
prepareMethodLists
函数后,我们p mlists
打印容器,p $7[63]
取出刚才存放在最后的mlist
,p $8->get(index)
打印数据:
发现排序后的顺序为: [ func1, func3 , func2 ]
,确实不是根据sel字符串
进行的排序。
- 我们使用
p/x $8->get(0)
,打印SEL地址:
-
0x0000000100003e12
<0x0000000100003e18
<0x0000000100003e1e
,发现我们SEL地址
确实是从小到大排列的。
所以验证了:
函数的排序:不是
根据SEL字符串
排序,也不是
通过imp
进行排序,而是
通过SEL地址
进行排序
- 排序后,我们通过
attachLists
完成数据的绑定
4.4 attachLists 绑定数据
- 进入
attachLists
:
拓展函数:
memcpy(开始位置,放置内容,占用大小)
:内存拷贝
memmove(开始位置,移动内容,占用大小)
:内存平移
LRU算法:
Least Recently Used
的缩写,最近最少使用
算法,越容易被调用(访问)的放前面
。回想一下,不管我们是
动态插入
函数,还是添加分类
,一定是有需求时才这么操作。而新加入的数据,明显访问频率
会高于
默认模板内容
。所以我们addedLists
使用LRU
算法,将旧数据
放在最后面
,新数据
永远插入最前面
。 这样可以提高查询效率
,减少运行时资源的占用
。
这里有3种情况:
- 0->1: 首次加入,直接将addedLists[0]
赋值给list
,是一维数组
。
(首次加载是本类
数据在extAllocIfNeeded
时,从macho
中读取ro
中的对应数据
加入)
- 1->多: 此时扩容为二维数组
,旧数据
插入后面
,新数据
插入前面
:
将数组扩容
到newCount
大小
-> array()
的count
记录个数
-> 如果有旧数据
,插入
到lists
容器尾部
-> 调用memcpy内存拷贝
,从array()首地址
开始,将addedLists
插入,占用addedCount
个元素大小。
- 多 -> 更多: 类似于1->多
的操作,也是旧数据
移到后面
,新数据
插入前面
将数组扩容
到newCount
大小
-> array()
的count
记录个数
-> 调用memmove内存评议
,从array()首地址偏移addedCount个元素位置
开始,移动array()旧数据
,占用oldCount
个元素大小
-> 调用memcpy内存拷贝
,从array()首地址
开始,将新数据addedLists
插入,占用addedCount
个元素大小。
所以这里rwe
的函数、属性、协议
都是attachLists
进行处理后完成的赋值。
5. attachCategories的调用
此时,我们通过一条线,完整熟悉了attachCategories
将分类数据
添加到rwe
中的整个流程和细节。
- 我们可以反过来搜索
attachCategories
被哪些地方调用:
我们发现,除了我们已分析的attachToClass
函数,就只有load_categories_nolock
函数调用了attachCategories
。
- 进入
load_categories_nolock
,加入测试代码:
const char *mangledName = cls->mangledName();
const char * HTPersonName = "HTPerson";
if (strcmp(HTPersonName, mangledName) == 0 ) {
auto ht_ro = (const class_ro_t *)cls->data();
auto ht_isMeta = ht_ro->flags & RO_META;
if (!ht_isMeta) {
printf("%s - 精准定位: %s\n", __func__, mangledName);
}
}
- 再检查
load_categories_nolock
在哪里被调用:
第一处被调用:loadAllCategories
继续搜索loadAllCategories
,发现在load_images
被调用:
第二处被调用:_read_images
的第8步 分类的加载
。
- 而
_read_images
的加载,是从map_images
过来的。
总结:
分类的加载
,总得来说有2个
大的调用路径
:
map_images
->map_images_nolock
->_read_images
有2个可能路径:
路径一:第8步 分类的处理
->load_categories_nolock
->attachCategories
路径二:第9步 实现非懒加载类
->realizeClassWithoutSwift
->methodizeClass
->attachToClass
->attachCategories
load_images
->loadAllCategories
->load_categories_nolock
->attachCategories
至此,文初的2个问题,rwe
何时加载?分类
如何加载? 相信大家都十分清楚了
本节,我们已经熟悉了分类
的加载方式
。
- 但是我们一切研究都是在
本类
和分类
都实现+Load
方法的前提,那其他组合的情况是怎样呢? -
attachCategories
这些调用路径在什么情况下进入哪条路径呢?
下一节OC底层原理十九:类的加载(下) 本类与分类load区别 & 关联属性,我们将所有情况都一一分析。