Swift - Codable 解码设置默认值

掘金同步更新:https://juejin.cn/user/3378158048121326/posts

上一篇 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

https://onevcat.com/2020/11/codable-default/
https://docs.swift.org/swift-book/LanguageGuide/Properties.html#ID617
http://marksands.github.io/2019/10/21/better-codable-through-property-wrappers.html

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,088评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,715评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,361评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,099评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 60,987评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,063评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,486评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,175评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,440评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,518评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,305评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,190评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,550评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,880评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,152评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,451评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,637评论 2 335

推荐阅读更多精彩内容