使用可解码的动态键解码和扁平化JSON

114 阅读9分钟

使用可解码的动态键解码和扁平化JSON

hudson 译 原文

可解码协议Decodable是在Swift 4中引入的。 从那时起,它已成为开发人员解码从远程服务器接收的JSON的标准方式。

有大量的教程教你如何利用Decodable来解码各种类型的JSON结构。 然而,所有这些教程都没有涵盖一种特定类型的JSON结构——带有动态键的JSON

带有动态键的JSON是什么意思? 看看以下显示学生列表的JSON示例:

{
  “S001”: {
    “firstName”: “Tony”,
    “lastName”: “Stark”
  },
  “S002”: {
    “firstName”: “Peter”,
    “lastName”: “Parker”
  },
  “S003”: {
    “firstName”: “Bruce”,
    “lastName”: “Wayne”
  }
}

如您所见,学生ID是键,学生信息是值。

作为iOS开发人员,我们通常需要的是一组学生,以便学生名单可以轻松地显示在表格视图上。 因此,除了解码JSON外,我们还需要将结果扁平化(使学生ID成为学生对象的一部分)并将其转换为数组。

当面对这种JSON结构时,一些开发人员可能会倒退回旧的解码方法:通过使用JSONSerialization类并手动循环和解析每个键值对 。

然而,我不喜欢JSONSerialization方式,因为它更容易出错。 除此之外,我们将失去使用Decodable协议带来的所有好处。

在本文中,我将向您介绍使用Decodable可解码协议的解码方法。 之后,我将使解码逻辑通用,以便它可以被其他对象类型重用。

说了这么多,让我们直接进入主题。

提取值

回顾一下,这是我们试图解码的JSON:

{
  “S001”: {
    “firstName”: “Tony”,
    “lastName”: “Stark”
  },
  “S002”: {
    “firstName”: “Peter”,
    “lastName”: “Parker”
  },
  “S003”: {
    “firstName”: “Bruce”,
    “lastName”: “Wayne”
  }
}

为了简单起见,让我们暂时专注于解码firstName名字和lastName。我们稍后会回到学生ID上。

首先,让我们定义一个符合Decodable协议的Student结构。

struct Student: Decodable {
    let firstName: String
    let lastName: String
}

接下来,我们需要一个包含学生数组的Decodable结构。我们将使用此结构来保存所有已解码的学生对象。让我们把这个结构称为DecodedArray

struct DecodedArray: Decodable {
    var array: [Student]
}

为了访问JSON的动态键,我们必须定义自定义CodingKey结构。当我们想从JSONDecoder创建解码容器时,需要这个自定义CodingKey结构。

struct DecodedArray: Decodable {

    var array: [Student]
    
    // Define DynamicCodingKeys type needed for creating 
    // decoding container from JSONDecoder
    private struct DynamicCodingKeys: CodingKey {

        // Use for string-keyed dictionary
        var stringValue: String
        init?(stringValue: String) {
            self.stringValue = stringValue
        }

        // Use for integer-keyed dictionary
        var intValue: Int?
        init?(intValue: Int) {
            // We are not using this, thus just return nil
            return nil
        }
    }
}

请注意,我们只对字符串值初始化器感兴趣,因为我们的键是字符串类型,因此我们可以在整数值初始化器中返回nil

有了所有这些,我们现在可以开始实现DecodedArray初始化器了。

struct DecodedArray: Decodable {

    var array: [Student]
    
    // Define DynamicCodingKeys type needed for creating
    // decoding container from JSONDecoder
    private struct DynamicCodingKeys: CodingKey {

        // Use for string-keyed dictionary
        var stringValue: String
        init?(stringValue: String) {
            self.stringValue = stringValue
        }

        // Use for integer-keyed dictionary
        var intValue: Int?
        init?(intValue: Int) {
            // We are not using this, thus just return nil
            return nil
        }
    }

    init(from decoder: Decoder) throws {

        // 1
        // Create a decoding container using DynamicCodingKeys
        // The container will contain all the JSON first level key
        let container = try decoder.container(keyedBy: DynamicCodingKeys.self)

        var tempArray = [Student]()

        // 2
        // Loop through each key (student ID) in container
        for key in container.allKeys {

            // Decode Student using key & keep decoded Student object in tempArray
            let decodedObject = try container.decode(Student.self, forKey: DynamicCodingKeys(stringValue: key.stringValue)!)
            tempArray.append(decodedObject)
        }

        // 3
        // Finish decoding all Student objects. Thus assign tempArray to array.
        array = tempArray
    }
}

