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

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

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

合理的處理 Response Null 欄位資料、不一定都要重寫 init decoder

Photo by Zan

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
36
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,優點越做越少….

參考資料

回看

===

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

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

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

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

Comments powered by Disqus.