文章

Codable 解码进阶技巧|区分 Null 与栏位缺失,实现 Core Data 增量更新

针对 API 回传栏位可能为 null 或缺失的问题,教你用 OptionalValue Enum 精准区分栏位状态,避免重写 init decoder,并透过 KeyedDecodingContainer 扩充支援 0/1 转 Bool,提升 Codable 在 iOS Core Data 实务的弹性与效率。

Codable 解码进阶技巧|区分 Null 与栏位缺失,实现 Core Data 增量更新

基于 SEO 考量,本文标题与描述经 AI 调整,原始版本请参考内文。

文章目录


现实使用 Codable 上遇到的 Decode 问题场景总汇(下)

合理的处理 Response Null 栏位资料、不一定都要重写 init decoder

Photo by [Zan](https://unsplash.com/@zanilic?utm_source=unsplash&utm_medium=referral&utm_content=creditCopyText){:target="_blank"}

Photo by Zan

前言

既上篇「 现实使用 Codable 上遇到的 Decode 问题场景总汇 」后,开发进度继续迈进又遇到了新的场景新的问题,故出了此下篇,继续把遇到的情景、研究心路都记录下来,方便日后回头查阅。

前篇主要解决了 JSON String -> Entity Object 的 Decodable Mapping,有了 Entity Object 后我们可以转换成 Model Object 在程式内传递使用、View Model Object 处理资料显示逻辑…等等; 另一方面我们需要将 Entity 转换成 NSManagedObject 存入本地 Core Data 中

主要问题

假设我们的歌曲 Entity 结构如下:

1
2
3
4
5
6
7
8
9
struct Song: Decodable {
    var id: Int
    var name: String?
    var file: String?
    var converImage: String?
    var likeCount: Int?
    var like: Bool?
    var length: Int?
}

因 API EndPoint 并不一定会回传完整资料栏位(只有 id 是一定会给),所以除 id 之外的栏位都是 Optional;例如:取得歌曲资讯的时候会回传完整结构,但若是对歌曲收藏喜欢时仅会回传 idlikeCountlike 三个有关联更动的栏位资料。

我们希望 API Response 有什么栏位资料都能一并存入 Core Data 里,如果资料已存在就更新变动的栏位资料(incremental update)。

但此时问题就出现了:Codable Decode 换成 Entity Object 后我们无法区别 「资料栏位是想要设成 nil」 还是 「Response 没给」

1
2
3
4
5
A Response:
{
  "id": 1,
  "file": null
}

对于 A Response、B Response 的 file 来说都是 null 、但意义不一一样 ;A 是想把 file 栏位设为 null (清空原本资料)、 B 是想 update 其他资料,单纯没给 file 栏位而已。

Swift 社群有开发者提出 增加类似 date Strategy 的 null Strategy 在 JSONDecoder 中 ,让我们能区分以上状况,但目前没有计划要加入。

解决方案

如前所述,我们的架构是JSON String -> Entity Object -> NSManagedObject,所以当拿到 Entity Object 时已经是 Decode 后的结果了,没有 raw data 可以操作;这边当然可以拿原始 JSON String 比对操作,但与其这样不如不要用 Codable。

首先参考 上一篇 使用 Associated Value Enum 当容器装值。

1
2
3
4
5
6
7
8
9
10
11
12
enum OptionalValue<T: Decodable>: Decodable {
    case null
    case value(T)
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        if let value = try? container.decode(T.self) {
            self = .value(value)
        } else {
            self = .null
        }
    }
}

使用泛型,T 为真实资料栏位型别;.value(T) 能放 Decode 出来的值、.null 则代表值是 null。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
struct Song: Decodable {
    enum CodingKeys: String, CodingKey {
        case id
        case file
    }
    
    var id: Int
    var file: OptionalValue<String>?
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        
        self.id = try container.decode(Int.self, forKey: .id)
        
        if container.contains(.file) {
            self.file = try container.decode(OptionalValue<String>.self, forKey: .file)
        } else {
            self.file = nil
        }
    }
}

var jsonData = """
{
    "id":1
}
""".data(using: .utf8)!
var result = try! JSONDecoder().decode(Song.self, from: jsonData)
print(result)

jsonData = """
{
    "id":1,
    "file":null
}
""".data(using: .utf8)!
result = try! JSONDecoder().decode(Song.self, from: jsonData)
print(result)

jsonData = """
{
    "id":1,
    "file":\"https://test.com/m.mp3\"
}
""".data(using: .utf8)!
result = try! JSONDecoder().decode(Song.self, from: jsonData)
print(result)

范例先简化成只有 idfile 两个资料栏位。

Song Entity 自行复写实践 Decode 方式,使用 contains(.KEY) 方法判断 Response 有无给该栏位(无论值是什么),如果有就 Decode 成 OptionalVale ;OptionalValue Enum 中会再对真正我们要的值做 Decode ,如果有值 Decode 成功则会放在 .value(T) 、如果给的值是 null (或 decode 失败)则放在 .null 。

  1. Response 有给栏位&值时:OptionalValue.value(VALUE)

  2. Response 有给栏位&值是 null 时:OptionalValue.null

  3. Response 没给栏位时:nil

这样就能区分出是有给栏位还是没给栏位,后续要写入 Core Data 时就能判断是要更新栏位成 null、还是没有要更新此栏位。

其他研究 — Double Optional ❌

Optional!Optional! 在 Swift 上就很适合处理这个场景。

