重学iOS系列之APP启动(三)objc(runtime)

导读

上一节我们了解了dyld在APP冷启动中扮演的角色,并且引申出_objc_init()方法的调用,_objc_init()内部调用了_dyld_objc_notify_register(),将map_images()、load_images()、unmap_image()这3个函数地址注册到dyld中。本文将通过源码详细的分析这三个函数的内部实现,源码版本为objc4-818.2。源码解读并非跟着文章看一遍就能记住学会,这个过程需要反复的跟读,所以建议读者将源码下载下来,跟着笔者的进度同时对照着源码学习效果才会最佳,也不至于看得云里雾里。

源码下载地址:https://opensource.apple.com/tarballs/objc4/

简单回顾一下dyld2的加载过程:

第一步:设置运行环境。

第二步:加载共享缓存。

第三步:实例化主程序。

第四步:加载插入的动态库。

第五步:链接主程序。

第六步:链接插入的动态库。

第七步:执行弱符号绑定

第八步:执行初始化方法。_objc_init方法在这里被调用

第九步:查找入口点并返回。


objc-os.mm文件里,我们找到_objc_init函数的具体实现如下

在_objc_init()函数内,先进行了一个是否已经初始化的判断,防止重复调用。

接着调用了environ_init()

我们可以看到,这里主要是读取影响Runtime的一些环境变量,如果需要,还可以打印环境变量帮助提示。

我们可以在终端上测试一下,直接输入export OBJC_HELP=1可以看到不同的环境变量对应的内容都被打印出来了。

tls_init()

这里执行的是关于线程 key 的绑定,比如每个线程数据的析构函数.

static_init()

执行c++静态构造函数,在 dyld 调用我们的静态构造函数之前,libc 会调用 _objc_init,所以这里我们不得不自己来做初始化,并且这里只会初始化系统内置的 C++ 静态构造函数,我们自己代码里面写的并不会在这里初始化。

runtime_init()

这里初始化了2个散列表,一个用于存储未添加到主类的categories,一个用于存储已经分配内存的类。

这两个对象的类型继承关系如下:

UnattachedCategories --> ExplicitInitDenseMap  -->  ExplicitInit

allocatedClasses = ExplicitInitDenseSet --> ExplicitInit

exception_init()

全局搜索发现有2个具体的实现,如下:


一个里面什么都没做,另外一个说明是初始化objc库的异常处理系统,而且是被map_images()。我们可以大胆猜想,在_objc_init()调用的是nothing to do 的这个,然后在map_images()内部才会调用真正的初始化。

异常处理这个模块其实是工程开发中的重中之重,后续会单独开一篇来讲解怎么处理crash异常,以及怎么防止crash。

cache_t::init()

初始化一些缓存相关的变量,和内核有关,不做过多分析。

_imp_implementationWithBlock_init()

启动回调机制。通常这不会做什么,因为所有的初始化都是惰性的,但是对于某些进程,我们会迫不及待的加载trampolines dylib,涉及其他的动态库,暂不做分析。

_dyld_objc_notify_register(&map_images, load_images, unmap_image)

这个我们在上一章节已经详细讲解过了,由dyld加载动态库libSystem,然后libSystem初始化方法里调用了libdispatch_init(),libdispatch_init调用了os_objc_init(),然后再调用_dyld_objc_notify_register向dyld注册map_images,load_images和unmap_image这3个函数指针,之后会在dyld初始化完所有的动态库之后,进行主程序的初始化,这个时候,dyld会调用注册的map_images,然后调用load_images。


我们先从简单的开始分析,这3个函数最简单的是unmap_image();

内部加了2个锁,然后调用unmap_image_nolock()

一些准备工作,然后调用_unload_image(),随后就 removeHeader(hi) 以及 free(hi)。

unattachedCategories,是不是很熟悉?这不就是runtime_init()函数里面初始化的其中一个?

将类的所有category获取到cat数组中,然后从loadable_list中移除cat数组中所有的category。

接着进行类的unload操作。从_getObjc2ClassList和nlclslist获取到class的list,添加到classes数组中,然后遍历classes数组,对每个class调用detach_class() 和 free_class() 。

到此,unmap_image()函数 执行完毕。


map_images()

map_images内部实现很简单,直接return map_images_nolock()。我们看看map_images_nolock的内部实现:

首先定义了一些变量,接着判断是否是第一次(即冷启动),第一次就执行preopt_init(),该函数内部会读取OBJC_DISABLE_PREOPTIMIZATION环境变量来判断是否需要预先优化。

while循环读取mach head 信息:

1、addHeader函数内部会判断是否有预先优化,如果有则从共享缓存中读取head_info,否则alloc一个。然后将totalClasses+=1,最后将header_info添加到一个header_info类型的链表的末尾。

