#37 第10課 逐步改善「我的App」
在第1單元第8課曾提過「逐步改善」的觀念,軟體最大的優勢就是彈性,能夠逐步擴充App的內容、持續改善用戶體驗與服務流程,以因應新的需求。
持續改善的過程,其實也是持續學習的過程,沒有人第一次寫App就一炮而紅,總是從觀摩或模仿別人的App開始,甚至要從使用者回饋的問題中學習,最後才能寫出自己獨特的App。
在本單元最後一課,我們就來試試如何擴充並改善前一課所寫的App。
初步的想法,是利用 Swift Playgrounds 4.0 本身的新功能:一個App可以包含多個Swift程式,分散在多個檔案裡。本課再寫一個「台灣生物多樣性」的程式,然後將「特有種鳥類」與「生物多樣性」透過 SwiftUI 的 TabView 整合在一個App裡面,如下圖:
其次,在第1單元第8課也介紹過「物件導向」的程式設計方法,不過本單元到目前為止,大多將函式(func)與資料類型(struct)分開,觀念上屬於函式化程式設計(functional programming),兩個方法都能有效解決問題,但背後設計理念不同,物件導向更適合解決複雜問題,在本課我們就改用物件導向方法來試試看有何差異。
這次我們選用的內容是「政府開放資料平台(Open Data)」 -- 農委會特生中心(特有生物研究保育中心)的「台灣生物多樣性網絡(TBN)」,這裏搜集了台灣2萬多種動植物的物種分類與1千多萬筆百年來的觀測紀錄,堪稱是台灣目前最完整的生態資料庫,最棒的是提供Open API開放給大眾使用。
根據「台灣生物多樣性網絡」Open API說明文件,資料結構分為兩層,第一層有3個欄位:meta, links 與 data,第二層才是個別資料,我們需要的都在 data 裡面。對應的資料類型宣告如下:
struct 物種列表: Codable {
var meta: 總數
var links: 頁次
var data: [物種資料]
}
struct 總數: Codable {
var total: Int
}
struct 頁次: Codable {
var next: String
}
struct 物種資料: Codable, Hashable {
var taxonUUID: String //分類編號
var taxonName: String //分類名稱(中英文)
var scientificName: String //學名(拉丁文)
var vernacularName: String //本地名稱(中文)
var taxonRank: String //分類階層(中文)
var family: String //科名(中英文)
}
有了資料結構,就可以用「傑森解碼器」來取得資料,需要連結的網址格式範例為:
https://www.tbn.org.tw/api/v2/species?name=佛甲草&limit=30
基本上就是將「name=佛甲草」參數改為其他搜尋字串即可。
我們先仿照上一課範例3-9a寫一個文字模式程式,來驗證資料結構與傑森解碼器是否正確,但改用「物件導向」的方法,完整程式如下:
// 3-10a 台灣生物多樣性 Open Data (TBN)
// Created by Heman, 2022/01/31
// TBN Open Data: https://www.tbn.org.tw/data/api
import Foundation
struct 物種列表: Codable {
var meta: 總數
var links: 頁次
var data: [物種資料]
init() {
meta = 總數(total: 0)
links = 頁次(next: "")
data = []
}
mutating func 更新(_ 名稱: String) async {
// Sample URL: "https://www.tbn.org.tw/api/v2/species?name=佛甲草&limit=30"
if 名稱 == "" { return }
var 合成網址 = URLComponents()
合成網址.scheme = "https"
合成網址.host = "www.tbn.org.tw"
合成網址.path = "/api/v2/species"
合成網址.query = "name=\(名稱)&limit=30"
guard let myURL = 合成網址.url else { return }
do {
let (回傳資料, 回傳碼) = try await URLSession.shared.data(from: myURL)
self = try JSONDecoder().decode(物種列表.self, from: 回傳資料)
print(回傳碼)
} catch {
print("無法更新列表")
}
}
}
struct 總數: Codable {
var total: Int
}
struct 頁次: Codable {
var next: String
}
struct 物種資料: Codable, Hashable {
var taxonUUID: String //分類編號
var taxonName: String //分類名稱(中英文)
var scientificName: String //學名(拉丁文)
var vernacularName: String //本地名稱(中文)
var taxonRank: String //分類階層(中文)
var family: String //科名(中英文)
}
Task {
var 列表 = 物種列表()
await 列表.更新("佛甲草")
for 物種 in 列表.data {
print(物種.vernacularName, 物種.scientificName)
}
}
import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true
這次的「傑森解碼器」最大的不同,就是將函式寫在「struct 物種列表」宣告裡面,名稱改為「更新()」,呼叫的方式也有所不同,原來3-9a的用法:
//3-9a
let 特有種清單 = try await 傑森解碼器(網址)
現在的用法是:
//3-10a
await 列表.更新("佛甲草")
前者(3-9a)將函式(傑森解碼器)與資料(特有種清單)明顯區分開來,資料是函式(動作)回傳的結果。後者(3-10a)則將資料與函式視為一體,合稱為「物件(Object)」,資料是這個物件的屬性,函式是這個物件的動作(或稱方法),動作可能變更物件本身的屬性,這是一種將虛擬資料實體化,或反過來想,實體物件虛擬化的做法。
可別小看這樣小小的改變,這個改變在30-40年前掀起的軟體革命仍然延續至今。
這樣的「物件化」程式設計有什麼好處呢?事實上,資料與函式物件化之後,整個內涵完全不一樣,帶來許多好處,包括:
1. 函式可取相同名稱而不會衝突
例如上一課台灣特有種鳥類的傑森解碼器,可以同樣改名為物件的「更新()」函式,但一個是「特有種清單.更新()」,一個是「物種列表.更新()」,同樣的函式名稱包在不同物件裡面,就不會衝突。
否則,如果兩個函式都放在外面,取名為「傑森解碼器()」,語法上就會有重複宣告的衝突。若所有程式一個人從頭寫到尾,會知道改名稱,影響還不大,若是多人共同開發一個專案,就可能產生大麻煩。
對許多資料類型,都同樣有「增刪查改」(或稱"CRUD")的需要,也就是 Create (建立)、Read (讀取)、Update (更新)、Delete (刪除)等動作,用函式方法來設計會很麻煩,改用物件導向就容易多了。
2. 物件裡同一個函式(物件方法)允許重複宣告,產生多種用法
例如本課3-10a的更新(),傳入參數是搜尋的物種「名稱」,可再增加一個用法是以「網址」為參數,做到同樣功能,使用時就根據參數不同來區分。同一個函式多種用法,就可以應對各種情況,大大增加函式(動作)的彈性,例如前面提過 List 物件就有20多種使用方法。
範例程式為了簡化起見,我們先只用 async 來宣告「更新()」,不用 throws 傳遞錯誤,所以程式最後在 Task 裡面更新列表時,就不需要 do-try-catch:
Task {
var 列表 = 物種列表()
await 列表.更新("佛甲草")
for 物種 in 列表.data {
print(物種.vernacularName, 物種.scientificName)
}
}
使用物件方法之前,必須先產生一個物件實例。
var 列表 = 物種列表()
「物種列表」是物件類型(type),在第一單元曾將類型比喻為物件的「模子」,類型名稱加上()就相當於用模子複製出一個物件實體或實例("instance"),而這個 () 複製過程,事實上是物件的初始化函式 init() 做的,所以在物件裡面必須有一個或多個初始化函式:
init() {
meta = 總數(total: 0)
links = 頁次(next: "")
data = []
}
初始化函式最重要的任務,就是必須將物件的「所有屬性」(資料欄位)都給予初始值,這是物件導向非常重要的觀念,如果有一個資料欄位沒有初始化,就無法完成複製程序,生不出任何物件實例出來。
想想看,我們在第2單元學SwiftUI,所有範例程式幾乎都在做同一件事:初始化 View 物件的 body 屬性!
最後,我們將更新後的物種列表印出物種的中文名稱與學名,就可確定連接網路並解碼成功。程式執行結果如下圖:
註解
- 為什麼我們宣告 View 物件的時候,幾乎都沒有寫 init() 呢?因為任何物件宣告時,系統都會附上一個預設的 init(),若所有屬性(var/let)都有預設值,複製實例時就可直接用「物件類型()」不帶參數,若沒有預設值,就必須傳遞參數進去,例如:
meta = 總數(total: 0)
links = 頁次(next: "")
這就是系統預設的 init() 負責處理的。
- 所以 View 物件的 var body { } ,其實就是在指定 body 的預設值(body = 匿名函式的回傳值)。
- 更新()會變更物件的屬性,所以必須宣告為 mutating,參考第1單元第9課。
- 佛甲草在台灣野外分布相當廣,從海平面東北角海岸,到海拔3000公尺的高山,都有其分佈,但不容易找到,台灣共有20多種,下圖是筆者在雪山圈谷拍攝的台灣特有種--玉山佛甲草。