Swift程式設計[第3單元] 網路程式基礎

#30 邊界問題(boundary conditions)

剛剛發現上一節的程式有個bug,看似小小的,卻非常嚴重,會導致整個程式閃退。
// 原來有bug的寫法
func 因數分解(_ n: Int) async throws -> [Int] {
if n < 0 { // 排除零與負數
throw 因數分解錯誤.負整數
} else if n == 0 {
throw 因數分解錯誤.等於零
} else if n == 1 {
return [1]
}
var 因數: [Int] = [1] // 1與n是基本的因數
let 近平方根 = Int(sqrt(Double(n)))
for i in 2...近平方根 {
if n % i == 0 {
因數 = 因數 + [i]
let 商數 = Int(n / i)
if 商數 != 近平方根 {
因數 = 因數 + [商數]
}
}
}
因數 = 因數 + [n]
return 因數.sorted()
}

上面這個函式,若是呼叫「因數分解(2)」或「因數分解(3)」,也就是參數n=2或n=3,會有什麼後果呢?執行結果既不會丟(throw)出錯誤代碼,也不會回傳因數陣列,而是無法繼續執行,程式直接死掉!

上圖錯誤訊息:Range requires lowerBound <= upperBound

原來 for 迴圈的使用規則,例如 for i in a...b { } 有個非常重要的限制,就是其中的範圍(Range) a...b,後數b (upperBound)必須大於或等於前數a (lowerBound),否則程式就無法繼續執行。也就是說,語法上 a...b 只能往前數,不能倒退數。

所以如果 a或b是程式內定義的常數或變數時,就必須仔細檢查該常數或變數的所有可能值,會不會違反這個規則。這個步驟通常稱為「邊界條件(boundary condition,或稱邊際條件)檢查」。

所以在上面範例中 for 迴圈的寫法:
let 近平方根 = Int(sqrt(Double(n)))
for i in 2...近平方根 { }

常數「近平方根」必須大於或等於2,而「近平方根」約為n的平方根,因此 n 必須大於或等於4。n 來自於參數,參數由呼叫者提供,我們無法限制呼叫者給的參數大小(語法上只要符合資料類型 Int即可),所以就必須在函式內檢查可能的邊界條件。

邊界條件隨程式的邏輯各有不同,對這個「因數分解()」函式而言,有幾個邊界條件:

(1) n 不能小於或等於零,因為這個因數分解是要給質因數分解使用,只考慮正整數。
(2) n 若等於1, 2, 3,不適用 for 迴圈內的算法。
(3) n 若等於或大於4,則 for 迴圈算法都相同。
(4) 即使n等於最大整數(Int.max),for 迴圈仍然適用。

因此,修正後的函式如下:
// debug後正確的寫法
func 因數分解(_ n: Int) async throws -> [Int] {
if n < 0 { // 排除零與負數
throw 因數分解錯誤.負整數
} else if n == 0 {
throw 因數分解錯誤.等於零
} else if n == 1 {
return [1]
} else if n == 2 || n == 3 { // 增加一個邊界條件
return [1, n]

}
var 因數: [Int] = [1] // 1與n是基本的因數
let 近平方根 = Int(sqrt(Double(n)))
for i in 2...近平方根 {
if n % i == 0 {
因數 = 因數 + [i]
let 商數 = Int(n / i)
if 商數 != 近平方根 {
因數 = 因數 + [商數]
}
}
}
因數 = 因數 + [n]
return 因數.sorted()
}

註解
  1. 千萬不要小看這樣的bug,幾年前iPhone有個嚴重的bug,只要收到特定內容的簡訊,就會當機,這就是因為負責收簡訊的 iMessage 沒有嚴密檢查邊界條件所造成。參考2015年報導2017年報導2018年報導
#31 錯誤傳遞(Error Propagation) -- 非同步質因數分解

關於Swift程式的錯誤處理,還有一個地方要學,就是在throw函式裡面可以呼叫其他throw函式嗎?

前面提過,Swift「錯誤處理」工作分為兩部分,負責「偵測」的函式在宣告時要加 throws 指令,故可稱為 throw函式(原文則稱 "throwing functions"),呼叫函式者(caller)則用do-try-catch來負責「應對」,基本句型如下:

