¿Cómo decodificar una estructura JSON anidada con el protocolo Swift Decodable?


90

Aquí está mi JSON

{
    "id": 1,
    "user": {
        "user_name": "Tester",
        "real_info": {
            "full_name":"Jon Doe"
        }
    },
    "reviews_count": [
        {
            "count": 4
        }
    ]
}

Aquí está la estructura en la que quiero que se guarde (incompleta)

struct ServerResponse: Decodable {
    var id: String
    var username: String
    var fullName: String
    var reviewCount: Int

    enum CodingKeys: String, CodingKey {
       case id, 
       // How do i get nested values?
    }
}

He consultado la documentación de Apple sobre la decodificación de estructuras anidadas, pero todavía no entiendo cómo hacer los diferentes niveles de JSON correctamente. Cualquier ayuda será muy apreciada.

Respuestas:


109

Otro enfoque es crear un modelo intermedio que se asemeje mucho al JSON (con la ayuda de una herramienta como quicktype.io ), deje que Swift genere los métodos para decodificarlo y luego seleccione las piezas que desee en su modelo de datos final:

// snake_case to match the JSON and hence no need to write CodingKey enums / struct
fileprivate struct RawServerResponse: Decodable {
    struct User: Decodable {
        var user_name: String
        var real_info: UserRealInfo
    }

    struct UserRealInfo: Decodable {
        var full_name: String
    }

    struct Review: Decodable {
        var count: Int
    }

    var id: Int
    var user: User
    var reviews_count: [Review]
}

struct ServerResponse: Decodable {
    var id: String
    var username: String
    var fullName: String
    var reviewCount: Int

    init(from decoder: Decoder) throws {
        let rawResponse = try RawServerResponse(from: decoder)

        // Now you can pick items that are important to your data model,
        // conveniently decoded into a Swift structure
        id = String(rawResponse.id)
        username = rawResponse.user.user_name
        fullName = rawResponse.user.real_info.full_name
        reviewCount = rawResponse.reviews_count.first!.count
    }
}

Esto también le permite iterar fácilmente reviews_count, en caso de que contenga más de 1 valor en el futuro.


Okay. este enfoque parece muy limpio. Para mi caso, creo que lo
usaré

Sí, definitivamente lo pensé demasiado: @JTAppleCalendarforiOSSwift, deberías aceptarlo, ya que es una mejor solución.
Hamish

@ Hamish está bien. Lo cambié, pero tu respuesta fue extremadamente detallada. Aprendí mucho de eso.
FlowUI. SimpleUITesting.com

Tengo curiosidad por saber cómo se puede implementar Encodablepara la ServerResponseestructura siguiendo el mismo enfoque. ¿Es siquiera posible?
nayem

1
@nayem, el problema es que ServerResponsetiene menos datos que RawServerResponse. Puede capturar la RawServerResponseinstancia, actualizarla con propiedades de ServerResponsey luego generar el JSON a partir de eso. Puede obtener una mejor ayuda publicando una nueva pregunta con el problema específico al que se enfrenta.
Code Different

95

Para resolver su problema, puede dividir su RawServerResponseimplementación en varias partes lógicas (usando Swift 5).


# 1. Implementar las propiedades y las claves de codificación necesarias

import Foundation

struct RawServerResponse {

    enum RootKeys: String, CodingKey {
        case id, user, reviewCount = "reviews_count"
    }

    enum UserKeys: String, CodingKey {
        case userName = "user_name", realInfo = "real_info"
    }

    enum RealInfoKeys: String, CodingKey {
        case fullName = "full_name"
    }

    enum ReviewCountKeys: String, CodingKey {
        case count
    }

    let id: Int
    let userName: String
    let fullName: String
    let reviewCount: Int

}

# 2. Establecer la estrategia de decodificación para la idpropiedad

extension RawServerResponse: Decodable {

    init(from decoder: Decoder) throws {
        // id
        let container = try decoder.container(keyedBy: RootKeys.self)
        id = try container.decode(Int.self, forKey: .id)

        /* ... */                 
    }

}

# 3. Establecer la estrategia de decodificación para la userNamepropiedad

extension RawServerResponse: Decodable {

    init(from decoder: Decoder) throws {
        /* ... */

        // userName
        let userContainer = try container.nestedContainer(keyedBy: UserKeys.self, forKey: .user)
        userName = try userContainer.decode(String.self, forKey: .userName)

        /* ... */
    }

}

# 4. Establecer la estrategia de decodificación para la fullNamepropiedad

extension RawServerResponse: Decodable {

    init(from decoder: Decoder) throws {
        /* ... */

        // fullName
        let realInfoKeysContainer = try userContainer.nestedContainer(keyedBy: RealInfoKeys.self, forKey: .realInfo)
        fullName = try realInfoKeysContainer.decode(String.self, forKey: .fullName)

        /* ... */
    }

}

