[HandyJSON] 设计思路简析

早早就说好要分享一下HandyJSON的实现思路了,今天总算有点时间,这里就记录一下吧。

背景

我所在iOS团队是从去年11月份切Swift的。我们服务端和客户端数据交互格式一直用的是JSON,而当时Swift中处理JSON名气比较大的库貌似只有SwiftyJSON,工程切到Swift后,我们也用了这个库。用上之后,需求是满足了,但是对一些复杂的Model,代码写得看起来非常糟糕,因为每次取值都需要 json["akey"]["bkey"]["ckey"].value 形式,写的时候对着文档没觉得啥问题,但过后在脱离文档的情况下,通篇都是字符串表达的key,很难从代码中感觉出Model结构。所以我们都会把一段sample数据写在注释里。但仍然比较凌乱,另外key写错了debug起来也费劲,一个大小写问题有时候debug半天。

于是我们进化了一下,先写好Model,然后Model类中写convert函数,也用上了KVC遍历key赋值。写起来舒服多了,但还是麻烦,而且要求每一个类都继承自NSObject。不久后,我们认识了ObjectMapper库,二话不说,就换了上去。世界顿时干净多了。

但还是感觉差了一点,因为ObjectMapper需要自己指明映射关系。通常JSON中key和Model中字段名都是一致的,每次都要额外写一坨东西,总觉得多余,字段有改动的时候也费劲。新来刚接触Swift的同事,也表示不太舒服,因为他们之前使用的JSON反序列化库,无论Java中还是Objective-C中,都是自然使用Model字段名去取值的。

所以就想着研究一下,Swift中能不能做到这种效果。

Swift中存在的限制

无论是Java或者Objective-C中的JSON反序列化库,通常都是,在运行时获取Model的字段名集合,遍历该集合,拿Key去JSON中取值并完成赋值。这些步骤,Java依赖反射机制可以实现,Objective-C通过class_copyPropertyList方法加上KVC机制,也能轻松实现。而Swift会卡在最后一步: 无法赋值。

Swift的反射是只读的,就是说,我们能在运行时获取一个Model实例的所有字段、字段值,但却无法给它赋值。事实上,我们拿到的value是原值的一个只读拷贝,即使获取到这个拷贝的地址写入新值,也是无效的。

class Animal {
    var name: String?
}

Mirror(reflecting: Animal()).children.forEach { (child) in
    print(child.label ?? "", child.value) // working correctly
    child.value = "cat" // error,不能直接赋值
}

而且迄今,苹果官网文档上对实现反射机制的Mirror类仍然是这么描述: Mirrors are used by playgrounds and the debugger,态度非常含糊,似乎不太鼓励,但生产中很多类库都用上了。只能说,苹果不会轻易撤下这个能力,但期待它对这个能力做出改进(比如支持运行时赋值),是希望渺茫的。

如何绕过限制

最简单的方式,就是在Swift中定义Model时继承NSObject,让这个Model的实例存在于objc运行时中,上述的class_copyPropertyList方法和KVC就能用上了。目前看见的Swift中不需要指明映射关系的JSON库,都是这种方式。

然后就是以ObjectMapper为代表的库,通过运算符重载,在指定映射关系时完成赋值。走这一类实现的库也非常多了。

但我想做到的是,既支持运行在Swift运行时的纯Swift类,又不需要显示指定每一个字段的映射关系。那么,不能走反射赋值,那就直接写入内存吧。

具体实现

Swift中,一个类实例的内存布局是有规律的:

  1. 32位机器上,类前面有4+8个字节存储meta信息,64位机器上,有8+8个字节;
  2. 内存中,字段从前往后有序排列;
  3. 如果该类继承自某一个类,那么父类的字段在前;
  4. Optional会增加一个字节来存储.None/.Some信息;
  5. 每个字段需要考虑内存对齐;

这方面尚未从官方的资料找到参考,上述规律一些是从网上其他大神的总结中收集,一些从Clang的一些说明文档中挖掘,加上自己的反复验证得到。

