iOS探索底层-类的结构探索(下)

279 阅读15分钟

前言

iOS探索底层-类的结构探索(上)一文中,我们探索了类的结构中很重要的两个指针,Class isaClass superclass。在这我们继续探索类的结构。

类的数据存储区

WWDC 2020

WWDC 2020里面有一段是苹果介绍他对整个runtime的机制进行调整,从而优化了类在内存中的占用。具体地址如下:Advancements in the Objective-C runtime。下面我们针对其中关于类的结构的地方做一些分享。

clean memory & dirty memory

clean memory

  • clean memory是指加载后不会发生改变的内存
  • class_ro_t属于clean memory因为它是只读的
  • clean memory可以进行移除,节省更多的内存空间,当需要使用时再从磁盘加载

dirty memory

  • dirty memory是指在进程运行时会发生改变的内存
  • 当类开始使用的时候,系统在运行时会为它分配一片额外的内存,这片内存就是dirty memory
  • dirty memory使用起来代价很高,只要进程在运行,它就必须一直存在

类的结构优化

在2020年之前,苹果设计的类的结构是下面这样的

image.png 我们看到每一个类,在使用后都会创建出一片内存用来存储他在运行时可能修改的数据,也就是dirty memory。我们知道dirty memory是非常昂贵的,苹果显然也意识到了这一点。在苹果的统计数据中,只有大约10%的类真正的修改了他们的方法。因此苹果将class_rw_t这个结构中的一些数据进行拆分,将class_rw_t中不是每个类必须存在的方法拆分到了class_rw_ext_t这个结构中。

image.png 这样,整个class_rw_t的结构大小大约就减少了一半,对于不需要那些额外信息的类就如图示下面这部分内存不需要申请可以直接节省下来。对于那些确实需要额外信息的类,则如下图的流程

image.png

class_data_bits_t

通过苹果在WWDC中的介绍,我们大致的了解了类的结构,下面我们来看看代码

struct objc_class : objc_object {
  objc_class(const objc_class&) = delete;
  objc_class(objc_class&&) = delete;
  void operator=(const objc_class&) = delete;
  void operator=(objc_class&&) = delete;
    // 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_data_bits_t bits

struct class_data_bits_t {
    ……省略部分……
    class_rw_t* data() const {
      return (class_rw_t *)(bits & FAST_DATA_MASK);
    }
    ……省略部分……
}

其中最重要的方法就是这个data()方法可以获取到一个class_rw_t *的结构,也就是苹果介绍的记录类的方法、属性之类数据的地方,继续往里面看去

struct class_rw_t {
    ……省略部分……
    class_rw_ext_t *ext() const {
      return get_ro_or_rwe().dyn_cast<class_rw_ext_t *>(&ro_or_rw_ext);
    }
    
    const class_ro_t *ro() const {
        auto v = get_ro_or_rwe();
        if (slowpath(v.is<class_rw_ext_t *>())) {
            return v.get<class_rw_ext_t *>(&ro_or_rw_ext)->ro;
        }
        return v.get<const class_ro_t *>(&ro_or_rw_ext);
    }    
    
    void set_ro(const class_ro_t *ro) {
        auto v = get_ro_or_rwe();
        if (v.is<class_rw_ext_t *>()) {
            v.get<class_rw_ext_t *>(&ro_or_rw_ext)->ro = ro;
        } else {
            set_ro_or_rwe(ro);
        }
    }

    const method_array_t methods() const {
        auto v = get_ro_or_rwe();
        if (v.is<class_rw_ext_t *>()) {
            return v.get<class_rw_ext_t *>(&ro_or_rw_ext)->methods;
        } else {
            return method_array_t{v.get<const class_ro_t *>(&ro_or_rw_ext)->baseMethods()};
        }
    }

