Objective-C 底层类的探究-下

1,042 阅读15分钟

“「这是我参与11月更文挑战的第1天,活动详情查看:2021最后一次更文挑战」。”

目录

1. 背景

学习不迷茫,无阻我飞扬!大家好我是Tommy!实在是惭愧我又拖更了……!一到年底工作就开始多了,大家是不是也是如此呢?好了废话我多说我们继续上路吧!(每一篇文章都是用心做的)

2. WWDC对runtime的优化讲解

  • 在上一篇内容中我们留下了一个问题,就是再探索objc_class源码时发现类中的方法、属性、成员变量、类方法都保存在 class_rw_t、class_ro_t中,那么这两个到底是起了什么作用呢?我们现在是一头雾水,下面我们就来简单的介绍一下。
  • clean Memory与dirty Memory
    • 在探究class_rw_t、class_ro_t前我需要先了解clean Memorydirty Memory
    • clean Memory:是程序运行后不会发生改变的内存,class_ro_t的占用空间属于在clean Memory
    • dirty Memory:则与之相反是指会在程序运行时发生变化的内存。class_rw_t的占用空间属于在dirty Memory
    • dirty Memory要比clean Memory更加宝贵,原因是只要进程在运行他就要一直存在,以保证程序可以正常运行。而clean Memory可以进行移除从而节省内存空间,如果再次需要读取这些移除的数据时,系统可以重新从磁盘再次加载。但是iOS不像我们PC上可以使用swap分区来防止内存不够用的问题,所以导致dirty Memory的空间就更加的珍贵了或者说要更加小心。 (Swap分区:是指在系统的物理内存不够用的时候,把内存中的一部分空间释放出来,以供当前运行的程序使用。那些被释放的空间可能来自一些很长时间没有什么操作的程序,这些被释放的空间被临时保存到Swap分区中,等到那些程序要运行时,再从Swap分区中恢复保存的数据到内存中) 图片.png
    • 因此减少dirty Memory空间并且尽量的将数据保存为clean Memory是最佳的策略,苹果发现这一点之后做了一些改造,将那些永远不会更改的数据分离到clean Memory中进行保存,例如:把类中的数据存储为clean Memory
  • 苹果的优化过程
    • 上面说了那么多,我们可以通过一个简单的例子来理解,可以通过观察类在运行时发生的变化感受内存的变化:

    • 1、首先在磁盘上(MachO文件)你的App的类类似这样,含有指向元类、超类、和方法缓存的指针,另外还有一个指向更多数据的指针,这个指针指向地方叫做class_ro_t图片.png

    • 2、当这个类加载到内存时它同样保持以上状态,但是如果是在运行时一经使用的话,它们就会发生变化,变化的原因是在运行时需要追踪每个类的更多信息。当一个类首次被使用的时候,在运行时会为他分配额外的存储容量,而承载这个容量的就是class_rw_t,以用于读取/写入数据。 图片.png

    • 其中成员变量FirstSubclassNextSiblingClass只会在被调用时才会生成信息,用于在运行时遍历当前使用的所有类(懒加载)。(在第二小节里我们会来验证这一个说法)

    • class_rw_t中还需要保存Methods、PropertiesProtocols信息,原因是再运行时有可能会发生改变。例如:动态的为类添加方法、或者从category分类加载新的方法等;由于class_ro_t是只读的,所以虽然内部也有方法、属性的信息,但是一经改变之后还需要转换到class_rw_t里去追踪。

    • 3、目前这种模式会占用很多的内存空间其中大部分是浪费的,苹果经过测算一台iphone上大约30兆字节都是这些class_rw_t结构的数据,那么如何来缩小这些结构呢?刚才我们已经知道class_ro_t只有在被修改时才会被移动到class_rw_t中进行追踪(占用dirty memory),可实际的情况是大约只有10%的类真正的发生了改变,此外成员变量Demangled Name只有Swift类会使用到,所以苹果对class_rw_t中数据进行了优化处理,将不常用的成员进行分离。

    • 思路梳理:

      • 1、当你从程序编译完成之后class会有指向class_ro_t的指针,其内部保存的信息就是你在代码中声明的属性和方法等信息。(假设你在代码里使用了runtimeAPI试图动态增加方法,但此时class_ro_t中并不会包含你所额外增加的方法,该方法只会在程序运行到这句代码时才会被动态添加进去,这一点要牢记住!)

      • 2、当程序启动后你所创建的类被使用时,程序会生成一个class_rw_t用于存放额外数据,其中除了将类的继承链条写入之外,其他的如 方法、属性、分类的信息应该与class_ro_t中的数据一致也会被写入进去。(此时这些方法、属性、分类的信息所占用的空间是浪费的)

      • 3、如果程序执行调用了运行时API动态改变了方法,那么这时改变的数据会被写入更新到class_rw_t中来进行追踪,则class_ro_t的内容是保持原样的。(但是实际上只有10%的类才会真正的发生改变,但是class_rw_t却为其创建了存储空间)

    • 综合以上原因苹果为其做出了优化,将那些不常使用的空间进行了分离,这将class_rw_t的大小减少了一半。那么对于真的有需要有改动的类来说,将会为它们分配一个扩展存储空间class_rw_ext_t,并把它添加到类中供其使用。(这样可理解为苹果将class_rw_ext_t的内容变成动态加载了,需要时再分配)到了这步我们就已经对class_rw_t、class_rw_ext_t、class_ro_t的用途和意义应该有所了解了。

      图片.png 图片.png

  • 节点小结:
    • 本小结的全部内容都是基于苹果官方提供的视频资料来进行的总结与说明,并不是作者本人自己yy的,大家有兴趣可以自己去看看:wwdc2020Runtime(由于“笨”同学在视频中说的很快,所以想进行下整理方便你们可以看懂并且理解)本节介绍的内容其实大家只需要做到了解就可以了,算是一个扩展内容吧,实际开发中我们根本无需关系这些东西,那么本小结内容到此结束。