让我们详细分解一下初始化器内部发生的事情。

  1. 使用DynamicCodingKeys结构创建一个解码容器。此容器将包含所有JSON的第一级动态键。

  2. 循环每个键来解码其各自的学生对象。

  3. 将所有解码的学生对象存储到学生数组中。

提取firstNamelastName就是这样。让我们在 Xcode playground中运行所有这些,看看它们的运行情况。

let jsonString = “””
{
  “S001”: {
    “firstName”: “Tony”,
    “lastName”: “Stark”
  },
  “S002”: {
    “firstName”: “Peter”,
    “lastName”: “Parker”
  },
  “S003”: {
    “firstName”: “Bruce”,
    “lastName”: “Wayne”
  }
}
“””

let jsonData = Data(jsonString.utf8)

// Ask JSONDecoder to decode the JSON data as DecodedArray
let decodedResult = try! JSONDecoder().decode(DecodedArray.self, from: jsonData)

dump(decodedResult.array)

// Output:
//▿ 3 elements
//▿ __lldb_expr_21.Student
//  - firstName: “Bruce”
//  - lastName: “Wayne”
//▿ __lldb_expr_21.Student
//  - firstName: “Peter”
//  - lastName: “Parker”
//▿ __lldb_expr_21.Student
//  - firstName: “Tony”
//  - lastName: “Stark”

在这里,我们将JSON字符串转换为Data,并要求JSONDecoder将JSON数据解码为DecodedArray类型。据此我们将能够通过DecodedArray.array访问所有解码的学生对象。

恭喜!您已成功解码所有学生对象。然而,还有工作要做。在下一节中,我们将研究将学生ID添加到学生结构中。

提取键

以我们目前的基础,将学生ID添加到学生结构中非常简单。我们需要做的是实现我们自己的Student初始化器,并手动解码lastNamefirstNamestudentId

看看以下更新的学生结构:

struct Student: Decodable {

    let firstName: String
    let lastName: String

    // 1
    // Define student ID
    let studentId: String

    // 2
    // Define coding key for decoding use
    enum CodingKeys: CodingKey {
        case firstName
        case lastName
    }

    init(from decoder: Decoder) throws {

        let container = try decoder.container(keyedBy: CodingKeys.self)

        // 3
        // Decode firstName & lastName
        firstName = try container.decode(String.self, forKey: CodingKeys.firstName)
        lastName = try container.decode(String.self, forKey: CodingKeys.lastName)

        // 4
        // Extract studentId from coding path
        studentId = container.codingPath.first!.stringValue
    }
}

让我们逐一介绍我们在Student结构上所做的更改:

  1. 定义studentId以保存提取键(学生ID)。

  2. 定义手动解码所需的编码键。

  3. 手动解码firstNamelastName

  4. 这就是魔法发生的地方。解码容器 codingPath CodingKey 的数组,其中包含在解码中达到这一点的编码键路径。就我们而言,它应该包含我们从DecodedArray中的DynamicCodingKeys获得键,即学生ID。

让我们在Xcode playground中再次运行它,看看最终结果。

let jsonData = Data(jsonString.utf8)
let decodedResult = try! JSONDecoder().decode(DecodedArray.self, from: jsonData)

dump(decodedResult.array)
// Output:
//▿ 3 elements
//▿ __lldb_expr_37.Student
//  - firstName: “Peter”
//  - lastName: “Parker”
//  - studentId: “S002”
//▿ __lldb_expr_37.Student
//  - firstName: “Tony”
//  - lastName: “Stark”
//  - studentId: “S001”
//▿ __lldb_expr_37.Student
//  - firstName: “Bruce”
//  - lastName: “Wayne”
//  - studentId: “S003”

因此,我们使用Decodable协议成功解码并扁平化了带有动态键的JSON。 🥳

在下一节中,让我们更进一步,改进DecodedArray结构的功能和可重用性。

支持定制集合

如果您仔细研究DecodedArray结构,它基本上只是Student数组的包装器。 这使得它成为转换为定制集合的完美候选者。