錯誤偵測者(throw):
func 函式(參數) throws -> 回傳類型 {
...
if ... { throw 錯誤代碼 }
...
}

錯誤應對者(do-try-catch):
do {
let x = try 函式(參數)
...
} catch { ... }

錯誤偵測者與錯誤應對者有點像棒球的投手與捕手之間的關係,一個拋(throw),一個接(catch)。

假若有個函式b呼叫錯誤偵測的throw函式a,一般情況下,函式b是呼叫者(caller),應該用 do-try-catch 來應對函式a丟出來的錯誤情況,如下圖。


不過,函式b仍可以宣告為 throw 函式,這時在函式b中呼叫函式a時所抓到的錯誤代碼,就可選擇自己處理,或丟給上一層呼叫者處理 -- 也就是函式b可以只傳遞錯誤代碼,完全不處理,如下圖所示。


這樣就有點像棒球的外野手要傳球回本壘時,可以透過二壘手傳遞一樣,二壘手可以選擇刺殺一壘過來的跑者,或是趕緊傳回本壘避免失分。

我們仍然用「質因數分解」來當作範例說明,在此範例中,「因數分解」宣告為 async throws,相當於上圖的函式a,「非同步質因數分解」也宣告為async throws,相當於上圖函式b,會往上傳遞錯誤代碼,在最後的程式本體中,再來處理錯誤狀況。
// 3-8d 錯誤傳遞:非同步質因數分解
// Revised by Heman, 2022/01/17
import Foundation

func 是質數嗎(_ n: Int) async -> Bool {
if n < 2 { // 只判別大於1的正整數
return false
} else if n == 2 || n == 3 {
return true
}
let 近平方根 = Int(sqrt(Double(n))) //n必須大於等於4
for i in 2...近平方根 {
if (n % i) == 0 {
return false
}
}
return true
}

enum 因數分解錯誤: Error {
case 負整數
case 等於零
}

func 因數分解(_ n: Int) async throws -> [Int] {
if n < 0 { // 排除零與負數
throw 因數分解錯誤.負整數
} else if n == 0 {
throw 因數分解錯誤.等於零
} else if n == 1 {
return [1]
} else if n == 2 || n == 3 {
return [1, n]
}

var 因數: [Int] = [1] // 1與n是基本的因數
let 近平方根 = Int(sqrt(Double(n))) //n必須大於等於4
for i in 2...近平方根 {
if n % i == 0 {
因數 = 因數 + [i]
let 商數 = Int(n / i)
if 商數 != 近平方根 {
因數 = 因數 + [商數]
}
}
}
因數 = 因數 + [n]
return 因數.sorted()
}

func 非同步質因數分解(_ n: Int) async throws -> [Int] {
let 因數 = try await 因數分解(n)
var 質因數: [Int] = []
for i in 因數 {
if await 是質數嗎(i) {
質因數 = 質因數 + [i]
}
}
return 質因數
}

let 某數 = [-20220109, 0, 20220109, 20220911, 20223009, 123456789012345, 135791357913579]
let 數量 = 某數.count
var 已完成 = 0
var 時間差: Double = 0.0
var 本地格式 = DateFormatter()
本地格式.dateStyle = .full
本地格式.timeStyle = .long

let 計時開始 = Date()
print("非同步質因數分解 -- 開始時間:\(本地格式.string(from: 計時開始))")
for i in 某數 {
Task {
do {
let 質因數 = try await 非同步質因數分解(i)
時間差 = Date().timeIntervalSince(計時開始)
let 秒數 = String(format: "%.5f秒", 時間差)
print("\(i): \(質因數) 時間差", 秒數)
} catch 因數分解錯誤.負整數, 因數分解錯誤.等於零 {
print("錯誤:僅限正整數的質因數分解(\(i))")
} catch {
print("錯誤:其他原因(\(i))")
}
已完成 = 已完成 + 1
if 已完成 == 數量 {
時間差 = Date().timeIntervalSince(計時開始)
print("[非同步質因數分解]共花費時間(秒):\(時間差)")
print("程式結束:\(本地格式.string(from: Date()))")
}
}
}

import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true

這個範例程式,除了示範在 throw函式中呼叫另一個 throw函式之外,也示範在 async 函式中呼叫另一個 async 函式,所以包括判斷質數的「是質數嗎()」也宣告為async,想想看,這樣有什麼不同?

