iOS底层-分类的加载分析

2,447 阅读12分钟

欢迎阅读iOS底层系列(建议按顺序)

iOS底层 - alloc和init探索

iOS底层 - 包罗万象的isa

iOS底层 - 类的本质分析

iOS底层 - cache_t流程分析

iOS底层 - 方法查找流程分析

iOS底层 - 消息转发流程分析

iOS底层 - dyld是如何加载app的

iOS底层 - 类的加载分析

iOS底层 - 分类的加载分析

1.本文概述

本文旨在通过分析分类的加载流程,类和分类分别在懒加载和非懒加载时的表现,完善所有类的加载流程。

2.分类相关探索

2.1 分类初探

上一篇文章类的加载分析分析了map_images的主要流程,此流程中最后为分类的加载部分,现在回头来解析下。

其先从macho__objc_catlist段下读取分类,然后遍历读取的分类。这两个步骤接收的类型都是category_t

2.2 分类的数据结构

自然的点击category_t,可以看到它的结构体:

struct category_t {
    const char *name;
    classref_t cls;
    struct method_list_t *instanceMethods;
    struct method_list_t *classMethods;
    struct protocol_list_t *protocols;
    struct property_list_t *instanceProperties;
    struct property_list_t *_classProperties;
};
  • name:经过验证是分类的名称,即括号里面的字段(部分文章说是类名)
  • cls:对应的类地址,remapClass时通过shiftclass得到类名
  • instanceMethods:实例方法
  • classMethods:类方法
  • instanceProperties:实例属性
  • classProperties:类属性

为什么需要instanceMethodsclassMethods

分类可以添加实例方法,也可以添加类方法。在类结构被加载时,对应的元类也会被加载。我们都知道,实例方法存在类中,类方法存在元类中。所以在类加载时取instanceMethods的数据,元类加载时取classMethods的数据。实例方法和类方法的处理不一样,自然需要分开存储。

classProperties 是什么?

Xcode 8开始,LLVM已经支持Objective-C显式声明类属性了,主要为了与Swift中的类属性互操作。

在定义属性时,增加class修饰符,即可定义为类属性,类属性需要手动实现settergetter。在需要解藕时,类属性或许能帮的上你。

2.3 验证类和分类的懒加载和非懒加载的存在

上篇文章分析了启动时类的加载,但是说明了只限于非懒加载类,这么说还存在懒加载类嘛?类如果区分懒加载和非懒加载,分类是否也区分?

2.3.1 验证类的懒加载和非懒加载存在

在类的加载源码中,可以看到这么一条注释:

// Realize non-lazy classes (for +load methods and static instances)
译:实现非懒加载类(通过+load方法和静态构造函数)

那么,就按它所说,创建CJFStudentCJFTeacherCJFPerson类,任意两个类实现下+load方法,一个不实现:

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

在源码中加入验证方法代码:

运行后可以看到控制台输出:

实现了+load的类被加载了,未实现的没有被加载。

得以验证:

存在懒加载类和非懒加载类,并且实现了+load方法为非懒加载类。

2.3.2 验证分类的懒加载和非懒加载存在

既然类的懒加载和非懒加载区别是+load方法,那分类也如法炮制验证下,

创建CJFPerson+text分类,在实现分类的源码中加入验证代码:

然后依次实现和不实现此分类的+load,得到控制台输出:

可以看到,启动时分类的加载和是否实现+load方法有关。也顺便验证了,分类的数据结构中,name为分类的名称。

得以验证:

无论分类还是类,是否实现了+load方法,确实会影响到启动时的加载流程。

那类和分类的是否只是根据+load方法或者静态构造函数来判断是懒加载还是非懒加载的?关键的+load方法何时被执行?类和分类互相之间是否也有关联影响到加载流程?这些问题一个一个来分析下。

2.4 load_images() 分析

既然+load方法如此关键,自然是有研究它的必要性。

上一篇文章类的加载分析中,只分析了libObjcdyld接收的三件事中的map_images,那现在分析下load_images

直接看其源码:

较为简单,只有两个步骤:

  • prepare_load_methods:准备load方法

  • call_load_methods:调用load方法

2.4.1 prepare_load_methods()

  • schedule_class_load:调度类的load方法。
static void schedule_class_load(Class cls){
    ...
    schedule_class_load(cls->superclass);
    add_class_to_loadable_list(cls);
    ...
}

总的来说,通过递归的方法,先父类后子类的把+load方法添加到对应的loadable表中,这解释了为什么+load方法会先调用父类,在调用子类

其保存的是个loadable_class结构体:

struct loadable_class {
    Class cls;  // may be nil
    IMP method;
};

结构体包含imp,方便后续调用。

  • 添加分类的+load方法到对应的loadable列表中(和类的loadable表不是同一张),添加之前调用realizeClassWithoutSwift,防止类未实现(另有妙用,做个标记,后面还要回来)。 其保存的是个loadable_category结构体:
struct loadable_category {
    Category cat;  // may be nil
    IMP method;
};

结构体同样包含imp,方便后续调用。