3. 感受类的加载

  • 刚才我们一起了解了苹果针对类进行的内存优化,这里我们应该可以感觉到整体逻辑就是将常用数据与不常用数据分开来进行管理,并且那些不常用的数据会在要被使用的时候才会进行加载,这里其实还有一个细节,对于被拆分之后的class_rw_t来说,内部成员FirstSubclassNextSiblingClass也是如此(懒加载),我们可以通过一个例子来验证。
  • 先创建2个有继承关系的类,例如ZXPerson继承与ZXHuman,然后通过上一节我们学到的LLDB来查看FirstSubclass字段发生的编号。 图片.png
  • O(∩*∩)O哈哈 是不是如此神奇!接下来我们再继续向objc_class的底层前进吧……。

4. 成员变量、属性以及编码

  • 概念:
    • 首先我们来说明下什么是成员变量、什么是属性这两个概念: 图片.png
    • 使用@property声明的字段叫做属性这个最好理解例如carHeightcarWidth
    • 而在类内部声明的叫做成员变量,例如:carNamecarEngine;但成员变量还有一种特殊情况,就是当变量的类型为非基本数据类型(基本数据类型包括:NSString、int、float、double、bool)时,我们也可以称为实例变量例如carEngine
  • 函数类型编码:
    • 所谓的函数类型编码:就是用于对一个方法函数进行描述并且以一套规范的编码方式进行定义,在运行时编码会跟该方法的SEL进行关联,用于帮助系统去执行他们。例如: 图片.png

    • 苹果官方对开发者开发了编码的规则,大家可以通过Command+Option+0来搜索ivar_getTypeEncoding,然后点击Type Encodings链接就会看到一个列表。 图片.png | Code |Meaning| | --- | --- | | c | A char | | i | An int | | s | A short | | l | A long ; l is treated as a 32-bit quantity on 64-bit programs. | | q | A long long | | C | An unsigned char | | I | An unsigned int | | S | An unsigned short | | L | An unsigned long | | Q | An unsigned long long | | f | A float | | d | A double | | B | A C++ bool or a C99 _Bool | | v | A void | | * | A character string (char *) | | @ | An object (whether statically typed or typed id) | | # | A class object (Class) | | : | A method selector (SEL) | | [array type] | An array | | {name=type... } | A structure | | (name=type... ) | A union | | bnum | A bit field of num bits | | ^type | A pointer to type | | ? | An unknown type (among other things, this code is used for function pointers)|

  • 编码含义:
    • 这里我们举两个个简单的例子来解读一下编码的含义,后续如果有需要可以对照编码表自己玩一玩。那我们就以carHeightsettergetter方法来举例说明吧。

    • getter 编码:"@16@0:8"

      • 第一个位置是返回值@代表返回的是一个对象;后面的16代表这个方法所占用的总共字节数是16字节;在后面的@0代表的是一个参数,参数类型是一个对象并且从第0个字节起始;最后的:8是第二个参数,参数类型是一个SEL并且从第8字节起始。 注意:这两个参数其实就是OC中每个方法都会存在的默认参数 self 跟 _cmd 他们的类型分别是id跟SEL
    • setter编码: "v24@0:8@16"

      • 第一个位置是返回值v代表是一个void无返回值;24代表这个方法所占用的总共字节数是24字节;@0:8就是两个参数self_cmd;最后的@16其实就是我们调用setter方法时传入的值,它是从第16字节起始的。
  • 节点小结:
    • 本小节内容只需要了解成员变量、实力变量、属性的概念即可;另外还需要了解函数类型编码,日后见到之后能够知道就可以了。

