Post

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

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

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

Click here to view the English version of this article.

點擊這裡查看本文章正體中文版本。

基于 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

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

Improve this page on Github.

This post is licensed under CC BY 4.0 by the author.