写在前面
在上一篇文章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
),生成的都是普通的isa
-
OBJC_PRINT_LOAD_METHODS
:打印Class
及Category
的+ (void)load
方法的调用信息 -
NSDoubleLocalizedStrings
:项目做国际化本地化(Localized
)的时候是一个挺耗时的工作,想要检测国际化翻译好的语言文字UI
会变成什么样子,可以指定这个启动项.可以设置NSDoubleLocalizedStrings
为YES
-
NSShowNonLocalizedStrings
:在完成国际化的时候,偶尔会有一些字符串没有做本地化
,这时就可以设置NSShowNonLocalizedStrings
为YES
,所有没有被本地化的字符串全都会变成大写
①.1 环境变量 - 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_images
-
init
等价于load_images
-
unmapped
等价于unmap_image
在dyld
源码--具体实现中,点击registerObjCNotifiers
进去有
所以 有以下等价关系
-
sNotifyObjCMapped
==mapped
==map_images
-
sNotifyObjCInit
==init
==load_images
-
sNotifyObjCUnmapped
==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
的方法列表
总结
综上所述,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方法中![](https://img.haomeiwen.com/i2340353/0ffd699280f6ee95.jpg?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
- 经过调试发现,是不会走
_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`![](https://img.haomeiwen.com/i2340353/fc38d8c09fef1304.jpg?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)
通过打印发现,仅对同名方法进行了排序
,而分类中的其他方法是不需要排序的,其中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
方法,会覆盖类中的方法,只执行一个(会执行最后被加载到内存中的分类的方法)
写在后面
和谐学习,不急不躁.我还是我,颜色不一样的烟火.
最后附录一张环境变量汇总表
网友评论