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

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

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

從基礎到進階,深入使用 Decodable 滿足所有可能會遇到的問題場景

Photo by [Gustas Brazaitis](https://unsplash.com/@gustasbrazaitis){:target="_blank"}

Photo by Gustas Brazaitis

前言

因應後端 API 升級需要調整 API 處理架構,近期趁這個機會一併將原本使用 Objective-C 撰寫的網路處理架構更新成 Swift;因語言不同,也不在適合使用原本的 Restkit 幫我們處理網路層應用,但不得不說 Restkit 的功能包山包海非常強大,在專案中也用得活靈活現,基本沒有太大的問題;但相對的非常笨重、幾乎已不再維護、純 Objective-C;未來勢必也要更換的。

Restkit 幾乎幫我們處理完所有網路請求相關會需要到的功能,從基本的網路處理、API 呼叫、網路處理,到 Response 處理 JSON String to Object 甚至是 Object 存入 Core Data 它都能一起處理實打實的一個 Framework 打十個。

隨著時代的演進,目前的 Framework 已不在主打一個包全部,更多的是靈活、輕巧、組合,增加更多彈性創造更多變化;因此再替換成 Swift 語言的同時,我們選擇使用 Moya 作為網路處理部分的套件,其他我們需要的功能再選擇其他方式進行組合。

正題

關於 JSON String to Object Mapping 部分,我們使用 Swift 自帶的 Codable (Decodable) 協議 & JSONDecoder 進行處理;並拆分 Entity/Model 加強權責區分、操作及閱讀性、另外 Code Base 混 Objective-C 和 Swift 也要考量進去。

* Encodable 的部份省略、範例均只展示實作 Decodable,大同小異,可以 Decode 基本也能 Encode。

開始

假設我們初始的 API Response JSON String 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
  "id": 123456,
  "comment": "是告五人,不是五告人!",
  "target_object": {
    "type": "song",
    "id": 99,
    "name": "披星戴月的想你"
  },
  "commenter": {
    "type": "user",
    "id": 1,
    "name": "zhgchgli",
    "email": "zhgchgli@gmail.com"
  }
}

由上範例我們可以拆成:User/Song/Comment 三個 Entity & Model,讓我們組合能複用,為方便展示先將 Entity/Model 寫在同個檔案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Entity:
struct UserEntity: Decodable {
    var id: Int
    var name: String
    var email: String
}

//Model:
class UserModel: NSObject {
    init(_ entity: UserEntity) {
      self.id = entity.id
      self.name = entity.name
      self.email = entity.email
    }
    var id: Int
    var name: String
    var email: String
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Entity:
struct SongEntity: Decodable {
    var id: Int
    var name: String
}

//Model:
class SongModel: NSObject {
    init(_ entity: SongEntity) {
      self.id = entity.id
      self.name = entity.name
    }
    var id: Int
    var name: String
}
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
// Entity:
struct CommentEntity: Decodable {
    enum CodingKeys: String, CodingKey {
      case id
      case comment
      case targetObject = "target_object"
      case commenter
    }
    
