load方法的调用时机

我们都知道,每个类都有两个初始化方法,其中一个就是load方法,对于每一个ClassCategory来说,必定会调用此方法,而且仅调用一次。当包含ClassCategory的程序被库载入系统时,就会执行此方法,并且此过程通常是在程序启动的时候执行。

不同的是,现在iOS系统中已经加入了动态加载特性,这是从macOS应用程序中迁移而来的特性,等应用程序启动好之后再去加载程序库。如果Class和其Category中都重写了load方法,则先调用Class中的。那么为什么会先调用Classload方法呢?通过这篇文章想必你会有个答案。

因为Objective-Cruntime只能在macOS下才能编译,所以,文章中的所有代码都是在macOS下运行了,这里推荐大家直接使用RetVal封装好的debug版最新源码进行断点调试,来追踪一下load方法的全部处理过程,以便于了解这个函数以及Objective-C强大的动态性。

创建一个Class文件GGObject和两个分类GGObject+GGNSString+GG,然后分别在这三个文件中添加load方法。运行程序,会看到load方法的调用时机是在入口函数主程序之前。



然后在GGObjectload方法下增加断点,查看其调用栈并跟踪函数执行时候的上层代码:



调用栈显示栈情况如下:

0 +[GGObject load]
1 call_class_loads()
2 call_load_methods()
3 load_images(const char*, const mach_header *)
4 dyld::notifySingle(dyld_image_states, ImageLoader const*, ImageLoader::InitializerTimingList*)
11 _dyld_start

追其源头,从_dyld_start开始研究。dyld(The Dynamic Link Editor)是苹果的动态链接库,系统内核做好程序启动的初始准备后,将其他事务交给dyld负责。这里不再细究。

在研究load_images方法之前,先来研究一下什么是imagesimages表示的是二进制文件编译后的符号、代码等。所以load_images的工作是传入处理过后的二进制文件并让runtime进行处理,并且每一个文件对应一个抽象实例来负责加载,这里的实例是ImageLoader,从调用栈的方法4可以清楚的看到参数类型:

dyld::notifySingle(dyld_image_states, ImageLoader const*, ImageLoader::InitializerTimingList*)

ImageLoader处理二进制文件的时机是在main入口函数以前,它在加载文件时主要做两个工作:

  • 在程序运行时它先将动态链接的image递归加载
  • 再从可执行文件image递归加载所有符号

我们可以通过断点来打印出所有加载的image。在刚才断点的调用栈中,选中3 load_images(const char*, const mach_header *),并添加断点:



这样可以将当前的image全部显示,我们列出来imagepathslice信息:

(const char *) $0 = 0x000000010004d0b8 "/Users/guiyongdong/Library/Developer/Xcode/DerivedData/objc-gursabanmdkytddcknzhdonlrrvk/Build/Products/Debug/libobjc.A.dylib"
(const mach_header *) $1 = 0x00000001000ad000
(const char *) $2 = 0x00007fffd60caec8 "/System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation"
(const mach_header *) $3 = 0x00007fffd60ca000
(const char *) $4 = 0x00007fffead2d9d0 "/usr/lib/libnetwork.dylib"
(const mach_header *) $5 = 0x00007fffead2d000
(const char *) $6 = 0x00007fffd52bbc50 "/System/Library/Frameworks/CFNetwork.framework/Versions/A/CFNetwork"
(const mach_header *) $7 = 0x00007fffd52bb000
(const char *) $8 = 0x00007fffda1a5610 "/System/Library/Frameworks/NetFS.framework/Versions/A/NetFS"
(const mach_header *) $9 = 0x00007fffda1a5000
(const char *) $10 = 0x00007fffe4ef0a20 "/System/Library/PrivateFrameworks/LanguageModeling.framework/Versions/A/LanguageModeling"
(const mach_header *) $11 = 0x00007fffe4ef0000
(const char *) $12 = 0x00007fffd5d42b10 "/System/Library/Frameworks/CoreData.framework/Versions/A/CoreData"
(const mach_header *) $13 = 0x00007fffd5d42000
(const char *) $14 = 0x00007fffeaa53ac0 "/usr/lib/libmecabra.dylib"
(const mach_header *) $15 = 0x00007fffeaa53000
...

这里会传入很多的动态链接库.dylib以及官方静态框架.framework的image,而path就是其对应的二进制文件的地址。在<mach-o/dyld.h>动态库头文件中,也为我们提供了查询所有动态库image的方法,如下:

#import <Foundation/Foundation.h>
#import <mach-o/dyld.h>
#import <stdio.h>
void listImages() {
uint32_t i;
uint32_t ic = _dyld_image_count();
printf("image 的个数 %d \n",ic);
for (i = 0; i < ic; i++) {
printf("%d: %p\t%s\t(slide: %ld)\n",
i,
_dyld_get_image_header(i),
_dyld_get_image_name(i),
_dyld_get_image_vmaddr_slide(i));
}
}
int main(int argc, const char * argv[]) {
listImages();
@autoreleasepool {
NSLog(@"Application start");
}
return 0;
}

我们可以通过系统库提供的接口方法,来深入学习官方的动态库情况:



load_images

此时,系统已经将所有的image加载进内存,然后交由load_images函数来解析。我们来分析一下load_images函数:

extern bool hasLoadMethods(const headerType *mhdr);
extern void prepare_load_methods(const headerType *mhdr);
void
load_images(const char *path __unused, const struct mach_header *mh)
{
// 先快速的查找image中是否有Class或者Category需要加载 如果没有 直接返回
if (!hasLoadMethods((const headerType *)mh)) return;
// 定义可递归锁对象
// 由于 load_images 方法由dyld进行回调,所以数据需要上锁才能保证线程安全
// 为了防止多次加锁造成的死锁情况,使用递归锁解决
recursive_mutex_locker_t lock(loadMethodLock);
// 收集所有的 load 方法
{
// 对 Darwin 提供的线程写锁的封装类
rwlock_writer_t lock2(runtimeLock);
// 提前准备好满足 load 方法调用条件的 Class
prepare_load_methods((const headerType *)mh);
}
// 调用 所有的load 方法 (without runtimeLock - re-entrant)
call_load_methods();
}

接下来我们一步一步分析。首先调用的是hasLoadMethods函数。其中为了查询load函数列表,会分别查询该函数在内存数据段上指定section区域是否有所记录。

// 快速查询image中是否有类列表或者分类类别
bool hasLoadMethods(const headerType *mhdr)
{
size_t count;
//查询image中是否有类
if (_getObjc2NonlazyClassList(mhdr, &count) && count > 0) return true;
//查询iamge中是否有Category
if (_getObjc2NonlazyCategoryList(mhdr, &count) && count > 0) return true;
return false;
}

objc-file.mm文件中存在以下定义:

// 通过宏处理泛型操作
// 函数内容是从内存数据段的某个区下查询改位置的情况,并回传指针
#define GETSECT(name, type, sectname) \
type *name(const headerType *mhdr, size_t *outCount) { \
return getDataSection<type>(mhdr, sectname, nil, outCount); \
} \
type *name(const header_info *hi, size_t *outCount) { \
return getDataSection<type>(hi->mhdr(), sectname, nil, outCount); \
}
// 根据dyld 对images的解析来特定区域查询内存
GETSECT(_getObjc2NonlazyClassList, classref_t, "__objc_nlclslist");
GETSECT(_getObjc2NonlazyCategoryList, category_t *, "__objc_nlcatlist");

Apple的官方文档中,我们可以在__DATA段中查询到__objc_classlist的用途,主要是用在访问Objective-C的类列表,而__objc_nlcatlist用于访问Objective-C的分类列表。这一块对类信息的解析是由dyld处理时期完成的,也就是我们上文提到的map_images方法的解析工作。而且从侧面可以看出,Objective-C的强大动态性,与dyld前期处理密不可分。

通过这一步,会将image中的类列表和分类列表的个数快速的查询出来,只要满足其中一个条件就能继续进行,否则image中连类列表和分类列表都没有,就一定不会有load方法。

可递归锁

接下来需要定义锁,然后加锁。在load_image方法所在的objc-runtime-new.mm中,全局loadMethodLock是一个recursive_mutex_t类型的变量。这个是苹果通过C实现的一个互斥递归锁Class,来解决多次上锁而不会发生死锁的问题。之所以用递归锁,是因为接下来会递归类的父类直到NSObject

recursive_mutex_t其作用与NSRecursiveLock相同,但不是由NSLock再封装,而是通过Cruntime的使用场景而写的一个Class。更多关于线程锁的知识,可以看看我这篇iOS多线程之各种锁的简单介绍

准备 load 运行的从属Class

