iOS之武功秘籍⑧: 类和分类加载过程

iOS之武功秘籍 文章汇总

写在前面

在上一篇文章iOS之武功秘籍⑦: dyld加载流程 -- 应用程序的加载中轻描淡写的提了一句_objc_init_dyld_objc_notify_register,本文将围绕它展开探索分析类和分类的加载.

本节可能用到的秘籍Demo

一、_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 isanonpointer isa指针地址 末尾为1 ),生成的都是普通的isa
  • OBJC_PRINT_LOAD_METHODS:打印 ClassCategory+ (void)load 方法的调用信息
  • NSDoubleLocalizedStrings:项目做国际化本地化(Localized)的时候是一个挺耗时的工作,想要检测国际化翻译好的语言文字UI会变成什么样子,可以指定这个启动项.可以设置 NSDoubleLocalizedStringsYES
  • NSShowNonLocalizedStrings:在完成国际化的时候,偶尔会有一些字符串没有做本地化,这时就可以设置NSShowNonLocalizedStringsYES,所有没有被本地化的字符串全都会变成大写

①.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的异常处理系统,注册异常处理的回调,从而监控异常的处理

  • 当有crashcrash是指系统发生的不允许的一些指令,然后系统给的一些信号)发生时,会来到_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_imagesdyldimage(镜像文件)加载进内存时,会触发该函数
  • load_imagedyld初始化image会触发该函数
  • unmap_imagedyldimage移除时,会触发该函数

二、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这个方法会调用doImageInitdoModInitFunctions(context)这两个方法就会进入libSystem框架里调用libSystem_initializer方法,最后就会调用_objc_init方法
  • _objc_init会调用_dyld_objc_notify_registermap_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方法
  • dyldrecursiveInitialization方法在调用完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加载流程,dyldObjc的关联如下图所示

  • dyld中注册回调函数,可以理解为 添加观察者
  • objcdyld注册,可以理解为发送通知
  • 触发回调,可以理解为执行通知selector

下面我们看下map_imagesload_imagesunmap_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_registerNameNoLockSEL添加到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) {}里面的流程.

④. 修复重映射一些没有被镜像文件加载进来的类

主要是将未映射的ClassSuper 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是类的名称,其源码实现如下,关键代码是addNamedClassaddClassTableEntry,源码实现如下

通过源码实现,主要分为以下几步:

  • ① 通过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

读取classdata数据,并将其强转为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指向
  • 通过addSubclassaddRootClass设置父子的双向链表指向关系,即父类中可以找到子类,子类中可以找到父类

这里有一个问题,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,此时的rw0x0,其中包括rorwe

我们看值都为空其中ro_or_rw_extro或者rw_extro是干净的内存(clean memory),rw_ext是脏内存(dirty memory).

此时打印cls,我们发现最后的地址为空的

  • 将断点移到if (isMeta) cls->cache.setBit(FAST_CACHE_META);继续打印cls发现最后的地址也为空.在cls->setData(rw);中对clsdata重新赋值了,为啥还为空?