這是本單元最後一個文字模式(主控台)的範例程式,特別將時間格式調整為本地模式,並且將秒數改為小數點5位 -- 美化的工作留到最後再做即可。DateFormatter() 的用法可參考第一單元第10課,String(format:)的用法,可以參考第一單元第9課

輸出結果如下,要注意觀察的地方,就是兩個錯誤代碼其實是從「因數分解()」偵測並拋給「非同步質因數分解()」,再傳遞給主程式處理 -- 顯示錯誤訊息。中間的「非同步質因數分解()」既不偵測,也不處理,只單純傳遞錯誤,所以程式碼很簡潔,不需要 if ... throw,也不需要 do-try-catch。

此外,「非同步質因數分解()」在呼叫非同步的「是質數嗎()」,只需要 await,不需要在外面加一層 Task,這是因為函式本身已經宣告為 async 的關係 -- 在 async 函式中呼叫另一個 async 函式,就是這麼簡單!
非同步質因數分解 -- 開始時間:2022年1月17日 星期一 GMT+8 下午2:02:51
錯誤:僅限正整數的質因數分解(-20220109)
錯誤:僅限正整數的質因數分解(0)
20220911: [97, 208463] 時間差 0.02320秒
20223009: [3, 1499] 時間差 0.02657秒
20220109: [7, 13, 222199] 時間差 0.02858秒
123456789012345: [3, 5, 283, 3851, 7552031] 時間差 9.10102秒
135791357913579: [3, 31, 37, 367, 2906161] 時間差 9.49314秒
[非同步質因數分解]共花費時間(秒):9.493432998657227
程式結束:2022年1月17日 星期一 GMT+8 下午2:03:01


註解
  1. 本(第8)課一開始提過,「當throw函式偵測到錯誤(例外狀況),執行到 throw 時,函式會直接返回,不會再繼續往下執行」,例如「因數分解(0)」的情況,函式執行到 if n == 0 { throw ... } 就會返回。
  2. 那麼,模擬一下「非同步質因數分解(0)」又會是如何呢?
    func 非同步質因數分解(_ n: Int) async throws -> [Int] {
    let 因數 = try await 因數分解(n)
    var 質因數: [Int] = []
    for i in 因數 {
    if await 是質數嗎(i) {
    質因數 = 質因數 + [i]
    }
    }
    return 質因數
    }

    答案是,執行到第一行 let 因數 = try await 因數分解(n) 就會返回,雖然沒有 throw 指令,但同樣會拋出(其實是傳遞)錯誤代碼,而沒有正常的回傳值。
  3. 所以簡單地說,Swift程式所謂的「錯誤處理(Error Handing)」,其中的「錯誤」是指函式無法正常回傳值的例外情況,若這些例外情況沒有應對處理好,就可能造成無法預期的結果。
#32 第9課 台灣特有種鳥類App

上個月(2021/12)剛發行的最新版Swift Playgrounds 4.0最大特色之一,就是能夠用iPad寫完整的 iOS App,而不需要用到電腦或Xcode,這一課我們就來試試,寫一個「台灣特有種鳥類App」。

第2單元第7課,我們曾經用過「台灣特有種鳥類」作為範例,說明JSON格式,以及如何使用JSON解碼器,那時候我們還不會網路程式,所以只能手動下載檔案,再匯入到Swift Playgrounds裡面來使用。

現在我們已經學會從網路直接抓JSON檔案,再根據JSON的欄位,抓取網路的圖片、音樂等資源,寫起App就容易許多。

App構想的內容是展示30種台灣特有種鳥類的圖片與鳴叫聲,圖片來源可從維基百科網站抓,鳴叫聲錄音則找了兩個美國學術機構的網站,版權也都是Creative Commons (CC)授權,方便使用。其中有4種鳥類沒有找到適合的叫聲,所以執行時會出現「錄音網址有誤」的訊息,但不會影響程式正常執行。

有了資料來源,我們要做的就是先編輯一個JSON檔案,做好特有種鳥類的列表,並將找到的圖片、錄音網址放入JSON欄位中,再寫Swift網路程式抓取播放。

用Swift Playgrounds 4.0寫App,目前只支援iPad版(MacOS版本暫不支援),也就是只有在iPad版的Swift Playgrounds 4.0才可以新增App,如下圖:


新增的App預設名稱是「我的App」,可自行更改。打開後畫面如下,有三個欄框,最左邊是ContentView,這是預設會執行的視圖(View)名稱,並且包含一個簡單的範例程式;中間欄位是 MyApp,裡面寫好預設的視圖名稱為ContentView();最右邊則是App預覽,會自動執行,程式碼若有任何修改,馬上就能看到結果。


先將ContentView裡面的範例程式移除乾淨,然後寫上我們的程式碼,這時候我們平常寫的第一行與最後一行(import PlaygroundSupport, PlaygroundPage.current....)就不需要了,必須刪除,其他程式碼不變:


最後將「MyApp」裡面執行的View改為我們的View名稱,也就是將「ContentView()」改為「台灣特有種鳥類()」,這樣就可以在「App預覽」看到執行結果:


下面影片是在 iPad 裡面寫App的執行結果,記得打開喇叭,聽聽鳥鳴聲。


後面會陸續說明這個App的程式碼。

註解
  1. 提供鳥叫聲的網站分別是:
  2. 「App預覽」與平常在Swift Playgrounds執行有何不同嗎?是的,兩者稍有不同,過去我們在Swift Playgrounds所寫的程式,是當成Playground電子書來設計,例如筆者寫的「第3單元」是一個Playground Book電子書,3-1a是第一頁,3-1b是第二頁,各頁之間沒有關聯;而「App預覽」則將所有程式頁合為一個獨立的App(稱為套件 "package"),透過底層「iOS模擬器」執行,與Xcode開發App類似,同樣具有管理套件(package manager)的功能。
謝謝分享,還在努力學習中
雪白西丘斯
不錯,一起努力,加油!
#33 台灣特有種鳥類App:非同步JSON解碼器

構想好「台灣特有種鳥類App」之後,第一步就是搜尋網路上可用的資源(圖片、聲音),然後編輯JSON檔案,JSON檔雖然是文字格式,但若直接編輯很容易出錯,幸好網路上有些工具可以轉換Excel表格與JSON檔。

筆者習慣用Google試算表,所以就找了一個能夠將Google試算表轉換成JSON檔的「外掛程式」,叫做 "Export Sheet Data",在「擴充功能」中啟用之後,就能夠將編輯好的試算表輸出成JSON檔案:


這個擴充功能可以將第一列的欄位名稱,包括 id、中文名、別名...等等,轉換為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
}

注意這裡的變數宣告用 let (宣告為常數),而不是用 var,是因為這些變數在第一次設定(呼叫傑森解碼器初始化)之後,就不會再更改,因此以let宣告為常數,會比var宣告更安全。能用 let 的地方,就不要用 var,這是Swift程式設計的一個好習慣。

有了JSON檔,就可以用程式來讀取。我們學過的兩種方法都可以用,一是將JSON檔匯入Swift Playgrounds再用Data()讀取,參考第2單元第7課;二是將JSON檔放在網路上,程式裡用URLSession.shared.data()去抓,參考本單元第7課

本單元學習網路程式,當然就用第二種方法,轉換好的JSON檔須找個雲端服務存放,以便程式抓取,在此筆者選擇放亞馬遜雲端服務(Amazon AWS)。

接下來要寫個「傑森解碼器」,仿照第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解碼有問題")
}
}

完整程式碼與執行結果如下,跟上一課一樣,在Swift Playgrounds文字模式測試非同步程式時,需加最後兩行程式碼,否則會提前結束:
// 3-9a 台灣特有種鳥類
// Revised (based on 2-7b) by Heman, 2022/01/24
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://byheman.s3.us-east-2.amazonaws.com/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


註解

  1. JSON檔案若要放在Google雲端硬碟讓程式抓取,必須啟用Google Drive API才行,需要額外設定。放在Amazon AWS比較單純。
  2. 外掛程式"Export Sheet Data"可以在Google試算表 -> 擴充功能 -> 外掛程式 -> 取得外掛程式 -> 搜尋 "JSON" 找到,如下圖:
CUNNING
我看老師的a欄是id,我的也是,轉出來就沒有id
雪白西丘斯
設定有兩個要打勾 1.Advanced - include first column 2.Advanced JSON - Empty value format - Empty string ("")
#34 SwiftUI 搭配 async/await

