Con JSONDecoder in Swift 4, le chiavi mancanti possono utilizzare un valore predefinito invece di dover essere proprietà opzionali?


114

Swift 4 ha aggiunto il nuovo Codableprotocollo. Quando lo uso JSONDecodersembra richiedere che tutte le proprietà non facoltative della mia Codableclasse abbiano chiavi in ​​JSON o genera un errore.

Rendere facoltativa ogni proprietà della mia classe sembra una seccatura non necessaria poiché quello che voglio veramente è usare il valore nel json o un valore predefinito. (Non voglio che la proprietà sia nulla.)

C'è un modo per fare questo?

class MyCodable: Codable {
    var name: String = "Default Appleseed"
}

func load(input: String) {
    do {
        if let data = input.data(using: .utf8) {
            let result = try JSONDecoder().decode(MyCodable.self, from: data)
            print("name: \(result.name)")
        }
    } catch  {
        print("error: \(error)")
        // `Error message: "Key not found when expecting non-optional type
        // String for coding key \"name\""`
    }
}

let goodInput = "{\"name\": \"Jonny Appleseed\" }"
let badInput = "{}"
load(input: goodInput) // works, `name` is Jonny Applessed
load(input: badInput) // breaks, `name` required since property is non-optional

Un'altra domanda cosa posso fare se ho più chiavi nel mio json e voglio scrivere un metodo generico per mappare json per creare un oggetto invece di dare zero dovrebbe dare almeno il valore predefinito.
Aditya Sharma

Risposte:


22

L'approccio che preferisco utilizza i cosiddetti DTO - oggetto di trasferimento dati. È una struttura conforme a Codable e rappresenta l'oggetto desiderato.

struct MyClassDTO: Codable {
    let items: [String]?
    let otherVar: Int?
}

Quindi avvia semplicemente l'oggetto che desideri utilizzare nell'app con quel DTO.

 class MyClass {
    let items: [String]
    var otherVar = 3
    init(_ dto: MyClassDTO) {
        items = dto.items ?? [String]()
        otherVar = dto.otherVar ?? 3
    }

    var dto: MyClassDTO {
        return MyClassDTO(items: items, otherVar: otherVar)
    }
}

Anche questo approccio è utile poiché puoi rinominare e modificare l'oggetto finale come preferisci. È chiaro e richiede meno codice rispetto alla decodifica manuale. Inoltre, con questo approccio puoi separare il livello di rete da altre app.


Alcuni degli altri approcci hanno funzionato bene, ma alla fine penso che qualcosa di simile sia l'approccio migliore.
zekel

Buono a sapersi, ma c'è troppa duplicazione del codice. Preferisco la risposta di Martin R
Kamen Dobrev

136

Puoi implementare il init(from decoder: Decoder)metodo nel tuo tipo invece di utilizzare l'implementazione predefinita:

class MyCodable: Codable {
    var name: String = "Default Appleseed"

    required init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        if let name = try container.decodeIfPresent(String.self, forKey: .name) {
            self.name = name
        }
    }
}

Puoi anche creare nameuna proprietà costante (se lo desideri):

class MyCodable: Codable {
    let name: String

    required init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        if let name = try container.decodeIfPresent(String.self, forKey: .name) {
            self.name = name
        } else {
            self.name = "Default Appleseed"
        }
    }
}

o

required init(from decoder: Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKeys.self)
    self.name = try container.decodeIfPresent(String.self, forKey: .name) ?? "Default Appleseed"
}

Re il tuo commento: con un'estensione personalizzata

extension KeyedDecodingContainer {
    func decodeWrapper<T>(key: K, defaultValue: T) throws -> T
        where T : Decodable {
        return try decodeIfPresent(T.self, forKey: key) ?? defaultValue
    }
}

potresti implementare il metodo init come

required init(from decoder: Decoder) throws {
    let container = try decoder.container(keyedBy: CodingKeys.self)
    self.name = try container.decodeWrapper(key: .name, defaultValue: "Default Appleseed")
}

ma non è molto più breve di

    self.name = try container.decodeIfPresent(String.self, forKey: .name) ?? "Default Appleseed"

Si noti inoltre che in questo caso particolare, è possibile utilizzare l' CodingKeysenumerazione generata automaticamente (quindi è possibile rimuovere la definizione personalizzata) :)
Hamish

@ Hamish: non si è compilato quando l'ho provato per la prima volta, ma ora funziona :)
Martin R

Sì, al momento è un po 'irregolare, ma verrà risolto ( bugs.swift.org/browse/SR-5215 )
Hamish

54
È ancora ridicolo che i metodi generati automaticamente non possano leggere i valori predefiniti da non-optionals. Ho 8 optionals e 1 non opzionale, quindi ora scrivere manualmente entrambi i metodi Encoder e Decoder porterebbe un sacco di boilerplate. ObjectMappergestisce questo molto bene.
Legoless

1
@LeoDabus Potrebbe essere che ti stai conformando Decodablee stai anche fornendo la tua implementazione init(from:)? In tal caso il compilatore presume che tu voglia gestire la decodifica manualmente e quindi non sintetizza un CodingKeysenum per te. Come dici tu, conformarsi a Codableinvece funziona perché ora il compilatore sintetizza encode(to:)per te e quindi sintetizza anche CodingKeys. Se fornisci anche la tua implementazione di encode(to:), CodingKeysnon verrà più sintetizzato.
Hamish

37

Una soluzione potrebbe essere quella di utilizzare una proprietà calcolata che abbia come impostazione predefinita il valore desiderato se la chiave JSON non viene trovata. Questo aggiunge un po 'di verbosità extra in quanto dovrai dichiarare un'altra proprietà e richiederà l'aggiunta CodingKeysdell'enumerazione (se non è già presente). Il vantaggio è che non è necessario scrivere codice di decodifica / codifica personalizzato.

