Runtime原理探究(二)—— Class结构的深入分析

1,947 阅读11分钟

Runtime系列文章

Runtime原理探究(一)—— isa的深入体会(苹果对isa的优化)

Runtime原理探究(二)—— Class结构的深入分析

Runtime原理探究(三)—— OC Class的方法缓存cache_t

Runtime原理探究(四)—— 刨根问底消息机制

Runtime原理探究(五)—— super的本质

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原理探究(一)—— isa的深入体会(苹果对isa的优化)

Runtime原理探究(二)—— Class结构的深入分析

Runtime原理探究(三)—— OC Class的方法缓存cache_t

Runtime原理探究(四)—— 刨根问底消息机制

Runtime原理探究(五)—— super的本质

Runtime原理探究(六)—— 面试题中的Runtime