# 5. Establecer la estrategia de decodificación para la reviewCountpropiedad

extension RawServerResponse: Decodable {

    init(from decoder: Decoder) throws {
        /* ...*/        

        // reviewCount
        var reviewUnkeyedContainer = try container.nestedUnkeyedContainer(forKey: .reviewCount)
        var reviewCountArray = [Int]()
        while !reviewUnkeyedContainer.isAtEnd {
            let reviewCountContainer = try reviewUnkeyedContainer.nestedContainer(keyedBy: ReviewCountKeys.self)
            reviewCountArray.append(try reviewCountContainer.decode(Int.self, forKey: .count))
        }
        guard let reviewCount = reviewCountArray.first else {
            throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: container.codingPath + [RootKeys.reviewCount], debugDescription: "reviews_count cannot be empty"))
        }
        self.reviewCount = reviewCount
    }

}

Implementación completa

import Foundation

struct RawServerResponse {

    enum RootKeys: String, CodingKey {
        case id, user, reviewCount = "reviews_count"
    }

    enum UserKeys: String, CodingKey {
        case userName = "user_name", realInfo = "real_info"
    }

    enum RealInfoKeys: String, CodingKey {
        case fullName = "full_name"
    }

    enum ReviewCountKeys: String, CodingKey {
        case count
    }

    let id: Int
    let userName: String
    let fullName: String
    let reviewCount: Int

}
extension RawServerResponse: Decodable {

    init(from decoder: Decoder) throws {
        // id
        let container = try decoder.container(keyedBy: RootKeys.self)
        id = try container.decode(Int.self, forKey: .id)

        // userName
        let userContainer = try container.nestedContainer(keyedBy: UserKeys.self, forKey: .user)
        userName = try userContainer.decode(String.self, forKey: .userName)

        // fullName
        let realInfoKeysContainer = try userContainer.nestedContainer(keyedBy: RealInfoKeys.self, forKey: .realInfo)
        fullName = try realInfoKeysContainer.decode(String.self, forKey: .fullName)

        // reviewCount
        var reviewUnkeyedContainer = try container.nestedUnkeyedContainer(forKey: .reviewCount)
        var reviewCountArray = [Int]()
        while !reviewUnkeyedContainer.isAtEnd {
            let reviewCountContainer = try reviewUnkeyedContainer.nestedContainer(keyedBy: ReviewCountKeys.self)
            reviewCountArray.append(try reviewCountContainer.decode(Int.self, forKey: .count))
        }
        guard let reviewCount = reviewCountArray.first else {
            throw DecodingError.dataCorrupted(DecodingError.Context(codingPath: container.codingPath + [RootKeys.reviewCount], debugDescription: "reviews_count cannot be empty"))
        }
        self.reviewCount = reviewCount
    }

}

Uso

let jsonString = """
{
    "id": 1,
    "user": {
        "user_name": "Tester",
        "real_info": {
            "full_name":"Jon Doe"
        }
    },
    "reviews_count": [
    {
    "count": 4
    }
    ]
}
"""

let jsonData = jsonString.data(using: .utf8)!
let decoder = JSONDecoder()
let serverResponse = try! decoder.decode(RawServerResponse.self, from: jsonData)
dump(serverResponse)

/*
prints:
▿ RawServerResponse #1 in __lldb_expr_389
  - id: 1
  - user: "Tester"
  - fullName: "Jon Doe"
  - reviewCount: 4
*/

13
Respuesta muy dedicada.
Hexfire

3
En lugar de structusar enumcon llaves. que es mucho más elegante 👍
Jack

1
Un gran agradecimiento por dedicar tiempo a documentar esto tan bien. Después de revisar tanta documentación sobre Decodable y analizar JSON, su respuesta realmente aclaró muchas preguntas que tenía.
Marcy

30

En lugar de tener una gran CodingKeysenumeración con todas las claves que necesitará para decodificar el JSON, le recomendaría dividir las claves para cada uno de sus objetos JSON anidados, utilizando enumeraciones anidadas para preservar la jerarquía:

// top-level JSON object keys
private enum CodingKeys : String, CodingKey {

    // using camelCase case names, with snake_case raw values where necessary.
    // the raw values are what's used as the actual keys for the JSON object,
    // and default to the case name unless otherwise specified.
    case id, user, reviewsCount = "reviews_count"

    // "user" JSON object keys
    enum User : String, CodingKey {
        case username = "user_name", realInfo = "real_info"

        // "real_info" JSON object keys
        enum RealInfo : String, CodingKey {
            case fullName = "full_name"
        }
    }