通过转换为定制集合,DecodedArray结构可以利用数组字面量以及过滤和映射等所有标准集合功能。

首先,让我们定义一个 typealias类型别名来表示Student数组,并相应地更新DecodedArray的另一部分。 当我们稍后使DecodedArray符合Collection协议时,需要typealias类型别名。

这是更新的DecodedArray,我用***标记了所做的更改。

struct DecodedArray: Decodable {

    // ***
    // Define typealias required for Collection protocl conformance
    typealias DecodedArrayType = [Student]

    // ***
    private var array: DecodedArrayType

    // Define DynamicCodingKeys type needed for creating decoding container from JSONDecoder
    private struct DynamicCodingKeys: CodingKey {

        // Use for string-keyed dictionary
        var stringValue: String
        init?(stringValue: String) {
            self.stringValue = stringValue
        }

        // Use for integer-keyed dictionary
        var intValue: Int?
        init?(intValue: Int) {
            // We are not using this, thus just return nil
            return nil
        }
    }

    init(from decoder: Decoder) throws {

        // Create decoding container using DynamicCodingKeys
        // The container will contain all the JSON first level key
        let container = try decoder.container(keyedBy: DynamicCodingKeys.self)

        // ***
        var tempArray = DecodedArrayType()

        // Loop through each keys in container
        for key in container.allKeys {

            // Decode Student using key & keep decoded Student object in tempArray
            let decodedObject = try container.decode(Student.self, forKey: DynamicCodingKeys(stringValue: key.stringValue)!)
            tempArray.append(decodedObject)
        }

        // Finish decoding all Student objects. Thus assign tempArray to array.
        array = tempArray
    }
}

接下来,让我们扩展DecodedArray并符合Collection协议。

extension DecodedArray: Collection {

    // Required nested types, that tell Swift what our collection contains
    typealias Index = DecodedArrayType.Index
    typealias Element = DecodedArrayType.Element

    // The upper and lower bounds of the collection, used in iterations
    var startIndex: Index { return array.startIndex }
    var endIndex: Index { return array.endIndex }

    // Required subscript, based on a dictionary index
    subscript(index: Index) -> Iterator.Element {
        get { return array[index] }
    }

    // Method that returns the next index when iterating
    func index(after i: Index) -> Index {
        return array.index(after: i)
    }
}

遵守Collection协议的细节超出了本文的范围。如果你想了解更多,我强烈推荐这篇很棒的文章

仅此而已,我们已经完全将DecodedArray结构转换为定制集合。

再一次,让我们在Xcode playground 中测试我们的更改。但这次我们从Collection协议一致性中获得了一些很酷的功能——数组字面量、mapfilter

let jsonData = Data(jsonString.utf8)
let decodedResult = try! JSONDecoder().decode(DecodedArray.self, from: jsonData)

// Array literal
dump(decodedResult[2])
//▿ __lldb_expr_5.Student
//- firstName: “Bruce”
//- lastName: “Wayne”
//- studentId: “S003”

// Map
dump(decodedResult.map({ $0.firstName }))
// Output:
//▿ 3 elements
//- “Tony”
//- “Peter”
//- “Bruce”

// Filter
dump(decodedResult.filter({ $0.studentId ==S002” }))
// Output:
//▿ __lldb_expr_1.Student
//- firstName: “Peter”
//- lastName: “Parker”
//- studentId: “S002”

很酷,不是吗?只需稍加点努力,让我们通过范型化DecodedArray使其更酷,以便我们可以在其他对象类型上重用它。

范型化DecodedArray,增加可重用性

要使我们的DecodedArray泛型化,我们只需要添加一个泛型参数子句,并将所有Student类型替换为占位符类型T

我再次用***标记了所有更改。

// ***
// Add generic parameter clause
struct DecodedArray<T: Decodable>: Decodable {

    // ***
    typealias DecodedArrayType = [T]

    private var array: DecodedArrayType

    // Define DynamicCodingKeys type needed for creating decoding container from JSONDecoder
    private struct DynamicCodingKeys: CodingKey {

        // Use for string-keyed dictionary
        var stringValue: String
        init?(stringValue: String) {
            self.stringValue = stringValue
        }

        // Use for integer-keyed dictionary
        var intValue: Int?
        init?(intValue: Int) {
            // We are not using this, thus just return nil
            return nil
        }
    }

