如何在 Swift 4 中实现 JSON 数据的多态解码?

How can I implement polymorphic decoding of JSON data in Swift 4?

我正在尝试根据从 API 端点返回的数据呈现视图。我的 JSON 看起来(大致)像这样:

{
  "sections": [
    {
      "title": "Featured",
      "section_layout_type": "featured_panels",
      "section_items": [
        {
          "item_type": "foo",
          "id": 3,
          "title": "Bisbee1",
          "audio_url": "http://example.com/foo1.mp3",
          "feature_image_url" : "http://example.com/feature1.jpg"
        },
        {
          "item_type": "bar",
          "id": 4,
          "title": "Mortar8",
          "video_url": "http://example.com/video.mp4",
          "director" : "John Smith",
          "feature_image_url" : "http://example.com/feature2.jpg"
        }
      ]
    }    
  ]
}

我有一个 object 表示如何在我的 UI 中布局视图。它看起来像这样:

public struct ViewLayoutSection : Codable {
    var title: String = ""
    var sectionLayoutType: String
    var sectionItems: [ViewLayoutSectionItemable] = []
}

ViewLayoutSectionItemable 是一种协议,其中包括标题和要在布局中使用的图像的 URL。

然而,sectionItems数组实际上是由不同的类型组成的。我想做的是将每个部分项目实例化为它自己的实例 class.

如何为 ViewLayoutSection 设置 init(from decoder: Decoder) 方法,让我遍历 JSON 数组中的项目并创建正确 class 的实例在每种情况下?

我建议您谨慎使用 Codable。如果您只想从 JSON 解码一个类型而不对其进行编码,那么仅使其符合 Decodable 就足够了。由于您已经发现需要手动解码(通过 init(from decoder: Decoder) 的自定义实现),问题就变成了:最不痛苦的方法是什么?

首先,数据模型。请注意 ViewLayoutSectionItemable 及其采用者不符合 Decodable:

enum ItemType: String, Decodable {
    case foo
    case bar
}

protocol ViewLayoutSectionItemable {
    var id: Int { get }
    var itemType: ItemType { get }
    var title: String { get set }
    var imageURL: URL { get set }
}

struct Foo: ViewLayoutSectionItemable {
    let id: Int
    let itemType: ItemType
    var title: String
    var imageURL: URL
    // Custom properties of Foo
    var audioURL: URL
}

struct Bar: ViewLayoutSectionItemable {
    let id: Int
    let itemType: ItemType
    var title: String
    var imageURL: URL
    // Custom properties of Bar
    var videoURL: URL
    var director: String
}

接下来,我们将如何解码 JSON:

struct Sections: Decodable {
    var sections: [ViewLayoutSection]
}

struct ViewLayoutSection: Decodable {
    var title: String = ""
    var sectionLayoutType: String
    var sectionItems: [ViewLayoutSectionItemable] = []

    // This struct use snake_case to match the JSON so we don't have to provide a custom
    // CodingKeys enum. And since it's private, outside code will never see it
    private struct GenericItem: Decodable {
        let id: Int
        let item_type: ItemType
        var title: String
        var feature_image_url: URL
        // Custom properties of all possible types. Note that they are all optionals
        var audio_url: URL?
        var video_url: URL?
        var director: String?
    }

    private enum CodingKeys: String, CodingKey {
        case title
        case sectionLayoutType = "section_layout_type"
        case sectionItems = "section_items"
    }

    public init(from decoder: Decoder) throws {
        let container     = try decoder.container(keyedBy: CodingKeys.self)
        title             = try container.decode(String.self, forKey: .title)
        sectionLayoutType = try container.decode(String.self, forKey: .sectionLayoutType)
        sectionItems      = try container.decode([GenericItem].self, forKey: .sectionItems).map { item in
        switch item.item_type {
        case .foo:
            // It's OK to force unwrap here because we already
            // know what type the item object is
            return Foo(id: item.id, itemType: item.item_type, title: item.title, imageURL: item.feature_image_url, audioURL: item.audio_url!)
        case .bar:
            return Bar(id: item.id, itemType: item.item_type, title: item.title, imageURL: item.feature_image_url, videoURL: item.video_url!, director: item.director!)
        }
    }
}

用法:

let sections = try JSONDecoder().decode(Sections.self, from: json).sections

多态设计是一件好事:许多设计模式都表现出多态性,使整个系统更加灵活和可扩展。

不幸的是,Codable 没有对多态性的“内置”支持,至少现在还没有……还有关于是否 this is actually a feature or a bug.

的讨论

幸运的是,您可以使用 enum 作为中间“包装器”轻松创建多态对象。

首先,我建议将 itemType 声明为 static 属性,而不是实例 属性,以便以后更容易打开它。因此,您的协议和多态类型将如下所示:

import Foundation

public protocol ViewLayoutSectionItemable: Decodable {
  static var itemType: String { get }