    const property_array_t properties() const {
        auto v = get_ro_or_rwe();
        if (v.is<class_rw_ext_t *>()) {
            return v.get<class_rw_ext_t *>(&ro_or_rw_ext)->properties;
        } else {
            return property_array_t{v.get<const class_ro_t *>(&ro_or_rw_ext)->baseProperties};
        }
    }

    const protocol_array_t protocols() const {
        auto v = get_ro_or_rwe();
        if (v.is<class_rw_ext_t *>()) {
            return v.get<class_rw_ext_t *>(&ro_or_rw_ext)->protocols;
        } else {
            return protocol_array_t{v.get<const class_ro_t *>(&ro_or_rw_ext)->baseProtocols};
        }
    }    
    
    ……省略部分……
}

可以看到里面如苹果介绍一样,含有class_rw_ext_t *ext,class_ro_t *ro等结构,还有存储方法列表、属性列表、协议列表的地方。

通过LLDB分析类的数据结构

从源码我们可以看到,类的结构确实如苹果介绍的那样,现在我们换一个方向,通过LLDB来验证一下,首先我们准备一个类

@interface DMPerson : NSObject {
    NSString *_action;
}
@property (nonatomic, copy) NSString *name;
@property (nonatomic, strong) NSString *nickname;

- (void)sayHello;
+ (void)sayBye;

@end

@implementation DMPerson

- (void)sayHello {
    NSLog(@"hello");
}

+ (void)sayBye {
    NSLog(@"bye");
}
@end

然后开始我们的探索之旅,首先我们需要先找到class_data_bits_t这个结构在哪,那么从哪里入手呢?我们能够获取到类的地址,也就是类的首地址,接下来就是确定class_data_bits_t相对于首地址需要偏移多少才能找到他,我们再来看下类的结构

image.png 我们知道 Class isaClass superclass都是结构体指针的类型,也就是他们一共占用了16个字节,接下来在我们只需要知道cache_t cache占用多少字节,就可以知道需要偏移多少了

image.png 可以看到cache_t cache结构中主要包含两个,一个是unsigned long类型的泛型,占用8个字节,下面是一个联合体,里面分别是一个结构体和一个preopt_cache_t *类型的泛型,其中preopt_cache_t *类型的泛型占用8个字节。在来看看联合体中的结构体,mask_t类型的泛型占用4个字节,_flags_occupied都是uint16_t分别占用2个字节,因此整个联合体占用8个字节,而cache_t就一共占用8+8=16字节。所以最终我们需要偏移16 + 16 = 32字节的位置,就能找到我们的class_data_bits_t

image.png 在上面的源代码中,我们了解到,class_rw_t的结构中,会存储我们的方法、属性、协议等,下面我们就分别进行探索

属性的获取

调用properties()方法,能够获取到类中的属性列表

image.png

可以看到这是一个list_array_tt的结构,我们可以理解为一个数组,继续往下面寻找

image.png 我们找到了一个叫做property_list_t的结构,打印他的内容看看

image.png 发现property_list_t中存储的也是一个数组,而且中间含有两个元素,那么简单了,获取这各两个元素看看分别是什么

image.png 就是我们在类中定义的属性namenickname

方法的获取

按照属性的获取的思路,我们对方法的获取进行同样的操作

image.png method_list_t中储存了6个元素,我们获取他们看看

image.png 发现打印出来的是个空,并不能看到我们想要找到的方法名,似乎按照属性的获取思路行不通了。那么我们来看看method_tproperty_t有什么区别。

struct property_t {
    const char *name;
    const char *attributes;
};

struct method_t {
    ……省略部分……
    struct big {
        SEL name;
        const char *types;
        MethodListIMP imp;
    };
    
    struct small {
        // The name field either refers to a selector (in the shared
        // cache) or a selref (everywhere else).
        RelativePointer<const void *> name;
        RelativePointer<const char *> types;
        RelativePointer<IMP> imp;

        bool inSharedCache() const {
            return (CONFIG_SHARED_CACHE_RELATIVE_DIRECT_SELECTORS &&
                    objc::inSharedCache((uintptr_t)this));
        }
    };
    