    var id: Int
    var comment: String
    var targetObject: SongEntity
    var commenter: UserEntity
}

//Model:
class CommentModel: NSObject {
    init(_ entity: CommentEntity) {
      self.id = entity.id
      self.comment = entity.comment
      self.targetObject = SongModel(entity.targetObject)
      self.commenter = UserModel(entity.commenter)
    }
    var id: Int
    var comment: String
    var targetObject: SongModel
    var commenter: UserModel
}
1
2
3
4
5
6
7
let jsonString = "{ \"id\": 123456, \"comment\": \"是告五人,不是五告人!\", \"target_object\": { \"type\": \"song\", \"id\": 99, \"name\": \"披星戴月的想你\" }, \"commenter\": { \"type\": \"user\", \"id\": 1, \"name\": \"zhgchgli\", \"email\": \"zhgchgli@gmail.com\" } }"
let jsonDecoder = JSONDecoder()
do {
    let result = try jsonDecoder.decode(CommentEntity.self, from: jsonString.data(using: .utf8)!)
} catch {
    print(error)
}

CodingKeys Enum?

當我們的 JSON String Key Name 與 Entity Object Property Name 不相匹配時可以在內部加一個 CodingKeys 枚舉進行對應,畢竟後端資料源的 Naming Convention 不是我們可以控制的。

1
2
case PropertyKeyName = "後端欄位名稱"
case PropertyKeyName //不指定則預設使用 PropertyKeyName 為後端欄位名稱

一旦加入 CodingKeys 枚舉,則必須列舉出所有非 Optional 的欄位,不能只列舉想要客製的 Key。

另外一種方式是設定 JSONDecoder 的 keyDecodingStrategy,若 Response 資料欄位與 Property Name 僅為 snake_case <-> camelCase 區別,可直接設定 .keyDecodingStrategy = .convertFromSnakeCase 就能自動匹配 Mapping。

1
2
3
let jsonDecoder = JSONDecoder()
jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
try jsonDecoder.decode(CommentEntity.self, from: jsonString.data(using: .utf8)!)

回傳資料是陣列時:

1
2
3
struct SongListEntity: Decodable {
    var songs:[SongEntity]
}

為 String 加上約束:

1
2
3
4
5
6
7
8
9
10
11
struct SongEntity: Decodable {
  var id: Int
  var name: String
  var type: SongType
  
  enum SongType {
    case rock
    case pop
    case country
  }
}

適用於有限範圍的字串類型,寫成 Enum 方便我們傳遞、使用;若出現為列舉的值會 Decode 失敗!

善用泛型包裹固定結構:

假設多筆回傳的 JSON String 固定格式為:

1
2
3
4
5
6
7
8
9
10
11
12
{
  "count": 10,
  "offset": 0,
  "limit": 0,
  "results": [
    {
      "type": "song",
      "id": 1,
      "name": "1"
    }
  ]
}

即可用泛型方式包裹起來:

1
2
3
4
5
6
struct PageEntity<E: Decodable>: Decodable {
    var count: Int
    var offset: Int
    var limit: Int
    var results: [E]
}

使用: PageEntity&lt;Song&gt;.self

Date/Timestamp 自動 Decode:

設定 JSONDecoderdateDecodingStrategy

  • .secondsSince1970/.millisecondsSince1970 : unix timestamp
  • .deferredToDate : 蘋果的 timestamp,罕用,不同於 unix timestamp,這是從 2001/01/01 起算
  • .iso8601 : ISO 8601 日期格式
  • .formatted(DateFormatter) : 依照傳入的 DateFormatter Decode Date
  • .custom : 自訂 Date Decode 邏輯

.cutstom 範例:假設 API 會回傳 YYYY/MM/DD 和 ISO 8601 兩種格式,兩中都要能 Decode:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var dateFormatter = DateFormatter()
var iso8601DateFormatter = ISO8601DateFormatter()

let decoder: JSONDecoder = JSONDecoder()
decoder.dateDecodingStrategy = .custom({ (decoder) -> Date in
    let container = try decoder.singleValueContainer()
    let dateString = try container.decode(String.self)
    
    //ISO8601:
    if let date = iso8601DateFormatter.date(from: dateString) {
        return date
    }
    
    //YYYY-MM-DD:
    dateFormatter.dateFormat = "yyyy-MM-dd"
    if let date = dateFormatter.date(from: dateString) {
        return date
    }
    
    throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode date string \(dateString)")
})

let result = try jsonDecoder.decode(CommentEntity.self, from: jsonString.data(using: .utf8)!)

*DateFormatter 在 init 時非常消耗性能,盡可能重複使用。

基本 Decode 常識:

