【iOS】objc.io – Swift 进阶 – 整理 – (十二)编码和解码

编码 (encoding) 和解码 (decoding): 程序内部的数据结构序列化为一些可交换的数据格式,以及反过来将通用的数据格式反序列化为内部使用的数据结构。

Codable 系统 (以这个系统提供的协议命名,而这个协议实际上只是一个别名) 定义了一套编码和解码数据的标准方法,所有自定义类型都能选择使用这套方法。它的设计主要围绕三个核心目标:

  • 普遍性 – 对结构体,枚举和类都适用。
  • 类型安全 – 像 JSON 这样的可交换格式通常都是弱类型的,而你的代码应该使用强类型数据结构。
  • 减少模板代码 – 当自定义类型加入这套系统时,应该尽可能减少开发者需要编写的 “适配代码”,编译器应该可以自动生成它们。

一个类型通过声明自己遵守 Encodable 和/或 Decodable 协议,来表明可以被序列化和/或反序列化。这两个协议都只约束了一个方法,其中:Encodable 约束了 encode(to:),它定义了一个类型如何对自身进行编码;而 Decodable 则约束了一个初始化方法,用来从序列化的数据中创建实例:

/// 一个类型可以将自身编码为某种外部表示形式。 
public protocol Encodable {
/// 将值编码到给定的 encoder 中。
    public func encode(to encoder: Encoder) throws 
}
/// 一个类型可以从某种外部表示形式中解码得到自身。 
public protocol Decodable {
/// 从给定的 decoder 中解码来创建新的实例。
    public init(from decoder: Decoder) throws 
}

/// 因为大多数实现了其中一个协议的类型,也会实现另一个,
/// 所以标准库中还提供了 Codable 类型别名,它是这两个协议组合后的简写:
public typealias Codable = Decodable & Encodable
复制代码

标准库中的所有基本类型,包括 Bool,数值类型和 String,都是实现了 Codable 的类型。另外,如果数组,字典,Set 以及 Range 中包含的元素实现了 Codable,那么这些类型自身也是实现了 Codable 的类型。包括 Data,Date,URL,CGPoint 和 CGRect 在内的许多 Apple 框架中的常用数据类型,也已经适配了 Codable。

一旦有了一个 Codable 类型的值,你就可以创建一个编码器 (Encoder),并让它将这个值转换成像是 JSON 这样的目标格式。反过来,一个解码器 (Decoder) 可以将序列化后的数据转回成原始类型的一个实例。表面上看,对应的 Encoder 和 Decoder 协议并没有比 Encodable 和 Decodable 复杂太多。它们的核心任务是管理一个容器的层次结构,这些容器用来存储序列化之后的数据。不过,除非要创建自己的编解码器,否则你很少会直接和 Encoder 及 Decoder 协议打交道。但是,当要自定义一个类型的编码过程时,了解这个层次结构以及结构中的三种不同容器还是很有必要的。

一个最小的例子

使用 Codable 系统将一个自定义类型的实例编码为 JSON。

自动遵循协议

只要让你的类型满足 Codable 协议,它就能变为可编解码的类型。如果类型中所有的存储属性都是可编解码的,那么 Swift 编译器会自动帮你生成实现 Encodable 和 Decodable 协议的代码。下面的 Coordinate 存储了一个 GPS 位置信息:

struct Coordinate: Codable { 
    var latitude: Double
    var longitude: Double //不需要实现
}
复制代码

因为 Coordinate 的两个存储属性都已经是可编解码的类型,所以只要声明 Coordinate 实现了 Codable,就完全可以满足编译器的需要了。同样地,我可以定义一个 Placemark 结构体,由于 Coordinate 已经实现了 Codable,它也就自动成为一个满足 Codable 的类型了:

struct Placemark: Codable { 
    var name: String
    var coordinate: Coordinate
}
复制代码

让编译器生成代码和一个常规的默认实现的唯一一个实质性的区别在于,后者意味着这部分代码是标准库的一部分。但现在,合成 Codable 实现的逻辑还属于编译器的职责。要把这部分职责迁移到标准库,需要 Swift 拥有比现如今更强大的类型反射能力。不过即便这些能力存在,运行时类型反射也有自己的额外开销 (通常,反射机制会比编译器在内部直接处理慢一些)。

Encoding

Swift 自带两个编码器,分别是 JSONEncoder 和 PropertyListEncoder (它们定义在 Foundation 中,而不是在标准库里)。另外,实现了 Codable 的类型和 Cocoa 中的 NSKeyedArchiver 也是兼容的。

可以像这样把一个 Placemark 数组编码为 JSON:

let places = [
Placemark(name: "Berlin", coordinate: Coordinate(latitude: 52, longitude: 13)),
Placemark(name: "Cape Town", coordinate: Coordinate(latitude: -34, longitude: 18))
]
do{
let encoder = JSONEncoder()
let jsonData = try encoder.encode(places) // 129 bytes 
let jsonString = String(decoding: jsonData, as: UTF8.self)
/*
[{"name":"Berlin","coordinate":{"longitude":13,"latitude":52}}, 
{"name":"Cape Town","coordinate":{"longitude":18,"latitude":-34}}] */
} catch {
    print(error.localizedDescription) 
}
复制代码

