详解 Codable 的用法和原理

Codable 是什么

Codable 本身就是个类型别名

typealias Codable = Decodable & Encodable

代表一个同时符合 Decodable 和 Encodable 协议的类型,即可解码且可编码的类型。

Codable 也可以代表苹果为 Swift 开发的一套编解码系统,从 Swift 4 开始引入,包含了 Encoder 和 Decoder 协议和他们的两个实现 JSONEncoderJSONDecoderPropertyListEncoderPropertyListDecoder。其中 Codable 及其相关协议放在了标准库中,而具体的 Encoder、Decoder 类放在了 Foundation 框架中。

Codable 的用法

Codable 是用来做系统自身数据结构和外部公共数据结构做转换的。系统内部数据结构可以是基础类型、结构体、枚举、类等,外部公共数据结构可以是 JSON、XML 等。

JSON 和 模型的相互转换

用 Objective-C 做 JSON 和模型转换时,一般要使用一些第三方库,这些第三方库基本上都是利用了 Objective-C Runtime 的强大特性来实现 JSON 和模型互转的。

但是 Swift 是一门静态语言,本身是没有像 Objective-C 那样的动态 Runtime 的。虽然在 Swift 中也可以通过继承 NSObject 的方式,来使用基于 OC Runtime 的 JSON 模型互转方案。但是这样就很不 Swift,也放弃了 Swift 作为一门静态语言的高性能,等于说自己降低了整个项目的运行性能,这是无法忍受的。

好在苹果提供了 JSONEncoderJSONDecoder 这两个结构体来方便得在 JSON 数据和自定义模型之间互相转换。苹果可以利用一些系统私有的机制来实现转换,而不需要通过 OC Runtime

只要让自己的数据类型符合 Codable 协议,就可以用系统提供的编解码器进行编解码。

struct User: Codable {
    var name: String
    var age: Int
}

具体编解码代码如下:

解码(JSON Data -> Model):

let json = """
    {
        "name": "zhangsan",
        "age": 25
    }
    """.data(using: .utf8)!
let user = JSONDecoder().decode(User.self, from: json)

编码(Model -> JSON Data):

let data = JSONEncoder().encode(user)

Codable 支持的数据类型

基础数据类型

在 Swift 标准库的声明文件中可以看到,基础类型都通过 extension 实现了 Codable 协议。

对于基础类型的属性,JSONEncoder 和 JSONDecoder 都可以正确的处理。

Date

JSONEncoder 提供了 dateEncodingStrategy 属性来指定日期编码策略。
同样 JSONDecoder 提供了 dateDecodingStrategy 属性。

就拿 dateDecodingStrategy 为例,它是一个枚举类型。枚举类型有以下几个 case:

case 名 作用
case deferredToDate 默认的 case
case iso8601 按照日期的 ios8601 标准来解码日期
case formatted(DateFormatter) 自定义日期解码策略,需要提供一个 DateFormatter 对象
case custom((_ decoder: Decoder) throws -> Date) 自定义日期解码策略,需要提供一个 Decoder -> Date 的闭包

通常使用比较多的就是 .iso8601 了,因为后端返回日期通常都是已 ios8601 格式返回的。只要 JSON 中的日期是 ios8601 规范的字符串,只要设置一行代码就能让 JSONDecoder 完成日期的解码。

struct User: Codable {
    var name: String
    var age: Int
    var birthday: Date
}

let json = """
    {
        "name": "zhangsan",
        "age": 25,
        "birthday": "2022-09-12T10:25:41+00:00"
    }
    """.data(using: .utf8)!

let decoder = JSONDecoder()
decoder.dateDecodingStrategy = .iso8601
let user = decoder.decode(User.self, from: json)
// user.birthday 正确解码为 Date 类型

嵌套对象

在自定义模型中嵌套对象的时候,只要这个嵌套对象也符合 Codable 协议,那整个对象就可以正常使用 JSONEncoderJSONDecoder 编解码。

struct UserInfo: Codable {
    var name: String
    var age: Int
}

struct User: Codable {
    var info: UserInfo
}

枚举

枚举类型必须它的 RawValue 的类型是可解码的,并且 RawValue 的类型和 JSON 字段类型对应,即可正确解码。

自定义 CodingKeys

自定义 CodingKeys 主要是两个目的

  1. 当数据类型属性名和 JSON 中字段名不同时,做 key 的映射。
  2. 通过在不添加某些字段的 case,来跳过某些字段的编解码过程。
struct User: Codable {
    var name: String
    var age: Int
    var birthday: Date?