void prepare_load_methods(const headerType *mhdr)
{
size_t count, i;
runtimeLock.assertWriting();
//收集有load方法的Class
//获取所有的类的列表
classref_t *classlist =
_getObjc2NonlazyClassList(mhdr, &count);
for (i = 0; i < count; i++) {
// 通过remapClass 获取类指针
// schedule_class_load 递归到父类逐层载入
schedule_class_load(remapClass(classlist[i]));
}
// 收集有load方法的Category
// 获取所有的Category列表
category_t **categorylist = _getObjc2NonlazyCategoryList(mhdr, &count);
for (i = 0; i < count; i++) {
category_t *cat = categorylist[i];
// 通过remapClass 获取Category对象存有的Class对象
Class cls = remapClass(cat->cls);
if (!cls) continue;
// 对类进行第一次初始化,主要用来分配可读写数据空间并返回真正的类结构
realizeClass(cls);
assert(cls->ISA()->isRealized());
// 将需要执行load的Category添加到一个全局列表中
add_category_to_loadable_list(cat);
}
}

prepare_load_methods作用是为load方法做准备,从代码中可以看出Classload方法是优先于Category。其中在收集Classload方法中,因为需要对Class关系树的根节点逐层遍历运行,在schedule_class_load方法中使用深层递归的方式递归到根节点,优先进行收集。

// 用来递归检查Class是否有load方法,包括父类
static void schedule_class_load(Class cls)
{
if (!cls) return;
// 查看 RW_REALIZED 是否被标记
assert(cls->isRealized());
// 查看 RW_LOADED 是否被标记
if (cls->data()->flags & RW_LOADED) return;
// 如果有父类 递归到深层运行
schedule_class_load(cls->superclass);
// 将有load方法的Class添加到一个全局列表中
add_class_to_loadable_list(cls);
// 标记 RW_LOADED 符号
cls->setInfo(RW_LOADED);
}

schedule_class_load中,Class的读取方式是cls指针方式,其中有很多内存符号位用来记录状态。isRealized()查看的就是RW_REALIZED位,改位记录的是当前Class是否初始化一个类的指标。而之后查看的RW_LOADED是记录当前类的load方法是否已经被检测。

// 检测Class是否有load函数 并将其添加到全局静态数组中
void add_class_to_loadable_list(Class cls)
{
//标记方法
IMP method;
loadMethodLock.assertLocked();
//获取类的load方法的IMP
method = cls->getLoadMethod();
//如果没有load方法 返回
if (!method) return;
if (PrintLoading) {
_objc_inform("LOAD: class '%s' scheduled for +load",
cls->nameForLogging());
}
//判断数组是否已满
if (loadable_classes_used == loadable_classes_allocated) {
// 动态扩容 为线性表释放空间
loadable_classes_allocated = loadable_classes_allocated*2 + 16;
loadable_classes = (struct loadable_class *)
realloc(loadable_classes,
loadable_classes_allocated *
sizeof(struct loadable_class));
}
// 将cls method 存储到loadable_classes 指针中
loadable_classes[loadable_classes_used].cls = cls;
loadable_classes[loadable_classes_used].method = method;
// 索引++
loadable_classes_used++;
}

在存储静态表的方法中,方法对象会以指针的方式作为传递参数,然后用名为loadable_classes的静态类型数组对即将运行的load方法进行存储,以及方法所属的Class。其下标索引loadable_classes_used为(从0开始)的全局量,并在每次录入方法后自加操作实现索引的偏移。

赛选过Class以后,接下来会继续赛选Category。通过_getObjc2NonlazyCategoryList获取到image中所有的Category后,遍历执行add_category_to_loadable_list方法,将有load方法的Category添加到全局loadable_categories静态类型的数组中。add_category_to_loadable_list方法的实现原理与add_class_to_loadable_list几乎一样。这里不再细说。

由此可以看出,在prepare_load_methods方法中,runtimeClassCategory进行了筛选工作,并且将即将执行的load方法以指针的形式组织成一个线性表结构,为之后执行操作打下基础。

通过函数指针让load方法跑起来

通过加载镜像(image)、缓存类和分类列表后,开始执行call_load_methods方法。

void call_load_methods(void)
{
//是否已经录入
static bool loading = NO;
//是否有关联的Category
bool more_categories;
loadMethodLock.assertLocked();
// 由于loading是全局静态布尔值,如果已经录入方法则直接退出
if (loading) return;
//修改全局标记 开始录入
loading = YES;
//声明一个autoreleasePool 对象
// 使用push操作其目的是为了创建一个新的 autoreleasePool 对象
void *pool = objc_autoreleasePoolPush();
do {
// 检查全局 load 方法数组的长度 并调用load 方法 知道调用完毕
while (loadable_classes_used > 0) {
call_class_loads();
}
// 调用 Category 中的load 方法
more_categories = call_category_loads();
// 只要 Class 或者 Category 其中一个有load 都会继续调用
} while (loadable_classes_used > 0 || more_categories);
// 将创建的 autoreleasePool 对象释放掉
objc_autoreleasePoolPop(pool);
// 修改全局标记 录入完毕
loading = NO;
}

