现实使用 Codable 上遇到的 Decode 问题场景总汇(上)
从基础到进阶,深入使用 Decodable 满足所有可能会遇到的问题场景

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 如下:
{
"id": 123456,
"comment": "是告五人,不是五告人!",
"target_object": {
"type": "song",
"id": 99,
"name": "披星戴月的想你"
},
"commenter": {
"type": "user",
"id": 1,
"name": "zhgchgli",
"email": "[email protected]"
}
}
由上范例我们可以拆成:User/Song/Comment 三个 Entity & Model,让我们组合能复用,为方便展示先将 Entity/Model 写在同个档案。
User:
// 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
}
Song:
// 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
}
Comment:
// 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
}
JSONDecoder:
let jsonString = "{ \"id\": 123456, \"comment\": \"是告五人,不是五告人!\", \"target_object\": { \"type\": \"song\", \"id\": 99, \"name\": \"披星戴月的想你\" }, \"commenter\": { \"type\": \"user\", \"id\": 1, \"name\": \"zhgchgli\", \"email\": \"[email protected]\" } }"
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 不是我们可以控制的。
case PropertyKeyName = "后端栏位名称"
case PropertyKeyName //不指定则预设使用 PropertyKeyName 为后端栏位名称
一旦加入 CodingKeys 枚举,则必须列举出所有非 Optional 的栏位,不能只列举想要客制的 Key。
另外一种方式是设定 JSONDecoder 的 keyDecodingStrategy,若 Response 资料栏位与 Property Name 仅为 snake_case <-> camelCase 区别,可直接设定 .keyDecodingStrategy = .convertFromSnakeCase 就能自动匹配 Mapping。
let jsonDecoder = JSONDecoder()
jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase
try jsonDecoder.decode(CommentEntity.self, from: jsonString.data(using: .utf8)!)
回传资料是阵列时:
struct SongListEntity: Decodable {
var songs:[SongEntity]
}
为 String 加上约束:
struct SongEntity: Decodable {
var id: Int
var name: String
var type: SongType
enum SongType {
case rock
case pop
case country
}
}
适用于有限范围的字串类型,写成 Enum 方便我们传递、使用;若出现为列举的值会 Decode 失败!
善用泛型包裹固定结构:
假设多笔回传的 JSON String 固定格式为:
{
"count": 10,
"offset": 0,
"limit": 0,
"results": [
{
"type": "song",
"id": 1,
"name": "1"
}
]
}
即可用泛型方式包裹起来:
struct PageEntity<E: Decodable>: Decodable {
var count: Int
var offset: Int
var limit: Int
var results: [E]
}
使用: PageEntity<Song>.self
Date/Timestamp 自动 Decode:
设定 JSONDecoder 的 dateDecodingStrategy
-
.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:
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 常识:
-
Decodable Protocol 内的的栏位类型(struct/class/enum),都须实作 Decodable Protocol;亦或是在 init decoder 时赋予值
-
栏位类型不相符时会 Decode 失败
-
Decodable Object 中栏位设为 Optional 的话则为可有可无,有给就 Decode
-
Optional 栏位可接受: JSON String 无栏位、有给但给 nil
-
空白、0 不等于 nil,nil 是 nil;弱型别的后端 API 需注意!
-
预设 Decodable Object 中有列举且非 Optional 的栏位,若 JSON String 没给会 Decode 失败(后续会说明如何处理)
-
预设 遇到 Decode 失败会直接中断跳出,无法单纯跳过有误的资料(后续会说明如何处理)