实际的编码步骤非常简单:创建并且配置编码器,然后将值传递给它进行编码。JSON 编码器通过 Data 实例的方式返回一个字节的集合,这里为了显示,将它转为了字符串。

除了通过一个属性来设定输出格式 (带有缩进的易读格式和/或按词典对键进行排序) 以外, JSONEncoder 还支持对日期的表达方式 (包括 ISO 8601 或者 Unix epoch 时间戳),Data 值的 形式 (比如进行 Base64 编码) 以及异常浮点数的处理方法 (例如,无穷或者NaN) 进行自定义。甚至可以使用编码器的 keyEncodingStrategy 选项让 JSON 中的键采用蛇形命名方式 (snake case),或者自定义生成键的函数。这些选项对所有值的编码是通用的,也就是说,不能指定 Date 在不同的类型中,采用不同的编码配置。如果需要这种粒度上的控制,你只能对受影响的类型编写自定义的 Codable 实现。

值得注意的是,所有这里提到的配置项都是针对 JSONEncoder 说的。其他的编码器会有不同的选项 (或者没有选项)。而且 encode(_:) 方法也是随着编码器不同而不同的,它并没有被定义在任何协议里。其他的编码器可能会返回一个 String 或者甚至是被编码后文件的 URL,而不像 JSONEncoder 那样返回一个 Data 值。

实际上,JSONEncoder 甚至都没有实现 Encoder 协议。相反,它只是一个叫做 _JSONEncoder 的私有类的封装,这个私有类实现了 Encoder 协议,并且进行实际的编码工 作。之所以这样做,是因为顶层编码器 (译注:这里指 JSONEncoder) 应该提供的 API (这个 API 通常只用于启动编码过程),和在编码过程中传递给可编码类型的 Encoder 对象 (译注:这 里指 _JSONEncoder) 是截然不同的。将这些任务清晰地分开,意味着在任意给定的情景下,使用编码器的一方只能访问到适当的 API。例如,一个 Codable 类型不能在编码过程中重新配置编码器,因为公开的配置 API 只暴露在顶层编码器的定义里。

Decoding

JSONEncoder 的解码器版本是 JSONDecoder。解码和编码遵循同样的模式:创建一个解码器,然后将 JSON 数据传递给它进行解码。JSONDecoder 接受一个 Data 实例,这个 Data 应该包含 UTF-8 编码的 JSON 文本。不过和编码器一样,其他类型的解码器也可能会有不同的接口:

do{
    let decoder = JSONDecoder()
    let decoded = try decoder.decode([Placemark].self, from: jsonData)
    // [Berlin (lat: 52.0, lon: 13.0), Cape Town (lat: -34.0, lon: 18.0)] 
    type(of: decoded) // Array<Placemark>
    decoded == places // true
} catch { 
    print(error.localizedDescription)
}
复制代码

注意 decoder.decode(_:from:) 接受两个参数。除了输入的数据,还需要指定解码的目标类型 (这里是 [Placemark].self)。这让代码在编译期间能够类型安全。而 JSON 中的弱类型数据到代码中的具体数据类型的转换,这个过程则是在后台自动完成的。

将解码的目标类型明确地作为解码方法的参数,是一个有意的设计选择。这其实不是严格必须的,因为编译器其实可以在很多情况下推断出正确的类型。但 Swift 认为增加 API 的明确性和避免产生歧义,要比最大化精简代码更重要。

和编码过程比起来,解码过程中的错误处理是非常重要的。有太多的事情能导致解码失败 – 比如数据缺失 (JSON 中缺少某个必要的字段)、类型错误 (服务器不小心将数字编码为了字符串)以及数据完全损坏等。可以通过 DecodingError 类型的文档来查看可能会遇到的完整错误列表。developer.apple.com/documentati…

编码过程

了解如何自定义类型编码方式的话,还需要再进行一些深入挖掘。

容器

/// 一个可以把值编码成某种外部表现形式的类型。
public protocol Encoder {
/// 编码到当前位置的编码键 (coding key) 路径
    var codingPath: [CodingKey] { get }
/// 用户为编码设置的上下文信息。
    var userInfo: [CodingUserInfoKey : Any] { get }
/// 返回一个容器,用于存放多个由给定键索引的值。
    func container<Key: CodingKey>(keyedBy type: Key.Type) -> KeyedEncodingContainer<Key>
/// 返回一个容器,用于存放多个没有键索引的值。
    func unkeyedContainer() -> UnkeyedEncodingContainer 
/// 返回一个适合存放单一值的编码容器。
    func singleValueContainer() -> SingleValueEncodingContainer 
}
复制代码

先忽略 codingPath 和 userInfo,显然 Encoder 的核心功能就是提供一个编码容器 (encoding container)。一个容器就是编码器内部存储的一种沙盒视图。通过为每个要编码的值创建一个新的容器,编码器能够确保每个值都不会覆盖彼此的数据。