    big &big() const {
        ASSERT(!isSmall());
        return *(struct big *)this;
    }
    
    objc_method_description *getDescription() const {
        return isSmall() ? getSmallDescription() : (struct objc_method_description *)this;
    }
    
    ……省略部分……
}

可以看到,property_t内部直接就是nameattributes,因此我们能够直接打印输出出来,而method_t则没有直接打印输出的地方,我们发现我们想要知道的内容都在一个叫big的结构体中,那么我们就使用方法去获取他。

image.png 打印后我们发现了我们写的实例方法和系统自动生成的seter&geter方法,以及系统生成的析构函数

在M1的电脑中,由于CPU架构不一样,调用big()方法会报错,提示isSmall = true会直接走断言。因此我们需要调用getDescription()方法来实现打印,具体如下 image.png

成员变量的获取

在之前属性的获取中我们发现,没有我们设置的成员变量_action的存在,那么成员变量存储在哪呢?在苹果的介绍中,我们发现在class_ro_t的结构中有一个ivars的东西,猜想是不是就在其中呢?

image.png 同样按照获取属性的流程,我们轻松的就找到了成员变量所在的地方。

类方法的获取

同样的,我们在刚刚的methods()中,并没有找到类方法的存在,那么类方法会存储在哪里呢?我们知道,实例方法都是类的实例调用的,而实例方法却在类中可以找到。我们同样知道,类也是一个对象,他是元类的实例对象,那么类方法是不是就存储在元类里面呢?我们继续我们的探索

image.png 通过方法的获取流程,我们在元类中找到了类方法的存在。

实际上在C/C++的底层函数中,并不存在实例方法类方法的区别,统一称为函数。而在OC中,我们将他们区分了开来,但是如果他们都储存在类中,如果有同名的方法,那么就冲突了,在调用时底层并不知道要调用哪个方法。元类就是为了解决这个问题,而诞生的一个结构,这也就回答了在上一篇文章中我们的问题,为什么会有元类的存在。

seter方法和geter方法在底层的实现

我们知道,属性(property)和成员变量之间的区别(ivar)就在于

property = _ivar + seter + geter

下面我们就来探索探索seter和getter方法在底层的具体实现

seter方法在底层的实现

依旧是我们的DMPerson类,这次我们通过iOS底层探索——对象的本质&isa分析中介绍的方法,将main函数转换成C/C++编写的底层代码。然后搜索DMPerson我们看到了下面的情况

image.png

很明显的看到,setName在底层调用的是objc_setProperty方法去进行赋值,而setNickname却不是,它使用的是内存偏移的方式,为什么呢?objc_setProperty又是什么呢?我们在回头来看看namenickname的区别

@property (nonatomic, copy) NSString *name;
@property (nonatomic, strong) NSString *nickname;

他们之间只有修饰符的区别,那么他们底层实现不同的原因到底是不是这个修饰符造成的呢?因为只直接通过clang编译看到的底层代码,所以我们直接去LLVM中寻找他们之间的关系。

LLVM分析objc_setProperty方法

LLVM代码中搜索关键字objc_setProperty,我们发现了下面这行代码 image.png 很明显,这是编译器在关联objc_setProperty。顺着这个线索,我们找谁调用了这个方法

image.png 这是一个中间层方法,那么继续往上找

image.png 我们在一个swich的分支中找到了我们需要的东西,那么很简单,既然有swich那么区分他们的strategy.getKind()这个值就是我们接下来继续寻找的线索,而getKind()strategy里面的一个get方法,重点关注PropertyImplStrategy这个结构

