利用 JSONDecoder 解析口罩剩餘數量 API

感謝大大們開發了口罩剩餘數量的 API,讓我們可以開發查詢口罩數量的網頁和 App,接下來就讓我們試試串接政府資料開放平臺 API & kiang 大大提供的藥局+衛生所即時庫存,利用 JSONDecoder解析 API 回傳的 JSON 資料。

一. 政府資料開放平臺 API

  • 連到健保特約機構口罩剩餘數量明細清單的說明網頁
  • 點選檢視資料
  • 複製 JSON 下載連結
[
{
"醫事機構代碼": "0145080011",
"醫事機構名稱": "衛生福利部花蓮醫院豐濱原住民分院",
"醫事機構地址": "花蓮縣豐濱鄉豐濱村光豐路41號",
"醫事機構電話": "(03)8358141",
"成人口罩剩餘數": "544",
"兒童口罩剩餘數": "293",
"來源資料時間": "2020/02/14 15:55:05"
},
{
"醫事機構代碼": "0291010010",
"醫事機構名稱": "連江縣立醫院",
"醫事機構地址": "連江縣南竿鄉復興村217號",
"醫事機構電話": "(083)623995",
"成人口罩剩餘數": "0",
"兒童口罩剩餘數": "84",
"來源資料時間": "2020/02/14 15:55:05"
},
  • 定義對應 JSON 資料的 Decodable 型別
struct Mask: Decodable {
let id: String
let name: String
let address: String
let tel: String
let adultCount: String
let childCount: String
let time: Date

enum CodingKeys: String, CodingKey {
case id = "醫事機構代碼"
case name = "醫事機構名稱"
case address = "醫事機構地址"
case tel = "醫事機構電話"
case adultCount = "成人口罩剩餘數"
case childCount = "兒童口罩剩餘數"
case time = "來源資料時間"
}
}

由於 JSON 資料的欄位名稱是中文,所以我們利用 enum CodingKeys 客製 JSON 對應的 property,將中文名轉換成方便程式使用的英文名字。

另外我們將時間的欄位定義成 Date 型別,方便未來程式做時間相關的運算,因此待會我們還要撰寫將時間字串轉換成 Date 的程式。

  • 利用 JSONDecoder 解析口罩 API 的 JSON 資料
if let url = URL(string: "https://quality.data.gov.tw/dq_download_json.php?nid=116285&md5_url=2150b333756e64325bdbc4a5fd45fad1") {
URLSession.shared.dataTask(with: url) { (data, response, error) in
let decoder = JSONDecoder()
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy/MM/dd HH:mm:ss"
decoder.dateDecodingStrategy = .formatted(dateFormatter)

if let data = data, let masks = try? decoder.decode([Mask].self, from: data) {
print(masks)
}
}.resume()
}

利用 JSONDecoder 解析口罩 API 回傳的 JSON 資料。

為了將特殊格式的時間字串轉換成 Date,我們額外設定 JSONDecoder 的 dateDecodingStrategy,利用 enum DateDecodingStrategy 的 case formatted(DateFormatter)。

由於 JSON 的時間格式是 2020/02/14 15:55:05,因此我們在 formatted 裡傳入 DateFormatter 物件,將它的格式設為 yyyy/MM/dd HH:mm:ss

let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy/MM/dd HH:mm:ss"
decoder.dateDecodingStrategy = .formatted(dateFormatter)

關於 yyyy/MM/dd HH:mm:ss 的相關說明可參考以下連結。

值得注意的,App 畫面若要顯示時間,也要撰寫利用 DateFormatter 將 Date 轉換成字串的程式,例如以下程式:

let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
return formatter
}()
override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
guard let cell = tableView.dequeueReusableCell(withIdentifier: "MaskTableViewCell", for: indexPath) as? MaskTableViewCell else{ return UITableViewCell() }

let mask = masks[indexPath.row]
let timeText = dateFormatter.string(from: mask.time)
cell.updateLabel.text = timeText

return cell
}

結果

  • 自訂 init(from:),將 adultCount & childCount 變 Int

如果想要 adultCount & childCount 變成方便程式比大小的整數,我們必須在 Mask 裡自訂 init(from:),自己實作 JSON 資料的解析。

struct Mask: Decodable {
let id: String
let name: String
let address: String
let tel: String
let adultCount: Int
let childCount: Int

let time: Date

enum CodingKeys: String, CodingKey {
case id = "醫事機構代碼"
case name = "醫事機構名稱"
case address = "醫事機構地址"
case tel = "醫事機構電話"
case adultCount = "成人口罩剩餘數"
case childCount = "兒童口罩剩餘數"
case time = "來源資料時間"
}

init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)

id = try container.decode(String.self, forKey: .id)
name = try container.decode(String.self, forKey: .name)
address = try container.decode(String.self, forKey: .address)
tel = try container.decode(String.self, forKey: .tel)
adultCount = Int(try container.decode(String.self, forKey: .adultCount)) ?? 0
childCount = Int(try container.decode(String.self, forKey: .childCount)) ?? 0
time = try container.decode(Date.self, forKey: .time)
}

}