2、判断如果dyld3优化了主可执行文件,那么动态映射中不需要任何SELREF,因此将selrefCount初始化为0,并且映射到内存中。

3、将hi添加到hList中,hList用于后续的遍历打印等操作。


1、判断是否是第一次,如果是第一次则调用sel_init()初始化一个ExplicitInitDenseSet类型的namedSelectors全局变量,namedSelectors是用于存储SEL的。

细心的同学会发现,这个类型在分析unmap_load里有提到,继承自ExplicitInit。

2、调用arr_init()函数,内部初始化了3个在runtime整个生命周期都非常重要的全局变量。

AutoreleasePoolPage

AutoreleasePoolPage 从命名就能猜出和OC的AutoreleasePool有关,这是一个继承自AutoreleasePoolPageData的类。

那么AutoreleasePoolPageData又是个什么类型,里面有什么变量呢?

细心的同学肯定已经注意到 parent 和 child这2个成员变量,他们都是AutoreleasePoolPage类型的。还有一个id类型的next。

咦,这是不是跟链表的结构差不多?没错,这就是一个链表结构,我们在写OC代码手动创建@AutoreleasePool{}的时候,编译器llvm的前端clang会将我们写的代码转换成,在进入{}之前创建一个AutoreleasePoolPage对象,然后在{}内部所有的必须的OC局部变量都会将该变量push到AutoreleasePoolPage对象链表中,在最后{}大括号结束的时候遍历链表进行pop操作。

编译器会将OC代码转换成_objc_autoreleasePoolPush这样的代码。如上图所示,最终都是调用AutoreleasePoolPage对象内部的pop,push函数进行操作。

为了让大家能更直观的感受到转换后的源码是什么样子的,笔者在objc工程里的一个函数内找到苹果工程师写的调用,我们的OC代码在转换完成后和下图类似:

当然还有个更直观的方法,在终端cd 进之前创建的demo工程main.m所在文件夹,然后使用如下命令:xcrun  -sdk  iphoneos  clang  -arch  arm64  -rewrite-objc main.m -o main_.cpp -framework UIKit

读者可以将main.m换成任何想编译的文件,当然如果需要链接其他库的话,在后面加 -framework XXX库。

按回车键就能得到main_.cpp文件了,打开该文件(文件代码行数非常多,7w多行,我们自己敲的代码在最底部,直接拉到最底部就能看到你写的代码转换成c++是什么样子了),全局搜索@autoreleasepool,你将会看到下图所示:

我们的@autoreleasepool,被转换成了__AtAutoreleasePool 类型的__autoreleasePool对象。

全局搜索__AtAutoreleasePool,看看这个结构体长什么样。

简单得令人发指,一个构造函数(push操作),一个析构函数(pop操作),外加一个void * atautoreleasepoolobj指针,这个指针用于存储所有在{}里创建的OC对象。

关于AutoreleasePool,由于篇幅原因不再深入进行源码分析,在重学iOS系列里的后续关于内存管理方面的章节会详细讲解。

SideTablesMap

从上图可以看出,SideTablesMap其实是一个静态全局变量。作用就是为下面的SideTables()函数服务的。我们看看ExplicitInit的实现:

这是一个C++的模板类,可以看到下面有2个函数,一个init,一个get。get函数返回的是_storage数组。arr_init()函数里调用的SideTablesMap.init()。其实就是new一个_storage数组。而SideTablesMap唯一的作用就是给SideTables()这个静态全局函数返回_storage,这个数组中存储的元素都是StripedMap类型的。下图截取了StripedMap的前面小部分代码:

StripedMap也是一个模板类,根据实际传递的参数类型来确定实例变量array里存储的元素类型。SideTables()的实现可以看出这里是将SideTable作为参数传入了模板类,说明array里元素都是SideTable类型的。

从注释我们可以知道StripedMap<T>是一个以viod *为key,T为value的hash表。从宏我们可以知道iphone上的StripedMap的元素个数StripeCount等于8,其他设备上StripeCount是64。

但是它只有一个array成员变量,没有key变量,它是怎么用数组实现一个hash表的呢?

看indexForPointer(const void *p)这个函数,p其实是传入的objc指针,然后使用C++的强制类型转换运算符reinterpret_cast,将p指针转换成数字。然后对这个数字进行位、异或运算,得到的结果然后再 模上StripeCount,前面说了StripeCount = 8,其实就是模 8 ,得到的值就是数组的下标,然后直接用下标去获取SideTable。

这就是hash算法的基本模式,用一种计算方式将传入的key转换成数组的下标,然后用下标获取数组元素进行值的读取和存入。

SideTable又是什么,为什么要做这些乱七八糟的骚操作呢?

先抛开下面的函数,我们只看上面的3个实例变量。

spinlock_t slock;

RefcountMap refcnts;

weak_table_t weak_table;