在前面兩課的範例程式,非同步的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非常巧妙的設計。

完整的程式碼與執行結果如下,在平常用的 Swift Playgrounds (電子書模式)裡測試即可,暫時不用放到 App 模式裡面。
// 3-9b 台灣特有種鳥類(List)
// Revised (based on 2-7b) by Heman, 2022/01/25
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://byheman.s3.us-east-2.amazonaws.com/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)
// .listRowSeparator(.hidden)
}
.task {
do {
特有種清單 = try await 傑森解碼器(網址)
} catch {
print("無法取得特有種清單")
}
}
}
}

PlaygroundPage.current.setLiveView(台灣特有種鳥類())



配合List()有個新的修飾語 .listRowSeparator(.hidden),可以將項目之間的「橫隔線」隱藏起來,如果覺得礙眼的話。

註解
  1. 本節SwiftUI寫法參考了Apple官方影片 Discover concurrency in SwiftUI
  2. declare, declarative, declaration 分別是「宣告」的動詞、形容詞與名詞,美國獨立宣言就叫做 "Declaration of Independence"。在Swift程式裡面,declaration 是「宣告句」的意思,參考第1單元第6課「Swift基本句型」
  3. 所謂 declarative programming 就是以「宣告句」為主的程式設計,是近十年才熱門起來的方法,優點是能大幅簡化程式設計工作,很多細節會由底層系統自動完成(例如List或VStack會自動排版),讓過去許多懼怕寫UI/UX程式的人,輕鬆很多。
  4. imperative mood 是英文文法的祈使句,例如 Freeze! (別動!),傳統程式設計以祈使句為主要語法,以動詞(指令)開頭,例如 print() 就是我們常用的祈使句(叫電腦「列印!」)。
CUNNING
如果是把json檔放在ipad的話要怎麼寫?
雪白西丘斯
第2單元第7課內容就是JSON檔與程式放在一起,你看過有覺得寫不清楚的地方嗎?還是我誤會你的問題?



import SwiftUI

import AVKit

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

}



func 傑森解碼器(_ 檔名: String) -> [鳥類]? {

if let 檔案 = Bundle.main.url(forResource: 檔名, withExtension: "json") {

do {

let 資料 = try Data(contentsOf: 檔案)

let 結果 = try JSONDecoder().decode([鳥類].self, from: 資料)

return 結果

} catch {

print("error: \(error)")

}

}

return nil

}



let 特有種清單 = 傑森解碼器("3-9a") ?? []





struct 圖片與聲音: View {

let 鳥種: 鳥類

var 播放器 = AVPlayer()

init(_ x: 鳥類) {

鳥種 = x

guard let myURL = URL(string: 鳥種.錄音網址) else {

print("錄音網址有誤:",鳥種.錄音網址)

return

}

播放器 = AVPlayer(url: myURL)

}

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 傑森解碼器(<#String#>)

特有種清單 = 傑森解碼器("3-9a") ?? []



} //catch {

//print("無法取得特有種清單")

//}

}

}

}






後面do跟catch搞不定,看不懂差在那邊
雪白西丘斯
改得不錯喔!自己動手才能發現問題,學得更好。剛剛試過,只要3-9a.json檔案匯入正確,應該能夠執行。
雪白西丘斯
do-catch 請參考第8章,在這邊因為傑森解碼器()沒有宣告throws,所以do-catch可以省略。而且 .task 也可改為 .onAppear,但不改也能執行。
CUNNING wrote:
import SwiftUIimport...(恕刪)



改成這樣?
CUNNING
onAppear在老師下個搜尋範例時,好像跟onChange不能同時併用了,它搜尋不會有作用
CUNNING
現在可以了,list(顯示清單)的地方我抄錯了
#35 展示鳥類圖片與叫聲

上一節用List列出傑森解碼器傳回的「特有種列表」,接下來,我們希望點選鳥種時,展示該鳥種的圖片及叫聲,這時候就需要用第3課學過的導覽視圖NavigationView影音播放

先仿照第3課範例程式3-3d,將上一節的List前後插入NavigationView及NavigationLink:
NavigationView {
List(特有種清單) { 鳥種 in
NavigationLink(destination: 圖片與聲音(鳥種)) {
Label(鳥種.中文名 + 鳥種.英文名, systemImage: "photo")
.font(.title2)
.lineLimit(1)
}
}
.navigationTitle("台灣特有種鳥類")
}