成功將 JSON 變成裝著 Mask 資料的 array 後,我們可以將它顯示在 App 的表格畫面,不過若想顯示在地圖上則有點困難,因為 JSON 裡並沒有包含經緯度。

當然我們可以自力自強,努力地利用 CLGeocoder 將地址轉換成經緯度,但這需要花費大量的時間,等轉換好口罩已經被買完了。

沒關係,我們還有第二條路,採用 kiang 大大提供的藥局+衛生所即時庫存。

二. kiang 提供的藥局+衛生所即時庫存

  • API 網址和 JSON 分析

API 網址如下

https://raw.githubusercontent.com/kiang/pharmacies/master/json/points.json

JSON 資料如下:

{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {
"id": "5901020554",
"name": "師大藥局",
"phone": "02 -23623479",
"address": "台北市大安區師大路99號1樓",
"mask_adult": 0,
"mask_child": 40,
"updated": "2020\/02\/14 22:59:03",
"available": "星期一上午看診、星期二上午看診、星期三上午看診、星期四上午看診、星期五上午看診、星期六上午看診、星期日上午休診、星期一下午看診、星期二下午看診、星期三下午看診、星期四下午看診、星期五下午看診、星期六下午看診、星期日下午休診、星期一晚上看診、星期二晚上看診、星期三晚上看診、星期四晚上看診、星期五晚上看診、星期六晚上看診、星期日晚上休診",
"note": "-",
"custom_note": "",
"website": "",
"county": "臺北市",
"town": "大安區",
"cunli": "古風里",
"service_periods": "NNNNNNYNNNNNNYNNNNNNY"
},
"geometry": {
"type": "Point",
"coordinates": [
121.528509,
25.02271
]
}
},

其中特別值得一提的欄位是 coordinates & service_periods。

coordinates:

代表經緯度,第一個數字代表經度,第二個數字代表緯度。有了經緯度,我們將可開發出口罩地圖 App。

service_periods:

代表看診星期,有 21 位元, 1~7 為每周一至周日上午開診情形, 8~14 為每周一至周日下午開診情形,15~21 為每周一至周日晚上開診情形,N=開診,Y =休診,因此 NNNNNNYNNNNNNYNNNNNNY 表示星期日整天休診,其它時間開診。

  • 定義對應 JSON 資料的 Decodable 型別
struct MaskData: Decodable {
let features: [Mask]

struct Mask: Decodable {
let properties: Properties
let geometry: Geometry
}

struct Properties: Decodable {
let name: String
let phone: String
let address: String
let maskAdult: Int
let maskChild: Int

let updated: Date
let available: String
let note: String
let customNote: String
let website: String
let county: String
let town: String
let cunli: String
let servicePeriods: String

}
struct Geometry: Decodable {
let coordinates: [Double]
}
}

JSON 裡像 mask_adult & mask_child 之類帶底線的名字被改成 Swift 習慣的 maskAdult & maskChild,待會我們將透過將 JSONDecoder 的 keyDecodingStrategy 設為 .convertFromSnakeCase 做轉換。

decoder.keyDecodingStrategy = .convertFromSnakeCase
  • 利用 JSONDecoder 解析 JSON
extension DateFormatter {
static let customFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateFormat = "yyyy/MM/dd HH:mm:ss"
return formatter
}()
}
if let url = URL(string: "https://raw.githubusercontent.com/kiang/pharmacies/master/json/points.json") {
URLSession.shared.dataTask(with: url) { (data, response, error) in
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
decoder.dateDecodingStrategy = .custom({ (decoder) -> Date in
let timeString = try decoder.singleValueContainer().decode(String.self)
return DateFormatter.customFormatter.date(from: timeString) ?? Date()
})


if let data = data, let maskData = try? decoder.decode(MaskData.self, from: data) {
print(maskData.features)
}
}.resume()
}

由於 JSON 的 updated 欄位有可能是空字串,因此我們不能使用 formatted(DateFormatter) 將字串轉換成 Date。

我們改用 enum DateDecodingStrategy 的 case custom((Decoder) throws -> Date) 做轉換,當轉換失敗時直接回傳目前的時間 Date()。

此外為了效率我們將負責轉換的 DateFormatter 物件儲存在 static 常數 customFormatter,這樣才不會在轉換每一筆資料時重新生成 DateFormatter 物件。

One more thing,利用 MKGeoJSONDecoder 解析 GeoJSON

剛剛 kiang 提供的藥局+衛生所即時庫存資料是一種稱為 GeoJSON 格式的資料,我們其實可使用 iOS 13 的 MKGeoJSONDecoder 解析,有興趣的朋友可參考以下製作口罩地圖 App 的範例說明。

--

--

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

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