    StrategyKind getKind() const { return StrategyKind(Kind); }

PropertyImplStrategy结构中的这行代码告诉我们,Kind的赋值的地方就是我们要寻找的地方,那么继续往下找

image.png 苹果的注释告诉了我们,如果修饰符是copy那么我们通常会使用setProperty来给属性赋值。由于retainarc中几乎已经不使用了,所以我们忽略,直接去找strong

image.png 发现当用strong修饰的时候,Kind会返回CopyStructCopy修饰返回的GetSetProperty不同。到这我们就知道了,使用objc_setProperty方法,确实与copy修饰符有关系。

属性的修饰符与objc_setProperty的关系

我们知道一个属性除了copy,strong这两个修饰符以外,还有nonatomicatomic这个,接下来我们就用实验的方式来测试并验证刚刚的猜想

@property (nonatomic, copy)     NSString *a;
@property (atomic, copy)        NSString *b;
@property (nonatomic, strong)   NSString *c;
@property (atomic, strong)     NSString *d;

重新定义4个属性并用不同的修饰符来修饰,然后查看底层代码如下

image.png 我们发现,是否使用objc_setProperty方法只与属性的修饰符是否是copy有关(Ps.由于ARC环境,我们暂时忽略retain)。

geter方法在底层的实现

实际上,在上面的过程中,我们看到不只是objc_setProperty,还有objc_getProperty这个方法,在调用getter方法的时候,也存在跟setter方法类似的情况,有调用方法和使用内存偏移两种方式来获取值,那么是不是也是值跟copy修饰符有关呢?我们继续去LLVM中寻找答案 除了上面的那种方法以外,我们还找到了另外一个方法

image.png 我们可以看到,在C/C++底层中,如果需要调用某个方法的话,会先引入这个方法

image.png 而在LLVM中,这里的Getr的值就是上图中红框中的代码,那么我们可以认为,只要进到了这个方法里面,那么就会调用objc_getProperty方法。而调用的条件是什么呢?

    bool GenGetProperty =
        !(Attributes & ObjCPropertyAttribute::kind_nonatomic) &&
        (Attributes & (ObjCPropertyAttribute::kind_retain |
                       ObjCPropertyAttribute::kind_copy));

看到这里我们就能得出结论

当属性以copy或者retain修饰并且不以nonatomic修饰的时候,使用objc_getProperty方法来赋值,否则使用内存偏移的方式赋值。

而上面a,b,c,d的getter方法底层代码,也验证了这一结论。

补充

关于 IMP,SEL之间的关系

  • SEL : 类成员方法的指针,但不同于C语言中的函数指针,函数指针直接保存了方法的地址,但SEL只是方法编号。
  • IMP:一个函数指针,保存了方法的地址

SELIMP的关系就可以解释为:

  • SEL就相当于书本的⽬录标题
  • IMP就是书本的⻚码
  • 函数就是具体页码对应的内容

image.png

编码相关的内容

在使用lldb查找类methods()的时候,我们在打印的结果中经常会看到方法的types那个属性的值是类似v24@0:8@16这样的。那么这到底是什么呢?经过查阅,我们发现,原来这是苹果为了方便使用的一套编码格式,其中v代表void返回值,@代表objcet也就是对象,:代表的是一个SEL。苹果在他的官方文档里面有一个对照表

image.png 现在我们知道了符号代表的含义,那么每个符号后面跟的数字是什么含义呢?

  • 首先第一个数字24,表示的是整个方法在内存中占用的空间是24字节
  • 第二个数字0代表@这个参数是从0号位开始存储,一个id占用8个字节
  • 第三个数字8代表:8号位开始存储,一个SEL也是一个指针,占用8字节
  • 第三个数字16代表@16号位开始存储,一个id占用8个字节
  • 总共占用8+8+8 = 24个字节

类相关的面试题

我们看看下面这个很经典的面试题

    BOOL re1 = [(id)[NSObject class] isKindOfClass:[NSObject class]]; 
    BOOL re2 = [(id)[NSObject class] isMemberOfClass:[NSObject class]]; 
    BOOL re3 = [(id)[DMPerson class] isKindOfClass:[DMPerson class]];    
    BOOL re4 = [(id)[DMPerson class] isMemberOfClass:[DMPerson class]];    
 