一把自旋锁,一个RefcountMap类型的refcnts,一个weak_table_t类型的weak_table。

RefcountMap 是保存引用计数的hash表。

weak_table_t 是用于保存weak对象的hash表。

我们的重点是APP启动流程,关于内存管理的相关分析,会和autoreleasepool放到一起单独开一个章节讲解。

到此,我们了解到SideTablesMap.init()内部是初始化了内存管理相关的一些静态全局变量,用于runtime进行内存管理。

_objc_associations_init()

从上面2张图可以发现_objc_associations_init内部也是初始化了一个hash表,用于存储Associations,这个是关联对象。我们都知道catagory添加的成员变量只会自动生成set,get方法的声明,是不会自动生成实现的,也不会生成_var成员变量。所以我们一般都是用关联对象来进行set,get方法的具体实现的。我们插入的关联对象就是存储在AssociationsHashMap里面的。具体不在本文展开分析了。

回到map_images_nolock()函数来,继续后续源码的解读。

后续就是调用_read_images()函数,然后遍历所有的load方法,依次调用+(void)load方法。

loadImageFuncs是个静态全局变量,存储了所有的load方法。这些load方法都是在_read_images函数里被加入到loadImageFuncs里的。

我们继续分析_read_images内部都做了什么。

_read_images()

_read_images的源码也是比较长的,笔者将根据不同的功能逻辑进行分段讲解。

在具体的功能代码前有个宏定义,EACH_HEADER,后面的代码中会出现很多的for(EACH_HEADER),在这提前说明下,这就是个正常的for循环判断语句。

1、第一次进入函数做的准备工作

一些不重要的逻辑代码被折叠起来,以便截图可以完整。

1、doneOnce是函数内部的静态变量,作用很明显,就是确保这个if内部的代码只会执行一次。 

2、宏判断是否支持non-pointer isa(isa指针优化),这个是苹果为了优化内存而开发的一项内存,我们都知道对象都是有一个isa指针的,这个指针占用了8个字节,而用于存储指向其类对象的地址不需要8*8个比特位,只需要33位,剩余的位数就用于存储该对象的一些信息,不如引用计数,是否有weak对象,是否有sidetable,是否有关联对象,是否有c++析构器等。其中有一位是用于判断是否是non-pointer,值是0就不是,值是1说明是优化过的isa。

补充下哪些情况会禁用non-pointer isa:

    2.1如果是 Swift 3.0 之前的代码,就需要禁用对 isa 的内存优化。

    2.2判断 macOS 的系统版本,如果小于 10.11 则说明 app 太陈旧了,需要禁用掉 non-pointer isa。

    2.3遍历所有的 Mach-O 的头部,判断如果有 __DATA__,__objc_rawisa 段的存在,则禁用掉 non-pointer isa ,因为很多新的 app 加载老的扩展的时候会需要这样的判断操作。

3、根据环境变量OBJC_DISABLE_TAGGED_POINTERS判断是否支持TaggedPointer,这个是类似与non-pointer isa的优化内存而开发的一项内存管理技术,简单来说就是将指针指向的对象的内容直接存储在指针里,这样即节省了内存又加快了寻址速度(因为根本不需要寻址)。举个例子:NSString *s = @"123"; s是字符串@"123"对象的一个指针,按照C++的方式,s内存里存储的应该是@"123"存储在内存中的地址。TaggedPointer新技术直接将123存储在s指针的地址中。

这2块都是属于内存优化相关的内容,暂不展开详细分析(后续会单独开一章专门讲解内存管理相关知识)。


4、创建一个用于存储类的散列表gdb_objc_realized_classes,优化过的类是不会存储到这个表中的。该散列表的装载因子是0.75,也就是说在散列表里存储的元素达到散列表的容量的75%的时候会进行扩容,扩容是直接扩大2倍。

gdb_objc_realized_classes表实际上存储的是不在 dyld 共享缓存里面的命名类,无论这些类是否实现。这是一张总表,会存储所有的类。


2、修复SEL的引用

for(EACH_HEADER)进入循环,判断SEL是否有预先优化,如果有则说明已经是注册过的,直接进入下个循环。如果没有则通过_getObjc2SelectorRefs从mach-o中拿到SEL数组,然后遍历数组,通过sel_cname函数强制转换成字符串,然后调用sel_registerNameNoLock进行方法的注册。最后判断sels的SEL跟注册后的SEL是否相等,不相等就将注册后的SEL赋值给sels的SEL元素(注册的过程其实就是在修复引用)。

也就是说这一流程最主要的目的就是注册 SEL,进入sel_registerNameNoLock内部,会发现它就是return了__sel_registerName()这个函数,继续分析__sel_registerName的实现

先判断方法名是否为空,为空则返回0。