    enum CodingKeys: String, CodingKey {
        case name = "userName"
        case age = "userAge"
    }
}

CodingKeys 必须是一个 RawValue 为 String 类型的枚举,并符合 CodingKey 协议。以上代码实现的效果为,为 name 和 age 字段做了 key 映射,让编解码过程中不包含 birthday 字段。

Codable 的原理

了解了 Codable 的用法,下面我们来看一看 Codable 的原理。

Decodable 协议

由于编码和解码的原理差不多只是方向不同,我们仅探索用的更多的解码过程。

如果想让一个对象支持解码应该怎么做呢,当然是符合 Decodable 协议。我们先看看一个对象符合 Decodable 协议需要做哪些事情。

Decodable 协议的定义如下:

public protocol Decodable {
    init(from decoder: Decoder) throws
}

也就是说只要实现一个传入 Decoder 参数的初始化方法,于是我们自己来实现 User。

struct User: Decodable {
    var name: String
    var age: Int
    
    init(from decoder: Decoder) throws {
        
    }
}

现在要来看看怎样让 User 的两个属性的值能从 Decoder 这个对象得到。

查看 Decoder 的定义,它是一个协议。
有两个属性:

var codingPath: [CodingKey] { get }

var userInfo: [CodingUserInfoKey : Any] { get }

还有三个方法:

func container<Key>(keyedBy type: Key.Type) throws -> KeyedDecodingContainer<Key> where Key : CodingKey

func unkeyedContainer() throws -> UnkeyedDecodingContainer

func singleValueContainer() throws -> SingleValueDecodingContainer

会发现这三个方法返回的都是 XXXContainer,从字面上理解是个容器,容器里面一定是容纳了某些东西。

Container

再查看这些 Container 的定义,会发现里面都有一系列 decode... 方法,来对各种类型进行 decode。

一共有三种类型的 Container:

Container 类型 作用
SingleValueDecodingContainer 代表容器中只保存了一个值
KeyedDecodingContainer 代表容器中保存的数据是按照键值对的形式保存的
UnkeyedDecodingContainer 代表容器中保存的数据是没有键的,也就是说,保存的数据是一个数组

回到上面 User 的例子,JSON 数据如下:

{
    "user": "zhangsan",
    "age": 25
}

这种数据显然是键值对,因此要用 KeyedDecodingContainer 来取数据。KeyedDecodingContainer 应该是最常用的 Container 了。

struct User: Decodable {
    var name: String
    var age: Int
    
    init(from decoder: Decoder) throws {
        decoder.container(keyedBy: <#T##CodingKey.Protocol#>)
    }
}

参数需要传一个符合 CodingKey 协议的对象的类型,于是这里必须自己实现 CodingKeys 枚举,并把 CodingKeys.self 传入参数。

struct User: Decodable {
    var name: String
    var age: Int
    
    enum CodingKeys: String, CodingKey {
        case name
        case age
    }
    
    init(from decoder: Decoder) throws {
        let container = decoder.container(keyedBy: CodingKeys.self)
    }
}

然后就可以从 container 中取数据出来赋给自身的属性。由于这几个方法都会抛出异常,因此都要加上 try

init(from decoder: Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKeys.self)
    name = try container.decode(String.self, forKey: .name)
    age = try container.decode(Int.self, forKey: .age)
}

同样的,我们也可以实现出编码。这时把 User 实现的协议改成 Codable

struct User: Codable {
    var name: String
    var age: Int
    
    enum CodingKeys: String, CodingKey {
        case name
        case age
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        name = try container.decode(String.self, forKey: .name)
        age = try container.decode(Int.self, forKey: .age)
    }
    
    func encode(to encoder: Encoder) throws {
        var encoder = encoder.container(keyedBy: CodingKeys.self)
        try encoder.encode(name, forKey: .name)
        try encoder.encode(age, forKey: .age)
    }
}

编码的过程就是和解码反过来,因为是键值对,从 encoder 中拿到 KeyedEncoderContainer,然后调用 encode 方法把属性的数据编码到 container 中,然后由 JSONEncoder 来处理接下来的事情。

接下来我们好奇的是,Container 中的数据是怎么保存的,Container 中的数据和 JSON 又是怎么互相转换的。

核心原理分析(Container <--> JSON)

JSONDecoder 的解码过程

JSONDecoder().decode(User.self, from: json) 这句开始分析。打开 swift-corelibs-foundation 中 JSONDecoder 的源码。