  1. Decodable Protocol 內的的欄位類型(struct/class/enum),都須實作 Decodable Protocol;亦或是在 init decoder 時賦予值
  2. 欄位類型不相符時會 Decode 失敗
  3. Decodable Object 中欄位設為 Optional 的話則為可有可無,有給就 Decode
  4. Optional 欄位可接受: JSON String 無欄位、有給但給 nil
  5. 空白、0 不等於 nil,nil 是 nil;弱型別的後端 API 需注意!
  6. 預設 Decodable Object 中有列舉且非 Optional 的欄位,若 JSON String 沒給會 Decode 失敗(後續會說明如何處理)
  7. 預設 遇到 Decode 失敗會直接中斷跳出,無法單純跳過有誤的資料(後續會說明如何處理)

[左:”” 右:nil](https://josjong.com/2017/10/16/null-vs-empty-strings-why-oracle-was-right-and-apple-is-not/){:target="_blank"}

左:”” / 右:nil

進階使用

到此為止基本的使用已經完成了,但現實世界不會那麼簡單;以下列舉幾個進階會遇到的場景並提出適用 Codable 的解決方案,從這邊開始我們就無法靠原始的 Decode 幫我們補 Mapping 了,要自行實作 init(from decoder: Decoder) 客製 Decode 操作。

*這邊暫時先只展示 Entity 的部分,Model 還用不到。

init(from decoder: Decoder)

init decoder,必須賦予所有非 Optional 的欄位初始值(就是 init 啦!)。

自訂 Decode 操作時,我們需要從 decoder 中取得 container 出來操作取值, container 有三種取得內容的類型。

第一種 container(keyedBy: CodingKeys.self) 依照 CodingKeys 操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
struct SongEntity: Decodable {
    var id: Int
    var name: String
    
    enum CodingKeys: String, CodingKey {
      case id
      case name
    }
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.id = try container.decode(Int.self, forKey: .id)
        //參數 1 接受支援:實作 Decodable 的類別
        //參數 2 CodingKeys
        
        self.name = try container.decode(String.self, forKey: .name)
    }
}

第二種 singleValueContainer 將整包取出操作(單值):

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
enum HandsomeLevel: Decodable {
    case handsome(String)
    case normal(String)
    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        let name = try container.decode(String.self)
        if name == "zhgchgli" {
            self = .handsome(name)
        } else {
            self = .normal(name)
        }
    }
}

struct UserEntity: Decodable {
    var id: Int
    var name: HandsomeLevel
    var email: String
    
    enum CodingKeys: String, CodingKey {
        case id
        case name
        case email
    }
}

適用於 Associated Value Enum 欄位類型,例如 name 還自帶帥氣程度!

第三種 unkeyedContainer 將整包視為一包陣列:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct ListEntity: Decodable {
    var items:[Decodable]
    init(from decoder: Decoder) throws {
        var unkeyedContainer = try decoder.unkeyedContainer()
        self.items = []
        while !unkeyedContainer.isAtEnd {
            //unkeyedContainer 內部指針會自動在 decode 操作後指向下一個對象
            //直到指向結尾即代表遍歷結束
            if let id = try? unkeyedContainer.decode(Int.self) {
                items.append(id)
            } else if let name = try? unkeyedContainer.decode(String.self) {
                items.append(name)
            }
        }
    }
}

let jsonString = "[\"test\",1234,5566]"
let jsonDecoder = JSONDecoder()
let result = try jsonDecoder.decode(ListEntity.self, from: jsonString.data(using: .utf8)!)
print(result)

適用不固定類型的陣列欄位。

Container 之下我們還能使用 nestedContainer / nestedUnkeyedContainer 對特定欄位操作:

*將資料欄位扁平化(類似 flatMap)

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
struct ListEntity: Decodable {
    
    enum CodingKeys: String, CodingKey {
        case items
        case date
        case name
        case target
    }
    
    enum PredictKey: String, CodingKey {
        case type
    }
    
    var date: Date
    var name: String
    var items: [Decodable]
    var target: Decodable
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        
        self.date = try container.decode(Date.self, forKey: .date)
        self.name = try container.decode(String.self, forKey: .name)
        