這段程式碼結構如下,內層由①NavigationLink將「Label()」與「圖片與聲音()」兩個視圖連結在一起,外層由②List()將「特有種清單」陣列的元素一一傳遞給Label(),形成列表,最外面再包一層③NavigationView,以控制整個螢幕畫面。


剩下的工作,就只需寫個展示「圖片與聲音」的視圖,這個工作又分成兩部分:展示圖片以及播放聲音。

圖片部分比較簡單,直接拿前面第6課範例3-6b來修改即可:
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()
}
}

差別只有在圖片下方增加了三行文字,當作圖說,以符合CC版權要求。

聲音部分稍有波折,主要參照第3課播放iTune歌曲的方法,不過這次不需要播放器外觀(而是在背景播放),所以可略過 VideoPlayer 物件,只需用 AVPlayer,兩者差異如下表。
物件 AVPlayer VideoPlayer
所屬物件庫 AVFoundation AVKit
用途 [內部控制]設定影音檔來源、透過作業系統控制播放參數(音量、播放、暫停等) [外觀]顯示播放器外觀,提供使用者控制介面(音量、播放、暫停等)

要在背景播放聲音檔(如網路的MP3),最基本只要兩行程式碼就能搞定:
let 播放器 = AVPlayer(url: myURL)
播放器.play()

就可播放 myURL 網址的mp3或其他聲音檔案,如果網址不對或聲音格式未支援,並不會拋出錯誤,只是聽不到任何聲音。

我們可以在視圖初始化函式 init() 中,帶入「鳥類」為參數,以指定「錄音網址」給播放器,然後利用 .onAppear 修飾語來播放聲音,相對的,在 .onDisappear 脫離視圖時暫停播放,程式碼如下:
struct 圖片與聲音: View {
let 鳥種: 鳥類
let 播放器: AVPlayer
init(_ 初始化參數: 鳥類) {
鳥種 = 初始化參數
if let myURL = URL(string: 鳥種.錄音網址) {
播放器 = AVPlayer(url: myURL)
} else {
print("錄音網址有誤:", 鳥種.錄音網址)
播放器 = AVPlayer()
}
}
var body: some View {
AsyncImage() { ... }
.onAppear { 播放器.play() }
.onDisappear { 播放器.pause() }
}
}

上面這段程式碼,原來的錯誤版本如下,相對比較直觀,但無法播放聲音,請比較一下兩者的差異:
struct 圖片與聲音: View {
let 鳥種: 鳥類
init(_ 初始化參數: 鳥類) { 鳥種 = 初始化參數 }
var body: some View {
AsyncImage() { .... }
.onAppear {
if let myURL = URL(string: 鳥種.錄音網址) {
let 播放器 = AVPlayer(url: myURL)
播放器.play()
} else { print("錄音網址有誤") }
}
}
}

「播放器」這個常數在哪裡宣告非常關鍵,因為影響其有效範圍(scope),如果是在 .onAppear 匿名函式中宣告,就只在匿名函式 { } 範圍中有效,問題是「播放器.play()」其實只是通知作業系統,執行後就立刻結束,脫離匿名函式範圍,導致常數「播放器」不再有效,作業系統因此會將 play() 指令也停掉。

所以必須將「播放器」常數放在「圖片與聲音」視圖屬性中,在一開始就初始化,這樣「播放器」的生存時間才能跟視圖一樣長。

完整程式碼與執行結果如下:
// 3-9c 台灣特有種鳥類(NavigationView)
// Revised (based on 2-7b) by Heman, 2022/01/25
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://byheman.s3.us-east-2.amazonaws.com/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(台灣特有種鳥類())



這樣就完成「台灣特有種鳥類App」的設計,最後只須將頭尾行去掉,其他複製到Swift Playgrounds新增「我的App」裡面,就得到一個網路程式的App了。

利用本課的方法,可以寫出很多App來,不管是何種興趣,一定都能在網路上找到豐富素材,例如對流行音樂有興趣、想到國外旅遊(世界自然遺址是不錯的選擇)、要搜集各地美食...等,任何搜尋到的網路素材,整理成Excel表格轉JSON檔,再利用Swift程式來讀取、展示,就是一個很好的App,程式碼雖然簡單,但是內容可以很豐富!