    // nested JSON objects in "reviews" keys
    enum ReviewsCount : String, CodingKey {
        case count
    }
}

Esto facilitará el seguimiento de las claves en cada nivel de su JSON.

Ahora, teniendo en cuenta que:

  • Un contenedor con clave se usa para decodificar un objeto JSON y se decodifica con un CodingKeytipo conforme (como los que hemos definido anteriormente).

  • Un contenedor no codificado se usa para decodificar una matriz JSON y se decodifica secuencialmente (es decir, cada vez que llama a un método de decodificación o contenedor anidado en él, avanza al siguiente elemento de la matriz). Vea la segunda parte de la respuesta para saber cómo puede iterar a través de uno.

Después de obtener su contenedor con clave de nivel superior del decodificador con container(keyedBy:)(ya que tiene un objeto JSON en el nivel superior), puede usar repetidamente los métodos:

Por ejemplo:

struct ServerResponse : Decodable {

    var id: Int, username: String, fullName: String, reviewCount: Int

    private enum CodingKeys : String, CodingKey { /* see above definition in answer */ }

    init(from decoder: Decoder) throws {

        // top-level container
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.id = try container.decode(Int.self, forKey: .id)

        // container for { "user_name": "Tester", "real_info": { "full_name": "Jon Doe" } }
        let userContainer =
            try container.nestedContainer(keyedBy: CodingKeys.User.self, forKey: .user)

        self.username = try userContainer.decode(String.self, forKey: .username)

        // container for { "full_name": "Jon Doe" }
        let realInfoContainer =
            try userContainer.nestedContainer(keyedBy: CodingKeys.User.RealInfo.self,
                                              forKey: .realInfo)

        self.fullName = try realInfoContainer.decode(String.self, forKey: .fullName)

        // container for [{ "count": 4 }] – must be a var, as calling a nested container
        // method on it advances it to the next element.
        var reviewCountContainer =
            try container.nestedUnkeyedContainer(forKey: .reviewsCount)

        // container for { "count" : 4 }
        // (note that we're only considering the first element of the array)
        let firstReviewCountContainer =
            try reviewCountContainer.nestedContainer(keyedBy: CodingKeys.ReviewsCount.self)

        self.reviewCount = try firstReviewCountContainer.decode(Int.self, forKey: .count)
    }
}

Ejemplo de decodificación:

let jsonData = """
{
  "id": 1,
  "user": {
    "user_name": "Tester",
    "real_info": {
    "full_name":"Jon Doe"
  }
  },
  "reviews_count": [
    {
      "count": 4
    }
  ]
}
""".data(using: .utf8)!

do {
    let response = try JSONDecoder().decode(ServerResponse.self, from: jsonData)
    print(response)
} catch {
    print(error)
}

// ServerResponse(id: 1, username: "Tester", fullName: "Jon Doe", reviewCount: 4)

Iterando a través de un contenedor sin clave

Teniendo en cuenta el caso en el que desea reviewCountser un [Int], donde cada elemento representa el valor de la "count"clave en el JSON anidado:

  "reviews_count": [
    {
      "count": 4
    },
    {
      "count": 5
    }
  ]

Deberá iterar a través del contenedor anidado sin clave, obtener el contenedor anidado con clave en cada iteración y decodificar el valor de la "count"clave. Puede usar la countpropiedad del contenedor sin clave para preasignar la matriz resultante y luego la isAtEndpropiedad para iterar a través de ella.

Por ejemplo:

struct ServerResponse : Decodable {

    var id: Int
    var username: String
    var fullName: String
    var reviewCounts = [Int]()

    // ...

    init(from decoder: Decoder) throws {

        // ...

        // container for [{ "count": 4 }, { "count": 5 }]
        var reviewCountContainer =
            try container.nestedUnkeyedContainer(forKey: .reviewsCount)

        // pre-allocate the reviewCounts array if we can
        if let count = reviewCountContainer.count {
            self.reviewCounts.reserveCapacity(count)
        }

        // iterate through each of the nested keyed containers, getting the
        // value for the "count" key, and appending to the array.
        while !reviewCountContainer.isAtEnd {

            // container for a single nested object in the array, e.g { "count": 4 }
            let nestedReviewCountContainer = try reviewCountContainer.nestedContainer(
                                                 keyedBy: CodingKeys.ReviewsCount.self)

            self.reviewCounts.append(
                try nestedReviewCountContainer.decode(Int.self, forKey: .count)
            )
        }
    }
}

una cosa para aclarar: ¿a qué te refieres I would advise splitting the keys for each of your nested JSON objects up into multiple nested enumerations, thereby making it easier to keep track of the keys at each level in your JSON?
FlowUI. SimpleUITesting.com

