木又的《Swift进阶》读书笔记——编码和解码

1,510 阅读11分钟

编码和解码

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

/// 一个类型可以将自身编码为某种外部表示形式。
public protocol Encodable {
  /// 将值编码到给定的 encoder 中。
  public func encode(to encoder: Encoder) throws
}

/// 一个类型可以从某种外部表示形式中解码得到自身。
public protocol Decodable {
  /// 从给定的 decoder 中解码来创建新的实例。
  public init(from decoder: Decoder) throws
}
public typealias Codable = Decodable & Encodable

一个最小的例子

自动遵循协议

下面的 Coordinate 存储了一个GPS位置信息:

struct Coordinate: Codable {
  var latitude: Double
  var longitude: Double
  // 不需要实现
}
struct Placemark: Codable {
  var name: String
  var coordinate: Coordinate
}

Encoding

Swift 自带两个编码器,分别是 JSONEncoderPropertyListEncoder (它们定义在 Foundation 中,而不是在标准库里)。

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)
}

Decoding

JSONEncoder 的解码器版本是 JSONDecoder。

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

编码过程

容器

来看看 Encoder 协议,这是编码器暴露给编码值的接口:

/// 一个可以把值编码成某种外部表现形式的类型。
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
}

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

容器有三种类型:

  • 键容器 (Keyed Container) 用于编码键值对。可以把键容器想象为一个特殊的字典,这是到目前为止,应用最普遍的容器。
  • 无键容器 (Unkeyed Container) 用于编码一系列值,但不需要对应的键,可以将它想象成保存编码结果的数组。
  • 单值容器 对单一值进行编码。

对于这三种容器,它们每个都对应了一个协议,来约束容器应该如何接受一个值并进行编码。下面是 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
}

UnkeyedEncodingContainerKeyedEncodingContainerProtocol 拥有和 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)
    }
  }
}

合成的代码

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)
}

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,它使用无键容器来编码上下边界)。

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

init(from:) 初始化方法

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

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

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)
  }
}

手动遵守协议

自定义 Coding Keys

控制一个类型如何编码自己最简单的方式,是为它创建自定义的 CodingKeys 枚举。它可以让我们用一种简单的,声明式的方法,改变类型的编码方式。在这个枚举中,我们可以:

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

想要设置一个不同的名字,我们需要明确将枚举的底层类型设置为 String

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

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

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

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

struct Placemark4: Codable {
  var name: String
  var coordinate: Coordinate?
}

假设这样的一个 JSON:

let invalidJSONInput = """
[
  {
    "name": "Berlin",
    "coordinate": {}
  }
]
"""

因为 coordinate 中并不存在 latitudelongitude 字段,所以会触发 .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)
}

常见的编码任务

让其他人的代码满足 Codable

import CoreLocation

struct Placemark5: Codable {
  var name: String
  var coordinate: CLLocationCoordinate2D
} 
// 错误:无法自动合成'Decodable'/'Encodable'的适配代码,
// 因为'CLLocationCoordinate2D'不遵守相关协议
extension CLLocationCoordinate2D: Codable {}
// 错误:不能在类型定义的文件之外通过扩展自动合成实现'Encodable'的代码。
extension Placemark5 {
  private enum CodingKeys: String, CodingKey {
    case name
    case latitude = "lat"
    case longitude = "lon"
  }
  
  func encode(to encoder: Encoder) throws {
    var container = encoder.contaienr(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)
    )
  }
}

另一种方案是使用嵌套容器来编码经纬度。KeyedDecodingContainer有一个叫做nestedContainer(keyedBy:forKey:)的方法,它可以在forKey指定的键上,新建一个嵌套的键容器,这个嵌套键容器使用 keyedBy 参数指定的另一套编码键。于是,我们只要再定义一个实现了 CodingKeys 的枚举,用它作为键,在嵌套的键容器中编码纬度和经度就好了:

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: CoordinatedCodingKeys.self, forKey: .coordinate)
    try coordinateContainer.encode(coordinate.latitude, forKey: .latitude)
    try coordinateContainer.encode(coordinate.longitude, forKey: .longitude)
  }
}

这样,我们就在 Placemark 结果体里,有效地重建了 Coordinate 类型的编码方式,而没有向 Codable 系统暴露这个内嵌的类型。

另一种策略,在 PlaceMark 里,我们定义一个 Coordinate 类型的私有属性 _coordinate,用它存储位置信息。然后,给用户暴露一个 CLLocationCoordinate2D 类型的计算属性 coordinate

struct Placemark7: 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"
  }
}

让类满足 Codable

我们不能为一个非 final 的类用扩展的方式事后追加 Codable 特性。推荐的方式是写一个结构体来封装类,并且对这个结构体进行编解码。

让枚举满足 Codable

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

Either 是一个非常常用的表达二选一概念的类型,它表示的值既可以是泛型参数 A 的对象,也可以是泛型参数 B 的对象:

enum Either<A,B> {
  case left(A)
  case right(B)
}
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)
    }
  }
}
let values: [Either<String,Int>] = [
  .left("Forty-two"),
  .right(42)
]

do {
  let encoder = PropertyListEncoder()
  encoder.outputFormat = .xml
  let xmlData = try encoder.encode(values)
  let xmlString = String(decoding: xmlData, as: UTF8.self)
/*
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<array>
  <dict>
  	<key>left</key>
  	<string>Forty-two</string>
  </dict>
  <dict>
  <key>right</key>
  <integer>42</integer>
 </dict>
</array>
</plist>
*/
  
  let decoder = PropertyListDecoder()
  let decoded = try decoder.decode([Either<String, Int>].self, from: xmlData)
/*
[Either<Swift.String, Swift.Int>.left("Forty-two"),
 Either<Swift.String, Swift.Int>.right(42)]
*/
} catch {
  print(error.localizedDescription)
}

解码多态集合

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

假设我们想编码这样一个 UIView 的数组。(假定 UIView 和它的子类现在都满足 Codable,当然现在它们并不是这样的类型。)

let views: [UIView] = [label, imageView, button]

如果我们编码这个数组,在对它进行解码,解码器能还原回来的只是普通的 UIView 对象,因为它对被解码数据类型的全部了解就是 [UIView].self。

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

enum View {
  case view(UIView)
  case label(UILabel)
  case imageView(UIImageView)
  // ...
}