    init(from decoder: Decoder) throws {

        // Create decoding container using DynamicCodingKeys
        // The container will contain all the JSON first level key
        let container = try decoder.container(keyedBy: DynamicCodingKeys.self)

        var tempArray = DecodedArrayType()

        // Loop through each keys in container
        for key in container.allKeys {

            // ***
            // Decode T using key & keep decoded T object in tempArray
            let decodedObject = try container.decode(T.self, forKey: DynamicCodingKeys(stringValue: key.stringValue)!)
            tempArray.append(decodedObject)
        }

        // Finish decoding all T objects. Thus assign tempArray to array.
        array = tempArray
    }
}

有了这一切,我们现在可以用它来解码任何对象类型。为了在实践中看到这一点,让我们使用我们支持范型的DecodedArray来解码以下JSON。

{
  “Vegetable”: [
    { “name”: “Carrots” },
    { “name”: “Mushrooms” }
  ],
  “Spice”: [
    { “name”: “Salt” },
    { “name”: “Paper” },
    { “name”: “Sugar” }
  ],
  “Fruit”: [
    { “name”: “Apple” },
    { “name”: “Orange” },
    { “name”: “Banana” },
    { “name”: “Papaya” }
  ]
}

上面的JSON表示按类别分组的Food对象数组。 因此,我们必须首先定义Food结构。

struct Food: Decodable {

    let name: String
    let category: String

    enum CodingKeys: CodingKey {
        case name
    }

    init(from decoder: Decoder) throws {

        let container = try decoder.container(keyedBy: CodingKeys.self)

        // Decode name
        name = try container.decode(String.self, forKey: CodingKeys.name)

        // Extract category from coding path
        category = container.codingPath.first!.stringValue
    }
}

定义Food结构后,我们现在准备使用范型化的DecodedArray解码给定的JSON。

let jsonString = “””
{
  “Vegetable”: [
    { “name”: “Carrots” },
    { “name”: “Mushrooms” }
  ],
  “Spice”: [
    { “name”: “Salt” },
    { “name”: “Paper” },
    { “name”: “Sugar” }
  ],
  “Fruit”: [
    { “name”: “Apple” },
    { “name”: “Orange” },
    { “name”: “Banana” },
    { “name”: “Papaya” }
  ]
}
“””

let jsonData = Data(jsonString.utf8)

// Define DecodedArray type using the angle brackets (<>)
let decodedResult = try! JSONDecoder().decode(DecodedArray<[Food]>.self, from: jsonData)

// Perform flatmap on decodedResult to convert [[Food]] to [Food]
let allFood = decodedResult.flatMap{ $0 }

dump(allFood)
// Ouput:
//▿ 9 elements
//▿ __lldb_expr_11.Food
//  - name: “Apple”
//  - category: “Fruit”
//▿ __lldb_expr_11.Food
//  - name: “Orange”
//  - category: “Fruit”
//▿ __lldb_expr_11.Food
//  - name: “Banana”
//  - category: “Fruit”
//▿ __lldb_expr_11.Food
//  - name: “Papaya”
//  - category: “Fruit”
//▿ __lldb_expr_11.Food
//  - name: “Salt”
//  - category: “Spice”
//▿ __lldb_expr_11.Food
//  - name: “Paper”
//  - category: “Spice”
//▿ __lldb_expr_11.Food
//  - name: “Sugar”
//  - category: “Spice”
//▿ __lldb_expr_11.Food
//  - name: “Carrots”
//  - category: “Vegetable”
//▿ __lldb_expr_11.Food
//  - name: “Mushrooms”
//  - category: “Vegetable”

请注意,decodedResultFood数组([[Food]])的数组。因此,为了获得Food对象([Food])的数组,我们在解码结果上应用flatMap来将[[Food]]转换为[Food]

小结

本文仅演示了2层的解码和扁平化JSON,您绝对可以在3层或更多层的JSON上应用相同的概念。我会把它留给你作为练习!

如果您想在Xcode playground上尝试解码方法,以下是完整的示例代码

你觉得这种解码方法怎么样?请随时在下面的评论部分留下您的评论或想法。

如果您喜欢这篇文章,请务必查看我创作的其他与Swift相关的文章

您也可以在推特上关注我,以获取更多与iOS开发相关的文章。

感谢您的阅读。🧑🏻‍💻