5. setter方法的底层原理

  • 编译OC来看底层:
    • 每当我们要探索底层时,最常用的方式就得对OC文件进行编译,我们通过编译后的代码来入手分析。我们现在就对上一小节的ZXCar类来进行编译。 图片.png 查看编译后的文件,我们发现了有意思的地方; 我们再OC里面定义的属性carHeightcarWidth被注释掉了,转而变为与成员变量carNamecarEngine一致的成员变量了。这就说明无论在OC层面如何定义一个变量或属性,当转到底层执行时都会处理成一样的方式。
  • 发现set方式底层的不同:
    图片.png
    • 1、我们先看I_ZXCar_setCarHeight方法在对carHeight赋值时,会根据self增加一个偏移量OBJC_IVAR_$_ZXCar$_carHeight来进行赋值的。
    • 2、而下面的I_ZXCar_setCarWidth方法则是通过objc_setProperty这个函数来进行赋值的。这样就会让人产生疑惑,同样是set方法为什么在底层处理的方式会不同呢?产生这个原因的主要问题是在我们声明变量时选择的参数不一致,我们来看下两个变量在声明上有什么不同。 图片.png
    • 首先,carHeight在赋值的时候用的是strong关键字,而carWidth用的是copy关键字,从现状来看就是大概率是因为这两个关键字不同而造成了底层的不同。那么实际情况是否跟我们想象的一致呢?这就需要我们要对LLVM的源码进行探索了,那为什么是LLVM的源码而不是objc的源码呢?这是因为我们的程序是在编译后发生区别的而不是在运行时,所以需要探索编译阶段的代码才能了解,而OC是由LLVM编译的,所以LLVM就是我们最终要探索的地方。
  • 探索LLVM:
    • 我们使用vsCode打开llvm源码,先搜索objc_setProperty关键字来看看有什么线索。 图片.png
    • CGObjCMac.cpp这个文件的有一个getSetPropertyFn方法,内部有创建objc_setProperty方法的语句,通过源码我们可以了解到是在运行时进行创建的,这里不光是setter方法被创建了,在247行我们发现getter方法也是如此。我们可以再看一下这个CreateRuntimeFunction方法具体是在干什么?点进去之后看到注释写着:“创建一个新的运行时方法和描述”,参数包括一个类型+一个名称,那么刚才的FTy就是类型,而“objc_setProperty”字符串就是对应的名称了;后面在跟进参数来调用GetOrCreateLLVMFunction方法来获取新方法的指针地址,这里从方法名就能推敲出来,如果存在这个name的方法就直接返回,如果没有才会创建。当然到这里再深入就不是我们的目的了,我们还是回到上层来继续。 图片.png 回到getSetPropertyFn方法,既然知道了LLMV会在运行时创建方法,那么我们可以看看getSetPropertyFn方法会在什么时候被调用到,我们可以搜索getSetPropertyFn图片.png 搜索结果是getSetPropertyFn方法会有一个封装的GetPropertySetFunction()方法,我们继续对其搜索。 得到结果我们发现GetPropertySetFunction()方法的调用是在一个switch语句里,决定调用条件的是case的属性,那么我们就可以找到case初始化是如何实现的,就可以知道具体逻辑了,我们再继续跟进代码,发现case是通过strategy.getKind()的方法来获取的,而strategy只是一个对象,实际类型是PropertyImplStrategy类,我们就搜索这个。 图片.png 图片.png 看到结果后,我们就找到答案了,如果是copy就会将kind赋值为GetSetProperty,然后就会在上面的switch分支中调用GetPropertySetFunction()方法,在后面调用最终的getSetPropertyFn。到这里我们就找到了声明copy后在底层编译阶段发送的变化。 图片.png
  • 节点小结
    • 1、我们通过在OC声明不同的前缀的属性,发现在编译后会出现不同的代码。
    • 2、通过对LLVM源码进行分析,我们发现了在底层对成员变量和属性会根据前缀不同而发生不同的逻辑。
    • 3、当OC变量声明copy时,在底层会对其进行特殊处理。这一点的原因是因为copy声明的属性在进行setter时,会对内存空间(zone)进行复制操作,这与不声明copy时所需的成本存在很大差异。所以需要在编译阶段就最好就能区分出来进行预先处理。以上内容大家只有能够明白原理就可可以了,我们分析底层其实就是为了解释上层的现象。那么本小结内容到此结束。

6. 类方法的存储引出设计元类的原因

  • 这里我们不根据语言或语种的特性来讨论,我们只根据我们迄今为止对底层探索到的知识来进行讨论。首先在上一篇章中我们对底层的方法的存放有了了解;成员方法存到了类中,类方法被存到了元类中,这里我们可以先做一个假设,就是类方法不会被放到元类中,而是也放到类中,我们来分析看看会出现什么问题。 图片.png

    但是如果再底层的话这两个方法都会被存放到ZXCar类中的methods中,这样就会带来问题了!同名方法会导致冲突问题。所以苹果为了解决此问题才会创建一个元类,且将类方法放到该类的元类中进行保存。从这点我们就可以得知其实无论是类方法还是成员方法在底层时是不会被区分的。只有我们对底层的东西有所了解了才能自己理解消化。这比死记硬背要好得多!

7. 总结

  • 1、本章节我们现对WWDC2020中苹果对类的优化进行了解释,了解到了clean Memorydrty Memory

  • 2、知道原理之后我们感受了类的加载,以及对成员变量、属性以及函数的类型编码进行了详细说明;

  • 3、本章重点是我们探索了成员变量、属性,在声明不同参数时在底层发生的不同变化。

  • 4、最后我们根据我们自己的理解,解释了设计元类的原因。

  • 写到最后:
  • 上一篇::《Objective-C 底层类的探究-上 》 下一篇:待续.....