然后调用函数search_builtins进行查找SEL,内部其实是调用了一个dyld的函数_dyld_get_objc_selector,这个函数会在dyld的_objcSelectorHashTable中通过传入的name作为key去查询是否有SEL,_objcSelectorHashTable是启动闭包的一个hash表,说明这一步是在查找启动闭包缓存中的数据。如果找到了就直接返回。

如果没找到,则从namedSelectors中获取到存储数据的数组it(第一位存储的key,第二位存储的是value),namedSelectors前文有讲过的,忘记的读者可以往前翻到sel_init()分析。

如果没有匹配上则调用sel_alloc进行注册,然后将结果存储到namedSelectors中。

sel_alloc内部会做判断,如果传入的第二个参数copy是YES,则调用strdupIfMutable函数,否则直接将name返回。

strdupIfMutable内部又做了一次判断,判断_dyld_is_memory_immutable,如果memory不允许copy则直接将name返回,否则就malloc一份出来,然后将name拷贝到alloc出来的内存里,将这块内存的指针返回。

简单来说,这一步就是将所有的方法SEL注册到namedSelectors表中。

这里借用网上找到的一张图来说明下为什么要修复引用

我们的程序中会有很多不同的框架,框架中可以声明相同的函数名字。我们在分析dyld的时候是不是有很多方法都是带dyld前缀的?类似于dyld::XXX(),这是C++中的命名空间,不同的命名空间是允许声明相同的函数名字的,这也是为什么在dyld源码中搜索一些函数名字会出现好几个实现。当然一个框架可以有多个命名空间,并非是一对一的。

当每个框架都有一个class方法时,在执行该方法时,需要将方法平移到程序出口的位置进行执行,那么在Foundation框架中的class方法,则为0, 在CoreFoundation框架中的class方法则为0 + Foundation大小。因此,地址不同,方法需要进行平移调整。


3、发现类,修正未解析的 future 类,标记 bundle 类。

先通过 _getObjc2ClassList 从mach-o中获取到所有的类

接着还是遍历所有的Mach-O的header部分,然后通过mustReadClasses来判断哪些条件可以跳过读取类这一步骤

读取header是否是Bundle

读取header是否开启了预优化

遍历_getObjc2ClassList取出的所有的类

通过readClass来读取类信息

判断如果不相等并且readClass结果不为空,则需要重新为类开辟内存。

重点是readClass,这个函数里才是class的加载。

readClass()

源码比较长,笔者将会分为三段进行讲解。

首先获取了一个mangledName,然后做了个判断,判断是否有弱引用的父类,如果有,则返回nil,但是在返回之前调用了addRemappedClass(),将class加入到remap的散列表中,并且设置class的superclass也为nil。

判断mangledName是否为空,为空则跳过这段逻辑。

然后判断popFutureNamedClass(mangledName)中能否查找到这个class,如果找不到则跳出逻辑。popFutureNamedClass内部根据传入的参数作为key,查找future_named_class_map散列表中的value,如果找到了,则将结果cls移出future_named_class_map,移出后判断future_named_class_map中的元素是否为空,为空则释放future_named_class_map的内存空间,并且将其置为nil。如果能找到,说明此名称以前被分配为将来的类。将objc_类复制到future类的结构中。保留future的rw数据块。

最后也是调用了addRemappedClass()函数,将其加入到remap表中。

顺便提一句,正常情况这个if 是不会进入的。

判断是否是优化过的class并且没有进入上面的if语句(一般是不会进入),因为只有进入了if语句replacing才会有值。然后做这个断言判断ASSERT(cls == getClass(name));

进入else,判断mangledName是否有值(一些Swift泛型类可以延后地生成它们的名称),有值则将其加入到gdb_objc_realized_classes类的总表中。没有值,则对该类的类对象做2个断言。

然后调用addClassTableEntry将该类加入到allocatedClasses散列表中。

allocatedClasses之前在runtime_init()方法里已经进行过初始化了,所以这里是可以直接使用的。

allocatedClasses是使用objc_allocateClassPair分配过内存的所有类(和元类)的散列表。

addClassTableEntry内部会判断是否有元类mateClass,如果有,则一起加入到allocatedClasses表中。

总结下:readClass()内部最终就是将传入的cls对象加入到remap表以及allocatedClasses表,如果有元类,也会将元类一起加入allocatedClasses表。

4、修复重映射Classes

类的list和非懒加载类list没有被重映射 (也就是_objc_classlist)

由于消息转发,类引用和父类引用会被重映射 (也就是_objc_classrefs)

调用noClassesRemapped()判断是否有类已经被重映射了,如果有则不进if逻辑。

之后进入for循环,通过_getObjc2ClassRefs和_getObjc2SuperRefs从mach-o遍历到类引用和父类引用,然后通过remapClassRef进行类重映射。

