定義 Decodable 的 init(from:) 解析 JSON

透過 JSONDecoder,我們可以方便地將 JSON 資料變成遵從 protocol Decodable 的自訂型別,而且大部分的時候我們只要宣告型別裡的 property,不用自己撰寫解析 JSON 的 init(from:),因為 Apple 已幫我們寫好 init(from:) 的程式。

以產生隨機 meme 的 API 為例。

https://some-random-api.ml/meme

它的 JSON 內容如下。

{"id":2,"image":"https://i.some-random-api.ml/VM2Dkpp7VX.png","caption":"Hmm I wonder if this was planned","category":"math"}

我們可用 JSONDecoder 將它變成型別 Meme 的資料。

struct Meme: Decodable {
let id: Int
let image: URL
let caption: String
}

然而遇到以下情況,我們可能會想自訂 init(from:) 解析 JSON。

  • property 的型別不是 Decodable,無法自動產生 init(from:),因此我們要自己定義 init(from:)。

下圖的 color 型別是 Color,沒有遵從 protocol Decodable,無法解碼。

  • 想對 JSON 裡的資料做特別處理,比方將它整理成方便 App 使用的 model 型別。

接下來就讓我們透過實際的例子,學習如何定義 init(from:) 解析 JSON。

利用 container(keyedBy:) 解析第一層的 dictionary

我們先以剛剛產生隨機 meme 的 API 為例,說明如何搭配 CodingKey 解析 JSON 的 dictionary。

https://some-random-api.ml/meme

以下為 JSON 的內容。

{"id":2,"image":"https://i.some-random-api.ml/VM2Dkpp7VX.png","caption":"Hmm I wonder if this was planned","category":"math"}

JSON 對應的型別 Meme 如下。

import Foundation 

struct Meme: Codable {
let id: Int
let image: URL
let caption: String

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(Int.self, forKey: .id)
image = try container.decode(URL.self, forKey: .image)
caption = try container.decode(String.self, forKey: .caption)
}
}

我們遵從 protocol Codable,然後在 init(from: Decoder) 撰寫解析 JSON 的程式。由於 JSON 的第一層是 dictionary,所以我們在 Meme 宣告 property id,image & caption 儲存 dictionary 的內容,利用 try decoder.container(keyedBy: CodingKeys.self) 得到包含 dictionary 的 container。

傳入的參數 keyedBy 十分重要,它代表我們讀取 dictionary 時可以傳入的 key。

參數 keyedBy 的型別是 protocol CodingKey,當我們遵從 prototcol Codable 時,Swift 會幫我們生成遵從 protocol CodingKey 的型別 CodingKeys,因此我們可以直接使用,在呼叫 container(keyedBy:) 時傳入 CodingKeys.self。它是一個 enum,裡面宣告的 case 的名稱將對應 property 的名字,所以會有 case id,image & caption。

通常 property & case 的名字會跟 JSON key 一模一樣,不過有時我們會想要 property & case 的名字跟 JSON key 不一樣,相關的做法可參考以下連結。

ps: 也許有人想到不一定要遵從 protocol Codable,解碼可以只遵從 Decodable。不過若是只遵從 protocol Decodable,並不會自動產生 CodingKeys,我們必須自己定義 CodingKeys。

得到 container 後,我們呼叫它的 function decode 讀取內容,在 decode 的第一個參數傳入資料的型別,第二個參數傳入對應的 key。值得注意的,key 的型別和生成 container 時傳入的參數 keyedBy 有關。我們剛剛在 keyedBy 傳入 CodingKeys.self,因此 forKey 的型別為 CodingKeys,我們可傳入的 key 是 CodingKeys 的 case,有 caption,id,image 三種。

我們想讀取 JSON dictionary key id, image & caption 的內容,它們的型別分別為 Int, URL & String,因此我們利用以下程式讀取內容,然後存入 Meme 的 property。

