在Swift 4中,使用JSONDecoder时,缺失的键是否可以使用默认值而不是必须是可选属性?

176

Swift 4新增了新的Codable协议。当我使用JSONDecoder时,它似乎要求我的Codable类的所有非可选属性在JSON中都有对应的键,否则会抛出错误。

将我的类的每个属性都变为可选的看起来像是一件不必要的麻烦,因为我真正想要的是使用JSON中的值或默认值。(我不想让属性为空。)

有没有方法可以做到这一点?

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

还有一个问题,如果我的json有多个键,并且我想编写一个通用方法来映射JSON以创建对象,而不是给出nil,它应该至少给出默认值。 - Aditya Sharma
8个回答

186
您可以在您的类型中实现 init(from decoder: Decoder) 方法,而不是使用默认实现:
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
        }
    }
}

您也可以将name设置为常量属性(如果需要):
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"
        }
    }
}

或者
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"
}

关于您的评论:使用自定义扩展程序

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

您可以将init方法实现为以下方式:

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

但这并没有比原文短多少。
    self.name = try container.decodeIfPresent(String.self, forKey: .name) ?? "Default Appleseed"

还要注意,在这种情况下,您可以使用自动生成的 CodingKeys 枚举(因此可以删除自定义定义) :) - Hamish
是的,目前还有一些问题,但会被修复(https://bugs.swift.org/browse/SR-5215) - Hamish
85
自动生成的方法不能读取非可选类型的默认值仍然是荒谬的。我有8个可选类型和1个非可选类型,所以现在手动编写编码器和解码器方法会带来很多样板代码。 ObjectMapper 处理这个问题非常好。 - Legoless
2
当我们使用 codable 但仍必须为缺失的 JSON 键自定义时,这真的很恼人 :( - lee
1
@LeoDabus 你是否正在遵循Decodable并提供自己的init(from:)实现?在这种情况下,编译器会认为您想要手动处理解码,因此不会为您合成CodingKeys枚举。正如您所说,改为遵循Codable可以工作,因为现在编译器正在为您合成encode(to:),因此也会合成CodingKeys。如果您还提供自己的encode(to:)实现,则不再合成CodingKeys - Hamish
显示剩余9条评论

70

如果未找到JSON键,您可以使用计算属性,默认为所需值。

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

    // this is the property that gets actually decoded/encoded
    private var _name: String?

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

如果你想要属性具有读写功能,你也可以实现setter:

var name: String {
    get { _name ?? "Default Appleseed" }
    set { _name = newValue }
}

这会增加一些额外的冗长性,因为您需要声明另一个属性,并且需要添加CodingKeys枚举(如果不存在)。好处是您不需要编写自定义的解码/编码代码,这在某些时候可能会变得繁琐。

请注意,此解决方案仅在JSON键的值只包含字符串或不存在时有效。如果JSON可能具有另一种形式的值(例如它是整数),则可以尝试此解决方案


有趣的方法。它确实增加了一些代码,但在对象创建后非常清晰和可检查。 - zekel
3
我最喜欢的解决方案。它允许我仍然使用默认的JSONDecoder,并轻松地为一个变量做出异常处理。谢谢。 - iOS_Mouse
1
注意:使用此方法后,您的属性将变为只读,您无法直接为该属性分配值。 - Ganpat
1
不幸的是,当使用JSONEncoder.encode时,它不会生成任何内容,就像留下了一个字符串{} - user2875404
@user2875404,这主要适用于Decodable(已更新答案),因为计算属性不会隐式参与编码过程。 - Cristik
显示剩余2条评论

33

我偏好的方法是使用所谓的DTO - 数据传输对象。 它是一个符合Codable规范并代表所需对象的结构体。

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

然后,您只需使用该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)
    }
}

这种方法也很好,因为您可以根据自己的意愿重命名和更改最终对象。它清晰明了,需要的代码比手动解码少。此外,使用这种方法,您可以将网络层与其他应用程序分离。


其他一些方法也行得通,但最终我认为这种方式是最好的。 - zekel
虽然这些都是有用的信息,但代码重复太多了。我更喜欢Martin R的答案。 - Kamen Dobrev
1
如果您使用 https://app.quicktype.io 这样的服务从JSON生成DTO,则不会出现代码重复。实际上,键入的内容甚至更少。 - Leonid Silver
我喜欢quicktype.io并在服务器端使用它来生成C++代码,你的评论启发了我,让我意识到我已经有了需要生成Swift客户端模型代码的示例JSON,哇,我感到很受启发,你能感受到吗? :-) - moodboom
@LeonidSilver 这怎么实现?我在 quicktype.io 上没有看到任何选项或者能够添加默认值的功能? - user2161301

