Swift - Codable 解码设置默认值

1,774 阅读6分钟

上一篇 Swift - Codable 使用小记 文章中介绍了 Codable 的使用,它能够把 JSON 数据转换成 Swift 代码中使用的类型。本文来进一步研究使用 Codable 解码如何设置默认值的问题。

解码遇到的问题

之前的文章中提到了,遇到 JSON 数据中字段为空的情况,把属性设置为可选的,当返回为空对象或 null 时,解析为 nil。
当我们希望字段为空时,对应的属性要设置一个默认值,我们处理的一种方法是重写 init(from decoder: Decoder) 方法,在 decodeIfPresent 判断设置默认值,代码如下:

struct Person: Decodable {
    let name: String
    let age: Int
    
    enum CodingKeys: String, CodingKey {
        case name, age
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        name = try container.decode(String.self, forKey: .name)
        age = try container.decodeIfPresent(Int.self, forKey: .age) ?? -1
    }
}

let data = """
{ "name": "小明", "age": null}
"""
let p = try JSONDecoder().decode(Person.self, from: data.data(using: .utf8)!)
//Person(name: "小明", age: -1)

这种方法显然很麻烦,需要为每个类型添加 CodingKeys 和 init(from decoder: Decoder) 代码,有没有更好、更方便的方法呢?
我们先来了解一下 property wrapper 。

Property Wrapper

property wrapper 属性包装器,在管理属性如何存储和定义属性的代码之间添加了一层隔离。当使用属性包装器时,你只需在定义属性包装器时编写一次管理代码,然后应用到多个属性上来进行复用。它相当于提供一个特殊的盒子,把属性值包装进去。当你把一个包装器应用到一个属性上时,编译器将合成提供包装器存储空间和通过包装器访问属性的代码。

例如有个需求,要求属性值不得大于某个数,实现的时候要一个个在属性 set 方法中判断是否大于,然后进行处理,这样很显然很麻烦。这时就可以定义一个属性包装器,在这里进行处理,然后把包装器应用到属性上去,代码如下:

@propertyWrapper
struct SmallNumber {
    private var maximum: Int
    private var number: Int
    
    var wrappedValue: Int {
        get { return number }
        set { number = min(newValue, maximum) }
    }
    
    init() {
        maximum = 12
        number = 0
    }
    init(wrappedValue: Int) {
        maximum = 12
        number = min(wrappedValue, maximum)
    }
    init(wrappedValue: Int, maximum: Int) {
        self.maximum = maximum
        number = min(wrappedValue, maximum)
    }
}

struct SmallRectangle {
    @SmallNumber var height: Int
    @SmallNumber(wrappedValue: 10, maximum: 20) var width: Int
}
var rect = SmallRectangle()
print(rect.height, rect.width) //0 10

rect.height = 30
print(rect.height) //12

rect.width = 40
print(rect.width) //20

print(rect)
//SmallRectangle(_height: SmallNumber(maximum: 12, number: 12), _width: SmallNumber(maximum: 20, number: 20))

上面例子中 SmallNumber 定义了三个构造器,可使用构造器来设置被包装值和最大值, height 不大于 12,width 不大于 20。
通过打印的内容可看到 _height: SmallNumber(maximum: 12, number: 12),被 SmallNumber 声明的属性,实际上存储的类型是 SmallNumber 类型,只不过编译器进行了处理,对外暴露的类型依然是原来的类型 Int。
编译器对属性的处理,相当于下面的代码处理方法:

struct SmallRectangle {
    private var _height = SmallNumber()
    var height: Int {
        get { return _height.wrappedValue }
        set { _height.wrappedValue = newValue }
    }
    //...
}

将属性 height 包装在 SmallNumber 结构体中,get set 操作的值其实是结构体中 wrappedValue 的值。
弄清楚这些之后,我们利用属性包装器给属性包装一层,在 Codable 解码的时候操作的是 wrappedValue ,这时我们就可以在属性包装器中进行判断,设置默认值。顺着这个思路下面我们来实现以下。

设置默认值

通过前面的分析,大概有了思路,定义一个能够提供默认值的 Default property wrapper ,利用这个 Default 来包装属性,Codable 解码的时候把值赋值 Default 的 wrappedValue,如解码失败就在这里设置默认值。

初步实现

初步实现的代码如下:

@propertyWrapper
struct Default: Decodable {
    var wrappedValue: Int
    
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        wrappedValue = (try? container.decode(Int.self)) ?? -1
    }
}

