SwiftUI 使用自定义键类型对Swift字典进行编码和解码 (大胖严选)

使用自定义键类型对Swift字典进行编码和解码

如今Codable,几乎每个项目都在使用,该项目于2017年随Swift 4引入。

快进到2021年,Codable仍然可以令我们感到惊讶:
在本文中,让我们看一下有关带有自定义键类型的字典的鲜为人知的行为。

我们将专注于json示例,同样适用于plists。

字典101

Swift字典是键值元素的通用集合,其中Key类型需要符合Hashable(出于性能原因),并且Value类型没有限制。

这是一个具有四个键值对的示例:

SwiftUI技术交流QQ群:518696470
let dictionary: [String: Int] = [
  "f": 1,
  "i": 2,
  "v": 3,
  "e": 4
]

当涉及json时,以上相同的字典将具有以下格式:

SwiftUI技术交流QQ群:518696470
{
  "f": 1,
  "i": 2,
  "v": 3,
  "e": 4
}

必须在其中引号的位置。

惊喜

假设我们正在构建一个小型应用程序,其中每个模型具有不同语言的不同名称,所有名称都将随意显示给用户。

存储所有名称变体的一种方法是引入一种新Codable Language类型,用作我们模型名称的字典键:

enum Language: String, Codable {
  case english
  case japanese
  case thai
}

然后,我们将以这种方式使用它:

let names: [Language: String] = [
  .english:  "Victory Monument",
  .japanese: "戦勝記念塔",
  .thai:     "อนุสาวรีย์ชัยสมรภูมิ"
]

一切都很好……直到是时候将其编码为json了:

let encodedDictionary = try JSONEncoder().encode(names)
// ["english", "Victory Monument", "japanese", "戦勝記念塔", "thai", "อนุสาวรีย์ชัยสมรภูมิ"]

那是一个数组,不完全是一个字典。
怎么解码呢?

let jsonData = """
{
 "english":  "Victory Monument",
 "japanese": "戦勝記念塔",
 "thai":     "อนุสาวรีย์ชัยสมรภูมิ"
}
""".data(using: .utf8)!

let decoded = try JSONDecoder().decode([Language: String].self, from: jsonData)
// typeMismatch(
//   Swift.Array<Any>, 
//   Swift.DecodingError.Context(
//     codingPath: [],
//     debugDescription: "Expected to decode Array<Any> but found a dictionary instead.", 
//     underlyingError: nil
//    )
//  )

解码失败:尽管试图解码成字典([Language: String]),Swift仍希望对数组进行解码。

如果我们更换密钥类型Language有String,那么一切都按预期工作:这是为什么?

经过一番挖掘,事实证明这种行为实际上是预期的:

  • 由于Codable类型可以编码为任何内容(包括另一个字典),因此只有在Key类型为String或时,Swift才会将Swift字典编码为json / plist字典Int。
  • 所有其他String非Int Key类型或非类型的Swift字典都将被编码为交替键和值的数组。

这既解释了上述解码中的错误,又解释了“意外”编码。

四种解决方案

至此,我们知道,只有字典的键为aString或a时,Swift字典才可将其编码为json字典/从json字典进行解码Int,如何在示例中克服这一问题?让我们看一些解决方案。

经典swift方式

首先是放弃我们模型的json字典表示,并使用预期的交替键和值数组:

// Encoding
let names: [Language: String] = [
  .english:  "Victory Monument",
  .japanese: "戦勝記念塔",
  .thai:     "อนุสาวรีย์ชัยสมรภูมิ"
]

let encoded = try JSONEncoder().encode(names)
// ["english", "Victory Monument", "japanese", "戦勝記念塔", "thai", "อนุสาวรีย์ชัยสมรภูมิ"]

// Decoding
let jsonData = """
[
 "english",  "Victory Monument",
 "japanese", "戦勝記念塔",
 "thai",     "อนุสาวรีย์ชัยสมรภูมิ"
]
""".data(using: .utf8)!

let decoded = try JSONDecoder().decode([Language: String].self, from: jsonData)
// [
//   .english: "Victory Monument",
//   .japanese: "戦勝記念塔", 
//   .thai: "อนุสาวรีย์ชัยสมรภูมิ"
// ]

当它是同时存储和读取数据的应用程序时,
这种方法非常有用:目前我们并不真正在乎数据的存储方式,Swift会为我们处理编码和解码。

使用整数/字符串