2.4.2 call_load_methods()

  • call_class_loads:调用类的表中的+load方法

按照表中顺序,依次取出调用+load方法的imp,通过函数指针的方式实现快速调用。

  • call_category_loads:调用分类的表中的+load方法 同样是,按照表中顺序,依次取出调用+load方法的imp,通过函数指针的方式实现快速调用。

通过这个调用流程,也解释了为什么+load方法会先调用类,在调用分类

而无继承关系的类+load方法调用取决于被加入表的顺序,也就是Compile Sources的顺序;同一个类的分类的+load方法调用也是取决于被加入表的顺序,也是Compile Sources的顺序。

至于直接使用函数指针调用的方式也很好理解,目前还是处于启动阶段,如果采用发送消息是比较耗时的。

2.5 类和分类的懒加载和非懒加载的表现

已知两种研究对象,两种加载类型,那就有4种排列组合。

2.5.1 非懒加载类和非懒加载分类

这种情况就是上一篇文章类的加载分析分析的, 同时实现+load方法,那类必然会在启动时被加载。

所以 read_images - realizeClassWithoutSwift - methodizeClass,这个顺序是固定的,可是来到methodlizeClass准备附加分类时,对应分类还未被加入哈希表中,

category_list *cats = unattachedCategoriesForClass(cls, true /*realizing*/);
attachCategories(cls, cats, false /*don't flush caches*/);

这一步unattachedCategoriesForClass获取的分类就是空的,分类并不是在methodlizeClass被附加,而是在read_images中处理分类的部分进行加载。

void _read_images(header_info **hList, uint32_t hCount, int totalClasses, int unoptimizedTotalClasses){
    ...
    addUnattachedCategoryForClass(cat, cls, hi);
    if (cls->isRealized()) {
         remethodizeClass(cls);
         classExists = YES;
    }
    ...
}

这里先把未附加但是已读取到的分类addUnattachedCategoryForClass加入对应的哈希表中,判断类是否实现cls->isRealized(),因为是非懒加载类,类必然已经实现,就调用remethodizeClass重新附加分类。

2.5.2 非懒加载类和懒加载分类

先在创建的分类中添加方法名为categoryInstanceMethodcategoryClassMethod的两个方法。然后再分析:

既然是非懒记载类,那read_images - realizeClassWithoutSwift - methodizeClass顺序是固定的,

static void methodizeClass(Class cls){
    ...
    category_list *cats = unattachedCategoriesForClass(cls, true /*realizing*/);
    attachCategories(cls, cats, false /*don't flush caches*/);
    ...
}

来到methodlizeClass准备附加分类时,对应分类是懒加载的,不会被存入哈希表中,unattachedCategoriesForClass获取的就是空的(这里和第一种情况同样是获取到的哈希表是空的,但是第一种是还未添加到表中,这种是不会被添加到表中,需要加以区分)。

这时候懒加载分类被安置到哪里了?

methodlizeClass查看类的ro可以看到

categoryInstanceMethodcategoryClassMethod分别被添加到CJFPerson的类和元类的ro中(这是类和元类两次调用的图,看它们的count不一样就可以证明),也验证分类的数据结构中,需要有instanceMethodsclassMethods字段的原因(分开存储)。

所以当是非懒加载分类时,分类的数据从早被编译好的ro中读取出来,接下来被复制到类的rw中。

2.5.3 懒加载类和懒加载分类

因为是懒加载分类,数据依然是先被加载到ro中;

因为是懒加载类,在启动时类不会被加载。

那么类何时被加载?

可以想像,懒加载的原理就是第一次被使用的时候被加载。那么尝试调用下方法使用它,调用方法自然会来到消息发送流程,这部分在方法查找流程分析过lookUpImpOrForward方法,但是那时候忽略了一个步骤:

IMP lookUpImpOrForward(Class cls, SEL sel, id inst, 
                       bool initialize, bool cache, bool resolver{     ...
    if (!cls->isRealized()) {
          cls = realizeClassMaybeSwiftAndLeaveLocked(cls, runtimeLock);
    }
    ...
}

在发送消息时,会判断发送消息的这个类是否被实现过,如果没有,就会层层调用到realizeClassWithoutSwift去实现类。

realizeClassWithoutSwift - methodizeClass的顺序依然是固定的。

methodizeClass中,从ro读取分类信息到类的rw中。

2.5.4 懒加载类和非懒加载分类

因为是懒加载类,那read_images中的非懒加载类处理不会被执行;

因为是非懒加载分类,read_images中的分类处理会被执行。

void _read_images(header_info **hList, uint32_t hCount, int totalClasses, int unoptimizedTotalClasses){
    ...
    addUnattachedCategoryForClass(cat, cls, hi);

    if (cls->isRealized()) {
         remethodizeClass(cls);
         classExists = YES;
    }
    ...
}

此时,分类数据被addUnattachedCategoryForClass添加到哈希表中,可是类还没有被实现,cls->isRealized()false,不会进来执行remethodizeClass附加分类信息到类。

这也好理解,类都没准备好,分类怎么能附加。

那么这时候,类怎么加载呢,也是等第一次发送消息嘛,可是分类的+load方法已经在启动时急不可耐的需要被执行,此时分类的主体--类却还没被加载,有些说不过去。

还记得上面分类的+load方法调用流程嘛(特意标记过,可以回头结合这里理解下)。

在分类的+load方法被调用前,系统会执行realizeClassWithoutSwift做个容错来保证类被加载过。可是除了容错,主要目的是同时兼顾处理了这么一种情况(懒加载类和非懒加载分类)。

所以,当非懒加载分类的+load方法被执行前,会调用realizeClassWithoutSwift - methodizeClass来加载类,此时就不是通过ro了,

static void methodizeClass(Class cls){
    ...
    category_list *cats = unattachedCategoriesForClass(cls, true /*realizing*/);
    attachCategories(cls, cats, false /*don't flush caches*/);
    ...
}

而是通过unattachedCategoriesForClass取出哈希表中的分类,调用attachCategories把分类附加到类中。

2.5.5 总结

这四种情况比较绕,需要静下心来分析理解原理,这里也直接给出结论:

  • 是否是懒加载,取决于+load方法。
  • 如果分类是懒加载的,编译时就会被加入ro
  • 如果分类是非懒加载的,根据类是否是懒加载来决定加载流程。如果是懒加载类,会在分类的+load方法调用前被加载;如果是非懒加载类,类更早一步被加载。
  • 如果类是懒加载的,根据分类是否是懒加载来决定加载流程。如果是懒加载分类,在第一次使用时被加载;如果是非懒加载分类,在分类的+load方法调用前被加载。

2.6 分类和类拓展

分析完分类,顺便分析下类拓展。

分类和类拓展的区别是什么?这可能是面试过程中经常被问到的问题之一。

类拓展可以添加属性;分类不能直接添加属性。答案是很简单,那为什么分类不能直接添加属性?

创建CJFPerson的类拓展CJFPerson+Extension,并导入头文件,添加属性extensionName,在之前CJFPerson的分类中添加属性categoryName

然后

clang -rewrite-objc CJFPerson.m -o CJFPerson.cpp

查看编译后的源文件,搜索两个属性名

很明显,extensionName在编译时就已经被确定,categoryName却没有。

所以类拓展在编译时就被当作类的一部分被加载,而分类却是运行时把数据附加到类的。

而编译后ro已经确定,属性会生成的ivar要加入ro是不被允许的,rw中又没有ivar_list字段。而且系统只声明类settergetter方法,并没有实现。

所以分类不能直接添加属性,但是可以使用关联对象动态添加。

需要注意的是,如果没有导入类拓展的头文件,类拓展不会被系统编译。

2.7 +initialize 分析

+initialize+load方法因为都是系统较早执行的方法,经常被拿来做比较。

简单来分析下+initialize的实现源码:

总的来说,系统调用某个类的+initialize前,会先递归实现父类的+initialize,然后调用callInitialize来真正实现

void callInitialize(Class cls)
{
    ((void(*)(Class, SEL))objc_msgSend)(cls, SEL_initialize);
    asm("");
}

callInitialize内部是通过发送消息的方式调用SEL_initialize的。

那么+initialize何时被调用?

创建CJFPerson类,父类和分类,都实现+initialize,初始化CJFPerson,然后运行

然后在做三件事件事:

  • 取消CJFPerson的初始化,在运行,发现没有任何打印。
  • 初始化多个CJFPerson,在运行,发现没有多次打印。
  • 注释类和分类的+initialize,在运行,发现父类的被打印两次。

测试后,基本可以有这样的结论

  • +initialize在类第一次发送消息的时候被执行
IMP lookUpImpOrForward(Class cls, SEL sel, id inst, 
                       bool initialize, bool cache, bool resolver)
{
    ...
    if (initialize  &&  !cls->isInitialized()) {
        initializeNonMetaClass (_class_getNonMetaClass(cls, inst));
    }
    ...
}

来到lookUpImpOrForward,发现还有一处细节,发送消息前会检查这个类是否被初始化,未初始化会调用initializeNonMetaClass,结论得以验证。

  • 分类的+initialize把主类的+initialize"覆盖"了。

这是因为是发送消息,方法会被添加到方法列表,分类的方法会添加在主类之前,导致这一现象。上篇文章详细的分析过这个现象。

  • 如果子类没有实现+initialize,父类有实现,在初始化子类时,父类的+initialize会被多次调用。

这是因为initializeNonMetaClass中是递归查找父类的,所有优先执行父类的+initialize,又因为是发送消息的,在调用子类的+initialize时,子类方法列表找不到,就会去父类的方法列表查找,造成多次调用的现象。

3.写在最后

分类是日常开发中常用的技术,也是面试中的常客,是必须要掌握的。分类和类是不可分割的,所以这一章的内容是需要和上一章类的加载分析结合来理解的。

近期在研究逆向相关,导致底层系列的文章更新较慢。后续会陆续更新多线程block等底层原理,及组件化实战过程。敬请关注。