// 1
var parser = JSONParser(bytes: Array(data))
let json = try parser.parse()
// 2
return try JSONDecoderImpl(userInfo: self.userInfo, from: json, codingPath: [], options: self.options)
    .unwrap(as: T.self) // 3

decode 方法的实现主要是这三行代码。

  1. 先把 data 转化为一个类型为 JSONValue 的 json 对象。
  2. 然后构造一个 JSONDecoderImpl 对象
  3. 调用 JSONDecoderImpl 对象的 unwrap 方法得到要转换成的对象。

查看 JSONValue 的定义,它通过枚举嵌套把 JSON 的类型定义了出来。具体的数据通过关联值携带在了这个枚举类型中。

enum JSONValue: Equatable {
    case string(String)
    case number(String)
    case bool(Bool)
    case null

    case array([JSONValue])
    case object([String: JSONValue])
}

在获取 KeyedDecodingContainer 的时候也就是通过 JSONValue 构建 Container 对象。

// 这里 self.json 是保存在 JSONDecoderImpl 中的 JSONValue 类型
switch self.json {
case .object(let dictionary): // JSONValue 和 .object 这个 case 匹配,取出字典数据
    let container = KeyedContainer<Key>(
        impl: self,
        codingPath: codingPath,
        dictionary: dictionary // 传入字典数据
    )
    return KeyedDecodingContainer(container)

可以看到,KeyedDecodingContainer 只有当 self.json 匹配为字典时才能正确创建。数据在里面以 let dictionary: [String: JSONValue] 形式保存。

再看其他代码可以发现:

SingleValueContainer 就是直接存了一个 let value: JSONValue 在里面。

UnkeyedDecodingContainer 则是存了一个数组 let array: [JSONValue]

因此在 Container 调用 decode 方法获取数据时,就是根据参数 key 和类型从自身保存的数据中获取数据。这个源码很简单,看一下就明白了。

最后一步的 unwrap 方法,通过源码可以看到,最终调用的就是对象自己实现的 init(from decoder: Decoder) 方法

因此可以得出 JSON -> Model 的步骤如下:

  1. JSONParser 对传入的二进制 JSON data 进行解析,解析为 JSONValue 对象。
  2. 构建 JSONDecoderImpl,将相关的数据保存在里面。
  3. 调用 JSONDecoderImpl 的 unwrap 方法,开始调用对象实现的 init(from: decoder: Decoder) 方法
  4. 在 ``init(from: decoder: Decoder)` 方法中,首先根据数据类型获取对应的 Container。
  5. 调用 Container 的 decodeXXX 方法得到具体的值赋值给属性。

Model -> JSON 的步骤也是差不多的,只是方向反过来,有兴趣可以自己看一下源码。

在 Swift 的 JSON 转模型方法中,通过观察 Github 上的开源库可以发现一共有三种实现方案:

  • Objective-C Runtime 一众本身就是 OC 开发的库基本都用的这个方案,比如 YYModel,这种方案使用起来非常简单,代码非常少,但不符合 Swift。
  • Key 映射 比如 ObjectMapper 就是这种,这种的缺点是每个对象都要写一大堆映射代码,比较麻烦
  • 利用对象底层内存布局 SwiftyJSON 就属于这种,这种方法使用起来一样很方便,但是依赖苹果的私有代码,苹果如果调整了内部实现就会失效。

通过上面分析 Codable 原理发现,Codable 基本上就是 Key 映射的方案,只不过编译器帮我们自动合成了很多代码来让我们使用起来一样可以非常简单。由于编译器不会帮第三方库合成代码,因此 Codable 秒杀了一众基于 key 映射实现的第三方库。

编译器帮我们做了什么?

我们发现,只要让自己的对象符合 Codable 协议,就可以正常用 JSONEncoderJSONDecoder 编解码,并不需要实现协议中定义的方法。

那是因为编译器帮我们生成了。这种编译器合成代码在很多地方都会用到,例如为结构体和枚举自动合成实现 Equatable 和 Hashable 的代码,为枚举合成实现 CaseIterable 的代码等。

上面的 User 例子,编译器为我们合成的代码如下:

struct User: Codable {
    var name: String
    var age: Int
    
    // 编译器合成
    enum CodingKeys: String, CodingKey {
        case name
        case age
    }
    
    // 编译器合成
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        name = try container.decode(String.self, forKey: .name)
        age = try container.decode(Int.self, forKey: .age)
    }
    
    // 编译器合成
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(name, forKey: .name)
        try container.encode(age, forKey: .age)
    }
}

可以见到,编译器自动合成了 CodingKeys 枚举的定义,并合成了实现 Encodable 和 Decodable 协议的代码。这给开发人员提供了方便。

默认值问题

编译器自动生成的编解码实现有个问题就是不支持默认值。如果需要支持默认值就需要自己来用 decodeIfPresent 来实现:

struct User: Decodable {
    var name: String
    var age: Int
    
    enum CodingKeys: String, CodingKey {
        case name
        case age
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
        age = try container.decodeIfPresent(Int.self, forKey: .age) ?? 0
    }
}

但是这样每个结构体都要自己实现一次,非常麻烦。其实这个网上已经有很多文章在说了,就是用 @propertyWrapper 属性包装器来解决这个问题。

属性包装器 @propertyWrapper

属性包装器用来给属性和定义属性的结构之间包装一层,用来实现一些通用的 setter 和 getter 逻辑或初始化逻辑等。

例如对于 Int 型,可以如下定义属性包装器。

@propertyWrapper
public struct DefaultInt: Codable {
    public var wrappedValue: Int
    
    public init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        wrappedValue = (try? container.decode(BaseType.self)) ?? 0
    }
    
    public func encode(to encoder: Encoder) throws {
        try wrappedValue.encode(to: encoder)
    }
}

以上代码实现了 init(from decoder: Decoder) 方法来为属性在解码失败时提供一个默认值 0。实现 encode(to encoder: Encoder) 是为了编码时直接编码内部值而不是编码整个属性包装类型。

其它的很多基础类型都是一样的逻辑,为了避免重复代码,可以用范型来统一实现。

public protocol HasDefaultValue {
    static var defaultValue: Self { get set }
}

@propertyWrapper
public struct DefaultBaseType<BaseType: Codable & HasDefaultValue>: Codable {
    public var wrappedValue: BaseType
    
    public init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        wrappedValue = (try? container.decode(BaseType.self)) ?? BaseType.defaultValue
    }
    
    public func encode(to encoder: Encoder) throws {
        try wrappedValue.encode(to: encoder)
    }
}

然后可以考虑用类型别名来定义出各个类型的属性包装关键字。因为如果包含 <. 等字符,写起来会比较麻烦。

typealias DefaultInt = DefaultBaseType<Int>
typealias DefaultString = DefaultBaseType<String>

但是有些类型需要特殊实现一下。

枚举

枚举类型可以利用 rawValue 来进行数据和类型相互转换。

@propertyWrapper
public struct DefaultIntEnum<Value: RawRepresentable & HasDefaultEnumValue>: Codable where Value.RawValue == Int {
    private var intValue = Value.defaultValue.rawValue
    
    public var wrappedValue: Value {
        get { Value(rawValue: intValue)! }
        set { intValue = newValue.rawValue }
    }
    
    public init() {
        
    }
    
    public init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        intValue = (try? container.decode(Int.self)) ?? Value.defaultValue.rawValue
    }
    
    public func encode(to encoder: Encoder) throws {
        try intValue.encode(to: encoder)
    }
}

数组

由于数组需要通过 UnkeyedDecodingContainer 拿数据,需要单独特殊处理。

@propertyWrapper
public struct DefaultArray<Value: Codable>: Codable {
    public var wrappedValue: [Value]
    
    public init() {
        wrappedValue = []
    }
    
    public init(wrappedValue: [Value]) {
        self.wrappedValue = wrappedValue
    }
    
    public init(from decoder: Decoder) throws {
        var container = try decoder.unkeyedContainer()
        var results = [Value]()
        while !container.isAtEnd {
            let value = try container.decode(Value.self)
            results.append(value)
        }
        wrappedValue = results
    }
    
    public func encode(to encoder: Encoder) throws {
        try wrappedValue.encode(to: encoder)
    }
}

对象

因为对象的结构都是不一样的,没法给出一个的默认值。因此设计了一个 EmptyInitializable 协议,里面只有一个无参数的初始化方法。

public protocol EmptyInitializable {
    init()
}

需要提供默认值的对象可以实现这个协议。不过这里需要权衡一下,如果对内存空间占用有比较高的要求,用可选值可能是更好的方案,因为一个空对象占用的空间和有数据的对象占用的空间是一样多的。

属性包装器的使用

使用属性包装器封装各个类型后,只要像这样使用就可以了,decode 的时候就如果不存在对应字段数据属性就会初始化为默认值。

struct User {
    @DefaultString var name: String
    @DefaultInt var age: Int
}

我简单封装了一个库,目前我们的新 Swift 项目在使用,完整代码在这里: https://github.com/liuduoios/CodableDefaultValue

参考资料:

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

推荐阅读更多精彩内容