这系列记录的文章是由一个实际需求引发的 —— 我们能够在 OC 中畅快的使用 KVC ,而在 Swift 中,如果想要使用 KVC ,那么操作对象应该直接或者间接继承 NSObject 。原因是 KVC 属于 Runtime 中的内容,而 Runtime 专属于 OC ,所以我们要使用 KVC 就必须使用 OC 的类来进行。
KVC 有哪些好处我就不多说,越是深入到 Runtime 中越能体会到它的强大。一个简单的例子就足以说明 —— 如果我们希望对一个实例对象的所有属性赋值,除了最简单而又最繁琐的直接使用.
语法逐个赋值之外,还有一种更加简单的方式:通过 Runtime 获取对象所有属性的集合,然后遍历这个属性,使用 KVC 统一赋值 —— 这可以写成一个分类,最后暴露的仅仅是一个接口了。
如果 Swift 要做到这一步该怎么做? 本人不才,除了继承 OC 类使用 KVC ,并没有找到其他现成的方案去实现纯 Swift 的 “KVC” 。好吧,这确实是一个悲伤的故事。
而这一切并非没有转机:直到我使用 HandySwift 这个Json 和 Swift 对象互转的框架之后,不仅感叹作者的思路开阔,当然更多的是惊喜,在纯 Swift 中实现 KVC 就靠它了: HandySwift 使用了内存赋值的方式,在“暗地里”给纯 Swift 类的实例对象赋值,避免了开发者面临非 OC 对象需要逐个点语法赋值的尴尬。这不正是另形式的 “KVC” 吗?
这一系列文章就如何实现 内存赋值 进行的探究做了一个记录。
1. 对象如何存在内存中
我们申请一个对象之后,系统会为它分配内存,至于分配在哪里,这将视情况而定,一般来说,局部变量等都会放在栈区,它的作用域很小。 而一个常量或者全局变量则一般会在堆中。我们今天讨论的主角是 Swift 中的实例对象的堆存赋值情况,大多数的使用场景是在全局的状态下,也即是在堆区讨论。 其实就算是在栈区也是一样的,只要对象还存活,我们只需要获取它的地址,然后赋值就达到了我们的目的。
Swift 中,内存有三种状态:
- 未绑定类型同时未初始化值
- 绑定类型但是未初始化值
- 绑定类型同时初始化值
如果我们申请一个对象,除了基础对象之外,复杂的对象都会申请一个指向它的内存的指针,我们通过指针区访问这个内存中的值。但是为了正确的得到被访问值所占空间的真实大小,还需要指定这个内存的类型,以便快速得到内存中的值。
在 Swift 中,任何一个存活的对象都有对应的存储内存,这个内存初始化的值就是该对象,而相对应的内存的类型就是对象的类型。 在初始化对象的时候,系统根据对象属性的类型和内存对齐的规则计算对象内存的大小,之后,对象的属性修改也将在各个属性的地址上做修改。
比如,如果有一个 Person 类:
class Person {
var name: String!
let isMan: Bool!
var age: Int!
var hight:Double!
init...
}
let p = Person()
对象p
被初始化之后,我们分析一下它的内存。
本身p
就是一个指针,这个指针的类型是Person,内存中的值就则为p
的内容。而在这个指针p
指向的区域中,存放name
的那部分内存的类型应该是一个String类型,其内部的值为name
指针,除了String是指针之外,其余的都是基本数据类型, 他们对应的内存的类型是各自的类型,并且值则为各自的值。
假设String 类型占2个字节,Bool 占 1 个字节,Int 占4个字节,Double 8个字节。
如果不考虑内存对齐的话,那么 p 占用的内存应该为15个连续字节。排列看起来像这样:
这样紧密排布,看似很节省空间,但是因为 CPU 寻址的方式的特点,这种连续存储的方式,会导致访问的效率急剧下降。因此,实际上对于编程语言来说,还需要进行专门的内存使用设计 —— 内存布局,以便提升 CPU 的访问效率,其中内存对齐就是其一。
假如,Swift在64位设备上内存对齐模数为 8 ,对于p来说,它的真实内存布局应该是这样的: 内存对齐之后
也即是一共需要 32字节的内存空间。跟我们想象中的不太一样,实际上假如有属性出现可选类的时候,内存的占用将更大:比如将
hight
属性的类型更改为 Double?,这时候他将占用 9 个字节,如果算上内存对齐,它需要的实际空间是 16 个字节。其他的各种内存布局方式跟编程语言的内存模型有关。
2. 如何给内存中的对象的属性赋值
仅仅知道每个对象的存储原则是不够的,我们不知道对象的每个属性是如何在对象内存区域存储的。当我们已经深入到了内存存储的时候,有关的类的大部分信息就不再能够使用正常的渠道获取。比如,尽管我们知道某一块的内存中存了一些我们想要的信息。但是我们并不够准确知道,每一个地址区域对应的内容是什么。
好在,Swift 为类型对象保留了一个关于对象的内存布局描述 —— MemoryLayout。
当使用指针分配或绑定内存时,可以使用MemoryLayout作为关于类型信息的来源。
再看看MemoryLayout中的一些内容:
static var alignment: Int
The default memory alignment of `T`, in bytes.
static var size: Int
The contiguous memory footprint of `T`, in bytes.
static var stride:<wbr> Int
The number of bytes from the start of one instance of `T` to the start of the next when stored in contiguous memory or in an `Array<T>`.
内部包含了一个对象的占用空间: size
, 实际长度 stride
, 以及对齐模数 alignment
。MemoryLayout很有用,我们通过这个类对不同的设备做内存布局的适配。有了这个三个属性,我们可以对一个对象基本可以确定一个对象在不同的设备上的占用内存信息。但是这些信息依然不够,因为这个内存布局并没有体现每一个属性的内存布局信息。
HandySwift 的作者在实现内存赋值的时候也遇到了这个问题,他通过总结发现 Swift 中,一个类实例的属性内存布局是有规律的:
32位机器上,类前面有4+8个字节存储meta信息,64位机器上,有8+8个字节;
内存中,字段从前往后有序排列;
如果该类继承自某一个类,那么父类的字段在前;
Optional会增加一个字节来存储.None/.Some信息;
每个字段需要考虑内存对齐;(包括 meta 信息)
作者:xycn
链接:https://www.jianshu.com/p/eac4a92b44ef
來源:简书
这个规律在 HandySwift 中使用了很久,并且运行良好,我们可以认为上述规律是可行的。
这其中有个问题:如何获取对象的全部属性,这里注意要包含父类的继承属性。Swift 提供了反射机制 Mirro ,通过 Mirro 我们能够逐一获取到对象的所有属性信息,包含了属性的值和类型,唯一遗憾是我们并不能对属性赋值(不然这篇文章可以烧掉了)。并且这个信息是从父类开始,按字段有序排列,刚好符合我们的规律需求。
至于如何赋值就比较简单了,使用指针赋值就可以了。尽管 Swift 不推荐开发者使用指针,但是并非不能使用,详细的内容在后面的文中会介绍。
到这里,对于如何在赋值我们已经有了思路:
- 找到一个对象的起始地址,舍去 meta 相关的无用地址。找到第一个有效的地址。
- 通过遍历 Mirro 反射得到的属性列表,按照每个属性的类型计算它所占的字节数,注意,这里要考虑到内存对齐的情况,不然会出事。同时因为有了首地址,后面的地址逐个累加过去即可。
- 使用指针给对应的内存赋值。
- 接下来就是循环2、3。 直到属性列表中所有的属性全部赋值完毕。
以上思路完成了对一个对象的所有属性通过内存赋值的全过程。 实际上的操作肯定比这个要复杂的多了。 我们下篇文章中揭晓。
网友评论