    NSLog(@" re1 :%hhd\n re2 :%hhd\n re3 :%hhd\n re4 :%hhd\n,%hhd,%hhd",re1,re2,re3,re4,re9,re10);

    BOOL re5 = [(id)[NSObject alloc] isKindOfClass:[NSObject class]];       
    BOOL re6 = [(id)[NSObject alloc] isMemberOfClass:[NSObject class]];     
    BOOL re7 = [(id)[DMPerson alloc] isKindOfClass:[DMPerson class]];       
    BOOL re8 = [(id)[DMPerson alloc] isMemberOfClass:[DMPerson class]];     
    NSLog(@" re5 :%hhd\n re6 :%hhd\n re7 :%hhd\n re8 :%hhd\n",re5,re6,re7,re8);

而输出的结果如下

 re1 :1
 re2 :0
 re3 :0
 re4 :0
 
 re5 :1
 re6 :1
 re7 :1
 re8 :1

为了探究为什么是这个结果,我们在开源的源码中,找到了isKindOfClassisMemberOfClass的实现,并打上了断点,开始调试。

image.png 神奇的事情发生了,isKindOfClass并没有走我们的断点,整个项目直接断在了isMemberOfClass方法里。那么抛开isKindOfClass方法,我们先来分析isMemberOfClass方法

isMemberOfClass

isMemberOfClass的类方法,是将获取调用方的元类与传入的类进行比较,如果相等,则返回true,否则返回false isMemberOfClass的实例方法,是获取调用方的类对象与传入的类进行比较,如果相等,则返回true,否则返回false

我们来看上面的代码

  • re2是NSObject类调用类方法isMemberOfClassNSObject类比较,很明显,NSObject的元类与NSObject本身并不相等,所以返回false.
  • re4是DMPerson类调用类方法isMemberOfClassDMPerson类比较,DMPerson的元类与DMPerson本身并不相等,所以返回false.
  • re6是NSObject的实例调用实例方法isMemberOfClassNSObject类比较,明显的他们是相同的,所以返回true
  • re8是DMPerson的实例调用实例方法isMemberOfClassDMPerson类比较,明显的他们是相同的,所以返回true

isKindOfClass

分析完isMemberOfClass,我们再来研究下isKindOfClass,为什么没有进断点呢?肯定是有原因的,于是我们打开汇编

image.png 我们在汇编中看到,苹果在编译阶段又给我们重定向了,isKindOfClass并没有走源码中的方法,而是走的objc_opt_isKindOfClass,到源码中找到该方法,并且打上断点

image.png 发现确实走的是这方法,接下来我们开始分析他。

objc_opt_isKindOfClass

在上面的探索中,我们知道,类实际上也是一个对象,我们称之为类对象。所以不管我们在OC层调用的是类方法还是实例方法,最后都会重定向到objc_opt_isKindOfClass这个方法中来。简单的分析下他

  • 如果是类,那么获取他的元类与传入的类比较,相等返回true
  • 否则获取元类的父类与传入的类比较,相等返回true
  • 依次循环直到获取到的父类为nil,依旧没有找到则返回false
  • 如果是实例,则获取他的类与传入的类进行比较,其他步骤与类调用相同

接下来看上面的代码

  • re1 传入的是NSObject的类,获取元类与NSObject不等,继续寻找获取元类的父类为NSObject与传入的值相等,返回true
  • re3 传入的是DMPerson的类,获取元类与DMPerson不等,继续寻找获取元类的父类为NSObject的元类,与传入的值依旧不等,继续往上NSObject元类的父类为NSObject依旧不等,再往上就是nil,最后返回false
  • re5 传入的是NSObject的实例,获取对象的类,与NSObject相等,返回true
  • re7 传入的是DMPerson的实例,获取对象的类,与DMPerson相等,返回true

看到这里,我们知道苹果有时候会挖坑给我们,所以实践才是唯一的真理。