面试题 For BearLin语言特性iOS技术交流

load 和 initialize 方法的执行顺序以及类和对象的

2016-05-12  本文已影响1381人  AidenRao

先了解一下应用启动之后,做了什么。
main.m 中的 main() 是程序的入口,但在进入 main 函数之前,程序就执行了很多代码(不然也不会启动那么久)。
启动后执行顺序:
将程序依赖的动态链接库加载进内存
加载可执行文件中的所有符号、代码
runtime 解析被编译过的符号代码,遍历所有 Class,按继承层级依次调用Class 的 load 方法和其 Category 的 load 方法。

load 方法的执行顺序

首先来做点测试,来看看 load 方法的执行顺序。
先建一个 Single View Application。展开 Build Phases 的 Compile Sources,如下图:


屏幕快照 2016-05-10 13.02.51.png

在每个类的 @implementation 里加上

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

来看看每个类的 load 方法的调用顺序(main.m 我做了特殊处理)。以下是运行结果:

    JMTestDemo[14939:1769791] +[ViewController load]
    JMTestDemo[14939:1769791] +[AppDelegate load]
    JMTestDemo[14939:1769791] +[ClassMain load]

顺序和 Compile Sources 顺序一致,目前来看,Compile Sources 的顺序就是 load 方法的调用顺序。

再来一个测试,依次添加 ClassFather,以及它的子类 ClssSon,以及另一个 ClassA。结果 Compile Sources 的顺序和我想象中的不一样,如下图


屏幕快照 2016-05-10 13.10.18.png

看起来毫无规律啊。(这是一个问题,先记录一下)

这个时候我改变顺序,将 ClassSon 移动到最前面,ClassFather 移动到最后面。如图


屏幕快照 2016-05-10 13.49.31.png

下面是运行结果:

    JMTestDemo[15034:1788736] +[ClassFather load]
    JMTestDemo[15034:1788736] +[ClassSon load]
    JMTestDemo[15034:1788736] +[ViewController load]
    JMTestDemo[15034:1788736] +[AppDelegate load]
    JMTestDemo[15034:1788736] +[ClassA load]
    JMTestDemo[15034:1788736] +[ClassMain load]

也就是本应先执行 ClassSon load 方法,但先执行了 ClassFather load 方法,再来一次测试。
我将 ClassSon 里的 load 方法注释掉,以下是运行结果:

    JMTestDemo[15055:1791953] +[ViewController load]
    JMTestDemo[15055:1791953] +[AppDelegate load]
    JMTestDemo[15055:1791953] +[ClassA load]
    JMTestDemo[15055:1791953] +[ClassMain load]
    JMTestDemo[15055:1791953] +[ClassFather load]

也就是说,只有在你重写了 load 方法的时候,会在执行你的 load 之前,当父类未加载时,先执行父类的 load 方法。

再来一个分类的情况,分类就很有意思了。先添加了 ClssSon+category,ClssSon+category2,ClassFather+category。将它们移动至最上方。如图

屏幕快照 2016-05-10 14.10.56.png

运行结果

    JMTestDemo[15181:1808284] +[ClassFather load]
    JMTestDemo[15181:1808284] +[ClassSon load]
    JMTestDemo[15181:1808284] +[ViewController load]
    JMTestDemo[15181:1808284] +[AppDelegate load]
    JMTestDemo[15181:1808284] +[ClassA load]
    JMTestDemo[15181:1808284] +[ClassMain load]
    JMTestDemo[15181:1808284] +[ClassSon(category2) load]
    JMTestDemo[15181:1808284] +[ClassFather(category) load]
    JMTestDemo[15181:1808284] +[ClassSon(category) load]

明明应该最早执行 load 方法的分类,却统统最后执行,甚至晚于和它没有啥关系的 ClassMain load。所以分类低人一等,最晚执行 load 方法。

得出结论,load 的执行顺序满足以下几条

initialize 方法的执行顺序

这个时候来测试 initialize 的执行顺序,在 ClassFather ClassSon 以及他们的分类都重写 initialize 方法,并将 ClassFather 移动至 ClassSon 的前面,在 ClassFather load 里添加调用 ClassSon 的类方法的代码,如下