        let nestedContainer = try container.nestedContainer(keyedBy: PredictKey.self, forKey: .target)
        
        let type = try nestedContainer.decode(String.self, forKey: .type)
        if type == "song" {
            self.target = try container.decode(SongEntity.self, forKey: .target)
        } else {
            self.target = try container.decode(UserEntity.self, forKey: .target)
        }
        
        var unkeyedContainer = try container.nestedUnkeyedContainer(forKey: .items)
        self.items = []
        while !unkeyedContainer.isAtEnd {
            if let song = try? unkeyedContainer.decode(SongEntity.self) {
                items.append(song)
            } else if let user = try? unkeyedContainer.decode(UserEntity.self) {
                items.append(user)
            }
        }
    }
}

存取、Decode 不同階層的物件,範例展示 target/items 使用 nestedContainer flat 出 type 再依照 type 去做對應的 decode。

Decode & DecodeIfPresent

  • DecodeIfPresent: Response 有給資料欄位時才會進行 Decode(Codable Property 設 Optional 時)
  • Decode:進行 Decode 操作,若 Response 無給資料欄位會拋出 Error

*以上只是簡單介紹一下 init decoder、container 有哪些方法、功能,看不懂也沒關係,我們直接進入現實場景;在範例中感受組合起來的操作方式。

現實場景

回到原本的範例 JSON String。

場景1. 假設今天對誰留言可能是對歌曲或對人留言, targetObject 欄位可能的對象是 UserSong ? 那該如何處理?

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
{
  "results": [
    {
      "id": 123456,
      "comment": "是告五人,不是五告人!",
      "target_object": {
        "type": "song",
        "id": 99,
        "name": "披星戴月的想你"
      },
      "commenter": {
        "type": "user",
        "id": 1,
        "name": "zhgchgli",
        "email": "zhgchgli@gmail.com"
      }
    },
    {
      "id": 55,
      "comment": "66666!",
      "target_object": {
        "type": "user",
        "id": 1,
        "name": "zhgchgli"
      },
      "commenter": {
        "type": "user",
        "id": 2,
        "name": "aaaa",
        "email": "aaaa@gmail.com"
      }
    }
  ]
}

方式 a.

使用 Enum 做為容器 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
37
38
39
40
41
42
43
struct CommentEntity: Decodable {
    
    enum CodingKeys: String, CodingKey {
      case id
      case comment
      case targetObject = "target_object"
      case commenter
    }
    
    var id: Int
    var comment: String
    var targetObject: TargetObject
    var commenter: UserEntity
    
    enum TargetObject: Decodable {
        case song(SongEntity)
        case user(UserEntity)
        
        enum PredictKey: String, CodingKey {
            case type
        }
        
        enum TargetObjectType: String, Decodable {
            case song
            case user
        }
        
        init(from decoder: Decoder) throws {
            let container = try decoder.container(keyedBy: PredictKey.self)
            let singleValueContainer = try decoder.singleValueContainer()
            let targetObjectType = try container.decode(TargetObjectType.self, forKey: .type)
            
            switch targetObjectType {
            case .song:
                let song = try singleValueContainer.decode(SongEntity.self)
                self = .song(song)
            case .user:
                let user = try singleValueContainer.decode(UserEntity.self)
                self = .user(user)
            }
        }
    }
}

我們將 targetObject 的屬性換成 Associated Value Enum,在 Decode 時才決定 Enum 內要放什麼內容。

核心實踐是建立一個符合 Decodable 的 Enum 做為容器,decode 時先取關鍵欄位出來判斷(範例 JSON String 中的 type 欄位),若為 Song 則使用 singleValueContainer 將整包解成 SongEntity ,若為 User 亦然。

要使用時再從 Enum 中取出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//if case let
if case let CommentEntity.TargetObject.user(user) = result.targetObject {
    print(user)
} else if case let CommentEntity.TargetObject.song(song) = result.targetObject {
    print(song)
}

