上一节,我们了解了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:
image.png
- 遍历从
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:-
分类的
实例方法和类方法:
image.png -
分类的
属性:
image.png -
分类的
结构:
image.png
- 我们搜索
struct _category_t,可看到分类的完整格式:
image.png发现
编译期的HTPerson(CatA):name是HTPerson,cls也是HTPerosn类
- 分类的
实现:
image.png
本类属性和分类属性的区别:
本类属性:在
clang编译环节,会自动生成并实现对应的set和get方法分类属性:会存在set、get方法,但是
没有实现(需要runtime设置关联属性)。易混淆点: 分类属性存在
set、get方法,但没有实现。
检验方式: 使用person对象可以快捷访问到catA_age,并可以赋值。但是程序运行时会crash。 这是因为方法存在,但找不到对应的imp实现。
image.png
Q: 1.分类属性为何存在set、get方法? 2.如何让它不crash(关联属性的动态实现)第1个问题在本节后续探索中,会得到很清晰的答案。 第2个问题,我们下一节专门讲解
关联属性。
- 2.2 官方帮助文档
打开官方文档 (快捷键:shift + command + 0),搜索Categor:

-
切换语言为
Objective-C:
image.png 发现类型是
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的读取:
image.png
- 单步往下运行,发现最终会到达
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处调用了它:
image.png 发现都是
动态添加(函数、属性、协议、分类等)时,才会创建rwe。
还记得上面ro的读取吗?
- 当
rwe存在时:表示这个类有数据被修改了,所以需要从rwe返回数据。 - 而如果
rwe不存在,表明这个类的数据没有被动态修改过,所以可以直接从macho中拷贝一份ro返回即可。
附上
WWDC2020视频Advancements in the Objective-C runtime,回顾官方对于rwe的解释,会理解得更深刻。
4.2 数据读取和prepareMethodLists函数排序
初始化rwe后,我们读取分类数据:

- 查看
entry.cat结构:

- 查看
category_t结构,发现存储了分类所有数据。
image.png
所以分类的数据都是从entry.cat进行读取。
- 我们在上面
定位测试代码的打印处加上断点,运行代码,到达断点后,往下进入循环内:
image.png- 发现此时name已从编译时的
HTPerson变成了CatA,而我们的cls仍旧是HTPerson:
(类地址在内存中是唯一的,地址相同表示是一个类)
image.png
- 下面以
函数的读取为例,(属性、协议的读取和赋值方式一样):
image.png
将分类的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区别 & 关联属性,我们将所有情况都一一分析。













