写在前面
在上一篇文章iOS之武功秘籍⑦: dyld加载流程 -- 应用程序的加载中轻描淡写的提了一句_objc_init的_dyld_objc_notify_register,本文将围绕它展开探索分析类和分类的加载.
一、_objc_init方法
① environ_init方法
environ_init()方法是初始化一系列环境变量,并读取影响运行时的环境变量
- 此方法的关键代码是
for 循环里面的代码.
有以下两种方式可以打印所有的环境变量
-
将
for循环单独拿出来,去除所有条件,打印环境变量 -
通过终端命令
export OBJC_HELP = 1,打印环境变量
这些环境变量,均可以通过target -- Edit Scheme -- Run --Arguments -- Environment Variables配置,其中常用的环境变量主要有以下几个(环境变量汇总见文末!):
DYLD_PRINT_STATISTICS:设置DYLD_PRINT_STATISTICS为YES,控制台就会打印App的加载时长,包括整体加载时长和动态库加载时长,即main函数之前的启动时间(查看pre-main耗时),可以通过设置了解其耗时部分,并对其进行启动优化OBJC_DISABLE_NONPOINTER_ISA:杜绝生成相应的nonpointer isa(nonpointer isa指针地址末尾为1),生成的都是普通的isaOBJC_PRINT_LOAD_METHODS:打印Class及Category的+ (void)load方法的调用信息NSDoubleLocalizedStrings:项目做国际化本地化(Localized)的时候是一个挺耗时的工作,想要检测国际化翻译好的语言文字UI会变成什么样子,可以指定这个启动项.可以设置NSDoubleLocalizedStrings为YESNSShowNonLocalizedStrings:在完成国际化的时候,偶尔会有一些字符串没有做本地化,这时就可以设置NSShowNonLocalizedStrings为YES,所有没有被本地化的字符串全都会变成大写
① 环境变量 - OBJC_DISABLE_NONPOINTER_ISA
以OBJC_DISABLE_NONPOINTER_ISA为例,将其设置为YES,如下图所示
-
未设置
OBJC_DISABLE_NONPOINTER_ISA前,isa地址的二进制打印,末尾为1 -
设置
OBJC_DISABLE_NONPOINTER_ISA环境变量后,末尾变成了0
所以OBJC_DISABLE_NONPOINTER_ISA可以控制isa优化开关,从而优化整个内存结构
② 环境变量 - OBJC_PRINT_LOAD_METHODS
- 配置打印
load方法的环境变量OBJC_PRINT_LOAD_METHODS,设置为YES - 在
TCJPerson类中重写+load函数,运行程序,load函数的打印如下
所以,OBJC_PRINT_LOAD_METHODS可以监控所有的+load方法,从而处理启动优化(后续文章会讲解启动优化方法)
② tls_init方法
tls_init()方法是关于线程key的绑定,主要是本地线程池的初始化以及析构
③ static_init方法
static_init()方法注释中提到该方法会运行C++静态构造函数(只会运行系统级别的构造函数)
在dyld调用静态构造函数之前,libc会调用_objc_init,所以必须自己去实现
④ runtime_init方法
主要是运行时的初始化,主要分为两部分:分类初始化、类的表初始化(后续会详细讲解对应的函数)
⑤ exception_init方法
exception_init()主要是初始化libobjc的异常处理系统,注册异常处理的回调,从而监控异常的处理
-
当有
crash(crash是指系统发生的不允许的一些指令,然后系统给的一些信号)发生时,会来到_objc_terminate方法,走到uncaught_handler扔出异常 -
搜索
uncaught_handler,在app层会传入一个函数用于处理异常,以便于调用函数,然后回到原有的app层中,如下所示,其中fn即为传入的函数,即uncaught_handler等于fn
① crash分类
crash的主要原因是收到了未处理的信号,主要来源于三个地方:kernel内核,其他进行,App本身.
所以相对应的,crash也分为了3种
-
Mach异常:是指最底层的内核级异常.用户态的开发者可以直接通过Mach API设置thread,task,host的异常端口,来捕获Mach异常 -
Unix信号:又称BSD信号,如果开发者没有捕获Mach异常,则会被host层的方法ux_exception()将异常转换为对应的UNIX信号,并通过方法threadsignal()将信号投递到出错线程.可以通过方法signal(x, SignalHandler)来捕获single -
NSException应用级异常:它是未被捕获的Objective-C异常,导致程序向自身发送了SIGABRT信号而崩溃,对于未捕获的Objective-C异常,是可以通过try catch来捕获的,或者通过NSSetUncaughtExceptionHandler()机制来捕获.
针对应用级异常,可以通过注册异常捕获的函数,即NSSetUncaughtExceptionHandler机制,实现线程保活, 收集上传崩溃日志
② 应用级crash拦截
所以在开发中,会针对crash进行拦截处理,即app代码中给一个异常句柄NSSetUncaughtExceptionHandler,传入一个函数给系统,当异常发生后,调用函数(函数中可以线程保活、收集并上传崩溃日志),然后回到原有的app层中,其本质就是一个回调函数,如下图所示
上述方式只适合收集应用级异常,我们要做的就是用自定义的函数替代该ExceptionHandler即可
⑥ cache_t::init()方法
主要是缓存初始化,源码如下
⑦ _imp_implementationWithBlock_init方法
该方法主要是启动回调机制,通常这不会做什么,因为所有的初始化都是惰性的,但是对于某些进程,我们会迫不及待地加载libobjc-trampolines.dylib,其源码如下
⑧ _dyld_objc_notify_register:dyld注册
这个方法的具体实现在iOS之武功秘籍⑦: dyld加载流程 -- 应用程序的加载已经有详细说明,其源码实现是在dyld源码中,以下是_dyld_objc_notify_register方法的声明
从_dyld_objc_notify_register方法的注释中可以得出:
- 仅供
objc运行时使用 注册处理程序,以便在映射、取消映射和初始化objc图像时调用dyld将会通过一个包含objc-image-info的镜像文件的数组回调mapped函数
_dyld_objc_notify_register中的三个参数含义如下:
map_images:dyld将image(镜像文件)加载进内存时,会触发该函数load_image:dyld初始化image会触发该函数unmap_image:dyld将image移除时,会触发该函数
二、dyld与Objc的关联
其方法的源码实现与调用如下,即dyld与Objc的关联可以通过源码体现
dyld源码--具体实现
libobjc源码中--调用
从上可以得出
mapped等价于map_imagesinit等价于load_imagesunmapped等价于unmap_image
在dyld源码--具体实现中,点击registerObjCNotifiers进去有
所以 有以下等价关系
sNotifyObjCMapped==mapped==map_imagessNotifyObjCInit==init==load_imagessNotifyObjCUnmapped==unmapped==unmap_image
load_images调用时机
在iOS之武功秘籍⑦: dyld加载流程 -- 应用程序的加载中,我们知道了load_images是在notifySingle方法中,通过sNotifyObjCInit调用的,如下所示
map_images调用时机
关于load_images的调用时机已经在dyld加载流程中讲解过了,下面以map_images为例,看看其调用时机
-
dyld中全局搜索
sNotifyObjcMapped,在notifyBatchPartial方法中调用 -
全局搜索
notifyBatchPartial,在registerObjCNotifiers方法中调用
现在我们在梳理下dyld流程:
- 在
recursiveInitialization方法中调用bool hasInitializers = this->doInitialization(context);这个方法是来判断image是否已加载 doInitialization这个方法会调用doImageInit和doModInitFunctions(context)这两个方法就会进入libSystem框架里调用libSystem_initializer方法,最后就会调用_objc_init方法_objc_init会调用_dyld_objc_notify_register将map_images、load_images、unmap_image传入dyld方法registerObjCNotifiers- 在
registerObjCNotifiers方法中,我们把_dyld_objc_notify_register传入的map_images赋值给sNotifyObjCMapped,将load_images赋值给sNotifyObjCInit,将unmap_image赋值给sNotifyObjCUnmapped - 在
registerObjCNotifiers方法中,我们将传参赋值后就开始调用notifyBatchPartial() notifyBatchPartial方法中会调用(*sNotifyObjCMapped)(objcImageCount, paths, mhs);触发map_images方法dyld的recursiveInitialization方法在调用完bool hasInitializers = this->doInitialization(context)方法后,会调用notifySingle()方法- 在
notifySingle()中会调用(*sNotifyObjCInit)(image->getRealPath(), image->machHeader());上面我们将load_images赋值给了sNotifyObjCInit,所以此时就会触发load_images方法 sNotifyObjCUnmapped会在removeImage方法里触发,字面理解就是删除Image(映射的镜像文件)
所以有以下结论:map_images是先于load_images调用,即先map_images ,再load_images.
dyld与Objc关联
结合dyld加载流程,dyld与Objc的关联如下图所示
- 在
dyld中注册回调函数,可以理解为添加观察者 - 在
objc中dyld注册,可以理解为发送通知 触发回调,可以理解为执行通知selector
下面我们看下map_images、load_images、unmap_image都做了什么.
map_images:主要是管理文件中和动态库中所有的符号,即class、protocol、selector、category等load_images:加载执行load方法unmap_image: 卸载移除数据
其中代码通过编译,读取到Mach-O可执行文件中,再从Mach-O中读取到内存,如下图所示
三、map_images
在查看源码之前,首先需要说明为什么map_images有&,而load_images没有
map_images是引用类型,外界变了,跟着变load_images是值类型,不传递值
当镜像文件加载到内存时map_images会触发,即map_images方法的主要作用是将Mach-O中的类信息加载到内存.
map_images调用map_images_nolock,其中hCount表示镜像文件的个数,调用_read_images来加载镜像文件(此方法的关键所在)
_read_images
_read_images主要是加载类信息,即类、分类、协议等,进入_read_images源码实现,主要分为以下几部分:
- ①. 条件控制进行的一次加载 一一 创建表
- ②. 修复预编译阶段的
@selector的混乱问题 - ③. 错误混乱的类处理
- ④. 修复重映射一些没有被镜像文件加载进来的类
- ⑤. 修复一些消息
- ⑥. 当类里面有协议时:
readProtocol读取协议 - ⑦. 修复没有被加载的协议
- ⑧. 分类处理
- ⑨. 类的加载处理
- ⑩. 没有被处理的类,优化那些被侵犯的类
①. 条件控制进行的一次加载 一一 创建表
在doneOnce流程中通过NXCreateMapTable 创建表,存放类信息,即创建一张类的哈希表 -- gdb_objc_realized_classes,其目的是为了类查找方便、快捷
查看
gdb_objc_realized_classes的注释说明,这个哈希表用于存储不在共享缓存且已命名类,无论类是否实现,其容量是类数量的4/3.
②. 修复预编译阶段的@selector的混乱问题
主要是通过通过_getObjc2SelectorRefs拿到Mach_O中的静态段__objc_selrefs,遍历列表调用sel_registerNameNoLock将SEL添加到namedSelectors哈希表中
其中selector --> sel并不是简单的字符串,是带地址的字符串.
_getObjc2SelectorRefs的源码如下,表示获取Mach-O中的静态段__objc_selrefs,后续通过_getObjc2开头的Mach-O静态段获取,都对应不同的section name
sel_registerNameNoLock源码路径如下:sel_registerNameNoLock -> __sel_registerName,如下所示,其关键代码是auto it = namedSelectors.get().insert(name);,即将sel插入namedSelectors哈希表
③. 错误混乱的类处理
主要是从Mach-O中取出所有类,在遍历进行处理
通过代码调试,知道了在未执行readClass方法前,cls只是一个地址
在执行readClass方法后,cls是一个类的名称
到这步为止,类的信息目前仅存储了地址+名称
经过调试并没有执行if (newCls != cls && newCls) {}里面的流程.
④. 修复重映射一些没有被镜像文件加载进来的类
主要是将未映射的Class 和Super Class进行重映射,其中
_getObjc2ClassRefs是获取Mach-O中的静态段__objc_classrefs即类的引用_getObjc2SuperRefs是获取Mach-O中的静态段__objc_superrefs即父类的引用- 通过注释可以得知,被
remapClassRef的类都是懒加载的类,所以最初经过调试时,这部分代码是没有执行的
⑤. 修复一些消息
主要是通过_getObjc2MessageRefs 获取Mach-O的静态段 __objc_msgrefs,并遍历通过fixupMessageRef将函数指针进行注册,并fix为新的函数指针
⑥. 当类里面有协议时:readProtocol 读取协议
-
通过
NXMapTable *protocol_map = protocols();创建protocol哈希表,表的名称为protocol_map -
通过
_getObjc2ProtocolList获取到Mach-O中的静态段__objc_protolist协议列表,即从编译器中读取并初始化protocol -
循环遍历协议列表,通过
readProtocol方法将协议添加到protocol_map哈希表中
⑦. 修复没有被加载的协议
主要是通过 _getObjc2ProtocolRefs 获取到Mach-O的静态段 __objc_protorefs(与⑥中的__objc_protolist并不是同一个东西),然后遍历需要修复的协议,通过remapProtocolRef比较当前协议和协议列表中的同一个内存地址的协议是否相同,如果不同则替换
其中remapProtocolRef的源码实现如下
⑧. 分类处理
主要是处理分类,需要在分类初始化并将数据加载到类后才执行,对于运行时出现的分类,将分类的发现推迟到对_dyld_objc_notify_register的调用完成后的第一个load_images调用为止
⑨. 类的加载处理
主要是实现类的加载处理,实现非懒加载类
- 通过
_getObjc2NonlazyClassList获取Mach-O的静态段__objc_nlclslist非懒加载类表 - 通过
addClassTableEntry将非懒加载类插入类表,存储到内存,如果已经添加就不会载添加,需要确保整个结构都被添加 - 通过
realizeClassWithoutSwift实现当前的类,因为前面 ③中的readClass读取到内存的仅仅只有地址+名称,类的data数据并没有加载出来
苹果官方对于非懒加载类的定义是:
NonlazyClass is all about a class implementing or not a +load method. 所以实现了
+load方法的类是非懒加载类,否则就是懒加载类
懒加载:类没有实现 load 方法,在使用的第一次才会加载,当我们在给这个类发送消息,如果是第一次,在消息查找的过程中就会判断这个类是否加载,没有加载就会加载这个类非懒加载:类的内部实现了 load 方法,类的加载就会提前
为什么实现load方法就会变成非懒加载类?
- 主要是因为
load会提前加载(load方法会在load_images调用,前提是类存在)
懒加载类在什么时候加载?
- 在
调用方法的时候加载
⑩. 没有被处理的类,优化那些被侵犯的类
主要是实现没有被处理的类,优化被侵犯的类
我们需要重点关注的是 ③中 的readClass以及 ⑨中 realizeClassWithoutSwift两个方法
③中 的 readClass
readClass主要是读取类,在未调用该方法前,cls只是一个地址,执行该方法后,cls是类的名称,其源码实现如下,关键代码是addNamedClass和addClassTableEntry,源码实现如下
通过源码实现,主要分为以下几步:
-
① 通过
mangledName获取类的名字,其中mangledName方法的源码实现如下 -
② 当前类的父类中若有丢失的
weak-linked类,则返回nil,经调试不会走里面的判断 -
③ 正常情况下不会走进
popFutureNamedClass判断,这是专门针对未来的待处理的类的特殊操作,因此也不会对ro、rw进行操作(可打断点调试,创建类和系统类都不会进入) -
④ 通过
addNamedClass将当前类添加到已经创建好的gdb_objc_realized_classes哈希表,该表用于存放所有类 -
⑤ 通过
addClassTableEntry,将初始化的类添加到allocatedClasses表,这个表在_objc_init中的runtime_init就初始化创建了. -
⑥ 如果想在
readClass源码中定位到自定义的类,可以自定义加if判断
所以综上所述,readClass的主要作用就是将Mach-O中的类读取到内存,即插入表中,但是目前的类仅有两个信息:地址以及名称,而mach-O的其中的data数据还未读取出来.
⑨中 的 realizeClassWithoutSwift:实现类
realizeClassWithoutSwift方法中有ro、rw的相关操作,这个方法在消息流程的慢速查找中有所提及,方法路径为:慢速查找(lookUpImpOrForward) -- realizeAndInitializeIfNeeded_locked -- realizeClassMaybeSwiftAndLeaveLocked -- realizeClassMaybeSwiftMaybeRelock -- realizeClassWithoutSwift(实现类)
realizeClassWithoutSwift方法主要作用是实现类,将类的data数据加载到内存中,主要有以下几部分操作:
- ① 读取
data数据,并设置ro、rw - ② 递归调用
realizeClassWithoutSwift完善继承链 - ③ 通过
methodizeClass方法化类
① 读取 data 数据,并设置 ro、rw
读取class的data数据,并将其强转为ro,以及rw初始化和ro拷贝一份到rw中的ro
ro表示readOnly,即只读,其在编译时就已经确定了内存,包含类名称、方法、协议和实例变量的信息,由于是只读的,所以属于Clean Memory,而Clean Memory是指加载后不会发生更改的内存rw表示readWrite,即可读可写,由于其动态性,可能会往类中添加属性、方法、添加协议,在最新的2020的WWDC的对内存优化的说明Advancements in the Objective-C runtime - WWDC 2020 - Videos - Apple Developer中,提到rw,其实在rw中只有10%的类真正的更改了它们的方法,所以有了rwe,即类的额外信息.对于那些确实需要额外信息的类,可以分配rwe扩展记录中的一个,并将其滑入类中供其使用.其中rw就属于dirty memory,而dirty memory是指在进程运行时会发生更改的内存,类结构一经使用就会变成ditry memory,因为运行时会向它写入新数据,例如 创建一个新的方法缓存,并从类中指向它
② 递归调用 realizeClassWithoutSwift 完善 继承链
递归调用realizeClassWithoutSwift完善继承链,并设置当前类、父类、元类的rw
- 递归调用
realizeClassWithoutSwift设置父类、元类 - 设置
父类和元类的isa指向 - 通过
addSubclass和addRootClass设置父子的双向链表指向关系,即父类中可以找到子类,子类中可以找到父类
这里有一个问题,realizeClassWithoutSwift递归调用时,isa找到根元类之后,根元类的isa是指向自己,并不会返回nil,所以有以下递归终止条件,其目的是保证类只加载一次
在realizeClassWithoutSwift中
- 如果类
不存在,则返回nil - 如果类
已经实现,则直接返回cls
在remapClass方法中,如果cls不存在,则直接返回nil
③ 通过 methodizeClass 方法化类
通过methodizeClass方法,从ro中读取方法列表(包括分类中的方法)、属性列表、协议列表赋值给rw,并返回cls
断点调试 realizeClassWithoutSwift (objc4-818.2版本)
如果我们需要跟踪自定义类,同样需要在_read_images方法中的第九步的realizeClassWithoutSwift调用前增加自定义逻辑,主要是为了方便调试自定义类
-
_read_images方法中的第九步的realizeClassWithoutSwift调用前增加自定义逻辑 -
在
TCJPerson中重写+load方法,因为只有非懒加载类才会调用realizeClassWithoutSwift进行初始化 -
重新运行程序,我们就走到了
_read_images的第九步中的自定义逻辑部分 -
在
realizeClassWithoutSwift调用部分加断点,运行并断住 -
来到
realizeClassWithoutSwift方法中,在auto ro = (const class_ro_t *)cls->data();加断点,运行并断住---这主要是从组装的macho文件中读到data,按照一定数据格式转化(强转为class_ro_t *类型),此时的ro和我们的cls是没有关系的,往下走一步,看看ro里面有什么 -
其中
auto isMeta = ro->flags & RO_META;判断当前的cls是否为元类,这里不是元类,所有会走下面,在else里面的rw->set_ro(ro);处加断点,断住,查看rw,此时的rw是0x0,其中包括ro和rwe我们看值都为空其中
ro_or_rw_ext是ro或者rw_ext,ro是干净的内存(clean memory),rw_ext是脏内存(dirty memory).
此时打印cls,我们发现最后的地址为空的
- 将断点移到
if (isMeta) cls->cache.setBit(FAST_CACHE_META);继续打印cls发现最后的地址也为空.在cls->setData(rw);中对cls的data重新赋值了,为啥还为空?
这是因为ro为read only是一块干净的内存地址,那为什么会有一块干净的内存和一块脏内存呢?这是因为iOS运行时会导致不断对内存进行增删改查,会对内存的操作比较严重,为了防止对原始数据的修改,所以把原来的干净内存copy一份到rw中,有了rw为什么还要rwe(脏内存),这是因为不是所有的类进行动态的插入,删除.当我们添加一个属性,一个方法会对内存改动很大,会对内存的消耗很有影响,所以我们只要对类进行动态处理了,就会生成一个rwe.
这里我们需要去查看set_ro的源码实现,其路径为:set_ro -- set_ro_or_rwe(找到 get_ro_or_rwe,是通过ro_or_rw_ext_t类型从ro_or_rw_ext中获取) -- ro_or_rw_ext_t中的ro
通过源码可知ro的获取主要分两种情况:有没有运行时
-
如果
有运行时,从rw中读取 -
反之,如果没有运行时,从
ro中读取 -
我们继续往下走,来到重要的方法,如下图所示:
在这里会调用父类,以及元类让他们也进行上面的操作,之所以在此处就将父类,元类处理完毕的原因就是确定继承链关系,此时会有递归,当cls不存在时,就返回.
继续往下走,来到 if (isMeta) {代码处,此时的isMeta是YES,是因为它确实是元类. cls->setInstancesRequireRawIsa();此方法就是设置isa.
- 在
if (supercls && !isMeta)处加断点,继续运行断住,此时断点的cls是地址,而不是之前的TCJPerson了.这是为啥?这是因为上面metacls = realizeClassWithoutSwift(remapClass(cls->ISA()), nil);方法会取到元类.我们来验证一下我们看到此时的cls确实是元类.
methodizeClass:方法化类
其中methodizeClass的源码实现如下,主要分为几部分:
- 将
属性列表、方法列表、协议列表等贴到rwe中 - 附加
分类中的方法(将在下一篇文章中进行解释说明)
rwe的逻辑
方法列表加入rwe的逻辑如下:
- 获取
ro的baseMethods - 通过
prepareMethodLists方法排序 - 对
rwe进行处理即通过attachLists插入
方法如何排序
在消息流程的慢速查找流程iOS之武功秘籍⑥:Runtime文章中,方法的查找算法是通过二分查找算法,说明sel-imp是有排序的,那么是如何排序的呢?
-
进入
prepareMethodLists的源码实现,其内部是通过fixupMethodList方法排序 -
进入
fixupMethodList源码实现,是根据selector address排序
验证方法排序
下面我们可以通过调试来验证方法的排序
-
在
methodizeClass方法中添加自定义逻辑,并断住 -
读取
cj_ro中的methodlist -
进入
prepareMethodLists方法,将ro中的baseMethods进行排序,加自定义断点(主要是为了针对性研究),执行断点,运行到自定义逻辑并断住(这里加cj_isMeta,主要是用于过滤掉同名的元类中的methods) -
一步步执行,来到
fixupMethodList,即对sel排序,进入fixupMethodList源码实现,(sel根据selAdress排序) ,再次断点,来到下图部分,即方法经过了一层排序
所以 排序前后的methodlist对比如下,所以总结如下:methodizeClass方法中实现类中方法(协议等)的序列化.
- 回到
methodizeClass方法中
我们看到此时的rwe为NULL,也就是rew没有赋值,没有走(即data()->ro->rw->rwe(没有走))??这是为什么?此问题我们后面分析....
小伙到这,你是否又想起了另一个问题呢?
在非懒加载的时候我们知道realizeClassWithoutSwift的调用时机,那么懒加载是什么时候调用realizeClassWithoutSwift的呢.
在我们的测试代码里把+load方法注释掉
同时在main方法里调用cj_instanceMethod1方法
在
realizeClassWithoutSwift方法中打断点,断点过来,我们打堆栈信息,如下
为什么能到realizeClassWithoutSwift方法呢?因为我们调用了alloc方法,进行了消息的发送.这个流程我们在前面讲iOS之武功秘籍⑥:Runtime之方法与消息的时候说了.这就是懒加载的魅力所在,就是在第一次处理消息的时候才去现实类的加载.
所以懒加载类和非懒加载类的数据加载时机如下图所示
attachToClass方法
attachToClass方法主要是将分类添加到主类中,其源码实现如下
因为attachToClass中的外部循环是找到一个分类就会进到attachCategories一次,即找一个就循环一次.
attachCategories方法
在attachCategories方法中准备分类的数据,其源码实现如下
-
① 其中的
auto rwe = cls->data()->extAllocIfNeeded();是进行rwe的创建,那么为什么要在这里进行rwe的初始化??因为我们现在要做一件事:往本类中添加属性、方法、协议等,即对原来的clean memory要进行处理了- 进入
extAllocIfNeeded方法的源码实现,判断rwe是否存在,如果存在则直接获取,如果不存在则开辟 - 进入
extAlloc源码实现,即对rwe 0-1的过程,在此过程中,就将本类的data数据加载进去了
- 进入
-
② 其中关键代码是
rwe->methods.attachLists(mlists + ATTACH_BUFSIZ - mcount, mcount);即存入mlists的末尾,mlists的数据来源前面的for循环 -
③ 在调试运行时,发现
category_t中的name编译时是TCJPerson(参考clang编译时的那么),运行时是TCJA即分类的名字 -
④ 代码
mlists[ATTACH_BUFSIZ - ++mcount] = mlist;,经过调试发现此时的mcount等于1,即可以理解为倒序插入,64的原因是允许容纳64个(最多64个分类)
总结:本类中需要添加属性、方法、协议等,所以需要初始化rwe,rwe的初始化主要涉及:分类、addMethod、addProperty、addprotocol , 即对原始类进行修改或者处理时,才会进行rwe的初始化.
attachLists方法:插入
attachLists是如何插入数据的呢?方法属性协议都可以直接通过attachLists插入吗?
方法、属性继承于entsize_list_tt,协议则是类似entsize_list_tt实现,都是二维数组.
进入attachLists方法的源码实现
从attachLists的源码实现中可以得出,插入表主要分为三种情况:
- 情况①
多对多: 如果当前调用attachLists的list_array_tt二维数组中有多个一维数组- 通过
malloc根据新的容量大小,开辟一个数组,类型是 array_t,通过array()获取 - 倒序遍历把原来的数据移动到容器的末尾
- 遍历新的数据移动到容器的起始位置
- 通过
- 情况②
0对1: 如果调用attachLists的list_array_tt二维数组为空且新增大小数目为 1- 直接赋值
addedList的第一个list
- 直接赋值
- 情况③
1对多: 如果当前调用attachLists的list_array_tt二维数组只有一个一维数组- 通过malloc开辟一个容量和大小的集合,类型是 array_t,即创建一个数组,放到array中,通过array()获取
- 由于只有一个一维数组,所以直接赋值到新
Array的最后一个位置 - 循环遍历从数组起始位置存入新的list,其中array()->lists 表示首位元素位置
针对情况③1对多,这里的lists是指分类
- 这是日常开发中,为什么
子类实现父类方法会把父类方法覆盖的原因 - 同理,对于同名方法,
分类方法覆盖类方法的原因 - 这个操作来自一个算法思维
LRU即最近最少使用,加这个newlist的目的是由于要使用这个newlist中的方法,这个newlist对于用户的价值要高,即优先调用 - 会来到
1对多的原因 ,主要是有分类的添加,即旧的元素在后面,新的元素在前面 ,究其根本原因主要是优先调用category,这也是分类的意义所在
哼,只有原理没有操作,我信你个鬼,那接下来,我们就来验证一方.
rwe 数据加载(验证)
准备好测试代码本类TCJPerson,和分类TCJA和TCJB
rwe -- 本类的数据加载
下面通过调试来验证rwe数据0-1的过程,即添加类的方法列表
在attachCategories增加自定义逻辑,在extAlloc添加断点运行并断住,从堆栈信息可以看出是从attachCategories方法中auto rwe = cls->data()->extAllocIfNeeded();过来的,这里的作用是开辟rwe
那么为什么要在这里进行
rwe的初始化?因为我们现在要做一件事:往本类中添加属性、方法、协议等,即对原来的clean memory要进行处理了rwe是在分类处理时才会进行处理,即rwe初始化,且有以下几个方法会涉及rwe的初始化 ,分别是:分类 + addMethod + addPro + addProtocol
-
p rwe,p *$0, 此时的rwe中的list_array_tt是空的,初始化还没有赋值所以都是空的 -
继续往下执行到
if (list) {断住,并p list、p *$2,此时的list是TCJPerson本类的方法列表 -
在
attachLists方法中的if (hasArray()) {处设置断点,并运行断住,继续往下执行,会走到else-if流程,即0对1--TCJPerson本类的方法列表的添加会走0对1流程 -
p addedLists,此时是一个list指针的地址,给了mlists的第一个元素, 类型是method_list_t *const * -
接着
p addedLists[0]-->p *$6-->p $7.get(0).big()查看 -
继续
p addedLists[1]-->p *$9,此时看到没有值,访问的是别人的.(其实也会有值的情况,主要是因为内存是连续的)
总结 :所以 情况① -- 0对1是一种一维赋值.
rwe -- TCJA分类数据加载
接着前面的操作,继续执行一步,打印list, p list ,此时的list是method_list_t结构
接上面,继续往下执行,走到method_list_t *mlist = entry.cat->methodsForMeta(isMeta);,p mlist-->p *$12-->p $13.get(0).big() ,此时的mlist是 分类TCJA 的
在if (mcount > 0) {部分加断点,继续往下执行,并断住
往下执行一步,此时的mlists 为集合的集合
其中mlists + ATTACH_BUFSIZ - mcount为内存平移
p mlists + ATTACH_BUFSIZ - mcount, 因为mcount = 1,ATTACH_BUFSIZ = 64,从首位平移到63位,即最后一个元素
进入attachLists方法, 在if (hasArray()) {处加断点,继续执行,由于已经有了一个list,所以 会走到 1对多的流程
执行到最后,输出当前的array 即 p array()
这个list_array_tt<method_t, method_list_t, method_list_t_authed_ptr>表示 array中会放很多的 method_list_t,method_list_t中会放很多method_t.
总结:如果本类只有一个分类,则会走到情况③,即1对多的情况.
rwe -- TCJB分类数据加载
如果再加一个分类TCJB,走到第三种情况,即多对多
再次走到attachCategories -- if (mcount > 0) {,进入attachLists,走到 多对多的情况
查看当前 array 的形式 即 p array(),接着继续往下读,p *$25 ,第一个里面存储的TCJB的方法列表
也就是说经过一顿排序之后方法里面,最前面排的是分类TCJB的方法.信不信?不信是吧,我们把所有断点都关掉,来看看输出:
总结
综上所述,attachLists方法主要是将类和分类的数据加载到rwe中
- 首先
加载本类的data数据,此时的rwe没有数据为空,走0对1流程 - 当
加入一个分类时,此时的rwe仅有一个list,即本类的list,走1对多流程 - 再
加入一个分类时,此时的rwe中有两个list,即本类+分类的list,走多对多流程
类从Mach-O加载到内存的流程图如下所示
都到这了,那就先顺便讲讲分类的情况吧.
分类的本质
在之前的测试代码的main.m文件中定义TCJPerson的分类TCJ
① 通过clang将OC代码转化为C++代码
clang指令xcrun -sdk iphonesimulator clang -arch arm64 -rewrite-objc main.m -o main-arm64.cpp
② 底层分析
从cpp文件最下面看起,首先看到分类是存储在MachO文件的__DATA段的__objc_catlist中
其次能看到TCJPerson分类的结构
发现
TCJPerson改为_CATEGORY_TCJPerson_是被_category_t修饰的,我们看下_category_t是什么样的,搜索_category_
我们发现_category_t是个结构体,里面存在名字(这里的名字是类的名字,不是分类的名字),cls,对象方法列表,类方法列表,协议,属性.
为什么分类的方法要将实例方法和类方法分开存呢?
- 分类有两个方法列表是因为分类是没有元分类的,分类的方法是在
运行时通过attachToClass插入到class的
接着我们来看下方法
有三个对象方法和一个类方法,格式为:sel+签名+地址,和method_t结构体一样.
再来看看属性是啥情况
我们发现存在属性的变量名但是没有相应的set和get方法,我们可以通过关联对象来设置.(关于如何设置关联对象,下文在说..)
看完cpp文件,在来看看objc4-818.2版本源码中的category_t
分类的加载
通过前面的介绍我们知道了类分为懒加载类和非懒加载类,他们的加载时机不一样,那么分类又是如何呢?下面我们就依次来进行探究
准备工作:创建TCJPerson的两个分类:TCJA和TCJB
在前面的分析中的realizeClassWithoutSwift -> methodizeClass -> attachToClass -> load_categories_nolock -> extAlloc ->attachCategories中提及了rwe的加载,其中分析了分类的data数据是如何加载到类中的,且分类的加载顺序是:TCJA -> TCJB的顺序加载到类中,即越晚加进来,越在前面
其中查看methodizeClass的源码实现,可以发现类的数据和分类的数据是分开处理的,主要是因为在编译阶段,就已经确定好了方法的归属位置(即实例方法存储在类中,类方法存储在元类中),而分类是后面才加进来的
其中分类需要通过attatchToClass添加到类,然后才能在外界进行使用,在此过程,我们已经知道了分类加载三步骤的后面两个步骤,分类的加载主要分为3步:
- ① 分类数据
加载时机:根据类和分类是否实现load方法来区分不同的时机 - ②
attachCategories准备分类数据 - ③
attachLists将分类数据添加到主类中
分类的加载时机
下面我们来探索分类数据的加载时机,以主类TCJPerson + 分类TCJA、TCJB 均实现+load方法为例
通过 ②attachCategories准备分类数据 反推 ①的 加载时机
通过前面的学习,在走到attachCategories方法时,必然会有分类数据的加载,可以通过反推法查看在什么时候调用attachCategories的,通过查找,有两个方法中调用
-
load_categories_nolock方法中 -
addToClass方法中,这里经过调试发现,从来不会进到if流程中,除非加载两次,一般的类一般只会加载一次 -
不加任何断点,运行
objc4-818.2测试代码,可以得出以下打印日志,通过日志可以发现addToClass方法的下一步就是load_categories_nolock方法就是加载分类数据 -
全局搜索
load_categories_nolock的调用,有两次调用-
一次在
loadAllCategories方法中 -
一次在_read_images方法中
-
-
经过调试发现,是不会走
_read_images方法中的if流程的,而是走的loadAllCategories方法中的 -
全局搜索查看
loadAllCategories的调用,发现是在load_images时调用的 -
也可以在
attachCategories中加自定义逻辑的断点,bt查看堆栈信息
所以综上所述,该情况下的分类的数据加载时机的反推路径为:attachCategories -> load_categories_nolock -> loadAllCategories -> load_images
而我们的分类加载正常的流程的路径为:realizeClassWithoutSwift -> methodizeClass -> attachToClass ->attachCategories
其中正向和反向的流程如下图所示:
我们再来看一种情况:TCJPerson主类+分类TCJA实现+load,分类TCJB不实现+load方法
断点定在attachCategories中加自定义逻辑部分,一步步往下执行,p entry.cat-->p *$0
继续往下执行,会再次来到 attachCategories方法中断住,p entry.cat-->p *$2
总结:只要有一个分类是非懒加载分类,那么所有的分类都会被标记位非懒加载分类,意思就是加载一次 已经开辟了rwe,就不会再次懒加载,重新去处理 TCJPerson
分类和类的搭配使用
通过上面的两个例子,我们可以大致将类和分类是否实现+load的情况分为4种.
| 分类 | 分类 | |
|---|---|---|
| 类 | 分类实现+load | 分类未实现+load |
| 类实现+load | 非懒加载类+非懒加载分类 | 非懒加载类+懒加载分类 |
| 类未实现+load | 懒加载类+非懒加载分类 | 懒加载类+懒加载分类 |
非懒加载类 与 非懒加载分类
即主类实现了+load方法,分类同样实现了+load方法,在前文分类的加载时机时,我们已经分析过这种情况,所以可以直接得出结论,这种情况下
- 类的数据加载是通过
_getObjc2NonlazyClassList加载,即ro、rw的操作,对rwe赋值初始化,是在extAlloc方法中 分类的数据加载是通过load_images加载到类中的
其调用路径为:
map_images -> map_images_nolock -> _read_images -> readClass -> _getObjc2NonlazyClassList -> realizeClassWithoutSwift -> methodizeClass -> attachToClass,此时的mlists是一维数组,然后走到load_images部分load_images --> loadAllCategories -> load_categories_nolock -> load_categories_nolock -> attachCategories -> attachLists,此时的mlists是二维数组
非懒加载类 与 懒加载分类
即主类实现了+load方法,分类未实现+load方法
- 打开
realizeClassWithoutSwift中的自定义断点,看一下ro
从上面的打印输出可以看出,方法的顺序是 TCJB—>TCJA->TCJPerson类,此时分类已经 加载进来了,但是还没有排序,说明在没有进行非懒加载时,通过cls->data读取Mach-O数据时,数据就已经编译进来了,不需要运行时添加进去.
-
来到
methodizeClass方法中断点部分 -
来到
prepareMethodLists的for循环部分 -
来到
fixupMethodList方法中的if (sort) {部分-
其中
SortBySELAddress的源码实现如下:根据名字的地址进行排序 -
走到
mlist->setFixedUp();,在读取mlist
-
通过打印发现,仅对同名方法进行了排序,而分类中的其他方法是不需要排序的,其中imp地址是有序的(从小到大) -- fixupMethodList中的排序只针对 name 地址进行排序
总结:非懒加载类 与 懒加载分类的数据加载,有如下结论:
类 和 分类的加载是在read_images就加载数据了- 其中
data数据在编译时期就已经完成了
懒加载类 与 懒加载分类
即主类和分类均未实现+load方法
- 不加任何断点,运行程序,获取打印日志
其中realizeClassMaybeSwiftMaybeRelock是消息流程中慢速查找中有的函数,即在第一次调用消息时才有的函数
- 在
readClass断住,然后读取cj_ro,即读取整个data
此时的baseMethodList的count还是16,说明也是从data中读取出来的,所以不需要经过一层缓慢的load_images加载进来
总结:懒加载类 与 懒加载分类的数据加载是在消息第一次调用时加载,data数据在编译期就完成了
懒加载类 与 非懒加载分类
即主类未实现+load方法,分类实现了+load方法
-
不加任何断点,运行程序,获取打印日志
-
在
readClass方法中断住,查看cj_ro其中
baseMethodList的count是8个,打印看看:对象方法3个+属性的set和get方法共4个+1个cxx方法 ,即现在只有主类的数据. -
在
load_categories_nolock方法中自定义调试代码打断点,查看bt
总结:懒加载类 + 非懒加载分类的数据加载,只要分类实现了load,会迫使主类提前加载,即 主类强行转换为非懒加载类样式
分类和类的搭配使用总结
类和分类搭配使用,其数据的加载时机总结如下:
非懒加载类 + 非懒加载分类:类的加载在_read_images处,分类的加载在load_images方法中,首先对类进行加载,然后把分类的信息贴到类中非懒加载类 + 懒加载分类:类的加载在_read_images处,分类的加载则在编译时懒加载类 + 懒加载分类:类的加载在第一次消息发送的时候,分类的加载则在编译时懒加载类 + 非懒加载分类:只要分类实现了load,会迫使主类提前加载,即在_read_images中不会对类做实现操作,需要在load_images方法中触发类的数据加载,即rwe初始化,同时加载分类数据
四、load_images
load_images方法的主要作用是加载镜像文件,其中最重要的有两个方法:prepare_load_methods(加载) 和 call_load_methods(调用)
① load_images 源码实现
② prepare_load_methods 源码实现
②.1 schedule_class_load方法
这个方法主要是根据类的继承链递归调用获取load,直到cls不存在才结束递归,目的是为了确保父类的load优先加载
②.1.1 add_class_to_loadable_list 方法
此方法主要是将load方法和cls类名一起加到loadable_classes表中
②.1.2 getLoadMethod 方法
此方法主要是获取方法的sel为load的方法
②.2 add_category_to_loadable_list
主要是获取所有的非懒加载分类中的load方法,将分类名+load方法加入表loadable_categories
③ call_load_methods
此方法主要有3部分操作
- 反复调用
类的+load,直到不再有 - 调用一次
分类的+load - 如果有类或更多未尝试的分类,则运行更多的
+load
③.1 call_class_loads
主要是加载类的load方法
其中load方法中有两个隐藏参数,第一个为id 即self,第二个为sel,即cmd
③.2 call_category_loads
主要是加载一次分类的load方法
综上所述,load_images方法整体调用过程及原理图示如下
-
调用过程图示
-
原理图示
五、unmap_image
六、initalize分析
关于initalize苹果文档是这么描述的
Initializes the class before it receives its first message. 在这个类接收第一条消息之前调用.
然后我们在objc4-818.2源码中lookUpImpOrForward找到了它的踪迹
lookUpImpOrForward->realizeAndInitializeIfNeeded_locked->initializeAndLeaveLocked->initializeAndMaybeRelock->initializeNonMetaClass
在initializeNonMetaClass递归调用父类initialize,然后调用callInitialize
callInitialize是一个普通的消息发送
关于initalize的结论:
initialize在类或者其子类的第一个方法被调用前(发送消息前)调用- 只在类中添加
initialize但不使用的情况下,是不会调用initialize - 父类的
initialize方法会比子类先执行 - 当子类未实现
initialize方法时,会调用父类initialize方法;子类实现initialize方法时,会覆盖父类initialize方法 - 当有多个分类都实现了
initialize方法,会覆盖类中的方法,只执行一个(会执行最后被加载到内存中的分类的方法)
写在后面
和谐学习,不急不躁.我还是我,颜色不一样的烟火.
最后附录一张环境变量汇总表