“「这是我参与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 Memory
与dirty 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分区中恢复保存的数据到内存中)
- 因此减少
dirty Memory
空间并且尽量的将数据保存为clean Memory
是最佳的策略,苹果发现这一点之后做了一些改造,将那些永远不会更改的数据分离到clean Memory
中进行保存,例如:把类中的数据存储为clean Memory
。
- 在探究
-
苹果的优化过程
-
上面说了那么多,我们可以通过一个简单的例子来理解,可以通过观察类在运行时发生的变化感受内存的变化:
-
1、首先在磁盘上(MachO文件)你的
App
的类类似这样,含有指向元类、超类、和方法缓存的指针,另外还有一个指向更多数据的指针,这个指针指向地方叫做class_ro_t
。 -
2、当这个类加载到内存时它同样保持以上状态,但是如果是在运行时一经使用的话,它们就会发生变化,变化的原因是在运行时需要追踪每个类的更多信息。当一个类首次被使用的时候,在运行时会为他分配额外的存储容量,而承载这个容量的就是
class_rw_t
,以用于读取/写入数据。 -
其中成员变量
FirstSubclass
和NextSiblingClass
只会在被调用时才会生成信息,用于在运行时遍历当前使用的所有类(懒加载)。(在第二小节里我们会来验证这一个说法)
-
在
class_rw_t
中还需要保存Method
s、Properties
、Protocols
信息,原因是再运行时有可能会发生改变。例如:动态的为类添加方法、或者从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
的用途和意义应该有所了解了。
-
-
节点小结:
- 本小结的全部内容都是基于苹果官方提供的视频资料来进行的总结与说明,并不是作者本人自己yy的,大家有兴趣可以自己去看看:wwdc2020Runtime;
(由于“笨”同学在视频中说的很快,所以想进行下整理方便你们可以看懂并且理解)
本节介绍的内容其实大家只需要做到了解就可以了,算是一个扩展内容吧,实际开发中我们根本无需关系这些东西,那么本小结内容到此结束。
- 本小结的全部内容都是基于苹果官方提供的视频资料来进行的总结与说明,并不是作者本人自己yy的,大家有兴趣可以自己去看看:wwdc2020Runtime;
3. 感受类的加载
- 刚才我们一起了解了苹果针对类进行的内存优化,这里我们应该可以感觉到整体逻辑就是将常用数据与不常用数据分开来进行管理,并且那些不常用的数据会在要被使用的时候才会进行加载,这里其实还有一个细节,对于被拆分之后的
class_rw_t
来说,内部成员FirstSubclass
和NextSiblingClass
也是如此(懒加载)
,我们可以通过一个例子来验证。 - 先创建
2
个有继承关系的类,例如ZXPerson
继承与ZXHuman
,然后通过上一节我们学到的LLDB
来查看FirstSubclass
字段发生的编号。 - O(∩*∩)O哈哈 是不是如此神奇!接下来我们再继续向
objc_class
的底层前进吧……。
4. 成员变量、属性以及编码
-
概念:
- 首先我们来说明下什么是成员变量、什么是属性这两个概念:
- 使用
@property
声明的字段叫做属性这个最好理解例如carHeight
和carWidth
; - 而在类内部声明的叫做成员变量,例如:
carName
,carEngine
;但成员变量还有一种特殊情况,就是当变量的类型为非基本数据类型(基本数据类型包括:NSString、int、float、double、bool)
时,我们也可以称为实例变量例如carEngine
。
-
函数类型编码:
-
所谓的函数类型编码:就是用于对一个方法函数进行描述并且以一套规范的编码方式进行定义,在运行时编码会跟该方法的SEL进行关联,用于帮助系统去执行他们。例如:
-
苹果官方对开发者开发了编码的规则,大家可以通过
Command
+Option
+0
来搜索ivar_getTypeEncoding
,然后点击Type Encodings链接就会看到一个列表。 | Code |Meaning| | --- | --- | | c | Achar
| | i | Anint
| | s | Ashort
| | l | Along
;l
is treated as a 32-bit quantity on 64-bit programs. | | q | Along long
| | C | Anunsigned char
| | I | Anunsigned int
| | S | Anunsigned short
| | L | Anunsigned long
| | Q | Anunsigned long long
| | f | Afloat
| | d | Adouble
| | B | A C++bool
or a C99_Bool
| | v | Avoid
| | * | A character string (char *
) | | @ | An object (whether statically typed or typedid
) | | # | 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)|
-
-
编码含义:
-
这里我们举两个个简单的例子来解读一下编码的含义,后续如果有需要可以对照编码表自己玩一玩。那我们就以
carHeight
的setter
跟getter
方法来举例说明吧。 -
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
字节起始的。
- 第一个位置是返回值v代表是一个
-
-
节点小结:
- 本小节内容只需要了解成员变量、实力变量、属性的概念即可;另外还需要了解函数类型编码,日后见到之后能够知道就可以了。
5. setter方法的底层原理
-
编译OC来看底层:
- 每当我们要探索底层时,最常用的方式就得对
OC
文件进行编译,我们通过编译后的代码来入手分析。我们现在就对上一小节的ZXCar
类来进行编译。 查看编译后的文件,我们发现了有意思的地方; 我们再OC
里面定义的属性carHeight
和carWidth
被注释掉了,转而变为与成员变量carName
和carEngine
一致的成员变量了。这就说明无论在OC
层面如何定义一个变量或属性,当转到底层执行时都会处理成一样的方式。
- 每当我们要探索底层时,最常用的方式就得对
-
发现set方式底层的不同:
- 1、我们先看
I_ZXCar_setCarHeight
方法在对carHeight
赋值时,会根据self
增加一个偏移量OBJC_IVAR_$_ZXCar$_carHeight
来进行赋值的。 - 2、而下面的
I_ZXCar_setCarWidth
方法则是通过objc_setProperty
这个函数来进行赋值的。这样就会让人产生疑惑,同样是set
方法为什么在底层处理的方式会不同呢?产生这个原因的主要问题是在我们声明变量时选择的参数不一致,我们来看下两个变量在声明上有什么不同。 - 首先,
carHeight
在赋值的时候用的是strong
关键字,而carWidth
用的是copy
关键字,从现状来看就是大概率是因为这两个关键字不同而造成了底层的不同。那么实际情况是否跟我们想象的一致呢?这就需要我们要对LLVM
的源码进行探索了,那为什么是LLVM
的源码而不是objc
的源码呢?这是因为我们的程序是在编译后发生区别的而不是在运行时,所以需要探索编译阶段的代码才能了解,而OC
是由LLVM
编译的,所以LLVM
就是我们最终要探索的地方。
- 1、我们先看
-
探索LLVM:
- 我们使用
vsCode
打开llvm
源码,先搜索objc_setProperty
关键字来看看有什么线索。 - 在
CGObjCMac.cpp
这个文件的有一个getSetPropertyFn
方法,内部有创建objc_setProperty
方法的语句,通过源码我们可以了解到是在运行时进行创建的,这里不光是setter
方法被创建了,在247
行我们发现getter
方法也是如此。我们可以再看一下这个CreateRuntimeFunction
方法具体是在干什么?点进去之后看到注释写着:“创建一个新的运行时方法和描述”,参数包括一个类型+一个名称,那么刚才的FTy
就是类型,而“objc_setProperty”
字符串就是对应的名称了;后面在跟进参数来调用GetOrCreateLLVMFunction
方法来获取新方法的指针地址,这里从方法名就能推敲出来,如果存在这个name
的方法就直接返回,如果没有才会创建。当然到这里再深入就不是我们的目的了,我们还是回到上层来继续。 回到getSetPropertyFn
方法,既然知道了LLMV
会在运行时创建方法,那么我们可以看看getSetPropertyFn
方法会在什么时候被调用到,我们可以搜索getSetPropertyFn
。 搜索结果是getSetPropertyFn
方法会有一个封装的GetPropertySetFunction()
方法,我们继续对其搜索。 得到结果我们发现GetPropertySetFunction()
方法的调用是在一个switch
语句里,决定调用条件的是case
的属性,那么我们就可以找到case
初始化是如何实现的,就可以知道具体逻辑了,我们再继续跟进代码,发现case
是通过strategy.getKind()
的方法来获取的,而strategy
只是一个对象,实际类型是PropertyImplStrategy
类,我们就搜索这个。 看到结果后,我们就找到答案了,如果是copy
就会将kind
赋值为GetSetProperty
,然后就会在上面的switch
分支中调用GetPropertySetFunction()
方法,在后面调用最终的getSetPropertyFn
。到这里我们就找到了声明copy
后在底层编译阶段发送的变化。
- 我们使用
-
节点小结
- 1、我们通过在
OC
声明不同的前缀的属性,发现在编译后会出现不同的代码。 - 2、通过对
LLVM
源码进行分析,我们发现了在底层对成员变量和属性会根据前缀不同而发生不同的逻辑。 - 3、当
OC
变量声明copy
时,在底层会对其进行特殊处理。这一点的原因是因为copy
声明的属性在进行setter
时,会对内存空间(zone)
进行复制操作,这与不声明copy
时所需的成本存在很大差异。所以需要在编译阶段就最好就能区分出来进行预先处理。以上内容大家只有能够明白原理就可可以了,我们分析底层其实就是为了解释上层的现象。那么本小结内容到此结束。
- 1、我们通过在
6. 类方法的存储引出设计元类的原因
-
这里我们不根据语言或语种的特性来讨论,我们只根据我们迄今为止对底层探索到的知识来进行讨论。首先在上一篇章中我们对底层的方法的存放有了了解;成员方法存到了类中,类方法被存到了元类中,这里我们可以先做一个假设,就是类方法不会被放到元类中,而是也放到类中,我们来分析看看会出现什么问题。
但是如果再底层的话这两个方法都会被存放到
ZXCar
类中的methods
中,这样就会带来问题了!同名方法会导致冲突问题。所以苹果为了解决此问题才会创建一个元类,且将类方法放到该类的元类中进行保存。从这点我们就可以得知其实无论是类方法还是成员方法在底层时是不会被区分的。只有我们对底层的东西有所了解了才能自己理解消化。这比死记硬背要好得多!
7. 总结
-
1、本章节我们现对
WWDC2020
中苹果对类的优化进行了解释,了解到了clean Memory
和drty Memory
; -
2、知道原理之后我们感受了类的加载,以及对成员变量、属性以及函数的类型编码进行了详细说明;
-
3、本章重点是我们探索了成员变量、属性,在声明不同参数时在底层发生的不同变化。
-
4、最后我们根据我们自己的理解,解释了设计元类的原因。
-
写到最后:
- 学习资料:本小结代码例子gitHub下载地址
- 到此本篇内容结束!如果您喜欢的话别忘了赏个赞!您的点赞是我最大的动力源泉!
-
上一篇::《Objective-C 底层类的探究-上 》 下一篇:待续.....