remapClassRef内部其实就是调用了remapClass(),remapClass内部调用了remappedClasses()函数拿到remapped_class_map散列表,然后在表中查找是否有,如果没有找到,则直接返回cls,找到了则返回找到位置iterator。


5、修复旧objc_msgSend_fixup函数的调用位置

for循环遍历mach-o获取message_ref_t,然后调用fixupMessageRef修复message_ref_t结构体中sel指向函数地址的指针。

下图是fixupMessageRef的部分实现代码:

从上图可以看到修复函数指针其实就是将mach-o中读取到的alloc,retain,release等函数重新映射为objc_开头的函数地址。这是旧代码的函数指针历史遗留问题,SUPPORT_FIXUP这个宏定义的注释明确说明:fixup不会支持太久,也就是说会在后续的版本中完全修复。

6、发现protocols,修复protocols的引用

和前面class的逻辑非常像,进入for循环,拿到protocol_map散列表,然后遍历mach-o获取protolist,再遍历protolist将protocol添加到protocol_map散列表中。

进入for循环,遍历mach-o获取protolist,再遍历protolist,调用remapProtocolRef对每个protocol的引用进行重映射。

7、发现categories(不会执行)

看注释:仅在完成初始Category附加后执行此操作。对于启动时出现的Category,发现将推迟到调用_dyld_objc_notify_register完成后的第一次load_images调用。

也就是说这个if语句不会执行。我们看看didInitialAttachCategories这个值是不是false

全局搜索,发现有且只有一个地方有赋值为true,如下图

我们再看看这个赋值为true的情况是在哪个函数调用的

情况与注释所说的一致,第一次load_images调用的时候才会设置didInitialAttachCategories为true,再看后面这句代码:loadAllCategories(),加载所有的categories。

那么load_images和map_images哪个先调用呢?

笔者在这直接告诉你们答案,是先调用的map_images,将类,协议,方法都映射加载进内存,然后再调用load_images,执行+(void)load方法。不相信的同学可以自己在xcode中添加这2个函数的符号断点自行研究。

8、实现非懒加载的类

进入for循环,先从headinfo的nlclslist()拿到非懒加载的classlist,然后通过remapClass中去取class,没找到则跳过当前循环进入下个循环。找到则调用addClassTableEntry(cls),将类加入到已经分配过内存的allocatedClasses表中。最后调用realizeClassWithoutSwift()函数。

realizeClassWithoutSwift内部做了什么呢?从函数名称看得出来应该是跟初始化相关的。下图展示了realizeClassWithoutSwift内部实现最重要的一部分代码。

如果是future class ,则不需要为rw分配空间,如果是正常的类,则需要调用rwzalloc为rw分配空间。

rw是什么呢?rw是类结构里最重要的一个结构。里面保存了ro,rwe2个结构。

ro和rwe又是什么呢?简单来说,ro中存储着类最初状态的ivar,property,方法,协议。rwe存储着category里的property,方法,协议。

这部分解释起来要花很长的篇幅,我们后续另外再单开一章分析类的结构。


递归调用realizeClassWithoutSwift给父类和元类初始化rw。

上图说明如果是元类,则设置isa为非NONPOINTER_ISA,也就是说元类的isa是纯粹的isa指针,没有做任何内存优化

针对NONPOINTER_ISA做一些初始化操作。


给父类建立父子关系。如果没有父类,则将当然cls标记为根类。

最后调用methodizeClass()。这个注释很容易让人误解为methodizeClass内部是在添加categories的内容。其实这里只是将还未附加到类的category附加到类上,建立关联关系。

那么methodizeClass具体是怎么做的呢?

methodizeClass()


从cls中取出rw,ro,rew。

将ro的方法,属性,协议全部添加到rew中。

看ro取值都是base开头的 ,baseMethods 、baseProperties、baseProtocols。这明显说明这部分数据是cls中最原始的数据。

而rwe则将ro中的数据全部都添加进去,说明rwe的内存是可以改变的。

细心的读者会发现,rwe都是通过同一个函数attachLists进行数据的添加的,我们看看attachLists函数具体是怎么实现的。这里面涉及到list_array_tt类,读者最好对照着源码跟着一起分析,才能跟上节奏。

attachLists总结:根据传入的list适时的扩容,然后按顺序添加到数组中,新的会先添加,旧的会在新的添加完后再添加。

接着继续分析methodizeClass

前面的准备工作做完后,正式进入主题,未附加的unattachedCategories调用attachToClass将category附加到类上。attachToClass内部其实是调用了attachCategories()函数。

我们的重点在attachCategories

我们先看看注释:将方法列表、属性和协议从类别附加到类。假设cats中的类别都已加载并按加载顺序排序,最早的类别优先。

是不是很兴奋,终于到重点了!!!

但是,笔者很负责的告诉各位,这个函数是不会在现在调用的。

为什么呢,我们看看attachToClass内部的实现。

