iOS 分类加载流程分析

780 阅读20分钟

前言

  在上一篇文章iOS类加载流程分析(下)中我们已经探讨了类的加载流程,而今天我们将要对分类的加载流程进行详细的探讨研究。

学习重点

分类加载的条件

分类的加载方式

1. 分类加载流程探究

1.1 分类编译后的源码分析

  在探究分类的加载流程之前,我们首先来看看分类从OC编译成C++源码时的数据结构是如何的,首先,创建类以及分类并编写如下代码:

//Person.h 文件中代码
@interface Person : NSObject

@property (nonatomic, copy) NSString *name;

@property (nonatomic, assign) int age;

- (void)say;

+ (void)class_method;

@end

//Person.m 文件中代码
@implementation Person

- (void)say {
    NSLog(@"%s", __func__);
}

+ (void)class_method {
    NSLog(@"%s", __func__);
}

@end

//Person+CA.h 文件中代码
@interface Person (CA) <NSObject>

@property (nonatomic, copy) NSString *ca_name;

@property (nonatomic, assign) int ca_age;

- (void)ca_say;

- (void)ca_eat;

+ (void)ca_class_method;

@end

@implementation Person (CA)

- (void)ca_say {
    NSLog(@"%s", __func__);
}

- (void)ca_eat {
    NSLog(@"%s", __func__);
}

+ (void)ca_class_method {
    NSLog(@"%s", __func__);
}

@end

//main.m 文件中代码
int main(int argc, const char * argv[]) {
    Person *p = [[Person alloc] init];
    
    p.name = @"zzz";
    
    return 0;
}

  使用clang命令将main.m文件编译成C++源码,如下图所示:

image.png

  可以发现,在生成的main.cpp源文件中会编译出如下图所示的数据结构:

image.png

  上面代码中所定义的分类会根据_category_t这个数据类型编译生成对应的静态_category_t结构体变量_OBJC_$_CATEGORY_Person_$_CA,其各个成员变量默认值如下所示:

image.png

  也可以看到其遵守的协议,如下图所示:

image.png

  分类与类不同的一点是,你可以在Person类的方法列表找到类属性的getset方法,如下图所示:

image.png

  但是你在分类的方法列表中是找不到分类中定义的属性的getset方法的,如下图所示:

image.png

  也就是说明,其实分类中定义的属性不会成为类的成员变量,只能当做计算属性来使用,但是你也可以通过添加关联对象的方式来存储或读取分类中的属性的值。

  _category_t结构体在ObjC中其实就是category_t这种结构体,如下图所示:

2195.png

1.2 分类的加载流程分析

  在之前分析类的加载原理时,我们还遗留下了两个问题,我们一个一个来分析

  • 问题1:类的加载过程中,当调用methodizeClass函数时,其中获取到clsrwenil,但什么情况下rwe不为nil呢?什么时候才会对clsrwe进行初始化呢?

  首先我们查看ObjC的源码,如下图所示:

image.png

  在methodizeClass函数中是通过调用rwext()函数来获取class_rw_ext_t *指针变量的,也就是红框2中的代码,但此时此刻我们也注意到了下面的函数extAllocIfNeed,这个函数返回的也是一个class_rw_ext_t *指针变量,这个函数的代码逻辑是这样的,首先根据class_rw_t中成员ro_or_rw_ext中的_value值获取到一个地址指针,然后判断这个指针类型是不是class_rw_ext_t *这种类型,如果是,就返回这个地址指针,如果不是,就会调用extAlloc函数在内存中创建一个class_rw_ext_t *类型的指针并返回,extAlloc函数代码如下所示:

class_rw_ext_t *
class_rw_t::extAlloc(const class_ro_t *ro, bool deepCopy)
{
    runtimeLock.assertLocked();

    auto rwe = objc::zalloc<class_rw_ext_t>();

    rwe->version = (ro->flags & RO_META) ? 7 : 0;

    method_list_t *list = ro->baseMethods();
    if (list) {
        if (deepCopy) list = list->duplicate();
        rwe->methods.attachLists(&list, 1);
    }

    // See comments in objc_duplicateClass
    // property lists and protocol lists historically
    // have not been deep-copied
    //
    // This is probably wrong and ought to be fixed some day
    property_list_t *proplist = ro->baseProperties;
    if (proplist) {
        rwe->properties.attachLists(&proplist, 1);
    }

    protocol_list_t *protolist = ro->baseProtocols;
    if (protolist) {
        rwe->protocols.attachLists(&protolist, 1);
    }

    set_ro_or_rwe(rwe, ro);
    return rwe;
}

  这个函数中代码的作用就是将ro中的方法列表、属性列表以及协议列表的地址拷贝一份到rew中去,并且将ro的值存储到rew的成员变量ro中,然后将rwe的值设置到Person类中的rwro_or_rw_ext中存储起来。

  所以,我们现在关注的重点就是extAllocIfNeeded这个函数什么时候调用,因此我们全局搜索extAllocIfNeeded关键字,结果发下一共有8处地方对这个函数进行了调用,其中有demangledName函数、class_setVersion函数、addMethods_finish函数、class_addProtocol函数、_class_addProperty函数、objc_duplicateClass函数以及attachCategories函数,可见这与2020年苹果WWDC大会对rorwrwe的描述是一致的,当在分类中添加类属性、方法以及协议的时候才会创建rwe,而attachCategories这个函数就是用来将主类的所有分类中的方法、属性以及协议添加到其主类中去的,查找结果如下所示:

image.png

  而在attachToClass函数中对attachCategories进行了调用,在MethodizeClass函数中对attachToClass又进行了调用(除了if (previously)条件语句中的代码块进行的调用,还在下面的代码块进行了直接调用,而实际上previously这个参数在函数调用链中一直为nil,因此不会执行其中的代码),attachToClass中的函数代码如下所示:

image.png

  除了上面attachCategories这一处调用之外呢,源码中就仅有load_categories_nolock函数对其进行了调用,我们在load_categories_nolock函数中attachCategories调用之前编写如下代码,看看什么情况才会调用attachCategories函数,如下图所示:

image.png

  但是首先我们先来看看attachCategories都做了些什么事情,其代码如下图所示:

image.png

  这个函数中的代码逻辑如下:

  1. 初始化364大小的指针数组mlistsproplists以及protolists,类型分别为method_list_t *property_list_t *以及protocol_list_t *

  2. 设置三个计数变量mcountpropcount以及protocount,用来表示三个指针数组中已添加的相应类型指针的数量。

  3. 遍历主类的分类列表,从每个分类中获取方法列表、属性列表以及协议列表,

  4. 如果这些列表指针不为空,在这个过程中分别对应判断这三个指针数组中已添加元素的数量是否大于64,如果大于64,执行步骤5;否则执行步骤6

  5. 调用rwe指针的相应类型成员变量(方法列表对应methods,属性列表对应properties,协议列表对应protocols)中的函数attachLists附着对应列表(方法列表还需要进行排序),并将此列表类型的指针数组计数变量置为0,执行步骤6

  6. 添加此列表到对应指针数组中(从数组末尾空位向前添加),然后对应计数变量值加1

  7. 判断添加的方法列表的数量是否大于0,如果大于0,调用prepareMethodLists函数对mlists数组中的每个方法列表中的方法进行selector修复并按照Method中selector的地址进行排序,然后调用attachLists函数将所有分类的方法列表附着到主类方法列表中。

  8. 调用attachLists函数将所有分类的协议列表附着到主类协议列表中。

  9. 调用attachLists函数将所有分类的属性列表附着到主类属性列表中。

  以上就是attachCategories函数代码逻辑了,但是我们还不知道分类中的方法列表、属性列表以及协议列表是如何附着到主类相应列表中去的,但都是调用attachLists函数进行附着的。

2. 分类加载的方式

  接着,我们需要验证一下下面这几种类与分类的加载方式是否会调用attachCategories函数,因此在attachCategories函数中编写验证代码并打上断点,如下图所示:

image.png

2.1 懒加载主类以及懒加载分类

2.1.1 单个懒加载分类

  将Person类中的load类方法注释掉,然后编译运行程序,在realizeClassWithoutSwift函数中添加代码并打上断点,如下图所示:

image.png

  编译运行程序,程序卡在了断点,此时Person类的加载方式为懒加载,打印Person类中所有的方法

懒加载类

image.png

  可以清楚的看到,此时此刻,在未调用attachCategories函数之前,分类中的方法已经被加载到主类的方法列表中了,并且是加载到了robaseMethodList中,类的rw_extnil值,清空输出,过掉断点,程序并不会执行attachCategories函数。

  结论:当主类及其分类都为懒加载模式时,并不会调用attachCategories函数将分类中的方法附着到主类中,而是编译器完成了这部分操作,并且主类的方法列表是一维的,顺序是分类中的方法在主类方法前。

  分类数据加载后主类方法类别内存结构示意图如下所示:

ro-sbaseMethodsd fmothod Mot t )basoMethodLst 生炎方法列表版序(一级指针).png

2.1.2 多个懒加载分类

  但是到目前为止还有一个疑问,如果主类的分类很多,又是如何处理的呢?因此添加如下几个分类到man.m中,如下所示:

// Person+CB.h 文件中代码
@interface Person (CB) <NSObject>

- (void)say;

- (void)cb_say;

- (void)cb_eat;

+ (void)cb_class_method;

@end

// Person+CB.m 文件中代码
@implementation Person (CB)

- (void)say {
    NSLog(@"%s --- CB分类", __func__);
}

- (void)cb_say {
    NSLog(@"%s", __func__);
}

- (void)cb_eat {
    NSLog(@"%s", __func__);
}

+ (void)cb_class_method {
    NSLog(@"%s", __func__);
}

@end

// Person+CC.h 文件中代码
@interface Person (CC) <NSObject>

- (void)cc_say;

- (void)cc_eat;

+ (void)cc_class_method;

@end

// Person+CC.m 文件中代码

- (void)cc_say {
    NSLog(@"%s", __func__);
}

- (void)cc_eat {
    NSLog(@"%s", __func__);
}

+ (void)cc_class_method {
    NSLog(@"%s", __func__);
}

@end

// Person+CD.h 文件中代码
@interface Person (CD) <NSObject>

- (void)say;

- (void)cd_say;

- (void)cd_eat;

+ (void)cd_class_method;

@end


// Person+CD.m 文件中代码
@implementation Person (CD)

- (void)say {
    NSLog(@"%s --- CD分类", __func__);
}

- (void)cd_say {
    NSLog(@"%s", __func__);
}

- (void)cd_eat {
    NSLog(@"%s", __func__);
}

+ (void)cd_class_method {
    NSLog(@"%s", __func__);
}

@end

//main.m 文件中代码
#import <Foundation/Foundation.h>
#import "Person.h"
#import "Person+CA.h"
#import "Person+CB.h"
#import "Person+CD.h"
#import "Person+CC.h"

int main(int argc, const char * argv[]) {
    Person *p = [[Person alloc] init];
    
    [p say];
    
    return 0;
}

  需要注意的一点是,分类CB与分类CD中添加的方法say与主类中say方法是一样的,并在main.m函数中进行了调用,如下设置这些分类的编译顺序:

image.png

  编译运行程序,会发现依然执行的是主类的懒加载流程,打印主类中方法列表中的methot_t信息,如下图所示:

image.png

  可以发现,主类方法列表中的方法顺序是与分类的编译顺序有关的,每编译一个分类时,会将其所有的方法插入到其主类方法列表的最前面,示意图如下所示:

Porson (Co).png

  清空控制台输出信息,继续执行程序,查看打印输出信息,如下图所示:

image.png

  我们发现,虽然调用了Person主类中的实例方法say,但是打印结果却显示的是Person分类CB中的实例方法say,这个原因其实我们之前在方法的慢速查找流程中已经探究过了,也就是那个二分查找算法,如下图所示:

image.png

  其实也就是白色投影处代码逻辑导致的,这段代码的意思是如果在方法列表中找到了要查找的方法,还会向前进行遍历查找,如果在越晚编译的分类中再次找到了此方法,就会返回分类中的这个方法,然后调用执行,在上面的代码中,虽然分类Person分类CD以及Person分类CB都有主类中一样的say实例方法,但是却调用的是Person分类CB中的say实例方法,这就是因为Person分类CB中的say实例方法在主类方法列表最前面,最根本原因是因为Person分类CB这个分类编译顺序在Person分类CD分类后面。

2.2 非懒加载主类以及懒加载分类

2.2.1 单个懒加载分类

  首先,主类中添加load类方法,只留一个Person分类CA,编译运行代码,程序执行到断点,查看一下函数调用栈,如下图所示:

image.png

  打印输出主类中方法列表信息,如下图所示:

image.png

2.2.2 多个懒加载分类

  继续执行程序,发现程序依旧不会调用执行attachCategories函数,这种分类的加载类型与2.1中一致,然后再来试试添加多个分类会如下,添加CBCCCD分类,分类编译顺序如下图所示:

image.png

  并且这些分类中都没有实现load类方法,编译运行程序,程序执行到断点,查看函数调用栈并打印输出Person类中方法列表的方法顺序,如下图所示:

image.png

image.png

  示意图如下所示:

image.png

  继续执行程序,控制台打印输出结果如下图所示:

image.png image.png

2.3 非懒加载主类以及非懒加载分类

2.3.1 单个非懒加载分类

  首先,主类中添加load类方法,只留一个Person分类CA,并在Person分类CA中添加load类方法,在main函数中调用分类中的类方法,并打上如下断点:

image.png

编译运行代码,程序执行到断点,查看一下函数调用栈,如下图所示:

image.png

  打印输出主类中方法列表信息,如下图所示:

image.png

  可以看到主类中仅有自己的实例方法,过掉断点,继续执行程序,发现执行到了如下函数中的断点:

image.png

  函数调用栈如下所示:

image.png

  现在我们就明白了,当实现分类中的load类方法后,就会在ObjCload_images函数调用时加载分类,跳过断点,来到attachCategories函数,此时Person类中的rew就被初始化了,并且会调用attachLists函数将Person分类中的方法附着到主类的方法列表中,其代码如下所示:

image.png

  可以清楚的看到,在attachLists函数分三种情况附着列表的,由于添加方法列表、属性列表以及协议列表调用的都是这个函数,因此我们以方法列表为例来分析这个函数的执行流程。

  • 第一种:当分类中方法列表为空并且附着的方法列表数量为1时(listnil并且addedCount),直接将此方法列表的地址赋值到rwe的methods(C++method_array_t)的成员变量list(指针类型)中,这个list的值只是一个存储method_t的一维数组的地址。

  • 第二种:当rwemethods中只存在一个方法列表时,获取成员变量list的值,在内存中开辟一段(oldCount(为1) + addedCount)大小连续的内存空间(array_t *类型),用来存储(method_list_t *)类型的值,然后将list指针的值存储到这段内存空间的末尾,将新加入的方法列表的地址依次存储到这段内存空间前面。

  • 第三种:当rwemethods中存在多个方法列表时,获取已有的方法列表数量oldCount,开辟一段(oldCount + addedCount)大小连续的内存空间(array_t *类型),用来存储(method_list_t *)类型的值,后将旧的方法列表指针的值依次存储到这段内存空间的末尾,将新加入的方法列表的地址依次存储到这段内存空间前面。

  首先,在rwe初始化之前,我们先查看Person类中ro的地址以及其成员变量baseMethodList的值,因为baseMethodListPerson方法列表的起始地址,因此我们使用命令看看方法列表中内存数据是如何的,如下图所示:

image.png

  看这个数据你可能会有一些懵,因此本人结合Person中方法列表的数据以及内存数据的打印,画了Person主类中方法列表内存结构示意图,如下所示:

image.png

  经过以上的分析我们可以清楚的知道(method_list_t *)实际上相当于指向堆中存储多个method_t结构体类型数据的一段内存空间的首地址的指针。

  此时跳过断点,执行到attachLists函数中时,将会执行第二种分支中的代码,如下所示:

image.png

  执行过这段代码之后,类的方法列表数组数据结构示意图如下所示:

image.png