1
2
3
4
5
6
7
8
9
struct Song: Decodable {
    var id: Int
    var name: String??
    var file: String??
    var converImage: String??
    var likeCount: Int??
    var like: Bool??
    var length: Int??
}
  1. Response 有给栏位&值时:Optional(VALUE)

  2. Response 有给栏位&值是 null 时:Optional(nil)

  3. Response 没给栏位时:nil

但是….Codable JSONDecoder Decode 对 Double Optional 跟 Optional 都是 decodeIfPresent 在处理,都视为 Optional ,不会特别处理 Double Optional;所以结果跟原本一样。

其他研究 — Property Wrapper ❌

本来预想可以用 Property Wrapper 做优雅的封装,例如:

1
@OptionalValue var file: String?

但还没开始研究细节就发现有 Property Wrapper 标记的 Codable Property 栏位,API Response 就必须要有该栏位,否则会出现 keyNotFound error,即使该栏位是 Optional。?????

官方论坛也有针对此问题的 讨论串 …估计之后会修正。

所以选用 BetterCodableCodableWrappers 这类套件的时候要考虑到目前 Property Wrapper 的这个问题。

其他问题场景

1.API Response 使用 0/1 代表 Bool,该如何 Decode?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
import Foundation

struct Song: Decodable {
    enum CodingKeys: String, CodingKey {
        case id
        case name
        case like
    }
    
    var id: Int
    var name: String?
    var like: Bool?
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.id = try container.decode(Int.self, forKey: .id)
        self.name = try container.decodeIfPresent(String.self, forKey: .name)
        
        if let intValue = try container.decodeIfPresent(Int.self, forKey: .like) {
            self.like = (intValue == 1) ? true : false
        } else if let boolValue = try container.decodeIfPresent(Bool.self, forKey: .like) {
            self.like = boolValue
        }
    }
}

var jsonData = """
{
    "id": 1,
    "name": "告五人",
    "like": 0
}
""".data(using: .utf8)!
var result = try! JSONDecoder().decode(Song.self, from: jsonData)
print(result)

延伸前篇,我们可以自己在 init Decode 中,Decode 成 int/Bool 然后自己赋值、这样就能扩充原本的栏位能接受 0/1/true/false了。

2.不想要每每都要重写 init decoder

在不想要自干 Decoder 的情况下,复写原本的 JSON Decoder 扩充更多功能。

我们可以自行 extenstion KeyedDecodingContainer 对 public 方法自行定义,swift 会优先执行 module 下我们重定义的方法,复写掉原本 Foundation 的实作。

影响的就是整个 module。

且不是真的 override,无法 call super.decode,也要小心不要自己 call 自己(EX: decode(Bool.Type,for:key) in decode(Bool.Type,for:key) )

decode 有两个方法:

  • decode(Type, forKey:) 处理非 Optional 资料栏位

  • decodeIfPresent(Type, forKey:) 处理 Optional 资料栏位

范例1. 前述的主要问题就我们可以直接 extenstion:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
extension KeyedDecodingContainer {
    public func decodeIfPresent<T>(_ type: T.Type, forKey key: Self.Key) throws -> T? where T : Decodable {
        //better:
        switch type {
        case is OptionalValue<String>.Type,
             is OptionalValue<Int>.Type:
            return try? decode(type, forKey: key)
        default:
            return nil
        }
        // or just return try? decode(type, forKey: key)
    }
}

struct Song: Decodable {
    var id: Int
    var file: OptionalValue<String>?
}

因主要问题是 Optional 资料栏位、Decodable 类型,所以我们复写的是 decodeIfPresent<T: Decodable> 这个方法。

这边推测原本 decodeIfPresent 的实作是,如果资料是 null 或 Response 未给 会直接 return nil,并不会真的跑 decode。

所以原理也很简单,只要 Decodable Type 是 OptionValue 则不论如何都 decode 看看,我们才能拿到不同状态结果;但其实不判断 Decodable Type 也行,那就是所有 Optional 栏位都会试著 Decode。

范例2. 问题场景1 也能用此方法扩充:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
extension KeyedDecodingContainer {
    public func decodeIfPresent(_ type: Bool.Type, forKey key: KeyedDecodingContainer<K>.Key) throws -> Bool? {
        if let intValue = try? decodeIfPresent(Int.self, forKey: key) {
            return (intValue == 1) ? (true) : (false)
        } else if let boolValue = try? decodeIfPresent(Bool.self, forKey: key) {
            return boolValue
        }
        return nil
    }
}

struct Song: Decodable {
    enum CodingKeys: String, CodingKey {
        case id
        case name
        case like
    }
    
    var id: Int
    var name: String?
    var like: Bool?
}

var jsonData = """
{
    "id": 1,
    "name": "告五人",
    "like": 1
}
""".data(using: .utf8)!
var result = try! JSONDecoder().decode(Song.self, from: jsonData)
print(result)

结语

Codable 在使用上的各种奇技淫巧都用的差不多了,有些其实很绕,因为 Codable 的约束性实在太强、牺牲许多现实开发上需要的弹性;做到最后甚至开始思考为何当初要选择 Codable,优点越做越少….

参考资料

回看

有任何问题及指教欢迎 与我联络


🍺 Buy me a beer on PayPal

👉👉👉 Follow Me On Medium! (1,053+ Followers) 👈👈👈

本文首次发表于 Medium (点击查看原始版本),由 ZMediumToMarkdown 提供自动转换与同步技术。

Improve this page on Github.

本文由作者以 CC BY 4.0 授权。