美文网首页面试那些事儿iOS底层原理深度体会文学
Runtime笔记(二)—— Class结构的深入分析

Runtime笔记(二)—— Class结构的深入分析

作者: RUNNING_NIUER | 来源:发表于2019-08-08 12:07 被阅读52次

    Runtime系列文章

    Runtime笔记(一)—— isa的深入体会(苹果对isa的优化)
    Runtime笔记(二)—— Class结构的深入分析
    Runtime笔记(三)—— OC Class的方法缓存cache_t
    Runtime笔记(四)—— 刨根问底消息机制
    Runtime笔记(五)—— super的本质
    [Runtime笔记(六)—— Runtime的应用...待续]-()
    [Runtime笔记(七)—— Runtime的API...待续]-()
    Runtime笔记(八)—— 面试题中的Runtime

    我在OC对象的本质(下)—— 详解isa&superclass指针中,有介绍过Class对象的内存结构,如下图

    实例对象、类对象、元类对象的内存布局
    本文就以此为起点,来仔细挖掘一下Class内部都有哪些宝贝。

    (一)Class的结构简述

    首先我们来看一下objc源码对Class的定义

    struct objc_class : objc_object {
        // Class ISA;
        Class superclass;
        cache_t cache;             // formerly cache pointer and vtable  方法缓存
        class_data_bits_t bits;    // class_rw_t * plus custom rr/alloc flags  用于获取具体的类信息
    
        class_rw_t *data() { 
            return bits.data();
        }
        void setData(class_rw_t *newData) {
            bits.setData(newData);
        }
         //......下面的一堆方法/函数代码可以暂时不用关注
    };
    

    结构比较清晰

    • Class superclass;——用于获取父类,也就是元类对象,它也是一个Class类型
    • cache_t cache;——是方法缓存
    • class_data_bits_t bits;——用于获取类的具体信息,看到bits,想必看过我写的Runtime之isa的深入体会(苹果对isa的优化)这篇文章,一定会深有体会。
    • 紧接着有一个class_rw_t *data()函数,该函数的作用就是获取该类的可读写信息,通过class_data_bits_tbits.data()方法获得,点进该方法看一下
    class_rw_t* data() {
            return (class_rw_t *)(bits & FAST_DATA_MASK);
        }
    

    可以看到,跟我们之前通过isa获取对象地址操作很像,这里是将类对象里面的class_data_bits_t bits;和一个FAST_DATA_MASK进行&运算取得。返回的是一个指针,类型为class_rw_t *,查看该类型的源码如下

    struct class_rw_t {
        // Be warned that Symbolication knows the layout of this structure.
        uint32_t flags;
        uint32_t version;
    
        const class_ro_t *ro;
    
        method_array_t methods;        //⚠️⚠️⚠️方法列表
        property_array_t properties;    //⚠️⚠️⚠️属性列表
        protocol_array_t protocols;      //⚠️⚠️⚠️协议列表
    
        Class firstSubclass;
        Class nextSiblingClass;
    
        char *demangledName;
    }
    

    我们知道,OC类的方法、属性、协议都是可以动态添加,也就是可读可写的,从上面的源码中,可以发现确实是有对应的成员来保存方法、属性、协议的信息。而从该结构体的名字class_rw_t,也暗含了上述的方法、属性、协议信息,是可读可写的。另外,我们知道Class类里面的成员变量是不可以动态添加的,也就是属于只读内容,相应的,可以推断const class_ro_t *ro;就是指向了该部分内容信息的指针。同样,查看其源码

    struct class_ro_t {
        uint32_t flags;
        uint32_t instanceStart;
        uint32_t instanceSize;//instance对象占用的内存空间
    #ifdef __LP64__
        uint32_t reserved;
    #endif
    
        const uint8_t * ivarLayout;
        
        const char * name;//类名
        method_list_t * baseMethodList;//方法列表
        protocol_list_t * baseProtocols;//协议列表
        const ivar_list_t * ivars;//成员变量列表
    
        const uint8_t * weakIvarLayout;
        property_list_t *baseProperties;属性列表
    
        method_list_t *baseMethods() const {
            return baseMethodList;
        }
    };
    

    这个结构体里面,就存放了一些类相关的只读信息

    • uint32_t instanceSize;——instance对象占用的内存空间
    • const char * name;——类名
    • const ivar_list_t * ivars;——类的成员变量列表

    以上的发现都符合逻辑,但是发现这里面也有跟类的方法、属性、协议相关的信息
    method_list_t * baseMethodList;protocol_list_t * baseProtocols;property_list_t *baseProperties;
    这跟我们前面在class_rw_t中看到的
    method_array_t methods;property_array_t properties;protocol_array_t protocols;有何关联?有何不同呢?带着这些疑问,继续往下分析。

    首先上面的部分用一张图大致总结如下


    这个图可以理解成稳定状态下,Class的内部结构。但事实上,在程序启动和初始化过程中,Class并不是这样的结构,我们通过源码可以分析一下。我在Objective-C之Category的底层实现原理有分析过从objc初始化到category信息加载过程的源码执行路径,这里就不在重复,只作简单表述,有不明白的话可以通过该文章补充。
    • 首先在objc-os.mm中找到objc的初始化函数void _objc_init(void)
    • 继续进入_dyld_objc_notify_register(&map_images, load_images, unmap_image);里面的map_images函数
    • map_images函数里面继续进入map_images_nolock函数
    • map_images_nolock函数末尾,进入_read_images函数入口
    • _read_images函数实现里面,可以发现在处理category信息之前,也就是注释// Discover categories的上面的那部分代码,就是类的初始化处理步骤
    // Realize newly-resolved future classes, in case CF manipulates them
        if (resolvedFutureClasses) {
            for (i = 0; i < resolvedFutureClassCount; i++) {
                realizeClass(resolvedFutureClasses[i]);
                resolvedFutureClasses[i]->setInstancesRequireRawIsa(false/*inherited*/);
            }
            free(resolvedFutureClasses);
        }    
    
        ts.log("IMAGE TIMES: realize future classes");
    

    进入核心函数realizeClass

    static Class realizeClass(Class cls)
    {
        runtimeLock.assertWriting();
    
        const class_ro_t *ro;
        class_rw_t *rw;
        Class supercls;
        Class metacls;
        bool isMeta;
    
        if (!cls) return nil;
        if (cls->isRealized()) return cls;
        assert(cls == remapClass(cls));
    
        // fixme verify class is not in an un-dlopened part of the shared cache?
    
        ro = (const class_ro_t *)cls->data(); //-----⚠️⚠️⚠️最开始,类的data()得到的直接就是class_ro_t
        if (ro->flags & RO_FUTURE) {
            // This was a future class. rw data is already allocated.
            //-----⚠️⚠️⚠️如果rw已经分配了内存,则rw指向cls->data(),然后将rw的ro指针指向之前最开始的ro
            rw = cls->data();
            ro = cls->data()->ro;
            cls->changeInfo(RW_REALIZED|RW_REALIZING, RW_FUTURE);
        } else {
            // Normal class. Allocate writeable class data. //-----⚠️⚠️⚠️如果rw还没有分配内存
            rw = (class_rw_t *)calloc(sizeof(class_rw_t), 1); //-----⚠️⚠️⚠️给rw分配内存
            rw->ro = ro;将rw的ro指针指向初始的ro
            rw->flags = RW_REALIZED|RW_REALIZING;
            cls->setData(rw); //-----⚠️⚠️⚠️调整类的data()
        }
    
        isMeta = ro->flags & RO_META;
    
    
        ......
        ......
        ......
    }
    
    

    从该函数,我们可以看出,

    • 最开始的时候,Class是没有读写部分的,只有只读部分,也就是ro = (const class_ro_t *)cls->data();
    • 接下来,才会分配空间给读写信息,也就是rw,然后通过rw->ro = ro;class_rw_t自身的ro指针指向真正的ro信息。
    • 通过cls->setData(rw);cls->data()进行修改,并最终通过FAST_DATA_MASK作用指向rw

    完成上面的步骤之后,便可以开始处理category信息的加载了。接下来对于Class的分析,我只论述方法列表部分的处理,属性和协议部分其实跟方法的处理逻辑是相似的。
    回到我们上面的问题,class_ro_tclass_rw_t中都有方法列表,它们有什么区别呢?

    (二)class_ro_t

    实际上,class_ro_t代表Class的只读信息,也就是Class本身的固有信息,再直接一点就是是写在它的@interface@end之间的方法,属性,等信息,当然最重要的作用还是存放类的成员变量信息ivars,而且是被const修饰说明是不可修改的,这也就是为什么Runtime无法动态增加成员变量,底层结构决定的。我个人将这部分理解成OC的静态信息。class_ro_t中的method_list_t * baseMethodList;//方法列表,是一个一维数组,里面装的就是这个Class本身的方法。

    (三)class_rw_t

    在有了class_rw_t之后,便会进行category的处理,将Class本身的方法列表和category里面的方法列表先后放到class_rw_tmethod_array_t methods里面,Class自身的方法列表会被最先放入其中,并且置于列表的尾部,category方法列表的加入顺序等同与category文件参与编译的顺序,这部分流程的详细说明我在Objective-C之Category的底层实现原理一文里有详细介绍。
    因此,method_array_t methods;是一个二维数组,如下图

    (四)method_t

    上面我们剖析了class_rw_tclass_ro_t这两个重要部分的结构,并且主要关注了其中的方法列表部分,而从上面的分析,可发现里面最基本也是重要的单位是method_t,这个结构体包含了描述一个方法所需要的各种信息。下面,就对它进行一次彻底的扫描。
    首先看一下源码里面对它的定义

    struct method_t {
        SEL name;
        const char *types;
        IMP imp;
    };
    

    IMP imp——指向函数的指针(也就是方法/函数实现的地址),它也是在objc.h 里面被定义的:typedef id _Nullable (*IMP)(id _Nonnull, SEL _Nonnull, ...);

    SEL name——我们都知到SEL是方法选择器,可以理解成方法的名字,但是它到底是什么鬼?其实,他是在objc.h 里面被定义的:typedef struct objc_selector *SEL;,它就是一个指针类型,指向了结构体类型struct objc_selector,很遗憾苹果没有对其开源,但是请看下面的代码

    @implementation ViewController
    
    - (void)viewDidLoad {
      [super viewDidLoad];
     NSLog(@"%s",@selector(test));
    }
    
    -(void)test {
      NSLog(@"%s",__func__);
    }
    @end
    
    *************************************
    
    2019-08-05 21:37:11.603121+0800 iOS-Runtime[2093:302816] test
    

    从结果看,利用test方法的SEL指针进行打印,输出了test字符串,说明结构体struct objc_selector内部包含了一个char *的成员变量,并且应该是该结构体的第一个成员。因此我们可以用字符串来理解SEL,它主要的作用就是用来表示方法的名字。它与字符串之间可通过如下方法转换

    • 可以通过@selector()sel_registerName()将字符串转换成SEL
    • 可以通过sel_getName()NSStringFromSelector()SEL转换成字符串
    • 不同类中的相同名字的方法,所对应的SEL是相同的,也就是说对于XXX方法来说,它们的SEL都指向内存里同一个struct objc_selector结构体对象,不论有多少个类里面定义了该XXX方法。
    • const char *types——函数类型编码(包括返回值类型、参数类型),iOS提供了一个@encode指令,可以将具体的类型表示成字符串编码,也就是通过字符串来表示类型。主要目的是为了方便运行时,将函数的返回值和参数的类型通过字符串来描述并且存储。请看如下代码示例
           NSLog(@"%s",@encode(int));
           NSLog(@"%s",@encode(float));
           NSLog(@"%s",@encode(int *));
           NSLog(@"%s",@encode(id));
           NSLog(@"%s",@encode(void));
           NSLog(@"%s",@encode(SEL));
           NSLog(@"%s",@encode(float *));
    *******************************************
    2019-08-06 16:13:22.136917+0800 iOS-Runtime[8904:779780] i
    2019-08-06 16:13:22.137461+0800 iOS-Runtime[8904:779780] f
    2019-08-06 16:13:22.137549+0800 iOS-Runtime[8904:779780] ^i
    2019-08-06 16:13:22.137639+0800 iOS-Runtime[8904:779780] @
    2019-08-06 16:13:22.137718+0800 iOS-Runtime[8904:779780] v
    2019-08-06 16:13:22.137832+0800 iOS-Runtime[8904:779780] :
    2019-08-06 16:13:22.137912+0800 iOS-Runtime[8904:779780] ^f
    

    从上面的打印可以看出各种不同的类型所对应的字符串表达。OC的方法的类型也是按照这个方法来表示的,只不过是把方法里面的返回值和参数的类型组合起来表示
    例如- (int)test:(int)age height:(float)height,我们知道OC方法对应的底层函数前两个是默认参数id selfSEL cmd,那么刚才的方法从左到右,返回值和参数的类型分别为int->id->SEL->int->float,转换成类型编码,就是i-@-:-i-f,而最终系统是这样表示的i24@0:8i16f20,你应该会好奇,里面怎么多了一些数字,其实它们是用来描述函数的参数的长度和位置的的,从左到右可以这么解读:

    • i —— 函数的返回值类型为int
    • 24 —— 参数所占的总长度(24字节)
    • @ —— 第一个参数id
    • 0 —— 第一个参数在内存中的起始偏移量(0字节,也就是从第0个字节开始算起)
    • : —— 第二个参数SEL
    • 8 —— 第二个参数在内存中的起始偏移量(8字节,也就是从第8个字节开始算起,因此上面的id参数占之前的8个字节)
    • i —— 第三个参数int
    • 16 —— 第三个参数在内存中的起始偏移量(16字节,也就是从第16个字节开始算起,因此上面的SEL参数占用了之前的8个字节)
    • f —— 第四个参数float
    • 20 —— 第四个参数在内存中的起始偏移量(20字节,也就是从第20个字节开始算起,因此上面的int参数占用了前面的4个字节,而总长度为24,因此最后的4个字节是给float参数用的)

    如此一来,对于任意的OC方法,它的method_t里面的types字符串的值都可以依照上面的过程推导出来了。你在苹果官方文档搜索Type Encoding就可以找到更具体的介绍,里面有所有参数类型对应的字符串表达对照表。

    根据上面的研究,method_t里面的三个成员变量就提供了我们对于一个OC方法所需要的所有信息,关于method_t的解读就暂到这里。

    (五)cache_t方法缓存

    为了避免篇幅过长,方法缓存的探究请移步到下篇文章——
    Runtime笔记(三)—— OC Class的方法缓存cache_t

    Runtime系列文章

    Runtime笔记(一)—— isa的深入体会(苹果对isa的优化)
    Runtime笔记(二)—— Class结构的深入分析
    Runtime笔记(三)—— OC Class的方法缓存cache_t
    Runtime笔记(四)—— 刨根问底消息机制
    Runtime笔记(五)—— super的本质
    [Runtime笔记(六)—— Runtime的应用...待续]-()
    [Runtime笔记(七)—— Runtime的API...待续]-()
    Runtime笔记(八)—— 面试题中的Runtime

    相关文章

      网友评论

        本文标题:Runtime笔记(二)—— Class结构的深入分析

        本文链接:https://www.haomeiwen.com/subject/uucmdctx.html