+ (void)load {
    NSLog(@"%s", __func__); 
    [ClassSon method];
}

运行结果如下

    JMTestDemo[15325:1836741] +[ClassFather load]
    JMTestDemo[15325:1836741] +[ClassFather(category) initialize]
    JMTestDemo[15325:1836741] +[ClassSon(category) initialize]
    JMTestDemo[15325:1836741] +[ClassSon method]
    JMTestDemo[15325:1836741] +[ClassSon load]
    JMTestDemo[15325:1836741] +[ViewController load]
    JMTestDemo[15325:1836741] +[AppDelegate load]
    JMTestDemo[15325:1836741] +[ClassA load]
    JMTestDemo[15325:1836741] +[ClassMain load]
    JMTestDemo[15325:1836741] +[ClassSon(category2) load]
    JMTestDemo[15325:1836741] +[ClassSon(category) load]
    JMTestDemo[15325:1836741] +[ClassFather(category) load]

从运行结果来看,先执行了 ClassFather(category) initialize,再执行了 ClassSon(category) initialize,而 ClassSon load 在后面执行。
也就是说 load 方法还未执行也不会影响到这个类的使用。
另一个现象是执行子类 initialize 的时候会先执行其父类的 initialize。且 category 的覆写效应对 load 方法无效,但对 initialize 方法有效。且按 Complile Sources 的顺序,ClassSon(category2) 先覆写了 ClassSon 的 initialize 方法,接着 ClassSon(category) 覆写了 ClassSon(category2) 的 initialize。

如果将子类以及类别的 initialize 注释掉,再修改 ClassFather(category) initialize ,如下

+ (void)initialize {
    NSLog(@"调用者:%@ 调用方法:%s",NSStringFromClass(self), __func__);
}

结果如下

    JMTestDemo[15458:1863222] +[ClassFather load]
    JMTestDemo[15458:1863222] 调用者:ClassFather 调用方法:+[ClassFather(category) initialize]
    JMTestDemo[15458:1863222] 调用者:ClassSon 调用方法:+[ClassFather(category) initialize]
    JMTestDemo[15458:1863222] +[ClassSon method]

也就是子类会继承父类的 initialize 。当执行完父类的 initialize 方法,准备执行子类的 initialize 方法时,会根据继承链找到父类的 initialize 执行。为了防止重复执行 initialize 里的代码,可以根据调用者来决定是否执行 initialize 里的其它代码。

类和对象

这块写了一部分,但查资料的时候查到写的非常不错的,我觉得我没有写的必要了,留下链接,强烈建议想对 iOS 开发中的类和对象有更深了解的人看看。

还是自己写写掌握的更多点,写的有点乱,边学边写把,以后再整理。

对象是在 iOS 开发中最常用的东西,也是最多的东西,一个对象它会有自己的属性,自己的行为,想像中一个对象所占用的空间会很大,但其实一个对象所占的空间是非常少的,少到只存储了必要的数据。isa_t 指针(虽然它已经并不单纯是一个指针,但本文还是以它的主要用途来称呼它)和这个对象的成员变量的值。

比如一个 Student 类型对象,只有 height 和 weight 两个 NSInteger 类型成员变量。那么 Student 类型对象 jim 所占内存空间只需要 24 bytes (64 位机型 NSInteger 以及 isa_t 指针都是 8 bytes)。不过考虑到 CPU 的存取原理,不内存对齐一下 CPU 的内存开销会很大,所以这样一个 jim 对象只会占用 32 bytes。(有关内存对齐可以阅读我同学写的博客了解一下)

你平时操作的对象就只占这么点内存,成员变量的存储位置按顺序跟在 isa_t 指针后面。想知道 height 的值要放在哪个位置,那么 isa_t 的作用就体现出来了,isa_t 指针现在已经不单纯是个指针,isa_t 是一个 union 类型的结构体,之所以会从 isa 指针改成 isa_t 结构体,也是因为在 64 位的 CPU 上,使用整个指针大小的内存来存储 isa 指针有些浪费,在 ARM64 运行的 iOS 只使用了 33 位作为指针(33 位够指向 8GB 内存地址,近期内看是远远够用的,何况指向的是虚拟内存地址,一个 APP 想申请 8GB 以上的内存空间,这是要上天吗?),而剩下的 31 位用于其它目的。而 33 位的指针指向了这个对象对应的类对象 Student,而储存类对象是一个 obj_class 的结构体,里面含有一个 class_data_bits_t 结构体,里面只存放着一个 64 位的 uintptr_t 结构体 用于存储与类有关的信息,和 isa_t 结构体类似。

