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