id = try container.decode(Int.self, forKey: .id)
image = try container.decode(URL.self, forKey: .image)
caption = try container.decode(String.self, forKey: .caption)

在使用 decode(_:forKey:) 時有以下幾個小地方要特別注意:

1. 只能傳入遵從 Decodable 的型別,因為它只能將東西解碼成遵從 Decodable 的型別。

2. decode(_:forKey:) 有可能失敗。

要誠實,說謊的話 decode 會失敗。比方剛剛的 id 是整數,因此我們須傳入 Int.self。如果傳入 String.self 將造成解析失敗丟出錯誤。

定義好 JSON 對應的型別 Meme 後,我們即可利用 JSONDecoder 將 JSON 資料變成型別 Meme。

if let url = URL(string: "https://some-random-api.ml/meme") {
URLSession.shared.dataTask(with: url) { data, response, error in
if let data {
do {
let meme = try JSONDecoder().decode(Meme.self, from: data)
print(meme)
} catch {
print(error)
}
}
}.resume()
}

定義 init(from:) 時要設定每個 property

值得注意的,定義 init(from:) 時要確保每個 property 都有內容,除非它是 optional。比方以下例子忘了設定 caption,因此產生錯誤

Return from initializer without initializing all stored properties

快速輸入 init(from:)

透過 extension 遵從 Codable

struct Meme {
let id: Int
let image: URL
let caption: String
}

extension Meme: Codable {
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(Int.self, forKey: .id)
image = try container.decode(URL.self, forKey: .image)
caption = try container.decode(String.self, forKey: .caption)
}
}

我們也可以透過 extension 遵從 Codable,得到以下兩個好處。

  • 將 Codable 的相關程式切出來,讓程式更容易維護。
  • 讓資料原本自動生成的 initializer 保留,我們可以用 memberwise initializer 生成資料。

若是直接在 struct 裡定義 init,將失去原本自動生成的 initializer。

當 Codable 型別是 class, init(from:) 要加上 required 或加上 final

class Meme: Codable {
let id: Int
let image: URL
let caption: String

required init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(Int.self, forKey: .id)
image = try container.decode(URL.self, forKey: .image)
caption = try container.decode(String.self, forKey: .caption)
}
}

如果不想加上 required,另一個方法是在 class 前加上 final。

final class Meme: Codable {
let id: Int
let image: URL
let caption: String

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
id = try container.decode(Int.self, forKey: .id)
image = try container.decode(URL.self, forKey: .image)
caption = try container.decode(String.self, forKey: .caption)
}
}

將 JSON 裡的資料型別做轉換,比方字串轉數字

在定義 JSON 對應的自訂型別時,property 的型別通常會跟 JSON key 對應的 value 型別一致。不過有時我們希望可以轉換成方便 App 使用的型別,比方以下例子我們希望 id 是整數,然而 JSON 回傳的 id 是字串。

struct Meme: Codable {
let id: Int
let image: URL
let caption: String
}

let data = """
{
"id": "2",
"image": "https://i.some-random-api.ml/VM2Dkpp7VX.png",
"caption": "Hmm I wonder if this was planned",
"category": "math"
}
""".data(using: .utf8)

if let data = data {
do {
let meme = try JSONDecoder().decode(Meme.self, from: data)
print(meme)
} catch {
print(error)
}
}

因此以上解法將印出以下解析 JSON 失敗的錯誤訊息。

typeMismatch(Swift.Int, Swift.DecodingError.Context(codingPath: [CodingKeys(stringValue: "id", intValue: nil)], debugDescription: "Expected to decode Int but found a string/data instead.", underlyingError: nil))

為了轉換型別,我們必須定義 init(from:) 自己轉換。以下我們先將 id 解碼成 String,再將它轉換成 Int。不過字串轉數字有可能失敗,所以我們要在失敗時丟出錯誤,在此我們丟出內建的 DecodingError。

struct Meme {
let id: Int
let image: URL
let caption: String
}