有法子计算内存布局,剩下的事情就比较简单了。对一个实例:

  1. 获取它的起始指针,移动到有效起点;
  2. 通过Mirror获取每一个字段的字段名和字段类型;
  3. 根据字段名在JSON中取值,转换为和字段一样的类型,通过指针写入;
  4. 根据本字段类型的占位大小和下一个字段类型计算下一个字段的对齐起点;
  5. 移动指针,继续处理;
操作步骤

获取类实例的起始指针

Swift中,获取struct实例起始指针和获取class实例起始指针的方法是不一样的,和语言版本也相关。在Swift3中:

// 获取struct实例起始指针
mutating func headPointerOfStruct() -> UnsafeMutablePointer<Byte> {

    return withUnsafeMutablePointer(to: &self) {
        return UnsafeMutableRawPointer($0).bindMemory(to: Byte.self, capacity: MemoryLayout<Self>.stride)
    }
}

// 获取class实例起始指针
mutating func headPointerOfClass() -> UnsafeMutablePointer<Byte> {

    let opaquePointer = Unmanaged.passUnretained(self as AnyObject).toOpaque()
    let mutableTypedPointer = opaquePointer.bindMemory(to: Byte.self, capacity: MemoryLayout<Self>.stride)
    return UnsafeMutablePointer<Byte>(mutableTypedPointer)
}

通过Mirror获取字段名、类型

Mirror(reflecting: Animal()).children.forEach { (child) in
    print(child.label ?? "") // 获取字段名
    print(type(of: child.value)) // 获取字段类型
}

计算Model的每个属性字段占位大小

Swift3暴露了两个接口用于计算类型占位大小:MemoryLayout.size(ofValue: T)MemoryLayout<T>.size。这两者都没办法直接用,因为:

  1. 对于每个属性,我们目前只持有它的起始指针,而不是它的实例,第一个接口用不上;
  2. 对于每个属性,我们是在运行时中获取到它的类型,已经没办法再实例化出泛型类型MemoryLayout<T>来计算size。所以,我引入了HandyJSON类,在扩展中实现函数:
protocol HandyJSON {
}

extension HandyJSON {
    static func size() -> Int {
        return MemoryLayout<Self>.size
    }
}

于是,对于每一个实现HandyJSON协议的Model类T,直接调用 T.size() 就能获取到T的size了。

内存对齐的影响

类实例的属性并不是直接按照各自占位大小依次往下排列的,不然事情就简单了。和C/C++一样,Swift中实例内存布局也考虑了内存对齐。翻阅了Swift的docs和LLVM的一些资料,MemoryLayout提供了一个接口:MemoryLayout<T>.alignment,对齐的规则为,每个字段的起始地址必须为alignment值的整数倍。细节的出处我一下子找不到了。那么,HandyJSON中计算下一个字段起始地址的函数为:

// Returns the offset to the next integer that is greater than
// or equal to Value and is a multiple of Align. Align must be
// non-zero.
static func offsetToAlignment(value: Int, align: Int) -> Int {
    let m = value % align
    return m == 0 ? 0 : (align - m)
}

其他情况

基本类型按照上述方法处理就可以了,还有可选类型、数组类型、字典类型,通过遍历、递归解析等方式,处理方法也类似。如数组:

extension Array: ArrayTypeProtocol {
    static func getWrappedType() -> Any.Type {
        return Element.self
    }

    static func castArrayType(arr: [Any]) -> Array<Element> {
        return arr.map({ (p) -> Element in
            return p as! Element
        })
    }
}

获取到Array泛型实参类型,然后构造出该类型的一个数组,完成赋值就可以了。

结语

主要流程就是这样了,也比较简单,剩下处理继承、组合等情况,只是实现问题,就不再赘述了。总觉得自己对Swift指针这一套设施理解还不是很到位,也许有更好的用法,比如说,完全不需要空的init()函数就可以初始化出一个类的实例。有同学在这方面有更深入理解,有什么意见或者建议的,欢迎交流~

参考

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,968评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,601评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,220评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,416评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,425评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,144评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,432评论 3 401
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,088评论 0 261
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,586评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,028评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,137评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,783评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,343评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,333评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,559评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,595评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,901评论 2 345

推荐阅读更多精彩内容