调用attachCategories之前有个判断,将传入的previously在map中查找,it != map.end() 说明是在map找到了。笔者在前面说不会执行,则代表这个判断是false,说明在map中是找不到的。那么previously的值是什么呢?

previously这个参数又是从哪传进来的呢?通过往前翻看源码发现其实是从realizeClassWithoutSwift传进来的。

然后经过methodizeClass(Classcls,Classpreviously)传入到attachToClass,读者可以翻到前面看看realizeClassWithoutSwift的第二个参数传的是什么,是nil !!!

所以这段代码不会执行。苹果很棒啊,我们都被苹果的开发者骗了,笔者猜测methodizeClass()上面的注释:// Attach categories  是开发者忘记删除了,因为在上个版本的objc源码中,methodizeClass是没有第二个参数的。而且load_categories_nolock调用前也没有didInitialAttachCategories==ture的判断。

到此,methodizeClass分析完了,同时realizeClassWithoutSwift也分析完了。

继续分析_read_images()最后的一段有意义的逻辑代码。

9、初始化新解析出来的 future 类

这段代码的逻辑和前面分析的懒加载类差不多,重点都是realizeClassWithoutSwift(cls, nil);这句代码,由于第二个参数也是传的nil,且在上面已经分析过realizeClassWithoutSwift的实现了。

到此_read_images()也分析完了。

总结下_read_images具体实现了什么逻辑:

1、第一次进入函数做的准备工作

2、修复SEL的引用

3、发现类,修正未解析的 future 类,标记 bundle 类

4、修复重映射Classes

5、修复旧objc_msgSend_fixup函数的调用位置

6、发现protocols,修复protocols的引用

7、发现categories

8、实现非懒加载的类

9、初始化新解析出来的 future 类



接下来我们继续分析load_images()

注释里的categody其实是category手误打错了

我们在文章前半段分析过,loadcategories是会延后到load_images()中,加载完category后判断是否有load方法,如果没有则函数结束,如果有则调用load方法。

逻辑很简单,我们的重点应该是分析loadAllCategories、prepare_load_methods、call_load_methods这3个函数的具体实现。

loadAllCategories()


loadAllCategories简单粗暴的一个for循环调用load_categories_nolock。我们看看load_categories_nolock是怎么实现的。

load_categories_nolock里面定义了一个局部的匿名函数(可以把它理解成swift的闭包),然后直接就连着调用了2次。

再看看匿名函数里面做了什么?

又是一个for循环,然后拿到类别cat、类cls。

判断是否是跟类,如果是跟类,再判断cat里是否有需要添加到元类的数据,有的话调用addForClass进行附加。addForClass其实就是将cls和cat一个作为key,一个作为value,在哈希表中做了个映射。

如果不是跟类,说明有类对象(元类)。

先判断cat中是否有对象方法、协议、属性需要添加到主类,如果主类已经实现了,调用attachCategories进行添加。否则调用addForClass做映射,为什么会出现主类没有实现的这种情况呢?因为启动的时候只会加载非懒加载类,懒加载的类是不会被加载的。

然后判断cat中是否有类方法、协议、类属性需要添加到主类的类对象(其实就是元类),如果主类的类对象已经实现了,调用attachCategories进行添加。否则调用addForClass做映射。

所有的操作都是attachCategories做的,就是这个函数,我们之前分析被注释骗的函数!!!

下面截取了attachCategories比较重要的源码片段

首先定义了一个局部变量ATTACH_BUFSIZ=64,注释解释说,只有一小部分类的category才会超过64个,也就是说这个数字是苹果官方经过深思熟虑设定的。

接着定义了3个数组,分别用于保存方法,属性,协议。

最后获取了cls的rwe(这里提一句ro是read only的缩写,也就是说ro是只读的;rw是read write的缩写,说明rw是可以读写的)。

很多读者会很迷惑,又是ro,又是rw,怎么又来个rwe。笔者在这用源码的方式将3者的关系给捋清楚。

我们先看看rw和rwe的源码:

结构体class_rw_t 就是我们所说的rw。 结构体class_rw_ext_t 是我们说的 rwe。

从上图可以看出,rwe结构体中有第一个实例变量就是 ro,也就是说 rwe包含了ro。前文笔者说过rwe是包括ro以及category里的属性协议方法的,从源码也可以看到,rew除了有ro外,还有3个数组,从命名应该可以看出就是方法、属性、协议。

而从rw源码上看,好像没有包含rew,也没有包含ro。看最后一个大红圈,下面有个函数get_ro_or_rwe(),返回的是个ro_or_rw_ext_t类型,我们看看这个类型的定义:

ro_or_rw_ext_t = objc::PointerUnion<const class_ro_t, class_rw_ext_t, PTRAUTH_STR("class_ro_t"), PTRAUTH_STR("class_rw_ext_t")>;