@JTAppleCalendarforiOSSwift Quiero decir que en lugar de tener una gran CodingKeysenumeración con todas las claves que necesitará para decodificar su objeto JSON, debe dividirlas en varias enumeraciones para cada objeto JSON, por ejemplo, en el código anterior que tenemos CodingKeys.Usercon las claves para decodificar el objeto JSON del usuario ( { "user_name": "Tester", "real_info": { "full_name": "Jon Doe" } }), así que solo las claves para "user_name"& "real_info".
Hamish

Gracias. Respuesta muy clara. Todavía lo estoy mirando para entenderlo completamente. Pero funciona.
FlowUI. SimpleUITesting.com

Tenía una pregunta sobre reviews_countcuál es una matriz de diccionario. Actualmente, el código funciona como se esperaba. My reviewsCount solo tiene un valor en la matriz. Pero, ¿qué pasa si realmente quisiera una matriz de review_count, entonces simplemente tendría que declarar var reviewCount: Intcomo una matriz, verdad? -> var reviewCount: [Int]. Y luego también tendría que editar la ReviewsCountenumeración, ¿verdad?
FlowUI. SimpleUITesting.com

1
@JTAppleCalendarforiOSSwift Eso en realidad sería un poco más complicado, ya que lo que está describiendo no es solo una matriz Int, sino una matriz de objetos JSON, cada uno de los cuales tiene un Intvalor para una clave determinada, por lo que lo que debe hacer es iterar el contenedor sin clave y obtenga todos los contenedores con clave anidados, decodificando un Intpara cada uno (y luego agregándolos a su matriz), por ejemplo, gist.github.com/hamishknight/9b5c202fe6d8289ee2cb9403876a1b41
Hamish

4

Ya se han publicado muchas buenas respuestas, pero hay un método más simple que aún no se ha descrito en mi opinión.

Cuando los nombres de los campos JSON se escriben con snake_case_notation, aún puede usar camelCaseNotationen su archivo Swift.

Solo necesitas configurar

decoder.keyDecodingStrategy = .convertFromSnakeCase

Después de esta línea ☝️, Swift hará coincidir automáticamente todos los snake_casecampos del JSON con los camelCasecampos del modelo Swift.

P.ej

user_name` -> userName
reviews_count -> `reviewsCount
...

Aquí está el código completo

1. Escribir el modelo

struct Response: Codable {

    let id: Int
    let user: User
    let reviewsCount: [ReviewCount]

    struct User: Codable {
        let userName: String

        struct RealInfo: Codable {
            let fullName: String
        }
    }

    struct ReviewCount: Codable {
        let count: Int
    }
}

2. Configuración del decodificador

let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase

3. Decodificación

do {
    let response = try? decoder.decode(Response.self, from: data)
    print(response)
} catch {
    debugPrint(error)
}

2
Esto no aborda la pregunta original de cómo lidiar con diferentes niveles de anidación.
Theo

2
  1. Copie el archivo json a https://app.quicktype.io
  2. Seleccione Swift (si usa Swift 5, verifique el interruptor de compatibilidad para Swift 5)
  3. Use el siguiente código para decodificar el archivo
  4. ¡Voila!
let file = "data.json"

guard let url = Bundle.main.url(forResource: "data", withExtension: "json") else{
    fatalError("Failed to locate \(file) in bundle.")
}

guard let data = try? Data(contentsOf: url) else{
    fatalError("Failed to locate \(file) in bundle.")
}

let yourObject = try? JSONDecoder().decode(YourModel.self, from: data)

1
Funcionó para mí, gracias. Ese sitio es oro. Para los espectadores, si decodifica una variable de cadena json jsonStr, puede usar esto en lugar de los dos guard letanteriores: guard let jsonStrData: Data? = jsonStr.data(using: .utf8)! else { print("error") }luego convierta jsonStrDataa su estructura como se describe arriba en la let yourObjectlínea
Pregunte P

¡Esta es una herramienta increíble!
Postcodeísmo

0

También puede utilizar la biblioteca KeyedCodable que preparé. Requerirá menos código. Déjame saber lo que piensas al respecto.

struct ServerResponse: Decodable, Keyedable {
  var id: String!
  var username: String!
  var fullName: String!
  var reviewCount: Int!

  private struct ReviewsCount: Codable {
    var count: Int
  }

  mutating func map(map: KeyMap) throws {
    var id: Int!
    try id <<- map["id"]
    self.id = String(id)

    try username <<- map["user.user_name"]
    try fullName <<- map["user.real_info.full_name"]

    var reviewCount: [ReviewsCount]!
    try reviewCount <<- map["reviews_count"]
    self.reviewCount = reviewCount[0].count
  }

  init(from decoder: Decoder) throws {
    try KeyedDecoder(with: decoder).decode(to: &self)
  }
}
Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.