容器有三种类型:

  • 键容器(KeyedContainer)用于编码键值对。可以把键容器想像为一个特殊的字典,这是到目前为止,应用最普遍的容器。键容器内部使用的键是强类型的,提供了类型安全和自动补全的特性。编码器最终会在写入目标格式 (比如 JSON) 时,将键转换为字符串 (或者数字),不过这对开发者来说是隐藏的。修改编码后的键名是最简单的一种自定义编码方式的操作。
  • 无键容器(UnkeyedContainer)用于编码一系列值,但不需要对应的键,可以将它想像成保存编码结果的数组。因为没有对应的键来确定某个值,所以对无键容器中的值进行解码的时候,需要遵守和编码时同样的顺序。
  • 单值容器对单一值进行编码。你可以用它来处理只由单个属性定义的那些类型。例如: Int 这样的原始类型,或以原始类型实现了 RawRepresentable 协议的枚举。

对于这三种容器,它们每个都对应了一个协议,来约束容器应该如何接收一个值并进行编码。下面是 SingleValueEncodingContainer 的定义:

/// 支持存储和直接编码无索引单一值的容器。 
public protocol SingleValueEncodingContainer {
/// 编码到当前位置的编码键路径。 
    var codingPath: [CodingKey] { get }
/// 编码空值。
    mutating func encodeNil() throws
/// 编码原始类型的方法
    mutating func encode(_ value: Bool) throws
    mutating func encode(_ value: Int) throws 
    mutating func encode(_ value: Int8) throws 
    mutating func encode(_ value: Int16) throws 
    mutating func encode(_ value: Int32) throws 
    mutating func encode(_ value: Int64) throws 
    mutating func encode(_ value: UInt) throws 
    mutating func encode(_ value: UInt8) throws 
    mutating func encode(_ value: UInt16) throws 
    mutating func encode(_ value: UInt32) throws 
    mutating func encode(_ value: UInt64) throws 
    mutating func encode(_ value: Float) throws 
    mutating func encode(_ value: Double) throws 
    mutating func encode(_ value: String) throws
    mutating func encode<T: Encodable>(_ value: T) throws 
}
复制代码

可以看到,这个协议主要对 Bool,String,各种整数以及浮点数声明了一系列 encode(_:) 重载方法。另外,还有一个专门对 null 值进行编码的方法。所有的编码器和解码器都必须支持这些原始类型,而且所有的 Encodable 类型从根本上来说,也都必须归结到这些类型。

其他不属于原始类型的值,最后都会落到泛型的 encode<T: Encodable> 重载中。在这个方法里,容器最终会调用参数的 encode(to: Encoder) 方法,这使得整个过程会下降一个层级并重新开始,最终到达只剩下原始类型的情况。

UnkeyedEncodingContainer 和 KeyedEncodingContainerProtocol 拥有和 SingleValueEncodingContainer 相同的结构,不过它们具备更多的能力,比如可以创建嵌套的容器。如果想要为其它数据格式创建编码器或解码器,那么最重要的部分就是实现这些容器。

值是如何对自己编码的

之前的例子要编码的顶层类型是Array<Placemark>。而无键容器是保存数组编码结 果的绝佳场所 (因为数组说白了就是一串值的序列)。因此,数组将会向编码器请求一个无键容 器。然后,对自身的元素进行迭代,并告诉容器对这些元素一一进行编码。

extension Array: Encodable where Element: Encodable { 
    public func encode(to encoder: Encoder) throws {
        var container = encoder.unkeyedContainer() 
        for element in self {
        try container.encode(element) 
        }
    } 
}
复制代码

数组中的元素是 Placemark 实例。对于非原始类型的值,容器将继续调用这个值的 encode(to:) 方法。

合成的代码

Coding Keys

首先,在 Placemark里,编译器会生成一个叫做 CodingKeys 的私有枚举类型:

struct Placemark {
// ...
    private enum CodingKeys: CodingKey { 
        case name
        case coordinate
    } 
}
复制代码

这个枚举包含的成员与结构体中的存储属性一一对应。而枚举值即为键容器编码对象时使用的键。和字符串形式的键相比,因为编译器会检查拼写错误,所以这些强类型的键要更加安全和方便。不过,编码器最后为了存储需要,还是必须要能将这些键转为字符串或者整数值。而完成这个转换任务的,就是 CodingKey 协议:

/// 该类型作为编码和解码时使用的键
public protocol CodingKey {
/// 在一个命名集合 (例如:以字符串作为键的字典) 中的字符串值。
    var stringValue: String { get }
/// 在一个整数索引的集合 (一个整数作为键的字典) 中使用的值。 
    var intValue: Int? { get }
    init?(stringValue: String)
    init?(intValue: Int)
}
复制代码

所有键都必须可以用字符串的形式表示,另外,一个键类型也可以提供和整数互相转换的能力。如果使用整数更高效,编码器会选择整数形式的键。但它们也可以完全忽略掉这个特性而坚持使用字符串键,而JSONEncoder 就是这么做的。因此,编译器合成的默认代码也只包含了字符串键。

encode(to:) 方法

下面是编译器为 Placemark 结构体生成的 encode(to:) 方法:

struct Placemark: Codable {
// ...
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self) 
        try container.encode(name, forKey: .name)
        try container.encode(coordinate, forKey: .coordinate)
    } 
}
复制代码