extension Meme: Codable {
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let idString = try container.decode(String.self, forKey: .id)
if let id = Int(idString) {
self.id = id
} else {
throw DecodingError.dataCorruptedError(forKey: CodingKeys.id, in: container, debugDescription: "id error")
}
image = try container.decode(URL.self, forKey: .image)
caption = try container.decode(String.self, forKey: .caption)
}
}

利用 decodeIfPresent 解析 JSON 不一定會有的欄位

JSON 有時會出現不一定會有的欄位,此時我們可利用 decodeIfPresent 解碼,例如以下例子。

假設 Meme API 的 JSON 裡不一定會有 caption,因此我們將 struct Meme 的 property caption 宣告為 optional,然後利用 decodeIfPresent 解碼 JSON 裡的 caption 欄位。

struct Meme {
let image: URL
let category: String
let caption: String?
}

extension Meme: Codable {
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
image = try container.decode(URL.self, forKey: .image)
category = try container.decode(String.self, forKey: .category)
caption = try container.decodeIfPresent(String.self, forKey: .caption)
}
}

let data = """
{
"image": "https://i.some-random-api.ml/VM2Dkpp7VX.png",
"category": "math"
}
""".data(using: .utf8)

if let data {
do {
let meme = try JSONDecoder().decode(Meme.self, from: data)
print(meme)
} catch {
print(error)
}
}

ps: 我們也可以用 decode 解析不一定會有的欄位,但是前面要搭配 try?,如此失敗時才不會丟出錯誤。

caption = try? container.decode(String.self, forKey: .caption)

利用 unkeyedContainer 解析第一層的 array

以 Dcard API 為例。

https://dcard.tw/_api/posts

以下為 JSON 的內容。

JSON 對應的 Decodable 型別 DcardData 如下。

struct Dcard: Codable {
let title: String
}

struct DcardData {
var dcards: [Dcard]
}

extension DcardData: Codable {
init(from decoder: Decoder) throws {
dcards = [Dcard]()
var container = try decoder.unkeyedContainer()
while !container.isAtEnd {
let dcard = try container.decode(Dcard.self)
dcards.append(dcard)
}
}
}

由於 JSON 的第一層是 array,因此我們在 DcardData 裡宣告儲存 array 的 property dcards。我們利用 try decoder.unkeyedContainer() 得到包含 array 的 container。由於沒有 key,所以讀取的方式比較特別。我們使用 while 搭配 isAtEnd 判斷,迴圈將一直執行,直到已經讀完最後一筆資料。

得到 container 後,我們呼叫它的 function decode 解碼資料。當我們從 container 第一次呼叫 decode 時,它將解碼 array 裡的第一筆資料。若 decode 成功,container 將改變,下次 decode 時將解碼 array 裡的下一筆資料。因此我們將依序讀完 array 的每一筆資料,直到 isAtEnd 變成 true。若是資料型別有問題,解碼失敗,我們也會 throw 錯誤,因此不用擔心變成無窮迴圈。

利用 JSONDecoder 將 JSON 資料變成型別 DcardData。

if let url = URL(string: "https://dcard.tw/_api/posts") {
URLSession.shared.dataTask(with: url) { data, response, error in
if let data {
do {
let dcardData = try JSONDecoder().decode(DcardData.self, from: data)
print(dcardData)
} catch {
print(error)
}
}
}.resume()
}

ps: 如果沒有自訂 init(from: Decoder),我們也可用以下寫法解析剛剛的 JSON。

struct Dcard: Decodable {
let title: String
}

if let url = URL(string: "https://dcard.tw/_api/posts") {
URLSession.shared.dataTask(with: url) { data, response, error in
if let data {
do {
let dcardData = try JSONDecoder().decode([Dcard].self, from: data)
print(dcardData)
} catch {
print(error)
}
}
}.resume()
}

利用 nestedContainer & nestedUnkeyedContainer 讀取內層的 dictionary & array