//switch case let
switch result.targetObject {
case .song(let song):
    print(song)
case .user(let user):
    print(user)
}

方式 b.

改宣告欄位屬性為 Base Class。

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
struct CommentEntity: Decodable {
    enum CodingKeys: String, CodingKey {
      case id
      case comment
      case targetObject = "target_object"
      case commenter
    }
    enum PredictKey: String, CodingKey {
        case type
    }
    
    var id: Int
    var comment: String
    var targetObject: Decodable
    var commenter: UserEntity
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.id = try container.decode(Int.self, forKey: .id)
        self.comment = try container.decode(String.self, forKey: .comment)
        self.commenter = try container.decode(UserEntity.self, forKey: .commenter)
        
        //
        let targetObjectContainer = try container.nestedContainer(keyedBy: PredictKey.self, forKey: .targetObject)
        let targetObjectType = try targetObjectContainer.decode(String.self, forKey: .type)
        if targetObjectType == "user" {
            self.targetObject = try container.decode(UserEntity.self, forKey: .targetObject)
        } else {
            self.targetObject = try container.decode(SongEntity.self, forKey: .targetObject)
        }
    }
}

原理差不多,但這邊先使用 nestedContainer 衝進去 targetObjecttype 出來判斷,再決定 targetObject 要解析成什麼類型。

要使用時再 Cast :

1
2
3
4
5
if let song = result.targetObject as? Song {
  print(song)
} else if let user = result.targetObject as? User {
  print(user)
}

場景2. 假設資料陣列欄位放多種類型的資料該如何 Decode?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
  "results": [
    {
      "type": "song",
      "id": 99,
      "name": "披星戴月的想你"
    },
    {
      "type": "user",
      "id": 1,
      "name": "zhgchgli",
      "email": "zhgchgli@gmail.com"
    }
  ]
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
struct ListEntity: Decodable {
    enum CodingKeys: String, CodingKey {
        case results
    }
    enum PredictKey: String, CodingKey {
        case type
    }
    
    var results:[Decodable]
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        var nestedUnkeyedContainer = try container.nestedUnkeyedContainer(forKey: .results)
        
        self.results = []
        while !nestedUnkeyedContainer.isAtEnd {
            let type = try nestedUnkeyedContainer.nestedContainer(keyedBy: PredictKey.self).decode(String.self, forKey: .type)
            if type == "song" {
                results.append(try nestedUnkeyedContainer.decode(SongEntity.self))
            } else {
                results.append(try nestedUnkeyedContainer.decode(UserEntity.self))
            }
        }
    }
}

結合上述提到的 nestedUnkeyedContainer +場景1. 的解決方案即可;這邊也能改用 場景1.a.解決方案 ,用 Associated Value Enum 存取值。

場景3. JSON String 欄位有給值時才 Decode

1
2
3
4
5
6
7
8
9
10
11
[
  {
    "type": "song",
    "id": 99,
    "name": "披星戴月的想你"
  },
    {
    "type": "song",
    "id": 11
  }
]
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
struct TargetEntity: Decodable {
    enum CodingKeys: String, CodingKey {
        case type
        case id
        case name
    }
    var type: String
    var id: Int
    var name: String
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.id = try container.decode(Int.self, forKey: .id)
        self.type = try container.decode(String.self, forKey: .type)
        
        //方式 1:
        self.name = try container.decodeIfPresent(String.self, forKey: .name) ?? ""
        //或方式 2:
        self.name = (try? container.decode(String.self, forKey: .name)) ?? "" //not good
    }
}

let jsonString = "[ { \"type\": \"song\", \"id\": 99, \"name\": \"披星戴月的想你\" }, { \"type\": \"song\", \"id\": 11 } ]"
let jsonDecoder = JSONDecoder()
let result = try jsonDecoder.decode([TargetEntity].self, from: jsonString.data(using: .utf8)!)

使用 decodeIfPresent 進行 decode。