其实call_load_methods由以上代码可知,仅是运行load方法的入口,其中最重要的方法call_class_loadscall_category_loads会分别从loadable_classesloadable_categories列表中找出对应的ClassCategory,并分别使用selector(load)的实现并加载。

static void call_class_loads(void)
{
//声明下标
int i;
// 分离加载的 Class列表
struct loadable_class *classes = loadable_classes;
// 调用标记
int used = loadable_classes_used;
//重置之前的列表 标记
loadable_classes = nil;
loadable_classes_allocated = 0;
loadable_classes_used = 0;
// 调用列表中的Class 类的load方法
for (i = 0; i < used; i++) {
//获取 Class指针
Class cls = classes[i].cls;
// 获取load 方法
load_method_t load_method = (load_method_t)classes[i].method;
if (!cls) continue;
if (PrintLoading) {
_objc_inform("LOAD: +[%s load]\n", cls->nameForLogging());
}
//方法调用
(*load_method)(cls, SEL_load);
}
// 释放classes列表
if (classes) free(classes);
}

(*load_method)(cls, SEL_load)通过这一句就可以调用load方法。这是一个函数指针。其中load_method_t的定义如下:

typedef void(*load_method_t)(id, SEL);

可以看到,我们将ClassSEL传递过去,至此完成load方法的动态调用。call_category_loadscall_class_loads的调用机制类似,只是后续会继续做很多内存操作,有兴趣的可以看看。

至此完成了load方法的动态调用。

总结

你过去可能会听说,对于load方法的调用顺序有两条规则:

  1. 父类先于子类调用
  2. 类先于分类调用

通过我们的整体分析,你会发现这种现象是很有原因的。在schedule_class_load递归方法中,会保证父类先于子类加入到loadable_classes数组红,从而确保类的调用顺序的正确性。

而在call_load_methods方法中:

do {
while (loadable_classes_used > 0) {
call_class_loads();
}
more_categories = call_category_loads();
} while (loadable_classes_used > 0 || more_categories);

会一次性将所有类的load方法调用完毕,之后才会调用分类的load放法。至此,整个load调用流程图如下:



load可以说是我们日常开发中接触到调用时间最靠前的方法,这就成为了我们玩黑魔法的绝佳时机。

但是由于load方法的运行时间过早,所以这里可能不是一个理想的环境,因为某些类可能需要在在其它类之前加载,但是这是我们无法保证的。不过在这个时间点,所有的framework都已经加载到了运行时中,所以调用framework中的方法都是安全的。

扩展initialize

说到load方法就不得不提initialize方法,我们都知道load会在程序启动的时候加载,而initialize方法会在类或者类的子类收到第一条消息之前被调用。现在,我们已经非常清楚load方法的调用原理,至于initialize呢?我们现在继续分析。

紧接着我们刚才的例子,新建类GGSuperObject,并实现initialize方法,让GGObject继承GGSuperObject,接着实现GGObjectGGObject (GG)initialize方法,在main中,我们创建GGObject的实例,运行程序如下:



运行结果很符合我们的预期,父类会优先调用,分类会覆盖本类的initialize,下面我们通过代码来看具体的实现原理。当我们向某个类发送消息时,runtime会调用lookUpImpOrForward这个函数。

IMP lookUpImpOrForward(Class cls, SEL sel, id inst,
bool initialize, bool cache, bool resolver)
{
IMP imp = nil;
bool triedResolver = NO;
...
// 类没有初始化 对类进行初始化
if (initialize && !cls->isInitialized()) {
runtimeLock.unlockRead();
//初始化
_class_initialize (_class_getNonMetaClass(cls, inst));
runtimeLock.read();
// If sel == initialize, _class_initialize will send +initialize and
// then the messenger will send +initialize again after this
// procedure finishes. Of course, if this is not being called
// from the messenger then it won't happen. 2778172
}
...
return imp;
}

从中可以看到当类没有初始化时,会调用_class_initialize对类进行初始化,_class_getNonMetaClass这里主要是对类进行一些转换,我们这里不用过多考虑。

void _class_initialize(Class cls)
{
assert(!cls->isMetaClass());
Class supercls;
bool reallyInitialize = NO;
// 先找到父类
supercls = cls->superclass;
// 如果父类没有初始化 对父类进行初始化
// 我们发现 又有递归调用 从这里我们可以发现,父类的initialize比子类先调用
if (supercls && !supercls->isInitialized()) {
_class_initialize(supercls);
}
...
if (reallyInitialize) {
...
@try {
// 发送调用类的initialize的消息
callInitialize(cls);
...
}
...
}
...
}