在剛剛的 dcard 例子裡,我們將 array 裡的 dictionary decode 成型別 Dcard。

let dcard = try container.decode(Dcard.self)

不過我們也可以試試將 dictionary 的內容一個個取出再生成 dcard。

struct Dcard: Codable {
let title: String
let excerpt: String
}

struct DcardData {
var dcards: [Dcard]
}

extension DcardData: Codable {

enum DcardDataKeys: CodingKey {
case title, excerpt
}

init(from decoder: Decoder) throws {
dcards = [Dcard]()
var container = try decoder.unkeyedContainer()
while !container.isAtEnd {
let dcardContainer = try container.nestedContainer(keyedBy: DcardDataKeys.self)
let title = try dcardContainer.decode(String.self, forKey: .title)
let excerpt = try dcardContainer.decode(String.self, forKey: .excerpt)
let dcard = Dcard(title: title, excerpt: excerpt)
dcards.append(dcard)
}
}
}

由於 array 的成員是 dictionary,因此我們可用 container.nestedContainer(keyedBy: CodingKeys.self) 取得包含 dictionary 的 container,然後用 decode 將 key 對應的 value 一個個讀出後再生成 dcard 加到 array。

值得注意的,由於 DcardData 裡自動生成的 enum CodingKeys 只有 case dcards,沒有 title & excerpt,以下程式將產生錯誤,因此我們必須另外定義遵從 CodingKey 的 enum DcardDataKeys,如此才能讀取 title & excerpt。

既然有讀取 container 裡 dictionary 的 nestedContainer,當然也有讀取 container 裡 array 的 nestedUnkeyedContainer,比方以下 iTunes API 的例子。

https://itunes.apple.com/search?term=小球&media=music&country=TW

JSON 內容如下

若是自訂型別的架構和 JSON 一致,我們可以透過以下程式將 JSON 轉換成 SearchResponse。

struct StoreItem {
let artistName: String
let trackName: String
}

extension StoreItem: Codable { }

struct SearchResponse {
var results: [StoreItem]
}

extension SearchResponse: Codable { }

if let urlString = "https://itunes.apple.com/search?term=小球&media=music&country=TW".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed),
let url = URL(string: urlString) {
URLSession.shared.dataTask(with: url) { data, response, error in
if let data {
do {
let searchResponse = try JSONDecoder().decode(SearchResponse.self, from: data)
print(searchResponse)
} catch {
print(error)
}
}
}.resume()
}

不過為了學習 nestedUnkeyedContainer,讓我們認識以下比較複雜的寫法。值得注意的,我們定義了兩個遵從 CodingKey 的型別,SearchResponse 對應 JSON 第一層 dictionary 的 key,StoreItemKeys 對應 results array 裡 dictionary 的 key。

struct StoreItem {
let artistName: String
let trackName: String
}

extension StoreItem: Codable { }

struct SearchResponse {
var storeItems: [StoreItem]
}

extension SearchResponse: Codable {

enum SearchResponseKeys: CodingKey {
case results
}

enum StoreItemKeys: CodingKey {
case artistName, trackName
}

init(from decoder: Decoder) throws {
storeItems = [StoreItem]()
let container = try decoder.container(keyedBy: SearchResponseKeys.self)
var itemsContainer = try container.nestedUnkeyedContainer(forKey: .results)
while !itemsContainer.isAtEnd {
let itemContainer = try itemsContainer.nestedContainer(keyedBy: StoreItemKeys.self)
let artistName = try itemContainer.decode(String.self, forKey: .artistName)
let trackName = try itemContainer.decode(String.self, forKey: .trackName)
let song = StoreItem(artistName: artistName, trackName: trackName)
storeItems.append(song)
}
}
}

不固定數量和名字的 key

前面我們看到的 API,它們的 JSON dictionary key 都是固定的數量和名字,所以我們知道如何抄襲,明白如何在遵從 Decodable & 遵從 CodingKey 的自訂型別裡宣告 property & case。