虽然上述解决方案在仅本地使用数据时效果很好,但对于大多数使用而言,json对象可能来自服务器,这意味着我们将收到json字典。

在不更改预期json结构的情况下修补此问题的最简单方法是使用String或Int代替我们的自定义类型:

// Encoding
let names: [String: String] = [
  "english":  "Victory Monument",
  "japanese": "戦勝記念塔",
  "thai":     "อนุสาวรีย์ชัยสมรภูมิ"
]
let encodedDictionary = try JSONEncoder().encode(names)
// {
//   "english":  "Victory Monument",
//   "japanese": "戦勝記念塔",
//   "thai":     "อนุสาวรีย์ชัยสมรภูมิ"
// }

// Decoding
let jsonData = """
{
 "english":  "Victory Monument",
 "japanese": "戦勝記念塔",
 "thai":     "อนุสาวรีย์ชัยสมรภูมิ"
}
""".data(using: .utf8)!
let decoded = try JSONDecoder().decode([String: String].self, from: jsonData)
// [
//   "english":  "Victory Monument",
//   "japanese": "戦勝記念塔",
//   "thai":     "อนุสาวรีย์ชัยสมรภูมิ"
// ]

但这意味着:

放弃我们明确的声明/期望,也就是Language示例中可能出现的情况的列表
引入了无效/意外密钥(从服务器到服务器)的可能性。
到目前为止,这两种解决方案都不是一般情况下的理想选择,让我们看看下一步我们可以做什么。

自定义编码/解码

没办法解决:
如果我们想从json字典中编码/解码,我们将不得不通过Swift字典使用String或Int作为其Key类型。

话虽如此,我们可以仅将此类密钥类型用于编码/解码,然后使用我们的自定义类型将其存储在Swift中。

让我们围绕我们的字典创建一个新的包装器,这个包装器懒惰地命名为DictionaryWrapper,它将:

String在对它们进行编码之前将其转换为s
解码[String: String]字典然后变成[Language: String]字典

public struct DictionaryWrapper: Codable {
  var dictionary: [Language: String]

  init(dictionary: [Language: String]) {
    self.dictionary = dictionary
  }

  public init(from decoder: Decoder) throws {
    let container = try decoder.singleValueContainer()
    let stringDictionary = try container.decode([String: String].self)

    dictionary = [:]
    for (stringKey, value) in stringDictionary {
      guard let key = Language(rawValue: stringKey) else {
        throw DecodingError.dataCorruptedError(
          in: container,
          debugDescription: "Invalid key '\(stringKey)'"
        )
      }
      dictionary[key] = value
    }
  }

  public func encode(to encoder: Encoder) throws {
    let stringDictionary: [String: String] = Dictionary(
      uniqueKeysWithValues: dictionary.map { ($0.rawValue, $1) }
    )
    var container = encoder.singleValueContainer()
    try container.encode(stringDictionary)
  }
}

由于这个定义:

  • 我们不能再编码/解码无效密钥
  • 符合我们最初的期望
    现在,我们可以回到原始示例,并看到此新定义将按预期工作:

    // Encoding
    let names = DictionaryWrapper(
    dictionary: [
    .english:  "Victory Monument",
    .japanese: "戦勝記念塔",
    .thai:     "อนุสาวรีย์ชัยสมรภูมิ"
    ]
    )
    let encodedDictionary = try JSONEncoder().encode(names)
    // {
    //   "english":  "Victory Monument",
    //   "japanese": "戦勝記念塔",
    //   "thai":     "อนุสาวรีย์ชัยสมรภูมิ"
    // }
    // Decoding
    let jsonData = """
    {
    "english":  "Victory Monument",
    "japanese": "戦勝記念塔",
    "thai":     "อนุสาวรีย์ชัยสมรภูมิ"
    }
    """.data(using: .utf8)!
    let decoded = try JSONDecoder().decode(DictionaryWrapper.self, from: jsonData)
    // [
    //   .english: "Victory Monument",
    //   .japanese: "戦勝記念塔", 
    //   .thai: "อนุสาวรีย์ชัยสมรภูมิ"
    // ]

RawRapresentable

最后一个解决方案效果很好,但是对于特定的字典类型却进行了硬编码。将其扩展到更一般情况的一种方法是使用Swift的RawRepresentable协议,该协议表示可以与关联的原始值进行相互转换的类型:

public protocol RawRepresentable {
  /// The raw type that can be used to represent all values of the conforming type.
  associatedtype RawValue

  /// Creates a new instance with the specified raw value.
  init?(rawValue: Self.RawValue)