  var id: Int { get }
  var title: String { get set }
  var imageURL: URL { get set }
}

public struct Foo: ViewLayoutSectionItemable {
  
  // ViewLayoutSectionItemable Properties
  public static var itemType: String { return "foo" }
  
  public let id: Int
  public var title: String
  public var imageURL: URL
  
  // Foo Properties
  public var audioURL: URL
}

public struct Bar: ViewLayoutSectionItemable {
  
  // ViewLayoutSectionItemable Properties
  public static var itemType: String { return "bar" }
  
  public let id: Int
  public var title: String
  public var imageURL: URL
  
  // Bar Properties
  public var director: String
  public var videoURL: URL
}

接下来,为“包装器”创建一个枚举:

public enum ItemableWrapper: Decodable {
  
  // 1. Keys
  fileprivate enum Keys: String, CodingKey {
    case itemType = "item_type"
    case sections
    case sectionItems = "section_items"
  }
  
  // 2. Cases
  case foo(Foo)
  case bar(Bar)
  
  // 3. Computed Properties
  public var item: ViewLayoutSectionItemable {
    switch self {
    case .foo(let item): return item
    case .bar(let item): return item
    }
  }
  
  // 4. Static Methods
  public static func items(from decoder: Decoder) -> [ViewLayoutSectionItemable] {
    guard let container = try? decoder.container(keyedBy: Keys.self),
      var sectionItems = try? container.nestedUnkeyedContainer(forKey: .sectionItems) else {
        return []
    }
    var items: [ViewLayoutSectionItemable] = []
    while !sectionItems.isAtEnd {
      guard let wrapper = try? sectionItems.decode(ItemableWrapper.self) else { continue }
      items.append(wrapper.item)
    }
    return items
  }
  
  // 5. Decodable
  public init(from decoder: Decoder) throws {
    let container = try decoder.container(keyedBy: Keys.self)
    let itemType = try container.decode(String.self, forKey: Keys.itemType)
    switch itemType {
    case Foo.itemType:  self = .foo(try Foo(from: decoder))
    case Bar.itemType:  self = .bar(try Bar(from: decoder))
    default:
      throw DecodingError.dataCorruptedError(forKey: .itemType,
                                             in: container,
                                             debugDescription: "Unhandled item type: \(itemType)")
    }
  }
}

上面的内容是这样的:

  1. 您声明 Keys 与响应的结构相关。在给定的 API 中,您对 sectionssectionItems 感兴趣。您还需要知道哪个键代表类型,您在这里声明为 itemType.

  2. 然后您明确列出所有可能的情况:这违反了Open Closed Principle,但这样做“没问题”,因为它充当用于创建项目的“工厂”....

    基本上,在整个应用程序中,您只会一次,就在这里。

  3. 您为 item 声明了一个计算 属性:这样,您可以解包底层 ViewLayoutSectionItemable 而无需 需要关心实际的 case.

  4. 这是“包装器”工厂的核心:您将 items(from:) 声明为能够返回 [ViewLayoutSectionItemable]static 方法,这正是你想做的:传入一个 Decoder 并取回一个包含多态类型的数组!这是您实际使用的方法,而不是直接解码 FooBar 或这些类型的任何其他多态数组。