註解
  1. 程式碼第三行寫 import AVKit 或 import AVFoundation 都可以,AVFoundation 物件庫是 AVKit 的基礎,若導入 AVKit 就不需再寫 import AVFoundation;同樣的,Foundation 是很多其他物件庫的基礎,如 UIKit, SwiftUI,導入任何一個,都會自動包含 Foundation。
  2. 變數常數的有效範圍(scope)是程式設計非常重要的觀念,在本節更是播放網路mp3檔的關鍵,不清楚的請參考第1單元第5課內容。
CUNNING
要加上清單排序跟搜尋功能的話呢?像手機聯絡人那樣
雪白西丘斯
搜尋功能可以介紹,多欄位或中文的排序比較麻煩,暫時跳過。
#36 List搜尋(.searchable)

第2單元第9課介紹過「多才多藝的List」,List 稱得上是目前SwiftUI中最多花樣的視圖物件,在去(2021)年還新增一個可搜尋的修飾語 .searchable(),在 Swift Playgrounds 4.0 以後才能用。

利用上一節的範例程式,需要修改「台灣特有種鳥類」視圖,以增加搜尋功能(可搜尋鳥類「中文名」與「英文名」欄位),修改後的程式碼如下:
// 3-9d 台灣特有種鳥類(.searchable())
struct 台灣特有種鳥類: View {
@State var 特有種清單: [鳥類] = []
@State var 顯示清單: [鳥類] = []
@State var bird: String = ""
var body: some View {
NavigationView {
List(顯示清單) { 鳥種 in
NavigationLink(destination: 圖片與聲音(鳥種)) {
Label(鳥種.中文名 + 鳥種.英文名, systemImage: "photo")
.font(.title2)
.lineLimit(1)
}
}
.navigationTitle("台灣特有種鳥類")
}
.searchable(text: $bird)
.onChange(of: bird) { bird in
if bird == "" {
顯示清單 = 特有種清單
} else {
顯示清單 = 特有種清單.filter {
return $0.中文名.contains(bird) || $0.英文名.contains(bird)
}
}
}
.task {
do {
特有種清單 = try await 傑森解碼器(網址)
顯示清單 = 特有種清單
} catch {
print("無法取得特有種清單")
}
}
}
}

需要增加兩個狀態變數,原先的「特有種清單」用來保留原始清單,增加「顯示清單」用來過濾搜尋結果,另一個狀態變數 bird 則用來接受搜尋文字(這是Swift Playgrounds少數不能使用中文命名的地方)。

只要在 NavigationView { } 或 List { } 後面加上一行修飾語 .searchable(text: $bird),就會在列表上方出現搜尋框,以輸入搜尋文字,輸入的文字會指定給 $bird,前面加上 $ 是 "call by reference" 的用法,才能將指定值帶出函式,在第3課搜尋iTune音樂庫解釋過,那時候需要用 TextField 來輸入搜尋字串,比較麻煩。

要讓輸入的搜尋字串發生效果,需要另外一個修飾語,可以用 .onChange 或是 .onSubmit,前者(.onChange)在每輸入一個字元就會立刻更新清單,後者(.onSubmit)則是等輸入完整字串按下ENTER才會更新清單。
    .onChange(of: bird) { bird in
if bird == "" {
顯示清單 = 特有種清單
} else {
顯示清單 = 特有種清單.filter {
return $0.中文名.contains(bird) || $0.英文名.contains(bird)
}
}
}

更新清單的方法,用到陣列物件的修飾語,叫做 .filter(),這原本是要在以後進階課程才介紹的,陣列有許多進階用法,.filter是其中之一,後接一個匿名函式,如果回傳 true 就表示符合需要,否則就過濾掉。

$0 代表從陣列一一取出的元素,「特有種清單」的元素類型是「鳥類」,我們比較鳥類的「中文名」或「英文名」是否包含搜尋字串(bird),.contains 是字串物件的方法,用來判別大字串是否包含另一個小字串。

更新後,執行結果如下圖:
CUNNING
老師我剛才試了英文它會區分大小寫,不區分的話要怎麼寫?
CUNNING
$0.英文名.localizedCaseInsensitiveContains(bird)
關閉廣告
文章分享
評分
評分
複製連結

今日熱門文章 網友點擊推薦!