在编译期间,其中的 33 位作为一个指针指向 class_ro_t 结构体,class_ro_t 结构体主要用于存储当前类在编译期就已经确定的属性、方法以及遵循的协议。如下图

图片来源:Draveness

而在运行的时候由 runtime 将类对象的结构变成下图。

图片来源:Draveness

class_rw_t 结构体存储着指向只读区域 class_ro_t 结构体的 ro 指针,
以及将类自己实现的方法(包括分类)、属性和遵循的协议加载到 methods、 properties 和 protocols 列表中。
而 class_rw_t 的结构体里 methods 指向的是方法链表。runtime 加载分类的时候,会将分类里的方法从 methods 的头部开始添加。而发送消息的时候,在类里找方法也是从 methods 头部开始寻找,找到了就停止,所以分类的方法会”覆盖”本来类的同名方法。晚加载的分类也会”覆盖”早加载的分类的方法。
既然是链表,就意味着每次查找方法都会是比较耗时的操作,所以才会在 obj_class 结构体里有一个缓存已经调用过的方法的 cache_t 结构体。

cache_t 结构体里面有个指针指向一个散列表,用来以选择子作为 key,函数地址作为 value。查找方法对应的函数地址时,会先从这个散列表里查询有没有缓存,没有缓存命中的话才会去 methods 里查找方法。而之所以 methods 使用链表而不是更快的散列表是因为类对象的方法列表需要可以动态添加的。找不到的话就得去找爸爸帮忙了。爸爸,爸爸,这个行为我不会,你会的话帮我执行这个行为。爸爸也先找自己的缓存表,没有就在 methods 里找,也不会就找爷爷,爷爷也不会的话,看他还有在世的爸爸不,没有的话 看看方法接收类能不能动态添加这个行为,也就是现学,当然它的爸爸或者爷爷现学也行,都太懒不想学就吼一句,隔壁老王老李老赵你们有人会吗?有人会的话就将事情扔给他们,这就是方法转发。如果没人愿意的话那就不执行这个行为了,丢弃。有一点就是如果缓存中的内容大于容量的 3/4 就会扩充缓存,(为什么不是满了的时候而是 3/4 呢,毕竟是散列表,太满的话查询和插入数据的效率都会变低)使缓存的大小翻倍。但在缓存翻倍的过程中,当前类全部的缓存都会被清空,Objective-C 出于性能的考虑不会将原有缓存拷贝到新初始化的内存中。

再来看看 objc_ivar_list 结构体,里面有 objc_ivar 结构体数组,记录着每个成员变量的,名字,类型,偏移量。在新建一个对象的时候,就会根据名字,类型,偏移量来设计这个对象的内存结构,设计跟在 isa_t 结构体地址后面的成员变量的位置。

typedef struct objc_ivar_list {  
    int   ivar_count;                               
    struct objc_ivar {  
        const char* ivar_name;                      
        const char* ivar_type;                        
        int        ivar_offset;                             
    } ivar_list[1];                                
} IvarList, *IvarList_t;  

类的方法和属性都是可以动态添加的,但是成员变量不行,是因为当一个对象生成之后,会为它分配一块固定大小的空间,但是你添加了一个实例变量就相当于增加了需要的空间。成员变量是靠与对象地址的偏移量来确定地址的,就和一个声明了固定空间的数组一样,无法再在后面申请新的空间存放数据(因为这些内存可能有其它的对象在使用)。

未完待续(也可能太监)

参考资料

iOS程序main函数之前发生了什么
从 NSObject 的初始化了解 isa
深入解析 ObjC 中方法的结构

上一篇下一篇

猜你喜欢

热点阅读