在这里,显示对入参的父类进行递归调用,以确保父类优先于子类初始化,还有一个关键的地方,我们来看callInitialize发送消息的具体实现:

void callInitialize(Class cls)
{
((void(*)(Class, SEL))objc_msgSend)(cls, SEL_initialize);
asm("");
}

有没有很熟悉,runtime使用了发送消息objc_msgSend的方式对initialize方法进行调用,这样,initialize方法的调用就是与普通方法的调用是一致的,都是走的发送消息的流程,那么我们再回到lookUpImpOrForward方法中:

IMP lookUpImpOrForward(Class cls, SEL sel, id inst,
bool initialize, bool cache, bool resolver)
{
IMP imp = nil;
bool triedResolver = NO;
runtimeLock.assertUnlocked();
// 这里会先从缓存中查找 imp
if (cache) {
imp = cache_getImp(cls, sel);
if (imp) return imp;
}
runtimeLock.read();
// 注册类
if (!cls->isRealized()) {
runtimeLock.write();
realizeClass(cls);
runtimeLock.unlockWrite();
runtimeLock.read();
}
// 初始化类
if (initialize && !cls->isInitialized()) {
runtimeLock.unlockRead();
_class_initialize (_class_getNonMetaClass(cls, inst));
runtimeLock.read();
}
retry:
runtimeLock.assertReading();
// 先从缓存中查找 imp (本例中的imp 就是initialize)
imp = cache_getImp(cls, sel);
if (imp) goto done;
// 缓存中没有 去方法列表中找 imp 的实现
{
Method meth = getMethodNoSuper_nolock(cls, sel);
if (meth) {
log_and_fill_cache(cls, meth->imp, sel, inst, cls);
//找到了就调用
imp = meth->imp;
goto done;
}
}
// 去父类的缓存列表和方法列表中找imp 的实现
{
unsigned attempts = unreasonableClassCount();
//循环遍历父类
for (Class curClass = cls;
curClass != nil;
curClass = curClass->superclass)
{
if (--attempts == 0) {
_objc_fatal("Memory corruption in class list.");
}
// 去缓存中寻找
imp = cache_getImp(curClass, sel);
if (imp) {
if (imp != (IMP)_objc_msgForward_impcache) {
log_and_fill_cache(cls, imp, sel, inst, curClass);
goto done;
}
else {
break;
}
}
//去方法列表中找
Method meth = getMethodNoSuper_nolock(curClass, sel);
if (meth) {
log_and_fill_cache(cls, meth->imp, sel, inst, curClass);
imp = meth->imp;
goto done;
}
}
}
// 无论是类 或者父类 都没有找到 接下来走消息转发机制
if (resolver && !triedResolver) {
runtimeLock.unlockRead();
_class_resolveMethod(cls, sel, inst);
runtimeLock.read();
triedResolver = YES;
goto retry;
}
imp = (IMP)_objc_msgForward_impcache;
cache_fill(cls, sel, imp, inst);
done:
runtimeLock.unlockRead();
return imp;
}

至此,整个调用流程我们已经很清晰了,其实initialize走的就是完整的一个消息发送流程。

当我们第一次调用某个类的方法时,首先会递归遍历此类的父类,给父类发送initialize消息。接着又回调消息发送机制上,先查类的缓存,之后查类的方法列表,然后沿着继承链查父类的缓存,之后查父类的方法,如果都没有查到IMP,则走消息转发流程。至此,我们也明白为何子类会覆盖父类的方法,其实都是runtime的作用。

可能你还有个疑惑,为什么分类的initialize方法会覆盖本来的initialize方法呢?通过下面一段代码你会发现端倪:

Method* methodList_f = class_copyMethodList(object_getClass([GGObject class]),&count_f);
for(int i=0;i<count_f;i++) {
Method temp_f = methodList_f[i];
//方法名字符串
const char* name_s =sel_getName(method_getName(temp_f));
NSLog(@"方法名:%@",[NSString stringWithUTF8String:name_s]);
}
free(methodList_f);

你会发现打印了两个initialize,其实这是因为类先于分类加载,在加载分类的时候,会将分类的方法放在类的方法的前面,所以类的方法列表中有两个initialize方法,并不是分类中的方法覆盖了本类中的方法,只是runtime在遍历方法列表的时候,只要找到一个就会返回,runtime不知道后面还有一个initialize方法。想必你现在知道类和分类的调用关系了吧。



好了,至此load方法和initialize方法咱们已经说完。

参考资料

你真的了解 load 方法么?