場景4. 陣列資料略過 Decode 失敗錯誤的資料

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
  "results": [
    {
      "type": "song",
      "id": 99,
      "name": "披星戴月的想你"
    },
    {
      "error": "errro"
    },
    {
      "type": "song",
      "id": 19,
      "name": "帶我去找夜生活"
    }
  ]
}

如前述,Decodable 預設是所有資料剖析都正確才能 Mapping 輸出;有時會遇到後端給的資料不穩定,給一長串 Array 但就有幾筆資料缺了欄位或欄位類型不符導致 Decode 失敗;造成整包全部失敗,直接 nil。

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
struct ResultsEntity: Decodable {
    enum CodingKeys: String, CodingKey {
        case results
    }
    var results: [SongEntity]
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        var nestedUnkeyedContainer = try container.nestedUnkeyedContainer(forKey: .results)
        
        self.results = []
        while !nestedUnkeyedContainer.isAtEnd {
            if let song = try? nestedUnkeyedContainer.decode(SongEntity.self) {
                self.results.append(song)
            } else {
                let _ = try nestedUnkeyedContainer.decode(EmptyEntity.self)
            }
        }
    }
}

struct EmptyEntity: Decodable { }

struct SongEntity: Decodable {
    var type: String
    var id: Int
    var name: String
}

let jsonString = "{ \"results\": [ { \"type\": \"song\", \"id\": 99, \"name\": \"披星戴月的想你\" }, { \"error\": \"errro\" }, { \"type\": \"song\", \"id\": 19, \"name\": \"帶我去找夜生活\" } ] }"
let jsonDecoder = JSONDecoder()
let result = try jsonDecoder.decode(ResultsEntity.self, from: jsonString.data(using: .utf8)!)
print(result)

解決方式也類似 場景2.的解決方案nestedUnkeyedContainer 遍歷每個內容,並進行 try? Decode,如果 Decode 失敗則使用 Empty Decode 讓 nestedUnkeyedContainer 的內部指針繼續執行。

*此方法有點 workaround,因我們無法對 nestedUnkeyedContainer 命令跳過,且 nestedUnkeyedContainer 必須有成功 decode 才會繼續執行;所以才這樣做,看 swift 社群有人提增加 moveNext( ) ,但目前版本尚未實作。

場景5. 有的欄位是我程式內部要使用的,而非要 Decode

方式a. Entity/Model

這邊就要提一開始說的,我們拆分 Entity/Model 的功用了;Entity 單純負責 JSON String to Entity(Decodable) Mapping;Model initWith Entity,實際程式傳遞、操作、商業邏輯都是使用 Model。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
struct SongEntity: Decodable {
    var type: String
    var id: Int
    var name: String
}

class SongModel: NSObject {
    init(_ entity: SongEntity) {
        self.type = entity.type
        self.id = entity.id
        self.name = entity.name
    }
    
    var type: String
    var id: Int
    var name: String
    
    var isSave:Bool = false //business logic
}

拆分 Entity/Model 的好處:

  1. 權責分明,Entity: JSON String to Decodable, Model: business logic
  2. 一目瞭然 mapping 了哪些欄位看 Entity 就知道
  3. 避免欄位一多全喇在一起
  4. Objective-C 也可用 (因 Model 只是 NSObject、struct/Decodable Objective-C 不可見)
  5. 內部要使用的商業邏輯、欄位放在 Model 即可

方式b. init 處理

列出 CodingKeys 並排除內部使用的欄位,init 時給預設值或欄位有給預設值或設為 Optional,但都不是好方法,只是可以 run 而已。

[2020/06/26 更新] — 下篇 場景6.API Response 使用 0/1 代表 Bool,該如何 Decode?

[2020/06/26 更新] — 下篇 場景7.不想要每每都要重寫 init decoder