Per esempio:

class MyCodable: Codable {
    var name: String { return _name ?? "Default Appleseed" }
    var age: Int?

    private var _name: String?

    enum CodingKeys: String, CodingKey {
        case _name = "name"
        case age
    }
}

Approccio interessante. Aggiunge un po 'di codice ma è molto chiaro e ispezionabile dopo la creazione dell'oggetto.
zekel

La mia risposta preferita a questo problema. Mi consente di utilizzare ancora il JSONDecoder predefinito e di fare facilmente un'eccezione per una variabile. Grazie.
iOS_Mouse

Nota: utilizzando questo approccio la tua proprietà diventa di sola ricezione, non puoi assegnare un valore direttamente a questa proprietà.
Ganpat

8

Puoi implementare.

struct Source : Codable {

    let id : String?
    let name : String?

    enum CodingKeys: String, CodingKey {
        case id = "id"
        case name = "name"
    }

    init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        id = try values.decodeIfPresent(String.self, forKey: .id) ?? ""
        name = try values.decodeIfPresent(String.self, forKey: .name)
    }
}

sì, questa è la risposta più pulita, ma riceve ancora molto codice quando hai oggetti grandi!
Ashkan Ghodrat il

1

Se non vuoi implementare i tuoi metodi di codifica e decodifica, c'è una soluzione un po 'sporca intorno ai valori predefiniti.

Puoi dichiarare il tuo nuovo campo come facoltativo implicitamente scartato e controllare se è nullo dopo la decodifica e impostare un valore predefinito.

L'ho testato solo con PropertyListEncoder, ma penso che JSONDecoder funzioni allo stesso modo.


1

Mi sono imbattuto in questa domanda cercando la stessa identica cosa. Le risposte che ho trovato non sono state molto soddisfacenti anche se temevo che le soluzioni qui sarebbero state l'unica opzione.

Nel mio caso, la creazione di un decoder personalizzato richiederebbe una tonnellata di boilerplate che sarebbe stato difficile da mantenere, quindi ho continuato a cercare altre risposte.

Mi sono imbattuto in questo articolo che mostra un modo interessante per superare questo problema in casi semplici utilizzando un file @propertyWrapper. La cosa più importante per me era che fosse riutilizzabile e richiedesse un refactoring minimo del codice esistente.

L'articolo presume un caso in cui si desidera che una proprietà booleana mancante venga impostata su false senza errori, ma mostra anche altre varianti diverse. Puoi leggerlo in modo più dettagliato ma mostrerò cosa ho fatto per il mio caso d'uso.

Nel mio caso, avevo un arrayche volevo inizializzare come vuoto se mancava la chiave.

Quindi, ho dichiarato le seguenti @propertyWrappere ulteriori estensioni:

@propertyWrapper
struct DefaultEmptyArray<T:Codable> {
    var wrappedValue: [T] = []
}

//codable extension to encode/decode the wrapped value
extension DefaultEmptyArray: Codable {
    
    func encode(to encoder: Encoder) throws {
        try wrappedValue.encode(to: encoder)
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        wrappedValue = try container.decode([T].self)
    }
    
}

extension KeyedDecodingContainer {
    func decode<T:Decodable>(_ type: DefaultEmptyArray<T>.Type,
                forKey key: Key) throws -> DefaultEmptyArray<T> {
        try decodeIfPresent(type, forKey: key) ?? .init()
    }
}

Il vantaggio di questo metodo è che puoi facilmente superare il problema nel codice esistente semplicemente aggiungendo il file @propertyWrapperalla proprietà. Nel mio caso:

@DefaultEmptyArray var items: [String] = []

Spero che questo aiuti qualcuno ad affrontare lo stesso problema.


AGGIORNARE:

Dopo aver pubblicato questa risposta continuando a esaminare la questione, ho trovato questo altro articolo, ma soprattutto la rispettiva libreria che contiene alcuni comuni facili da usare @propertyWrapperper questo tipo di casi:

https://github.com/marksands/BetterCodable


0

Se pensi che scrivere la tua versione di init(from decoder: Decoder)sia travolgente, ti consiglio di implementare un metodo che controlli l'input prima di inviarlo al decoder. In questo modo avrai una posizione in cui puoi verificare l'assenza di campi e impostare i tuoi valori predefiniti.

Per esempio:

final class CodableModel: Codable
{
    static func customDecode(_ obj: [String: Any]) -> CodableModel?
    {
        var validatedDict = obj
        let someField = validatedDict[CodingKeys.someField.stringValue] ?? false
        validatedDict[CodingKeys.someField.stringValue] = someField

        guard
            let data = try? JSONSerialization.data(withJSONObject: validatedDict, options: .prettyPrinted),
            let model = try? CodableModel.decoder.decode(CodableModel.self, from: data) else {
                return nil
        }

        return model
    }

    //your coding keys, properties, etc.
}

E per avviare un oggetto da json, invece di:

do {
    let data = try JSONSerialization.data(withJSONObject: json, options: .prettyPrinted)
    let model = try CodableModel.decoder.decode(CodableModel.self, from: data)                        
} catch {
    assertionFailure(error.localizedDescription)
}

Init avrà questo aspetto:

if let vuvVideoFile = PublicVideoFile.customDecode($0) {
    videos.append(vuvVideoFile)
}

In questa situazione particolare preferisco occuparmi degli optional ma se hai un'opinione diversa puoi rendere lanciabile il tuo metodo customDecode (:)

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.