31

你可以实现。

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)
    }
}

4
是的,这是最简洁的答案,但当你有大型对象时,仍然会生成大量代码! - Ashkan Ghodrat
这更加简洁易读!对于我的带有 nil 值的 JSON 数据有效。我认为这真的应该成为被接受的答案。 - stromyc

12

我遇到了这个问题,正在寻找完全相同的解决方案。虽然我担心这里的解决方案可能是唯一的选择,但我发现的答案并不令人满意。

在我的情况下,创建一个自定义解码器需要大量的样板代码,而且很难维护,因此我继续寻找其他答案。

我遇到了这篇文章,它展示了一种有趣的方法来在简单情况下克服这个问题,使用@propertyWrapper。对我来说最重要的是,它是可重用的,并且需要最少的现有代码重构。

该文章假设您想要使缺失的布尔属性默认为false而不会失败,但也显示了其他不同的变体。您可以详细阅读它,但我将展示我为我的用例所做的事情。

在我的情况下,如果键丢失,我希望将其初始化为空的array

因此,我声明了以下@propertyWrapper和其他扩展:

@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()
    }
}

这种方法的优点是,你只需要简单地将@propertyWrapper应用到属性上,就可以轻松地解决现有代码中的问题。在我的情况下:

@DefaultEmptyArray var items: [String] = []

希望这能帮助到遇到相同问题的人。

更新:

发布了这个答案后,我继续研究此事并发现了这篇其他文章,但更重要的是包含一些常见易用的@propertyWrapper的库,适用于这些情况:

https://github.com/marksands/BetterCodable


当对象中的字段不再存在时,使用Firestore Codable是否有所帮助? - justdan0227
1
是的,您可以创建一个属性包装器,如果对象中缺少键,则默认基于类型为某个值。 - lbarbosa
几乎完美。不幸的是,即使我在声明中添加值,它仍然只留下一个空数组。它似乎忽略了最初设置的值,并默认为= [] - user2875404

3
如果您不想实现编码和解码方法,那么还有一种相对脏的解决方案是使用默认值。您可以将新字段声明为隐式展开的可选项,并在解码后检查其是否为nil并设置默认值。我仅在PropertyListEncoder中测试过这一点,但我认为JSONDecoder的工作方式也是相同的。

1
如果你认为编写自己的init(from decoder: Decoder)版本很困难,我建议你实现一个方法,在将输入发送到解码器之前检查它。这样,你就有一个地方可以检查字段缺失并设置自己的默认值。
例如:
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.
}

为了从JSON初始化一个对象,不要使用:

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

初始化将会是这样的:

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

在这种特殊情况下,我更喜欢处理可选项,但如果您有不同的意见,可以使您的customDecode(:)方法可抛出异常。

0

我希望在API响应中没有找到键时返回默认值,因此我实现了以下解决方案。希望能对你有所帮助。

class MyModel: Codable {
    
    let id: String? = "1122"
    let name: String? = "Syed Faizan Ahmed"
    
    enum CodingKeys: String, CodingKey {
        case id = "id"
        case name = "name"
    }
    
    required init(from decoder: Decoder) throws {
        let values = try decoder.container(keyedBy: CodingKeys.self)
        id = try values.decodeIfPresent(String.self, forKey: .id) ?? id
        name = try values.decodeIfPresent(String.self, forKey: .name) ?? name
    }
}

问题的目标是避免使用可选属性。没有必要将属性设置为可选项,也没有必要将它们设置为“var”。在??之后放置默认值,而不是将默认值放在属性初始化程序中。但是这时你得到的答案与“Martin R”相同。 - HangarRash
谢谢您的指出,我已经编辑了我的答案并将var更改为let。 我已经将属性设置为可选项,并设置了默认值,以便我们可以检查传入模型的值。 我在属性中放置了默认值,以便更好地显示模型类,我们可以清楚地看到模型的默认值是什么。 “Martin R”的答案不同,因此您需要编写一个额外的decodeWrapper功能。 - Syed Faizan Ahmed
“我已经将属性设置为可选的” - 但正如我所说,目标是避免使用可选属性。这就是原始问题的全部意义。你的更新并没有改进它。事实上,你的更新现在甚至无法编译。 - HangarRash

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接