这是个什么呢?这是一个Union联合体(不懂的读者自行查找资料)。联合的就是传入的前个参数class_ro_t和class_rw_ext_t,也就是说 rw 结构体中有且仅有一个ro或者一个rwe。如果get_ro_or_rwe()返回的是rwe,则取ro是这样的 ro = rwe -> ro.

总结下:ro是最小的,而且不可变的只读的,里面存储着类的成员变量。

rwe是可变的,可读写的,其中包含了ro,而且还有3个数组用于存储方法、属性、协议。

rw中包含了rwe或者ro,如果该类有category则rw中存储的则是rwe,因为存储category需要对内存进行写入,不管是取ro还是取rwe都是调用同一个函数get_ro_or_rwe()。如果是取ro,rw中还有一个单独的ro()方法,里面的实现也可以说明rw中存储的要么是rwe要么是ro。

v.is(type)这句代码是进行一个类型判断,将传入的type类型和v当前的类型进行一个比较,相同则返回true,不同则返回false。

看ro()源码,if判断其实就是判断get_ro_or_rwe取出来的是不是rwe,如果是rwe,则返回rwe->ro。如果不是则直接获取当然的v,这个时候v就是ro。

搞明白了这三者的关系,我们再回过头来看这句代码:

auto rwe = cls->data()->extAllocIfNeeded();

从函数名字也能看出,判断是否需要alloc给rwe分配空间,我们一直都说rwe是可读写的,既然要能写入,肯定要分配空间。

看代码if (fastpath(v.is<class_rw_ext_t *>())) 这不就是判断v是不是class_rw_ext_t类型吗?既然v已经是rwe了,那 说明已经分配好内存了,所以直接返回rwe就好了。

如果if语句返回false,说明v是ro,但是现在要获取的是rwe,所以需要重新alloc分配内存,因为rwe中除了ro还有3个数组需要分配内存。extAlloc内部会将传入的ro赋值给新alloc的rwe的ro变量中。然后为3个数组分配内存,并且会将ro中的方法、属性、协议通过attachLists附加到rwe的3个数组列表中,attachLists在前面已经分析过了。

同时在class_setVersion,addMethods_finish,class_addProtocol,_class_addProperty,objc_duplicateClass的时候也是有调用extAllocIfNeeded的。这说明在运行时进行处理的时候,才会进行rwe的创建,也间接说明rwe是可读写的。

回到attachCategories后续的源码分析:

逻辑非常清晰,for循环遍历所有的categories,然后分别取出方法,属性,协议的list,以倒序的方式添加到前面创建的各自的局部变量list中。

怎么是倒序的呢?我们看这句代码:mlists[ATTACH_BUFSIZ - ++mcount] = mlist;

ATTACH_BUFSIZ是list总的大小,减去 (mcount+1),mcount初始值是0,那么上面那句代码就可以改写成 :

count =  ATTACH_BUFSIZ-1;

 mlists[count--] = mlist;

就相当于取数组的count,然后从count-1开始遍历,每次循环都count--。

非常典型的倒序!

if(mcount == ATTACH_BUFSIZ) {

       prepareMethodLists(cls, mlists, mcount,NO, fromBundle,__func__);

       rwe->methods.attachLists(mlists, mcount);

       mcount =0;

 }

判断是否达到容量上线,如果达到容量上线,调用prepareMethodLists(),这个函数内部调用fixupMethodList(),fixupMethodList内部对传入的mlists根据sel的地址进行排序。这个排序是为了后面查找方法的时候可以使用速度更快的二分查找。fixupMethodList实现如下图,注意看红圈的注释:

然后再通过attachLists将mlist附加到rwe里。最后将mcount重新置0。这个置0就很妙,这样如果categories数量大于64的话,就会进入新一轮的加载。

读者肯定会有疑惑,如果categories个数不满64呢?那这个if语句不就不会进入了吗,是的,如果不满64个,则将list组合完成后就跳出了for循环。我们看看后面的代码做了什么:

后面的逻辑和for循环里的逻辑何其相似,一样的prepareMethodLists,然后attachLists。这逻辑是不是觉得很烂?不读源码你怎么知道苹果官方是怎么实现的呢?你以为的高大上说不定也没那么高大上呢。所以苹果开发也是普通人,我们跟着源码多读多学,不说超越,达到他们的水平肯定是没问题的。

注意:只有方法才会调用prepareMethodLists提前排序,属性和协议是没有这个步骤的,直接调用的attachLists附加就完事了。

到此,categories的加载就完成了。

总结下,loadAllCategories 读取mach-o中的categories,跟主类建立关系,读取主类的ro,分配内存创建rwe,将ro赋值到rwe,最后遍历categories将所有的方法,属性,协议以倒序的方式添加到主类的rwe的3个list表中。其中方法列表在附加之前会进行排序,方便后续方法查询的时候使用二分查找加快速度。