和编码 Placemark 数组时的主要区别是,Placemark 会将自己编码到一个键容器中。对于那些拥有多个属性的复合数据类型 (例如结构体和类),使用键容器是正确的选择 (这里有一个例外,就是 Range,它使用无键容器来编码上下边界)。注意代码中,Placemark 从编码器申请键容器时,是如何通过 CodingKeys.self 指定容器中的键值的。接下来的所有编码命令都必须使用与之相同的类型。由于键类型通常都是被编码类型私有的,因此,当实现 encode(to:) 方法时,不小心使用了其它类型的编码键几乎是不可能发生的事情。

编码过程的结果,最终是一棵嵌套的容器树。JSON 编码器可以根据树中节点的类型把这个结 果转换成对应的目标格式:键容器会变成 JSON 对象 ({ ⋯ }),无键容器变成 JSON 数组 ([ ⋯ ]), 单值容器则按照它们的数据类型,被转换为数字,布尔值,字符串或者 null。

init(from:) 初始化方法

当我们调用 try decoder.decode([Placemark].self, from: jsonData) 时,解码器会按照传入的类型 (这里是 [Placemark]),使用 Decodable 中定义的初始化方法创建一个该类型的实例。 和编码器类似,解码器也管理一棵由解码容器 (decoding containers) 构成的树,树中所包含的容器还是键容器,无键容器,以及单值容器。

每个被解码的值会以递归方式向下访问容器的层级,并且使用从容器中解码出来的值初始化对应的属性。如果某个步骤发生了错误 (比如由于类型不匹配或者值不存在),那么整个过程都会失败,并抛出错误。

因此,编译器为 Placemark 生成的解码初始化方法看上去是这样的:

struct Placemark: Codable { // ...
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        name = try container.decode(String.self, forKey: .name)
        coordinate = try container.decode(Coordinate.self, forKey: .coordinate)
    } 
}
复制代码

手动遵守协议

如果类型有特殊要求,可以通过手动实现 Encodable 和 Decodable 协议来进行满足。

自定义 Coding Keys

控制一个类型如何编码自己最简单的方式,是为它创建自定义的 CodingKeys 枚举 (顺带一提,虽然自动合成的代码也使用枚举实现了 CodingKey 协议,但这个类型实际上也可以不是枚举)。 它可以让我们用一种简单的,声明式的方法,改变类型的编码方式。在这个枚举中,我们可以:

  • 在编码后的输出中,用明确指定的字符串值重命名字段。
  • 将某个键从枚举中移除,以此跳过与之对应字段。

想要设置一个不同的名字,需要明确将枚举的底层类型设置为 String。例如,下面的代码会把 name 在 JSON 中映射为 “label”,但保持 coordinate 的名字不变:

struct Placemark2: Codable { 
    var name: String
    var coordinate: Coordinate
    private enum CodingKeys: String, CodingKey { 
        case name = "label"
        case coordinate
    }
// 编译器合成的 encode 和 decode 方法将使用覆盖后的 CodingKeys。 
}
复制代码

在下面的实现中,枚举里没有包含 name 键,因此编码时地图标记的名字将会被跳过,只有 GPS 坐标信息会被编码:

struct Placemark3: Codable {
    var name: String = "(Unknown)" 
    var coordinate: Coordinate
    private enum CodingKeys: CodingKey { 
        case coordinate
    } 
}
复制代码

注意给 name 属性赋一个默认值。如果没有这个默认值,为 Decodable 生成的代码将会编译失败,因为编译器会发现在初始化方法中它无法给 name 属性正确赋值。

在编码阶段跳过一些暂时值有时候会很有用,因为它们很容易被重新计算过或者根本没必要保存,例如缓存,或者保存的某些繁重计算的结果。编译器可以自己过滤出标记为 lazy 的属性,但如果想把普通的存储属性作为暂时值的话,就需要像上面这样自定义类型的编码键。

自定义的 encode(to:) 和 init(from:) 实现

如果需要更多的控制,实现 encode(to:) 和/或 init(from:) 总是一个可行的办法。JSONEncoder 和 JSONDecoder 默认就可以处理可选值。当目标类型中的一个属性是可选值,如果输入数据中对应的值不存在的话,解码器将会正确地跳过这个属性。

// 下面是 Placemark 类型的另外一种定义,coordinate 属性现在是可选值:
struct Placemark4: Codable { 
    var name: String
    var coordinate: Coordinate?
}

// 现在,我们的服务器发送的 JSON 数据中,"coordinate" 字段有可能不存在:
let validJSONInput = """ [
{ "name" : "Berlin" },
{ "name" : "Cape Town" } ]
"""
复制代码

当让 JSONDecoder 将这个输入解码为 Placemark4 值的数组时,解码器将自动把 coordinate 设为 nil。

不过,JSONDecoder 会对输入数据的结构十分挑剔,只要数据和所期待的形式稍有不同,就可能触发解码错误。现在假设服务器的配置是发送一个空的 JSON 对象来表示某个可选值空缺的情况,于是,发送的 JSON 就会变为这样:

let invalidJSONInput = """ 
[{"name" : "Berlin", "coordinate": {}}]
"""
复制代码

当尝试解码这个输入时,解码器本来期待 “latitude” 和 “longitude” 字段存在于 coordinate 中,但是由于这两个字段实际并不存在,所以这会触发 .keyNotFound 错误:

do {
    let inputData = invalidJSONInput.data(using: .utf8)!
    let decoder = JSONDecoder()
    let decoded = try decoder.decode([Placemark4].self, from: inputData)
} catch { 
    print(error.localizedDescription)
// The data couldn’t be read because it is missing.
}
复制代码

要让这些代码工作,可以重载 Decodable 的初始化方法,明确地捕获错误:

struct Placemark4: Codable {
    var name: String
    var coordinate: Coordinate?
// encode(to:) 依然由编译器合成
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self) 
        self.name = try container.decode(String.self, forKey: .name) 
        do {
            self.coordinate = try container.decodeIfPresent(Coordinate.self, forKey: .coordinate)
        } catch DecodingError.keyNotFound { 
            self.coordinate = nil
        }
    }    
}

// 现在,解码器就可以成功地解码这个错误的 JSON 了:
do {
    let inputData = invalidJSONInput.data(using: .utf8)! 
    let decoder = JSONDecoder()
    let decoded = try decoder.decode([Placemark4].self, from: inputData)
    decoded // [Berlin (nil)] 
} catch {
    print(error.localizedDescription)
}
复制代码

当遇到其他错误,比如输入数据完全损坏,或者在 name 字段上发生任何问题时,解码过程依 然会抛出异常。
在只有一两个类型需要处理时,这种自定义方式是不错的选择,但是它很难大规模运用。如果 一个类型有很多属性的话,就算你只想要自定义其中一个,你也将会需要对每个字段都手写代码。不过如果你可以控制输入的话,最好还是在问题的源头进行修正 (让服务器返回有效的 JSON),而不是在之后的阶段再去对奇怪的数据进行处理。

常见的编码任务

让其他人的代码满足 Codable

假设要把 Coordinate 换成 Core Location 框架中的 CLLocationCoordinate2D, CLLocationCoordinate2D 和 Coordinate 的结构完全一样。

不过问题是,CLLocationCoordinate2D 并不满足 Codable 协议。所以,编译器现在会 (正确地) 抱怨说它无法为 Placemark5 自动生成实现 Codable 的代码,因为它的 coordinate 属性不再是遵从 Codable 的类型了:

import CoreLocation
struct Placemark5: Codable {
    var name: String
    var coordinate: CLLocationCoordinate2D 
}
// 错误:无法自动合成 'Decodable'/'Encodable' 的适配代码,
// 因为 'CLLocationCoordinate2D' 不遵守相关协议

// 就算它定义在其它模块里,我们可以让 CLLocationCoordinate2D 也遵守 Codable 吗?
//在扩展 中给类型添加协议支持会造成一个错误:
extension CLLocationCoordinate2D: Codable { }
// 错误: 不能在类型定义的文件之外通过扩展自动合成实现 'Encodable' 的代码。
复制代码

Swift 只在两种情况下会自动合成协议实现的代码,分别是直接添加在类型定义上的协议,以及定义在同一个文件的类型扩展上的协议。因此,在我们的例子中,只能自己手工添加实现代码。不过即使这个限制不存在,通过扩展让一个不属于我们的类型适配 Codable 也并不是一个好主意。要是 Apple 决定在今后的 SDK 版本中自己来满足协议的话,怎么办?很可能 Apple 的实现与你自己的实现不兼容。也就是说,用自己的实现进行编码的结果,很可能在 Apple 的代码中无法解码,反之也是如此。而这就是问题所在了,因为解码器不知道自己到底应该使用哪个实现 – 它看到的只有这个值应该被解码为 CLLocationCoordinate2D 而已。

Apple 的工程师:实际上我会更进一步,并且建议在当你想要扩展别人的类型,使其满足 Encodable 或 Decodable 时,你几乎总是应该考虑写一个结构体把它封装起来,除非你有理由能够 确信这个类型自己绝对不会去遵循这些协议。 forums.swift.org/t/why-you-c…

先通过一个 略有不同 (但同样安全) 的方案来解决:为 Placemark5 提供自己的 Codable 实现,在那里直接对纬度和经度进行编码。这么做可以有效地对编码器和解码器隐藏 CLLocationCoordinate2D 的存在;从它们的角度来看,纬度和经度就好像是直接定义在 Placemark5 里的一样:

extension Placemark5 {
    private enum CodingKeys: String, CodingKey { 
        case name
        case latitude = "lat"
        case longitude = "lon"
    }
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self)
        try container.encode(name, forKey: .name) // 分别编码纬度和经度
        try container.encode(coordinate.latitude, forKey: .latitude)
        try container.encode(coordinate.longitude, forKey: .longitude) 
    }
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self) 
        self.name = try container.decode(String.self, forKey: .name)
        // 从纬度和经度重新构建 
    CLLocationCoordinate2D self.coordinate = CLLocationCoordinate2D(
        latitude: try container.decode(Double.self, forKey: .latitude),
        longitude: try container.decode(Double.self, forKey: .longitude) )
    } 
}
复制代码