这是因为roread 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) {代码处,此时的isMetaYES,是因为它确实是元类. cls->setInstancesRequireRawIsa();此方法就是设置isa.

  • if (supercls && !isMeta)处加断点,继续运行断住,此时断点的cls是地址,而不是之前的TCJPerson了.这是为啥?这是因为上面metacls = realizeClassWithoutSwift(remapClass(cls->ISA()), nil);方法会取到元类.我们来验证一下

我们看到此时的cls确实是元类.

methodizeClass:方法化类

其中methodizeClass的源码实现如下,主要分为几部分:

  • 属性列表、方法列表、协议列表等贴到rwe
  • 附加分类中的方法(将在下一篇文章中进行解释说明)
rwe的逻辑

方法列表加入rwe的逻辑如下:

  • 获取robaseMethods
  • 通过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方法中

我们看到此时的rweNULL,也就是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的初始化主要涉及:分类addMethodaddPropertyaddprotocol , 即对原始类进行修改或者处理时,才会进行rwe的初始化.

attachLists方法:插入

attachLists是如何插入数据的呢?方法属性协议都可以直接通过attachLists插入吗?

方法、属性继承于entsize_list_tt协议则是类似entsize_list_tt实现,都是二维数组.

进入attachLists方法的源码实现

attachLists的源码实现中可以得出,插入表主要分为三种情况:

  • 情况① 多对多: 如果当前调用attachListslist_array_tt二维数组中有多个一维数组
    • 通过malloc根据新的容量大小,开辟一个数组,类型是 array_t,通过array()获取
    • 倒序遍历把原来的数据移动到容器的末尾
    • 遍历新的数据移动到容器的起始位置
  • 情况② 0对1: 如果调用attachListslist_array_tt二维数组为空且新增大小数目为 1
    • 直接赋值addedList的第一个list
  • 情况③ 1对多: 如果当前调用attachListslist_array_tt二维数组只有一个一维数组
    • 通过malloc开辟一个容量和大小的集合,类型是 array_t,即创建一个数组,放到array中,通过array()获取
    • 由于只有一个一维数组,所以直接赋值到新Array的最后一个位置
    • 循环遍历从数组起始位置存入新的list,其中array()->lists 表示首位元素位置

针对情况③1对多,这里的lists是指分类

  • 这是日常开发中,为什么子类实现父类方法会把父类方法覆盖的原因
  • 同理,对于同名方法,分类方法覆盖类方法的原因
  • 这个操作来自一个算法思维 LRU即最近最少使用,加这个newlist的目的是由于要使用这个newlist中的方法,这个newlist对于用户的价值要高,即优先调用
  • 会来到1对多的原因 ,主要是有分类的添加,即旧的元素在后面,新的元素在前面 ,究其根本原因主要是优先调用category,这也是分类的意义所在

哼,只有原理没有操作,我信你个鬼,那接下来,我们就来验证一方.

rwe 数据加载(验证)

准备好测试代码本类TCJPerson,和分类TCJATCJB

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 listp *$2,此时的listTCJPerson本类的方法列表
  • 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 ,此时的listmethod_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 = 1ATTACH_BUFSIZ = 64,从首位平移到63位,即最后一个元素

进入attachLists方法, 在if (hasArray()) {处加断点,继续执行,由于已经有了一个list,所以 会走到 1对多的流程

执行到最后,输出当前的arrayp array()

这个list_array_tt<method_t, method_list_t, method_list_t_authed_ptr>表示 array中会放很多的 method_list_tmethod_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

① 通过clangOC代码转化为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结构体一样.

再来看看属性是啥情况

我们发现存在属性的变量名但是没有相应的setget方法,我们可以通过关联对象来设置.(关于如何设置关联对象,下文在说..)

看完cpp文件,在来看看objc4-818.2版本源码中的category_t

分类的加载

通过前面的介绍我们知道了类分为懒加载类非懒加载类,他们的加载时机不一样,那么分类又是如何呢?下面我们就依次来进行探究

准备工作:创建TCJPerson的两个分类:TCJATCJB

在前面的分析中的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://upload-images.jianshu.io/upload_images/2340353-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方法中断点部分
  • 来到prepareMethodListsfor循环部分
  • 来到fixupMethodList方法中的if (sort) {部分
    • 其中SortBySELAddress的源码实现如下:根据名字的地址进行排序
* 走到`mlist->setFixedUp();`,在读取`mlist`![](https://upload-images.jianshu.io/upload_images/2340353-fc38d8c09fef1304.jpg?imageMogr2/auto-orient/strip%7CimageView2/2/w/1240)

通过打印发现,仅对同名方法进行了排序,而分类中的其他方法是不需要排序的,其中imp地址是有序的(从小到大) -- fixupMethodList中的排序只针对 name 地址进行排序

总结:非懒加载类懒加载分类的数据加载,有如下结论:

  • 类 和 分类的加载是在read_images就加载数据了
  • 其中data数据编译时期就已经完成了
懒加载类 与 懒加载分类

主类和分类均未实现+load方法

  • 不加任何断点,运行程序,获取打印日志

其中realizeClassMaybeSwiftMaybeRelock是消息流程中慢速查找中有的函数,即在第一次调用消息时才有的函数

  • readClass断住,然后读取cj_ro,即读取整个data

此时的baseMethodListcount还是16,说明也是从data中读取出来的,所以不需要经过一层缓慢的load_images加载进来

总结:懒加载类懒加载分类数据加载是在消息第一次调用时加载,data数据在编译期就完成了

懒加载类 与 非懒加载分类

主类未实现+load方法,分类实现了+load方法

  • 不加任何断点,运行程序,获取打印日志
  • readClass方法中断住,查看cj_ro

其中baseMethodListcount8个,打印看看:对象方法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方法有两个隐藏参数,第一个为idself,第二个为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方法,会覆盖类中的方法,只执行一个(会执行最后被加载到内存中的分类的方法)

写在后面

和谐学习,不急不躁.我还是我,颜色不一样的烟火.
最后附录一张环境变量汇总表


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

推荐阅读更多精彩内容