但如果 key 的數量和名字是不固定的呢 ? 比方以下的動森 API。

https://acnhapi.com/v1/villagers

以下為 JSON 的內容。

此 API 將回傳動森的村民清單,因此我們希望能取得村民的 array,顯示類似以下的畫面。

像這種比較特別的 JSON,我們有以下兩種解析的方法:

  • 方法1: 先解析成 dictionary,再將 dictionary 的 value 組合成 array
struct Villager: Codable {
let id: Int
let gender: String
let icon_uri: URL
}

if let url = URL(string: "https://acnhapi.com/v1/villagers") {
URLSession.shared.dataTask(with: url) { data, response, error in
if let data {
do {
let villagersDic = try JSONDecoder().decode([String: Villager].self, from: data)
let villagers = Array(villagersDic.values)
print(villagers)
} catch {
print(error)
}
}
}.resume()
}

不過 dictionary 沒有順序性,因此我們每次組合產生的 array 順序都不一樣。若是希望使用者看到固定的順序,可使用 sorted 排序,比方以下例子利用 id 排序,數字大的 id 在前面。

if let url = URL(string: "https://acnhapi.com/v1/villagers") {
URLSession.shared.dataTask(with: url) { data, response, error in
if let data {
do {
let villagersDic = try JSONDecoder().decode([String: Villager].self, from: data)
let villagers = Array(villagersDic.values).sorted {
$0.id > $1.id
}
print(villagers)
} catch {
print(error)
}
}
}.resume()
}
  • 方法2 : 自訂 init(from:) 解析 JSON
struct Villager {
let id: Int
let gender: String
let icon_uri: URL
}

extension Villager: Decodable { }

struct VillagersResponse {
var villagers: [Villager]
}

extension VillagersResponse: Decodable {

struct CodingKeys: CodingKey {
var intValue: Int?
init?(intValue: Int) {
return nil
}
var stringValue: String
init?(stringValue: String) {
self.stringValue = stringValue
}
}

init(from decoder: Decoder) throws {
villagers = [Villager]()
let container = try decoder.container(keyedBy: CodingKeys.self)
for key in container.allKeys {
let villager = try container.decode(Villager.self, forKey: key)
villagers.append(villager)
}
}
}

以上的程式有個很特別的地方,我們以 struct 遵從 CodingKey,而不是 enum。因為 JSON 裡 dictionary 的 key 是不固定的,我們沒辦法以 enum 的 case 列出每一種可能的 key,所以我們改用 struct 定義,然後定義 CodingKey 需要實作的變數和 init。此處最重要的是以下程式,它將讓 dictionary 的 key 成為 container 的 key。

init?(stringValue: String) {
self.stringValue = stringValue
}

當我們用 try decoder.container(keyedBy: CodingKeys.self) 得到包含 dictionary 的 container 後,將可用 for key in container.allKeys 讀取每一個 key 對應的內容,將它們 decode 成 Villager 後加到 array 。

利用 JSONDecoder 將 JSON 資料變成型別 VillagersResponse。

if let url = URL(string: "https://acnhapi.com/v1/villagers") {
URLSession.shared.dataTask(with: url) { data, response, error in
if let data {
do {
let villagersResponse = try JSONDecoder().decode(VillagersResponse.self, from: data)
print(villagersResponse.villagers)
} catch {
print(error)
}
}
}.resume()
}

利用 CodingKey & init(from:) 將不同的 JSON key 對應到同一個 property

透過 init(from:) 解析 JSON 裡包裝成字串的 array 或 dictionary

其它參考範例

Apple 的 Decoding structured JSON。

--

--

彼得潘的 iOS App Neverland
彼得潘的 Swift iOS App 開發問題解答集

彼得潘的iOS App程式設計入門,文組生的iOS App程式設計入門講師,彼得潘的 Swift 程式設計入門,App程式設計入門作者,http://apppeterpan.strikingly.com