[2020/06/26 更新] — 下篇 場景8.合理的處理 Response 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
48
{
  "count": 5,
  "offset": 0,
  "limit": 10,
  "results": [
    {
      "id": 123456,
      "comment": "是告五人,不是五告人!",
      "target_object": {
        "type": "song",
        "id": 99,
        "name": "披星戴月的想你",
        "create_date": "2020-06-13T15:21:42+0800"
      },
      "commenter": {
        "type": "user",
        "id": 1,
        "name": "zhgchgli",
        "email": "zhgchgli@gmail.com",
        "birthday": "1994/07/18"
      }
    },
    {
      "error": "not found"
    },
    {
      "error": "not found"
    },
    {
      "id": 2,
      "comment": "哈哈,我也是!",
      "target_object": {
        "type": "user",
        "id": 1,
        "name": "zhgchgli",
        "email": "zhgchgli@gmail.com",
        "birthday": "1994/07/18"
      },
      "commenter": {
        "type": "user",
        "id": 1,
        "name": "路人甲",
        "email": "man@gmail.com",
        "birthday": "2000/01/12"
      }
    }
  ]
}
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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
import Foundation
//

let jsonString = """
{
  "count": 3,
  "offset": 0,
  "limit": 10,
  "results": [
    {
      "id": 123456,
      "comment": "是告五人不是五告人!",
      "target_object": {
        "type": "song",
        "id": 99,
        "name": "披星戴月的想你",
        "create_date": "2020-06-13T15:21:42+0800"
      },
      "commenter": {
        "type": "user",
        "id": 1,
        "name": "zhgchgli",
        "email": "zhgchgli@gmail.com",
        "birthday": "1994/07/18"
      }
    },
    {
      "error": "not found"
    },
    {
      "error": "not found"
    },
    {
      "id": 2,
      "comment": "哈哈我也是!",
      "target_object": {
        "type": "user",
        "id": 1,
        "name": "zhgchgli",
        "email": "zhgchgli@gmail.com",
        "birthday": "1994/07/18"
      },
      "commenter": {
        "type": "user",
        "id": 1,
        "name": "路人甲",
        "email": "man@gmail.com",
        "birthday": "2000/01/12"
      }
    }
  ]
}
"""
//
// Entity:
struct SongEntity: Decodable {
    enum CodingKeys: String, CodingKey {
        case type
        case id
        case name
        case createDate = "create_date"
    }
    var type: String
    var id: Int
    var name: String
    var createDate: Date
}

struct UserEntity: Decodable {
    var type: String
    var id: Int
    var name: String
    var email: String
    var birthday: Date
}

struct CommentEntity: Decodable {
    enum CodingKeys: String, CodingKey {
        case id
        case comment
        case commenter
        case targetObject = "target_object"
    }
    enum PredictKey: String, CodingKey {
        case type
    }
    enum ObjectType: String, Decodable {
        case song
        case user
    }
    var id: Int
    var comment: String
    var commenter: UserEntity
    var targetObject: Decodable
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.id = try container.decode(Int.self, forKey: .id)
        self.comment = try container.decode(String.self, forKey: .comment)
        self.commenter = try container.decode(UserEntity.self, forKey: .commenter)
        
        //targetObject cloud be UserEntity or SongEntity
        let targetObjectNestedContainer = try container.nestedContainer(keyedBy: PredictKey.self, forKey: .targetObject)
        let type = try targetObjectNestedContainer.decode(ObjectType.self, forKey: .type)
        switch type {
        case .song:
            self.targetObject = try container.decode(SongEntity.self, forKey: .targetObject)
        case .user:
            self.targetObject = try container.decode(UserEntity.self, forKey: .targetObject)
        }
    }
}

struct EmptyEntity: Decodable { }

struct PageEntity<E: Decodable>: Decodable {
    enum CodingKeys: String, CodingKey {
        case count
        case offset
        case limit
        case results
    }
    var count: Int
    var offset: Int
    var limit: Int
    var results: [E]
    