category里和主类的同名方法并不是覆盖,而是由于category中的方法因为附加到list是在前面,而主类的方法是被放在后面,而方法查找是从第一个方法开始查找,直到找到第一个同名的方法,则将该方法返回,所以会优先调用category的方法而忽略主类中的方法。不同cateory中的同名方法则是看哪个category文件最后编译,最后编译的会被调用。


prepare_load_methods()

从上图我们已经知道这个函数就是将有实现load方法的类和category分别存到2个数组中。我们具体看看schedule_class_load和add_category_to_loadable_list的实现。

schedule_class_load()

先判断了cls是否为空,因为可能remapClass表中不存在这个类

接着判断该类是否有实现load方法,没有实现则直接return了

然后递归调用了自己,参数传的是自己的superclass,也就是说父类会优先比当前类加入到loadable_classes数组,由此可以判断,父类的load方法会先调用

然后调用add_class_to_loadable_list真正把自己添加到loadable_classes中。

我们看看add_class_to_loadable_list具体是怎么将cls加入到数组中的。

这里会在此判断类是否有实现load方法:取出cls的load方法保存为method,然后判断method是否有值。

接着是个打印,然后是判断loadable_classes是否已经满了,满了则扩容。扩容后的容量为按原本2倍再加16。

最后将类和方法都保存到数组中,loadable_classes_used++。

下面是loadable_classes的定义,以及loadable_classes中存储的元素是loadable_class类型的。

同时loadable_categories的定义也在同一个位置,所以一起截了,这个数组用于存储category实现了load方法的cls。

loadable_classes_used 代表数组中已经保存了多少个元素。

loadable_classes_allocated 代表数组总共申请了多少空间,就是数组的容量。

loadable_class结构体很简单,一个cls存储类,一个method存储load方法的地址。

loadable_categories和loadable_classes一样就不再一一分析。

add_category_to_loadable_list()

添加category的逻辑和添加class的逻辑一模一样,都是先判断容量,然后添加到数组。


call_load_methods()

记性好的读者肯定发现了,这个函数在之前分析autoreleasePool的时候作为例子已经跟大家照过一面了。

为了防止多次调用,用了一个局部静态变量进行判断。

然后进入do while循环,先调用了call_class_loads,再调用call_category_loads,由函数名称可以猜测,load方法主类先调用,然后才是category调用

我们进入这2个方法看看具体是怎么调用的。

拿到method(load)函数地址,简单暴力的直接调用这个函数。注意:这是直接调用函数,并不是用的objc_msgSend发送消息。直接调用速度会更快,没有中间商!

所有的load方法都调用完成后将数组释放掉,回收内存。

category的load方法调用逻辑和cls的调用逻辑类似,但是在处理资源释放的时候多了一点其他的判断。读者可以尝试着自己解读下。

到此load_images()都分析完毕。

也可以说整个APP的启动流程分析完毕了。


总结

load_images():

1、加载所有的分类。

2、找到所有的load方法,包括主类,主类的父类和分类

3、分别调用主类的load方法和分类的load方法。调用顺序为:递归调用主类 -> 递归调用分类。 


map_images():

那么在map_images 和 load_images这个阶段我们能做什么优化来降低启动时间呢?

1、减少或者避免load方法的实现,将原本在load中要执行的任务延后到第一次使用该类的时候实现。

2、减少category的数量

3、减少无用的类和方法。



补充一个知识点:懒加载和非懒加载 

为了节约内存和速度,苹果不会把所有的类都加载,而是把我们自己的类以一种懒加载的形式按需加载,当实现load之后,就是非懒加载的形式了。而如果没有实现load,就是懒加载类,当类第一次收到消息的时候,就会被加载

懒加载类情况:数据加载推迟到第一次消息的时候

lookUpImpOrForward

realizeClassMaybeSwiftMaybeRelock

realizeClassWithoutSwift

methodizeClass

非懒加载类情况:map_images的时候 加载所有类数据

_getObjc2NonlazyClassList

readClass

realizeClassWithoutSwift

methodizeClass


上面分析的类的加载是基于非懒加载的情况,那么什么时候会被标记为非懒加载呢?

1、主类有实现load方法,不管有没有category都会被标记为非懒加载。

2、主类没有实现load方法,category有实现load方法,主类会被标记为懒加载,但是会在category加载的时候会被同时加载进内存,属于被迫加载。

3、主类没有实现load方法,category也没有实现load方法,主类会被标记为懒加载,而且不会被迫加载。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,132评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,802评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,566评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,858评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,867评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,695评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,064评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,705评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,915评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,677评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,796评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,432评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,041评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,992评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,223评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,185评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,535评论 2 343

推荐阅读更多精彩内容