struct Person: Decodable {
    @Default var age: Int
}

let data = #"{ "age": null}"#
let p = try JSONDecoder().decode(Person.self, from: data.data(using: .utf8)!)
print(p, p.age)
//Person(_age: Default(wrappedValue: -1)) -1

可以看到上面的例子中,JSON 数据为 null,解码到 age 设置了默认值 -1。

改进代码

接着我们来改进一下,上面例子只是对 Int 类型的设置了默认值,下面来使用泛型,扩展一下对别的类型支持。
还有一个问题就是,如果 JSON 中 age 这个 key 缺失的情况下,依然会发生错误,因为我们所使用的解码器默认生成的代码是要求 key 存在的。需要改进一下为 container 重写对于 Default 类型解码的实现。
改进后的代码如下:

protocol DefaultValue {
    associatedtype Value: Decodable
    static var defaultValue: Value { get }
}

@propertyWrapper
struct Default<T: DefaultValue> {
    var wrappedValue: T.Value
}

extension Default: Decodable {
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        wrappedValue = (try? container.decode(T.Value.self)) ?? T.defaultValue
    }
}

extension KeyedDecodingContainer {
    func decode<T>(_ type: Default<T>.Type, forKey key: Key) throws -> Default<T> where T: DefaultValue {
        //判断 key 缺失的情况,提供默认值
        (try decodeIfPresent(type, forKey: key)) ?? Default(wrappedValue: T.defaultValue)
    }
}


extension Int: DefaultValue {
    static var defaultValue = -1
}

extension String: DefaultValue {
    static var defaultValue = "unknown"
}


struct Person: Decodable {
    @Default<String> var name: String
    @Default<Int> var age: Int
}


let data = #"{ "name": null, "age": null}"#
let p = try JSONDecoder().decode(Person.self, from: data.data(using: .utf8)!)
print(p, p.name, p.age)
//Person(_name: Default<Swift.String>(wrappedValue: "unknown"), _age: Default<Swift.Int>(wrappedValue: -1))
//unknown  -1

这样如我们需要对某种类型在解码时设置默认值,我们只需要对应的添加个扩展,遵循 DefaultValue 协议,提供一个想要的默认值 defaultValue 即可。
而且对于 JSON 中 key 缺失的情况,也做了处理,重写了 container.decode() 方法,判断 key 缺失的情况,如 key 缺失,返回默认值。

设置多种默认值的情况

有时我们再不同情况下,同种类型的数据需要设置不同的默认值,例如 String 类型的属性,在有的地方默认值需要设置为 "unknown",有的地方则需要设置为 "unnamed",这是我们处理方法如下:

extension String {
    struct Unknown: DefaultValue {
        static var defaultValue = "unknown"
    }
    struct Unnamed: DefaultValue {
        static var defaultValue = "unnamed"
    }
}

@Default<String.Unnamed> var name: String
@Default<String.Unknown> var text: String

这样就实现了不同的情况定义不同的默认值。

其他问题

还有一个问题,自定义的数据类型,解码到异常的数据可能导致我们的代码崩溃,还是举之前文章中的例子,枚举类型解析,如下:

enum Gender: String, Codable {
    case male
    case female
}
struct Person: Decodable {
    var gender: Gender
}
//{ "gender": "other" }

当 JSON 数据中的 gender 对应的值不在 Gender 枚举的 case 字段中,解码的时候会出现异常,即使 gender 属性是可选的,也会出现异常。要解决这个问题,也可以重写 init(from decoder: Decoder) ,在里面进行判断是否解码异常,然后进行处理。

相比于使用枚举,其实这里用一个带有 raw value 的 struct 来表示会更好,代码如下:

struct Gender: RawRepresentable, Codable {
    static let male = Gender(rawValue: "male")
    static let female = Gender(rawValue: "female")
    
    let rawValue: String
}
struct XMan: Decodable {
    var gender: Gender
}
let mData = #"{ "gender": "other" }"#
let m = try JSONDecoder().decode(XMan.self, from: mData.data(using: .utf8)!)
print(m) //XMan(gender: Gender(rawValue: "other"))
print(m.gender == .male) //false

这样,就算以后为 Gender 添加了新的字符串,现有的实现也不会被破坏,这样也更加稳定。

References

onevcat.com/2020/11/cod…
docs.swift.org/swift-book/…
marksands.github.io/2019/10/21/…