  /// The corresponding value of the raw type.
  var rawValue: Self.RawValue { get }
}

我们可以DictionaryWrapper在需要字典Key符合的地方定义通用RawRepresentable,并在RawValue字典编码/解码时使用其关联的类型:

public struct DictionaryWrapper<Key: Hashable & RawRepresentable, Value: Codable>: Codable where Key.RawValue: Codable & Hashable {
  public var dictionary: [Key: Value]

  public init(dictionary: [Key: Value]) {
    self.dictionary = dictionary
  }

  public init(from decoder: Decoder) throws {
    let container = try decoder.singleValueContainer()
    let rawKeyedDictionary = try container.decode([Key.RawValue: Value].self)

    dictionary = [:]
    for (rawKey, value) in rawKeyedDictionary {
      guard let key = Key(rawValue: rawKey) else {
        throw DecodingError.dataCorruptedError(
          in: container,
          debugDescription: "Invalid key: cannot initialize '\(Key.self)' from invalid '\(Key.RawValue.self)' value '\(rawKey)'")
      }
      dictionary[key] = value
    }
  }

  public func encode(to encoder: Encoder) throws {
    let rawKeyedDictionary = Dictionary(uniqueKeysWithValues: dictionary.map { ($0.rawValue, $1) })
    var container = encoder.singleValueContainer()
    try container.encode(rawKeyedDictionary)
  }
}

由于有了这个新的定义DictionaryWrapper,只要所使用的键的关联RawValue类型为String或,就可以将任何Swift字典编码为json / plist字典Int。

更优解决方案 a property wrapper!

我们的字典很可能将成为需要编码/解码的模型的一部分。除了添加DictionaryWrapper作为每个属性类型定义的一部分之外,我们还可以DictionaryWrapper通过以下方式更新定义:

  • 将@propertyWrapper属性添加到我们的DictionaryWrapper声明中
  • 将内部dictionary属性名称替换为wrappedValue
    这就是制作DictionaryWrapper属性包装器所需的全部内容:

    @propertyWrapper
    public struct DictionaryWrapper<Key: Hashable & RawRepresentable, Value: Codable>: Codable where Key.RawValue: Codable & Hashable {
    public var wrappedValue: [Key: Value]
    
    public init() {
    wrappedValue = [:]
    }
    
    public init(wrappedValue: [Key: Value]) {
    self.wrappedValue = wrappedValue
    }
    
    public init(from decoder: Decoder) throws {
    let container = try decoder.singleValueContainer()
    let rawKeyedDictionary = try container.decode([Key.RawValue: Value].self)
    
    wrappedValue = [:]
    for (rawKey, value) in rawKeyedDictionary {
      guard let key = Key(rawValue: rawKey) else {
        throw DecodingError.dataCorruptedError(
          in: container,
          debugDescription: "Invalid key: cannot initialize '\(Key.self)' from invalid '\(Key.RawValue.self)' value '\(rawKey)'")
      }
      wrappedValue[key] = value
    }
    }
    
    public func encode(to encoder: Encoder) throws {
    let rawKeyedDictionary = Dictionary(uniqueKeysWithValues: wrappedValue.map { ($0.rawValue, $1) })
    var container = encoder.singleValueContainer()
    try container.encode(rawKeyedDictionary)
    }
    }

    进行此更改后,我们可以继续使用此新属性包装器声明任何模型:

struct FSModel: Codable {
  @DictionaryWrapper var names: [Language: String]
  ...
}

…然后直接访问字典,而不必进行详尽的操作DictionaryWrapper:

let jsonData = """
{
 "names": {
   "english": "Victory Monument",
   "japanese": "戦勝記念塔",
   "thai":     "อนุสาวรีย์ชัยสมรภูมิ"
  },
  ...
}
""".data(using: .utf8)!

let model = try JSONDecoder().decode(FSModel.self, from: jsonData)
model.names // [Language: String]
// [
//   .english: "Victory Monument",
//   .japanese: "戦勝記念塔", 
//   .thai: "อนุสาวรีย์ชัยสมรภูมิ"
// ]

结论

Codable Swift的引入是一件幸运的事:尽管在这里和那里都有一些缺点,但它也足够灵活,我们可以扩展它并使其满足我们的需求。

原文链接

https://fivestars.blog/


加入我们一起学习SwiftUI

QQ:3365059189
SwiftUI技术交流QQ群:518696470
教程网站:www.openswiftui.com

发表回复