上面这个例子就很好地展示了当编译器无法自动生成 Codable 实现时 (当然,这种情况下,编译器也不会为我们合成实现 CodingKey 协议的代码),不得不为每个类型手工编写的代码模板。

当编译器不能为我们生成上面的例子中的这些模板代码时,就必须为每个类型都自己写出这些代码 (为满足 CodingKey 协议的代码生成在这里也没有进行)。

另一种方案是使用嵌套容器来编码经纬度。KeyedDecodingContainer 有一个叫做 nestedContainer(keyedBy:forKey:) 的方法,它可以在 forKey 指定的键上,新建一个嵌套的键 容器 (译注:想象一下,原本这个键对应的应该是在原始容器中保存的编码结果),这个嵌套键容器使用 keyedBy 参数指定的另一套编码键。于是,只要再定义一个实现了 CodingKeys 的枚举,用它作为键,在嵌套的键容器中编码纬度和精度就好了 (这里给出了 Encodable 的实现;Decodable 也遵循同样的模式):

struct Placemark6: Encodable {
    var name: String
    var coordinate: CLLocationCoordinate2D
    private enum CodingKeys: CodingKey { 
        case name
    case coordinate
}
// 嵌套容器的编码键

private enum CoordinateCodingKeys: CodingKey {
    case latitude
    case longitude 
}
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self) 
        try container.encode(name, forKey: .name)
        var coordinateContainer = container.nestedContainer(
        keyedBy: CoordinateCodingKeys.self, forKey: .coordinate)
        try coordinateContainer.encode(coordinate.latitude, forKey: .latitude) 
        try coordinateContainer.encode(coordinate.longitude, forKey: .longitude)
    } 
}
复制代码

这样,在 Placemark 结构体里有效地重建了 Coordinate 类型的编码方式,而没有向 Codable 系统暴露这个内嵌的类型。当然,无论是直接编码、还是使用嵌套容器,这两种方式生成的 JSON 结果是完全相同的。

可以看到,无论上面哪种情况都要写很多代码。对这个特定的例子,推荐一种不同的策略。这次,在 Placemark 里定义一个 Coordinate 类型的私有属性 _coordinate,用它存储位置信息。然后,给用户暴露一个 CLLocationCoordinate2D 类型的计算属性 coordinate。这次,由于 Coordinate 已经实现了 Codable,因此整个 Placemark 类型就自动是一个实现 Codable 的类型了。所以唯一要做的事情,就是在 CodingKeys 枚举中,重命名 _coordinate 对应的键,让它和暴露给用户的属性同名。这样,用户仍旧可以像之前一样 使用 coordinate,而 Codable 系统则会完全忽略它,因为它只是一个计算属性:

var name: String
private var _coordinate: Coordinate
    var coordinate: CLLocationCoordinate2D {
        get {
            return CLLocationCoordinate2D(latitude: _coordinate.latitude,
            longitude: _coordinate.longitude) 
        }
        set {
            _coordinate = Coordinate(latitude: newValue.latitude,
            longitude: newValue.longitude) 
        }
    }
    private enum CodingKeys: String, CodingKey { 
        case name
        case _coordinate = "coordinate"
    } 
}
复制代码

这种方式之所以可以良好工作,是因为 CLLocationCoordinate2D 是一个很简单的类型而且自定义类型的相互转换也非常容易。

让类满足 Codable

作为一般性的原则,Codable 系统也能用在类上,但由于还可能存在子类,事情会因此变得更加复杂。试图让 UIColor 满足 Decodable 的话 (先暂时忽略 Encodable,因为它和这个讨论无关),会发生什么?

// UIColor 的一个自定义的 Decodable 实现看起来可能是这样的:
extension UIColor: Decodable {
    private enum CodingKeys: CodingKey {
        case red 
        case green 
        case blue 
        case alpha
    }
    
    
// 错误:在一个可继承的类里,`Decodable` 约束的 'init(from:)'
// 初始化方法只能通过在类定义中的一个`必须的`初始化方法满足。 
    public init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self) 
        let red = try container.decode(CGFloat.self, forKey: .red)
        let green = try container.decode(CGFloat.self, forKey: .green) 
        let blue = try container.decode(CGFloat.self, forKey: .blue)
        let alpha = try container.decode(CGFloat.self, forKey: .alpha)
        self.init(red: red, green: green, blue: blue, alpha: alpha) }
}
复制代码

上面的代码无法编译,它有好几个错误,最终它们可以归结到一个不可解决的冲突:只有必须的初始化方法 (required initializers) 才能满足协议的要求,而这类方法不能添加在扩展里,它们必须直接添加在类定义中。

一个必须的初始化方法 (通过 required 关键字标记) 表示所有的子类都必须实现这个初始化方法。定义在协议中的初始化方法必须都是 required 的,和协议的所有要求一样,这能够保证对该初始化方法的调用都能动态地作用在子类上。编译器必须保证类似这样的代码能够正确工作:

func decodeDynamic(_ colorType: UIColor.Type, from decoder: Decoder) throws -> UIColor { 
    return try colorType.init(from: decoder)
}
let color = decodeDynamic(SomeUIColorSubclass.self, from: someDecoder)
复制代码

要让这个动态派发正确工作,编译器需要在类的派发表中为 Decodable 约束的初始化方法创建一条记录。但这个表示在类定义被编译的时候创建的,它的大小是固定的,不能通过扩展再向其中添加新的记录。这就是为什么 required 初始化方法只能在类定义中存在的原因。

不能为一个非 final 的类用扩展的方式事后追加 Codable 特性。

不过即使这样,为不属于你的类型添加 Codable 还是问题重重,推荐的方式是写一个结构体来封装 UIColor,并且对这个结构体进行编解码。

extension UIColor {
    var rgba: (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat)? {
        var red: CGFloat = 0.0
        var green: CGFloat = 0.0
        var blue: CGFloat = 0.0
        var alpha: CGFloat = 0.0
        if getRed(&red, green: &green, blue: &blue, alpha: &alpha) {
            return (red: red, green: green, blue: blue, alpha: alpha) 
        } else {
            return nil
        } 
    }
}
复制代码

会在 encode(to:) 的实现里用到 rgba 属性。注意 rgba 是一个可选的多元组,这是因为并不是所有的 UIColor 实例都能被表示为 RGBA 的形式。如果有人想要编码一个不能转换为 RGBA 的颜色 (比如,一个从图案图片创建的颜色)会抛出一个编码错误。

下面是 UIColor.CodableWrapper 结构体的完整实现 (这个结构体放在 UIColor 的命名空间中,这样它们之间的关系可以更加明确):

extension UIColor {
    struct CodableWrapper: Codable {
        var value: UIColor
        init(_ value: UIColor) { 
            self.value = value
        }
        enum CodingKeys: CodingKey { 
            case red
        case green
        case blue
        case alpha 
        }
        func encode(to encoder: Encoder) throws { 
            // 如果颜色不能转为 RGBA,则抛出错误
            guard let (red, green, blue, alpha) = value.rgba else { 
                let errorContext = EncodingError.Context(
                codingPath: encoder.codingPath, debugDescription:
                "Unsupported color format: \(value)" )
                throw EncodingError.invalidValue(value, errorContext) 
            }
            var container = encoder.container(keyedBy: CodingKeys.self) 
            try container.encode(red, forKey: .red)
            try container.encode(green, forKey: .green)
            try container.encode(blue, forKey: .blue)
            try container.encode(alpha, forKey: .alpha) 
        }
        init(from decoder: Decoder) throws {
            let container = try decoder.container(keyedBy: CodingKeys.self) 
            let red = try container.decode(CGFloat.self, forKey: .red)
            let green = try container.decode(CGFloat.self, forKey: .green)
            let blue = try container.decode(CGFloat.self, forKey: .blue)
            let alpha = try container.decode(CGFloat.self, forKey: .alpha) 
            self.value = UIColor(red: red, green: green, blue: blue, alpha: alpha)
        } 
    }
}
复制代码

这个实现有一点不完美,因为它会在编码的时候把所有颜色都转换到 RGB 颜色空间。于是,之后解码的时候,即使之前编码的颜色定义在灰度颜色空间中,你也会得到一个 RGB 颜色。因为在 UIColor 的公开 API 中没有能提取颜色空间的方法,一个更好的实现是深入到底层的 CGColor,并确认颜色使用的颜色空间模型 (例如:RGB 或灰度),然后将颜色空间和对应空间中表示颜色的组件一起编码。在解码时,需要先解码颜色空间模型,然后再从解码容器中得到根据对应的解码键得到相应的颜色表示方法。

封装结构体的方式最大的缺点在于,需要手动在编码前和解码后将类型在 UIColor 和封装类型之间进行转换。比如你想要编码一个 UIColor 的数组:

let colors: [UIColor] = [ 
.red,
.white,
.init(displayP3Red: 0.5, green: 0.4, blue: 1.0, alpha: 0.8), 
.init(hue: 0.6, saturation: 1.0, brightness: 0.8, alpha: 0.9),
]

// 在将它传递给编码器之前,你需要先把数组中的元素映射为
// UIColor.CodableWrapper: 
let codableColors = colors.map(UIColor.CodableWrapper.init)

// 不光如此,现在编译器也无法为包含 UIColor 属性的类型自动合成 Codable 实现了。
// 下面这个定义就会产生错误,因为 UIColor 不是一个遵从 Codable 的类型:
// 错误:不能自动合成 `Encodable` 和 `Decodable` 的实现
struct ColoredRect: Codable {
    var rect: CGRect
    var color: UIColor 
}
复制代码

我们可以给 ColorRect 添加一个 UIColor.CodableWrapper 类型的私有属性 _color,用来它作为颜色值的存储。然后,再添加一个 UIColor 类型的计算属性 color,让它访问 _color 并返回其中的颜色值。另外,我们还需要添加一个接受 UIColor 为参数初始化方法。最后,我们还需要提供一个自定义的编码键枚举,把编码颜色值的键名从默认的 “_color” 改为 “color” (当然,这一步是可选的):

