Home 現實使用 Codable 上遇到的 Decode 問題場景總匯(下)
Post
Cancel

現實使用 Codable 上遇到的 Decode 問題場景總匯(下)

現實使用 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,優點越做越少….

參考資料

回看

有任何問題及指教歡迎 與我聯絡

===

View the English version of this article here.

本文首次發表於 Medium ➡️ 前往查看



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

使用 Google Site 建立個人網站還跟得上時代嗎?

iOS 14 剪貼簿竊資恐慌,隱私與便利的兩難