在Swift中自定义Codable类型
大多数现代应用程序的共同点是,它们需要对各种形式的数据进行编码或解码。无论是通过网络下载的JSON数据,还是本地存储的模型的某种形式的序列化表示 - 能够可靠地编码和解码不同的数据对于或多或少的任何Swift代码库都是必不可少的。
这是Swift的Codable API在作为Swift 4.0的一部分引入时如此重要的新功能的一个重要原因 - 从那时起它已经发展成为几种不同类型的编码和解码的标准,强大的机制 - 在Apple的平台,以及服务器端Swift。
使Codable如此出色的原因在于它与Swift工具链紧密集成,使编译器能够自动合成编码和解码各种值所需的大量代码。但是,有时我们确实需要自定义序列化时我们的值的表示方式 - 所以本周,我们来看看我们可以通过几种不同的方式调整我们的Codable实现来做到这一点。
改变钥匙
让我们从一种基本方法开始,我们可以自定义类型的编码和解码方式 - 通过修改用作序列化表示的一部分的键。假设我们正在开发一个用于阅读文章的应用程序,我们的核心数据模型之一如下所示:
struct Article: Codable {
var url: URL
var title: String
var body: String
}
我们的模型当前使用完全自动合成的Codable实现,这意味着它的所有序列化键都将匹配其属性的名称。但是,我们将解码Article值的数据(例如从服务器下载的JSON)可能使用稍微不同的命名约定,导致默认解码失败。
谢天谢地,这很容易修复。我们需要做的就是自定义Codable在解码(或编码)我们Article类型的实例时将使用的键是在其中定义CodingKeys枚举 - 并将自定义原始值分配给匹配我们希望自定义的键的案例 - 像这样:
extension Article {
enum CodingKeys: String, CodingKey {
case url = "source_link"
case title = "content_name"
case body
}
}
执行上述操作后,我们可以继续利用编译器生成的默认实现进行实际的编码工作,同时仍然允许我们更改将用于序列化的键的名称。
虽然上述技术非常适合我们想要使用完全自定义的键名称,但如果我们只想让Codable使用snake_case我们的属性名称版本(例如backgroundColor转入background_color) - 那么我们可以简单地改变我们的JSON解码器keyDecodingStrategy:
var decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
上述两个API的优点在于它们使我们能够解决Swift模型与用于表示它们的数据之间的不匹配问题,而无需我们修改属性的名称。
忽略键
虽然能够自定义编码密钥的名称非常有用,但有时我们可能希望完全忽略某些密钥。例如,我们现在说我们正在制作一个笔记记录应用程序 - 并且我们允许用户将各种笔记组合在一起形成一个NoteCollection,其中可以包括本地草稿:
struct NoteCollection: Codable {
var name: String
var notes: [Note]
var localDrafts = [Note]()
}
然而,尽管成为localDrafts我们NoteCollection模型的一部分真的很方便- 但是我们说在序列化或反序列化这样的集合时我们不希望包含这些草稿。这样做的原因可能是每次启动应用程序时给用户一个干净的名单,或者因为我们的服务器不支持草稿。
幸运的是,这也可以轻松完成,而无需更改实际的Codable实现NoteCollection。如果我们CodingKeys像之前一样定义枚举,并且只是省略localDrafts- 那么在编码或解码NoteCollection值时不会考虑该属性:
extension NoteCollection {
enum CodingKeys: CodingKey {
case name
case notes
}
}
为了使上述工作,我们省略的属性必须具有默认值 - localDrafts在这种情况下已经具有。
创建匹配结构
到目前为止,我们只调整了一个类型的编码键 - 虽然我们通常可以做到这一点,但有时我们需要在Codable自定义方面更进一步。
假设我们正在构建一个包含货币转换功能的应用程序,并且我们将给定货币的当前汇率作为JSON数据下载,如下所示:
{
"currency": "PLN",
"rates": {
"USD": 3.76,
"EUR": 4.24,
"SEK": 0.41
}
}
在我们的Swift代码中,我们希望将这些JSON响应转换为CurrencyConversion实例 - 每个实例包含一个ExchangeRate条目数组- 每种货币对应一个:
struct CurrencyConversion {
var currency: Currency
var exchangeRates: [ExchangeRate]
}
struct ExchangeRate {
let currency: Currency
let rate: Double
}
但是,如果我们只是继续使上述两个模型都符合Codable,我们再次得出我们的Swift代码和我们想要解码的JSON数据之间的不匹配。但这一次,它不仅仅是关键名称的问题 - 结构上存在根本区别。
当然,我们可以修改我们的Swift模型的结构以完全匹配我们的JSON数据的结构 - 但这并不总是实用的。虽然拥有正确的序列化代码很重要,但拥有适合我们实际代码库的模型结构同样重要。
相反,让我们创建一个新的专用类型 - 它将充当我们的JSON数据中使用的格式与Swift代码结构之间的桥梁。在该类型中,我们将能够封装将汇率的JSON字典转换为ExchangeRate模型数组所需的所有逻辑- 如下所示:
private extension ExchangeRate {
struct List: Decodable {
let values: [ExchangeRate]
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let dictionary = try container.decode([String : Double].self)
values = dictionary.map { key, value in
ExchangeRate(currency: Currency(key), rate: value)
}
}
}
}
使用上面的类型,我们现在可以定义一个私有属性,该属性与用于其数据的JSON密钥匹配 - 并且我们的exchangeRates属性只是充当该私有属性的面向公众的代理:
struct CurrencyConversion: Decodable {
var currency: Currency
var exchangeRates: [ExchangeRate] {
return rates.values
}
private var rates: ExchangeRate.List
}
上述工作的原因是因为在编码或解码值时从不考虑计算属性。
当我们想要使Swift代码与使用非常不同结构的JSON API兼容时,上述技术可以成为一个很好的工具 - 再次无需Codable从头开始实现。
转变价值观
在解码时,尤其是在使用我们无法控制的外部JSON API时,一个非常常见的问题是,类型的编码方式与Swift的严格类型系统不兼容。例如,我们要解码的JSON数据可能使用字符串来表示整数或其他类型的数字。
让我们看看一种可以让我们处理这些值的方法,再次以一种自包含的方式,不需要我们编写完全自定义的Codable实现。
我们在这里要做的就是将字符串值转换为另一种类型 - 让我们Int以此为例。我们首先定义一个协议,让我们将任何类型标记为StringRepresentable- 意味着它可以从字符串表示转换为字符串表示:
protocol StringRepresentable: CustomStringConvertible {
init?(_ string: String)
}
extension Int: StringRepresentable {}
我们将上述协议基于CustomStringConvertible标准库,因为它已经包含了将值描述为字符串的属性要求。有关将协议定义为其他协议的特殊方法的更多信息,请查看“Swift中的专业协议”。
接下来,让我们创建另一个专用类型 - 这次是任何可以由字符串支持的值- 并且它包含解码和编码字符串值所需的所有代码:
struct StringBacked: Codable {
var value: Value
init(from decoder: Decoder) throws {
let container = try decoder.singleValueContainer()
let string = try container.decode(String.self)
guard let value = Value(string) else {
throw DecodingError.dataCorruptedError(
in: container,
debugDescription: """
Failed to convert an instance of \(Value.self) from "\(string)"
"""
)
}
self.value = value
}
func encode(to encoder: Encoder) throws {
var container = encoder.singleValueContainer()
try container.encode(value.description)
}
}
就像我们之前为我们的JSON兼容的底层存储创建私有属性一样,我们现在可以对编码时由字符串后端的任何属性执行相同的操作 - 同时仍然将该数据暴露给我们的其余Swift代码类型。这是一个为Video类型的numberOfLikes属性做这样的例子:
struct Video: Codable {
var title: String
var description: String
var url: URL
var thumbnailImageURL: URL
var numberOfLikes: Int {
get { return likes.value }
set { likes.value = newValue }
}
private var likes: StringBacked
}
在必须为属性手动定义setter和getter的复杂性以及必须回退到完全自定义Codable实现的复杂性之间肯定存在权衡- 但对于类似上述Video结构的类型,其仅具有需要的一个属性使用私有支持属性进行自定义可能是一个很好的选择。
结论
虽然编译器能够自动合成所有Codable不需要任何形式定制的一致性,但真正太棒了- 我们能够在需要时自定义事物同样非常棒。
更妙的是,这样做往往并不真正需要我们赞成手工执行的彻底抛弃自动生成的代码-这是很多次可能只是稍微调整一个类型的编码或解码的方式,同时还让编译器做大部分繁重的工作。
链接:https://www.jianshu.com/p/62162d01d1df