struct ColoredRect: Codable { 
    var rect: CGRect
//存储颜色
    private var _color: UIColor.CodableWrapper 
    var color: UIColor {
        get { return _color.value }
        set { _color.value = newValue } 
    }
    init(rect: CGRect, color: UIColor) {
        self.rect = rect
        self._color = UIColor.CodableWrapper(color)
    }
    private enum CodingKeys: String, CodingKey { 
        case rect
        case _color = "color"
    } 
}

// 现在,编码一个 ColorRect 数组得到的 JSON 结果就是这样的:
let rects = [ColoredRect(rect: CGRect(x: 10, y: 20, width: 100, height: 200), color: .yellow)]
do {
    let encoder = JSONEncoder()
    let jsonData = try encoder.encode(rects)
    let jsonString = String(decoding: jsonData, as: UTF8.self)
    // [{"color":{"red":1,"alpha":1,"blue":0,"green":1},"rect":[[10,20],[100,200]]}]
} catch { 
    print(error.localizedDescription)
}
复制代码

让枚举满足 Codable

编译器也可以为实现了 RawRepresentable 协议的枚举自动合成实现 Codable 的代码,只要枚举的 RawValue 类型是这些原生就支持 Codable 的类型即可:Bool,String,Float,Double 以及各种形式的整数。而对于其它情况,例如带有关联值的枚举,你就只能手动添加 Codable 实现了。

给下面这个 Either 枚举添加 Codable 的支持。Either 是一个非常常用的表达二选一概念的类型,它表示的值既可以是泛型参数 A 的对象,也可以是泛型参数 B 的对象:

enum Either<A, B> { 
    case left(A)
    case right(B)
}
复制代码

只有在泛型参数 A 和 B 都支持 Codable 时,才能给 Either 添加一个合理的 Codable 实现。 如果没有这个约束,我们就不知道应该如何编码或解码 Either 的关联值。因此,Either 的 Codable 约束必须加上 A: Codable, B: Codable 这样的约束条件:

extension Either: Codable where A: Codable, B: Codable { 
    private enum CodingKeys: CodingKey {
        case left
        case right 
    }
    func encode(to encoder: Encoder) throws {
        var container = encoder.container(keyedBy: CodingKeys.self) 
        switch self {
        case .left(let value):
            try container.encode(value, forKey: .left) 
        case .right(let value):
            try container.encode(value, forKey: .right)
        }
    }
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        if let leftValue = try container.decodeIfPresent(A.self, forKey: .left) {
            self = .left(leftValue) 
        } else {
            let rightValue = try container.decode(B.self, forKey: .right)
            self = .right(rightValue) 
        }
    } 
}
复制代码

在 encode(to:) 的实现里,我们检查了枚举自身是 left 还是 right,然后将它的关联值编码在对 应的键下。同样,init(from:) 初始化方法先使用了容器的 decodeIfPresent 来检查容器是否拥 有左键。如果没有,那么它就无条件地解码右键,因为两个键必有其一。

解码多态集合

解码器要求我们为要解码的值传入具体的类型。直觉上这很合理:解码器需要知道具体的类型才能调用合适的初始化方法,而且由于被编码的数据一般不含有类型信息,所以类型必须由调用者来提供。这种对强类型的强调导致了一个结果,那就是在解码步骤中不存在多态的可能。

例如,想编码一个 UIView 的数组,数组中的元素则是 UILabel 或 UIImageView 这样的 UIView 的子类:

let views: [UIView] = [label, imageView, button]
复制代码

(假设现在 UIView 和它的子类现在都满足 Codable,尽管现在它们并不是这样的类型。)

如果编码这个数组,再对它进行解码,就会发现得到的结果和原来 views 并不相同 – 数组中元素的具体类型在解码回来后都消失了。解码器能还原回来的只是普通的 UIView 对象,因为它对被解码数据类型的全部了解就是 [UIView].self。

那么,应该如何编码这样的多态对象集合呢?最好的方式就是创建一个枚举,让它的每个 case 对应要支持的子类,而 case 的关联值则是对应的子类对象:

enum View {
case view(UIView)
case label(UILabel)
case imageView(UIImageView) // ...
}
复制代码

接下来,我们需要手写一个 Codable 实现,它和之前我们在 Either 枚举中做的事情遵循同样的 模式:

  • 在编码时,对要编码的对象和 View 的所有 case 进行匹配,然后将对象的类型和对象本身编码到各自的键中。
  • 在解码时,先解码出类型信息,然后根据具体的类型调用合适的初始化方法。

最后,还应该写两个简便方法,一个用于把 UIView 包装成 View,另一个从 View 解包得到原始的 UIView 对象。这样,只用一个 map 方法就能把原始数组传递给编码器,以及从解码器中还原回原始数组中的对象了。

当然,这并不是一个动态的解决方案;每次想要支持新的子类时,都需要手动更新 View 枚举。这不是很方便,但必须明确地告诉解码器代码中所能接受的每个类型的名字。其他方式可能会带来潜在的安全威胁,因为攻击者很可能通过操作程序包来初始化一些程序里未知的对象。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享