注意

  • method_list_t结构体继承于entsize_list_tt结构体(ivar_list_tproperty_list_t也是,但是protocol_list_t是一个单独结构体类型)实际上是一个类似于数组指针的存储结构(用来存储method_t结构体变量,结构体class_ro_t中成员变量baseMethodListivarsbaseProperties以及baseProtocols的分别是对应这些类型的指针,结构体``。

  • method_array_tproperty_array_t以及protocol_array_t均是C++中的类,均继承于list_array_tt,其内部类iterator(迭代器)中定义了一个共用体,这个共用体中有两个成员变量,分别为list(当只有一个列表数据时,list就为这个列表的地址)以及arrayAndFlag(通过这个判断是否存在列表数组,如果存在,可以通过这个字段返回这个列表数组的地址),均为uintptr_t类型,8字节大小,这三种类分别对应结构体class_rw_ext_t中成员变量methodsproperties以及protocols的类型。

2.3.1 多个非懒加载分类

  在工程中添加CBCC、以及CD分类,编译顺序如下图所示:

image.png

  在这几个分类中添加load类方法,编译运行程序,执行到如下断点,查看函数调用栈,打印类中的方法列表中的数据,如下所示:

image.png

image.png

  可以发现的是编译器并未将分类中的数据附着到主类中去。

  过掉断点,程序卡在了load_categories_nolock函数,函数调用栈如下图所示:

image.png

  过掉断点,程序卡在attachCategories函数中,此时打印mCount的值,如下所示:

image.png

  也就是当前加载的分类的数量为1,打印一下此分类的信息,如下所示:

image.png

  首先加载的是CB这个分类中的数据,如果执行到attachLists函数,应该会执行第二种分支中的代码,如下所示:

image.png

  也就是说CB方法列表在主类方法列表之前,过掉断点,再次执行load_categories_nolock函数,再过掉断点,来到attachCategories函数,打印mcount的值,以及当前加载的分类的信息,如下图所示:

image.png

  其次加载的是CC这个分类中的数据,如果执行到attachLists函数,应该会执行第三种分支中的代码,如下所示:

image.png

  过掉断点,再次来到attachCategories函数,打印mcount的值,以及当前加载的分类的信息,如下图所示:

image.png

  又加载了CD这个分类中的数据,如果执行到attachLists函数,应该会执行第三种分支中的代码,如下所示:

image.png

  再次过掉断点,再次来到attachCategories函数,打印mcount的值,以及当前加载的分类的信息,如下图所示:

image.png

  最后又加载了CA这个分类中的数据,执行到attachLists函数,还是会执行第三种分支中的代码,如下所示:

image.png

  那么为什么会这样一个一个的加载分类呢?其实原因就在于load_categories_nolock函数中获取到Person的所有分类,然后遍历分类,一个接一个的调用attachCategories函数的。

  所有分类数据加载完毕之后,我们打印一下这个方法列表数组的地址以及其中最后一个方法列表的数据,如下图所示:

image.png

image.png

image.png

  根据以上的探究数据,类的方法列表数组结构图如下所示:

image.png

2.4 懒加载主类以及非懒加载分类

2.4.1 单个非懒加载分类

  首先,移除掉主类中load类方法,只留一个Person分类CA,并在Person分类CA中添加load类方法,编译执行程序,程序卡在断点,函数调用栈如下所示:

image.png

  此时,虽然我们没有实现主类中的load类方法,但我们实现了分类中的类方法,却仍然以非懒加载的方式加载了主类的数据,打印主类中方法列表方法,如下所示:

image.png

  可以看到的是,编译器已经将分类中的数据附着到了主类中,过掉断点,程序并不会执行attachCategories函数。

2.4.2 多个非懒加载分类

  在工程中添加CBCC、以及CD分类,并且只在CACB中添加load类方法,编译顺序如下图所示:

image.png

  运行程序,程序执行到断点,函数调动栈如下所示:

image.png

  打印类中的方法列表数据,如下图所示:

image.png

  这种情况,编译器也没有将分类数据附着到主类中,过掉断点,程序执行到了attachToClass函数中,如下图所示:

image.png

  过掉断点,程序执行到attachCategories函数,打印mcount的值,以及分类信息,如下图所示:

image.png

  这种情况就是4个分类的数据都会被加载(虽然CC以及CD分类未实现load类方法),过掉断点,程序执行到attachLists函数,这时执行的应该是第二种分支中的代码,如下所示:

image.png

  按照这个分支中代码执行逻辑来讲,执行过后,主类方法列表数组中方法列表所属分类顺序应该CDCCCACB,最后是主类方法列表,如下图所示:

image.png

image.png

  根据以上的探究数据,类的方法列表数组结构图如下所示:

image.png

3 总结

  根据以上探究,实际上分类的加载共有以下的5种情况:

  1. 当主类为懒加载类,如果所有分类也为懒加载类,则会在类第一次发送消息时加载类数据,并且此时所有分类数据已经附着到了主类中,主类以及分类的方法列表、属性列表协议列表都是以一级指针的方式存储在ro中。   (函数调用栈:objc_msgSend->lookupImpOrForward->realizeClassWithSwift

  2. 当主类为懒加载类,但只有一个非懒加载的分类时,这时就会以非懒加载的形式加载主类,并且此时所有分类数据已经附着到了主类中,主类以及分类的方法列表、属性列表协议列表都是以一级指针的方式存储在ro中。   (函数调用栈:map_images->map_images_nolock->_read_images->realizeClassWithSwift

  3. 当主类为懒加载类,但有多个非懒加载的分类时,这时就会以非懒加载的形式加载主类,并且会调用attachCategories函数将所有分类数据一次性全部附着到主类中,主类以及分类的方法列表、属性列表以及协议列表都是以二级指针的方式存储在rwe中。   (函数调用栈:load_images->prepare_load_methods->realizeClassWithoutSwift->methodizeClass->attachToClass->attachCategories

  4. 当主类为非懒加载类,所有分类均为懒加载类时,这种情况与2中一致,主类以及分类的方法列表、属性列表以及协议列表都是以一级指针的方式存储在ro中。   (函数调用栈:map_images->map_images_nolock->_read_images->realizeClassWithSwift

  5. 当主类为非懒加载类,分类中存在至少一个非懒加载类时,除了会调用realizeClassWithoutSwift函数之外,还会调用attachCategories函数将分类数据一个接一个的按照编译顺序附着到主类中,主类以及分类的方法列表、属性列表以及协议列表都是以二级指针的方式存储在rwe中。   (函数调用栈:load_images->loadAllCategories->load_categories_nolock->attachCategories

注意:如果一个类的分类为空,这个分类是不会被加载的