    init(from decoder: Decoder) throws {
        let container = try decoder.container(keyedBy: CodingKeys.self)
        self.count = try container.decode(Int.self, forKey: .count)
        self.offset = try container.decode(Int.self, forKey: .offset)
        self.limit = try container.decode(Int.self, forKey: .limit)
        
        var nestedUnkeyedContainer = try container.nestedUnkeyedContainer(forKey: .results)
        
        self.results = []
        while !nestedUnkeyedContainer.isAtEnd {
            if let entity = try? nestedUnkeyedContainer.decode(E.self) {
                self.results.append(entity)
            } else {
                let _ = try nestedUnkeyedContainer.decode(EmptyEntity.self)
            }
        }
    }
}

// Model:
class UserModel: NSObject {
    var type: String
    var id: Int
    var name: String
    var email: String
    var birthday: Date
    init(_ entity: UserEntity) {
        self.type = entity.type
        self.id = entity.id
        self.name = entity.name
        self.email = entity.email
        self.birthday = entity.birthday
    }
}

class SongModel: NSObject {
    var type: String
    var id: Int
    var name: String
    var createDate: Date
    init(_ entity: SongEntity) {
        self.type = entity.type
        self.id = entity.id
        self.name = entity.name
        self.createDate = entity.createDate
    }
}

class CommentModel: NSObject {
    var id: Int
    var comment: String
    var commenter: UserModel
    var targetObject: NSObject?
    
    var displayMessage: String //simulation business logic
    
    init(_ entity: CommentEntity) {
        self.id = entity.id
        self.comment = entity.comment
        self.commenter = UserModel(entity.commenter)
        if let userEntity = entity.targetObject as? UserEntity {
            self.targetObject = UserModel(userEntity)
        } else if let songEntity = entity.targetObject as? SongEntity {
            self.targetObject = SongModel(songEntity)
        }
        self.displayMessage = "\(entity.commenter.name):\(entity.comment)"
    }
}
//

let jsonDecoder = JSONDecoder()
let iso8601DateFormatter = ISO8601DateFormatter()
var dateFormatter = DateFormatter()

jsonDecoder.dateDecodingStrategy = .custom({ (decoder) -> Date in
    let container = try decoder.singleValueContainer()
    let dateString = try container.decode(String.self)
    
    //ISO8601:
    if let date = iso8601DateFormatter.date(from: dateString) {
        return date
    }
    
    //YYYY-MM-DD:
    dateFormatter.dateFormat = "yyyy/MM/dd"
    if let date = dateFormatter.date(from: dateString) {
        return date
    }
    
    throw DecodingError.dataCorruptedError(in: container, debugDescription: "Cannot decode date string \(dateString)")
})

do {
    let pageEntity = try jsonDecoder.decode(PageEntity<CommentEntity>.self, from: jsonString.data(using: .utf8)!)
    let comments = pageEntity.results.compactMap { CommentModel($0) }
    comments.forEach { (comment) in
        print(comment.displayMessage)
    }
} catch {
    print(error)
}


Output:

1
zhgchgli:是告五人,不是五告人!

完整範例演示如上!

(下)篇&其他場景已更新:

總結

選擇使用 Codable 的好處,第一當然是因為原生,不用怕後續無人維護、還有寫起來漂亮;但相對的限制較嚴格、比較不能靈活解 JSON String,不然就是要如本文做更多的事去完成、還有效能其實不比使用其他 Mapping 套件優(Decodable 依然使用Objective 時代的 NSJSONSerialization 進行解析),但我想在後續的更新中或許蘋果會對此進行優化,那時我們也不必更動程式。

文中場景、範例或許有些很極端,但有時候遇到了也沒辦法;當然希望一般情況下單純的 Codable 就能滿足我們的需求;但有了以上招式之後應該沒有打不倒的問題了!

感謝 @saiday 大大技術支援。

告五人 Accusefive【帶我去找夜生活 Night life.Take us to the light】Official Music Video

===

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


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

使用 iPhone 簡單製作「偽」透視透明手機桌布

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