进阶使用
到此为止基本的使用已经完成了,但现实世界不会那么简单;以下列举几个进阶会遇到的场景并提出适用 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 操作:
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 将整包取出操作(单值):
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 将整包视为一包阵列:
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)

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 栏位可能的对象是 User 或 Song ? 那该如何处理?
{
"results": [
{
"id": 123456,
"comment": "是告五人,不是五告人!",
"target_object": {
"type": "song",
"id": 99,
"name": "披星戴月的想你"
},
"commenter": {
"type": "user",
"id": 1,
"name": "zhgchgli",
"email": "[email protected]"
}
},
{
"id": 55,
"comment": "66666!",
"target_object": {
"type": "user",
"id": 1,
"name": "zhgchgli"
},
"commenter": {
"type": "user",
"id": 2,
"name": "aaaa",
"email": "[email protected]"
}
}
]
}
方式 a.
使用 Enum 做为容器 Decode。
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 中取出:
//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。
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 冲进去 targetObject 拿 type 出来判断,再决定 targetObject 要解析成什么类型。
要使用时再 Cast :
if let song = result.targetObject as? Song {
print(song)
} else if let user = result.targetObject as? User {
print(user)
}
场景2. 假设资料阵列栏位放多种类型的资料该如何 Decode?
{
"results": [
{
"type": "song",
"id": 99,
"name": "披星戴月的想你"
},
{
"type": "user",
"id": 1,
"name": "zhgchgli",
"email": "[email protected]"
}
]
}
结合上述提到的 nestedUnkeyedContainer +场景1. 的解决方案即可;这边也能改用 场景1. 的 a.解决方案 ,用 Associated Value Enum 存取值。
场景3. JSON String 栏位有给值时才 Decode
[
{
"type": "song",
"id": 99,
"name": "披星戴月的想你"
},
{
"type": "song",
"id": 11
}
]
使用 decodeIfPresent 进行 decode。
场景4. 阵列资料略过 Decode 失败错误的资料
{
"results": [
{
"type": "song",
"id": 99,
"name": "披星戴月的想你"
},
{
"error": "errro"
},
{
"type": "song",
"id": 19,
"name": "带我去找夜生活"
}
]
}
如前述,Decodable 预设是所有资料剖析都正确才能 Mapping 输出;有时会遇到后端给的资料不稳定,给一长串 Array 但就有几笔资料缺了栏位或栏位类型不符导致 Decode 失败;造成整包全部失败,直接 nil。
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。
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 的好处:
-
权责分明,Entity: JSON String to Decodable, Model: business logic
-
一目了然 mapping 了哪些栏位看 Entity 就知道
-
避免栏位一多全喇在一起
-
Objective-C 也可用 (因 Model 只是 NSObject、struct/Decodable Objective-C 不可见)
-
内部要使用的商业逻辑、栏位放在 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 栏位资料
综合场景范例
综合以上基本使用及进阶使用的完整范例:
{
"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": "[email protected]",
"birthday": "1994/07/18"
}
},
{
"error": "not found"
},
{
"error": "not found"
},
{
"id": 2,
"comment": "哈哈,我也是!",
"target_object": {
"type": "user",
"id": 1,
"name": "zhgchgli",
"email": "[email protected]",
"birthday": "1994/07/18"
},
"commenter": {
"type": "user",
"id": 1,
"name": "路人甲",
"email": "[email protected]",
"birthday": "2000/01/12"
}
}
]
}
Output:
zhgchgli:是告五人,不是五告人!
完整范例演示如上!
(下)篇&其他场景已更新:
总结
选择使用 Codable 的好处,第一当然是因为原生,不用怕后续无人维护、还有写起来漂亮;但相对的限制较严格、比较不能灵活解 JSON String,不然就是要如本文做更多的事去完成、还有效能其实不比使用其他 Mapping 套件优(Decodable 依然使用Objective 时代的 NSJSONSerialization 进行解析),但我想在后续的更新中或许苹果会对此进行优化,那时我们也不必更动程式。
文中场景、范例或许有些很极端,但有时候遇到了也没办法;当然希望一般情况下单纯的 Codable 就能满足我们的需求;但有了以上招式之后应该没有打不倒的问题了!
感谢 @saiday 大大技术支援。



留言 · Comments