網路基礎-特種鳥
-
3-9a 台灣特有種鳥類
操作步驟如下:
選取畫面左方的「+」號新增電子書頁面。
將新增的電子書面頁命名為「(24)網路程式基礎-8特種鳥」。
JSON格式的變數名稱,對應的 struct 資料類型如下:
struct 鳥類: Codable, Identifiable { let id: Int let 中文名: String let 別名: String let 科名: String let 英文名: String let 圖片網址: String let 維基百科網址: String let 攝影者: String let 錄音網址: String let 錄音者: String }
有了JSON檔,就可以用程式來讀取。我們學過的兩種方法都可以用,一個是將JSON檔匯入Swift Playgrounds再用Data()讀取;另一個是將JSON檔放在網路上,程式裡用URLSession.shared.data()去抓。
本單元學習網路程式,當然就用第二種方法,轉換好的JSON檔須找個雲端服務存放,以便程式抓取,。
接下來要寫個「傑森解碼器」,仿照第2單元2-7a範例,並利用上一課學過的 async throws 函式,改寫一個「非同步版」的傑森解碼器如下:
enum 網路錯誤: Error { case 網址格式錯誤 } func 傑森解碼器(_ 網址: String) async throws -> [鳥類] { guard let myURL = URL(string: 網址) else { throw 網路錯誤.網址格式錯誤 } let (data, response) = try await URLSession.shared.data(from: myURL) print(response) let 結果 = try JSONDecoder().decode([鳥類].self, from: data) return 結果 }
因為函式宣告為 async throws,所以不需要用 Task,也不需要 do-try-catch,程式碼顯得比2-7a範例更乾淨俐落。雖然只有短短幾行,內涵可不簡單,在最後一行「return 結果」之前,有3個地方可能會拋出或傳遞錯誤碼,包括(1) URL 網址格式錯誤 (2) URLSession.shared.data() 網路連線錯誤 (3) JSONDecoder() 解碼失敗,這些錯誤情況都可以在呼叫傑森解碼器的地方加以處理。
現在我們知道用到 try 指令的地方,包括 URLSession.shared.data() 與 JSONDecoder().decode(),會拋出自己的錯誤碼,前者網路錯誤的情況比較多樣,共有50多種錯誤碼,後者解碼時會有4種錯誤情境。這些錯誤情境,目前我們還不需要逐一應對。
最後,我們寫一段文字模式程式(要開啟「主控台」畫面),來驗證JSON檔能否從網路擷取並正確解碼。這時候就必須用到 Task 物件來界定非同步工作的範圍,才能用 await 指令呼叫非同步版的傑森解碼器,然後用 do-try-catch 來處理可能發生的問題。
Task { do { let 特有種清單 = try await 傑森解碼器(網址) for 鳥種 in 特有種清單 { print(鳥種.id, 鳥種.中文名, "\t", 鳥種.英文名) } } catch 網路錯誤.網址格式錯誤 { print("網址格式錯誤") } catch { print("JSON解碼有問題") } }
* 在「Main」模組中撰寫程式:
// 3-9a 台灣特有種鳥類 // Revised (based on 2-7b) by Philip, Heman, Jean 2022/01/24 // Revised by Jean 2025/01/03 import Foundation struct 鳥類: Codable, Identifiable { let id: Int let 中文名: String let 別名: String let 科名: String let 英文名: String let 圖片網址: String let 維基百科網址: String let 攝影者: String let 錄音網址: String let 錄音者: String } let 網址 = "https:/joc.url.tw/Swift36/3-9a.json" enum 網路錯誤: Error { case 網址格式錯誤 } func 傑森解碼器(_ 網址: String) async throws -> [鳥類] { guard let myURL = URL(string: 網址) else { throw 網路錯誤.網址格式錯誤 } let (data, response) = try await URLSession.shared.data(from: myURL) print(try String(contentsOf: myURL)) print(response) let 結果 = try JSONDecoder().decode([鳥類].self, from: data) return 結果 } Task { do { let 特有種清單 = try await 傑森解碼器(網址) for 鳥種 in 特有種清單 { print(鳥種.id, 鳥種.中文名, "\t", 鳥種.英文名) } } catch 網路錯誤.網址格式錯誤 { print("網址格式錯誤") } catch { print("JSON解碼有問題") } } import PlaygroundSupport PlaygroundPage.current.needsIndefiniteExecution = true
* 執行結果
3-9b 台灣特有種鳥類(List)
在前面兩課的範例程式,非同步的async/await 指令都是在文字模式(主控台)下使用,若想在SwiftUI圖形介面裡用async/await,會有何不同呢?
確實稍有不同,因為文字模式與圖形模式的程式邏輯有所差異,SwiftUI是一種很新的「宣告式語法」(Declarative programming),只要用struct宣告一個新的View物件,其主體變數(var body)指定為其他已存在的視圖,就能做出一個UI/UX程式,很少用 for-in 迴圈或 if-else等「指令」,這在過去是難以想像的事。
相對的,在文字模式下或傳統的程式設計,即便是物件導向,還是遵循「指令式語法」(Imperative programming),就像烹飪食譜一樣,一步一步(指令)告訴電腦做什麼事,包括一些細節,如何種格式、什麼位置、多大尺寸...等等。
不知道大家有沒有發現,在View的主體(body)裡面,不能像文字模式那樣直接用 for-in 迴圈,也不能直接用非同步指令 async/await,必須將這些指令放在某些視圖修飾語(View Modifier)中,SwiftUI提供了一個新的視圖修飾語 .task 來使用 await 呼叫 async函式,用法如下:
struct 台灣特有種鳥類: View { @State var 特有種清單: [鳥類] = [] var body: some View { List(特有種清單) { 鳥種 in Label(鳥種.中文名 + 鳥種.英文名, systemImage: "photo") .font(.title2) .lineLimit(1) } .task { do { 特有種清單 = try await 傑森解碼器(網址) } catch { print("無法取得特有種清單") } } } }
我們先宣告一個空陣列的狀態變數「特有種清單」,用List來顯示「特有種清單」,所以一開始會出現一個空白畫面,然後再利用修飾語 .task 來更新狀態變數,也就是用try await 呼叫非同步的「傑森解碼器()」,正常解碼後指定給「特有種清單」,因為這是狀態變數(@State var),所以整個畫面就會隨著更新,各鳥種用Label()列出中英文名稱。
這樣的技巧經常用於SwiftUI,就像前面第2課3-2a程式一樣,不過那時用的是 .onAppear 修飾語,而 .task 就相當於非同步版的 .onAppear,async/await 只能配合 .task,其實這樣一來,就跟文字模式下的 Task { } 語法非常類似,這是SwiftUI非常巧妙的設計。
* 在「Main」模組中撰寫程式:
// 3-9b 台灣特有種鳥類(List) // Revised (based on 2-7b) by Philip, Heman, Jean 2022/01/25 // Revised by Jean 2025/01/03 import PlaygroundSupport import SwiftUI struct 鳥類: Codable, Identifiable { let id: Int let 中文名: String let 別名: String let 科名: String let 英文名: String let 圖片網址: String let 維基百科網址: String let 攝影者: String let 錄音網址: String let 錄音者: String } let 網址 = "https://joc.url.tw/Swift36/3-9a.json" enum 網路錯誤: Error { case 網址格式錯誤 } func 傑森解碼器(_ 網址: String) async throws -> [鳥類] { guard let myURL = URL(string: 網址) else { throw 網路錯誤.網址格式錯誤 } let (data, response) = try await URLSession.shared.data(from: myURL) print(response) let 結果 = try JSONDecoder().decode([鳥類].self, from: data) return 結果 } struct 台灣特有種鳥類: View { @State var 特有種清單: [鳥類] = [] var body: some View { List(特有種清單) { 鳥種 in Label(鳥種.中文名 + 鳥種.英文名, systemImage: "photo") .font(.title2) .lineLimit(1) } } } Task { do { let 特有種清單 = try await 傑森解碼器(網址) for 鳥種 in 特有種清單 { print(鳥種.id, 鳥種.中文名, "\t", 鳥種.英文名) } } catch 網路錯誤.網址格式錯誤 { print("網址格式錯誤") } catch { print("JSON解碼有問題") } } PlaygroundPage.current.setLiveView(台灣特有種鳥類())
* 執行結果
3-9c 台灣特有種鳥類(NavigationView)
展示鳥類圖片與叫聲
上一單元用List列出傑森解碼器傳回的「特有種列表」,接下來,我們希望點選鳥種時,展示該鳥種的圖片及叫聲,這時候就需要用之前學過的導覽視圖NavigationView與影音播放。
先仿照之前的範例程式,將上一節的List前後插入NavigationView及NavigationLink:
NavigationView { List(特有種清單) { 鳥種 in NavigationLink(destination: 圖片與聲音(鳥種)) { Label(鳥種.中文名 + 鳥種.英文名, systemImage: "photo") .font(.title2) .lineLimit(1) } } .navigationTitle("台灣特有種鳥類") }
這段程式碼結構如下,內層由①NavigationLink將「Label()」與「圖片與聲音()」兩個視圖連結在一起,外層由②List()將「特有種清單」陣列的元素一一傳遞給Label(),形成列表,最外面再包一層③NavigationView,以控制整個螢幕畫面。
剩下的工作,就只需寫個展示「圖片與聲音」的視圖,這個工作又分成兩部分:展示圖片以及播放聲音。
* 在「Main」模組中撰寫程式:
// 3-9c 台灣特有種鳥類(NavigationView) // Revised (based on 2-7b) by Philip, Heman, Jean 2022/01/25 // Revised by Jean 2025/01/17 import PlaygroundSupport import SwiftUI import AVKit struct 鳥類: Codable, Identifiable { let id: Int let 中文名: String let 別名: String let 科名: String let 英文名: String let 圖片網址: String let 維基百科網址: String let 攝影者: String let 錄音網址: String let 錄音者: String } let 網址 = "https://joc.url.tw/Swift36/3-9a.json" enum 網路錯誤: Error { case 網址格式錯誤 } func 傑森解碼器(_ 網址: String) async throws -> [鳥類] { guard let myURL = URL(string: 網址) else { throw 網路錯誤.網址格式錯誤 } let (data, response) = try await URLSession.shared.data(from: myURL) print(response) let 結果 = try JSONDecoder().decode([鳥類].self, from: data) return 結果 } struct 圖片與聲音: View { let 鳥種: 鳥類 let 播放器: AVPlayer init(_ 初始化參數: 鳥類) { 鳥種 = 初始化參數 if let myURL = URL(string: 鳥種.錄音網址) { 播放器 = AVPlayer(url: myURL) } else { print("錄音網址有誤:", 鳥種.錄音網址) 播放器 = AVPlayer() } } var body: some View { AsyncImage(url: URL(string: 鳥種.圖片網址)) { 狀態 in if let 圖片 = 狀態.image { VStack(alignment: .leading) { 圖片 .resizable() .scaledToFit() Text(鳥種.中文名 + 鳥種.英文名) Text("攝影者:\(鳥種.攝影者)") Text("錄音者:\(鳥種.錄音者)") } } else if 狀態.error != nil { Image(systemName: "xmark.icloud.fill") .scaleEffect(3) .foregroundColor(.red) } else { ProgressView() } } .onAppear { 播放器.play() } .onDisappear { 播放器.pause() } } } struct 台灣特有種鳥類: View { @State var 特有種清單: [鳥類] = [] var body: some View { NavigationView { List(特有種清單) { 鳥種 in NavigationLink(destination: 圖片與聲音(鳥種)) { Label(鳥種.中文名 + 鳥種.英文名, systemImage: "photo") .font(.title2) .lineLimit(1) } } .navigationTitle("台灣特有種鳥類") } .task { do { 特有種清單 = try await 傑森解碼器(網址) } catch { print("無法取得特有種清單") } } } } PlaygroundPage.current.setLiveView(台灣特有種鳥類())
* 執行結果