  5. 最后,你必须让ItemableWrapper实现Decodable方法。这里的技巧是 ItemWrapper 总是 解码 ItemWrapper:因此,这就是 Decodable 所期望的。

但是,由于它是一个 enum,因此它允许具有关联类型,这正是您对每种情况所做的。因此,您可以间接创建多态类型!

由于您在 ItemWrapper 中完成了所有繁重的工作,现在 非常 很容易从 Decoder 转到 `[ViewLayoutSectionItemable] ,您只需像这样:

let decoder = ... // however you created it
let items = ItemableWrapper.items(from: decoder)

@CodeDifferent 回复的更简单版本,解决了@JRG-Developer 的评论。无需重新考虑您的 JSON API;这是一个常见的场景。对于您创建的每一个新的ViewLayoutSectionItem,您只需要分别在PartiallyDecodedItem.ItemKind枚举和PartiallyDecodedItem.init(from:)方法中添加一个案例和一行代码。

与接受的答案相比,这不仅代码量最少,而且性能更高。在@CodeDifferent 的选项中,您需要使用 2 种不同的数据表示来初始化 2 个数组,以获得 ViewLayoutSectionItem 的数组。在此选项中,您仍然需要初始化 2 个数组,但通过利用 copy-on-write 语义,只能获得一种数据表示形式。

另请注意,没有必要在协议或采用的结构中包含 ItemType(在静态类型语言中包含描述类型的字符串没有意义)。

protocol ViewLayoutSectionItem {
    var id: Int { get }
    var title: String { get }
    var imageURL: URL { get }
}

struct Foo: ViewLayoutSectionItem {
    let id: Int
    let title: String
    let imageURL: URL

    let audioURL: URL
}

struct Bar: ViewLayoutSectionItem {
    let id: Int
    let title: String
    let imageURL: URL

    let videoURL: URL
    let director: String
}

private struct PartiallyDecodedItem: Decodable {
    enum ItemKind: String, Decodable {
        case foo, bar
    }
    let kind: Kind
    let item: ViewLayoutSectionItem

    private enum DecodingKeys: String, CodingKey {
        case kind = "itemType"
    }

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: DecodingKeys.self)
        self.kind = try container.decode(Kind.self, forKey: .kind)
        self.item = try {
            switch kind {
            case .foo: return try Foo(from: decoder)
            case .number: return try Bar(from: decoder)
        }()
    }
}

struct ViewLayoutSection: Decodable {
    let title: String
    let sectionLayoutType: String
    let sectionItems: [ViewLayoutSectionItem]

    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.title = try container.decode(String.self, forKey: .title)
        self.sectionLayoutType = try container.decode(String.self, forKey: .sectionLayoutType)
        self.sectionItems = try container.decode([PartiallyDecodedItem].self, forKey: .sectionItems)
            .map { [=10=].item }
    }
}

要处理蛇形大小写 -> 驼峰大小写转换,而不是手动输入所有的键,您可以简单地在 JSONDecoder

上设置一个 属性
struct Sections: Decodable {
    let sections: [ViewLayoutSection]
}

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let sections = try decode(Sections.self, from: json)
    .sections

我已经写了一篇关于这个确切问题的 blog post

综上所述。我建议在 Decoder

上定义一个扩展
extension Decoder {
  func decode<ExpectedType>(_ expectedType: ExpectedType.Type) throws -> ExpectedType {
    let container = try self.container(keyedBy: PolymorphicMetaContainerKeys.self)
    let typeID = try container.decode(String.self, forKey: .itemType)
     
    guard let types = self.userInfo[.polymorphicTypes] as? [Polymorphic.Type] else {
      throw PolymorphicCodableError.missingPolymorphicTypes
    }
     
    let matchingType = types.first { type in
      type.id == typeID
    }
     
    guard let matchingType = matchingType else {
      throw PolymorphicCodableError.unableToFindPolymorphicType(typeID)
    }
     
    let decoded = try matchingType.init(from: self)
     
    guard let decoded = decoded as? ExpectedType else {
      throw PolymorphicCodableError.unableToCast(
        decoded: decoded,
        into: String(describing: ExpectedType.self)
      )
    }
    return decoded
  }
} 

然后将可能的多态类型添加到 Decoder 实例:

var decoder = JSONDecoder()
decoder.userInfo[.polymorphicTypes] = [
  Snake.self,
  Dog.self
]

如果你有嵌套的聚合值,你可以编写一个 属性 包装器来调用这个解码方法,这样你就不需要定义自定义 init(from:).

这是解决这个确切问题的小 utility package

它是围绕一个配置类型构建的,该配置类型具有可解码类型的变体,定义了类型信息 discriminator

enum DrinkFamily: String, ClassFamily {
    case drink = "drink"
    case beer = "beer"

    static var discriminator: Discriminator = .type
    
    typealias BaseType = Drink

    func getType() -> Drink.Type {
        switch self {
        case .beer:
            return Beer.self
        case .drink:
            return Drink.self
        }
    }
}

稍后在您的集合中重载 init 方法以使用我们的 KeyedDecodingContainer 扩展。

class Bar: Decodable {
    let drinks: [Drink]

    private enum CodingKeys: String, CodingKey {
        case drinks
    }

    required init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        drinks = try container.decodeHeterogeneousArray(OfFamily: DrinkFamily.self, forKey: .drinks)
    }
}