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

#1 前言

Apple 新產品終於發布,新版的 MacOS 12 與 iOS/iPadOS 15 預計下週就可以更新,因此差不多可以開始進行下一單元的課程。

原本第3單元的規劃是要寫2D平面繪圖,這是 SwiftUI 另一個重要部分,然後第4單元才會學習網路程式設計,不過因為第2單元學過傑森解碼器(JSON),從JSON延伸到網路比較順理成章,而且網路應用比2D繪圖的效用更大更廣,因此決定第3單元先講網路程式的基礎。

本單元的目標是學習Swift 網路程式基本的指令與應用,預計包括以下主題(但章節還未確定):

* URL & URLSession 物件
* 下載網路圖片
* 用傑森解碼器連接網路API
* 搜尋 iTunes 音樂
* 連接Open API
* async/await 指令
* 錯誤處理
* 撰寫App (iPad版Swift Playgrounds 4.0適用)


本單元設想的對象仍是高中程度的初學者,但需要前兩單元的基礎知識,最好能按部就班從第1單元依次學習,會比較熟悉課程用語與程式風格。所需配備如下:

* 需要配備: Apple iPad 或 Macbook, iMac, Mac mini
* 需要軟體: Apple Swift Playgrounds App
* 作業系統: iPadOS 13 或 macOS 10.15 以上

本單元所有範例,均使用 Swift Playgrounds 作為編寫程式的環境,Swift Playgrounds 這個原廠免費App 可以在Mac 電腦與 iPad 上執行,在本單元後半部,會使用新版 MacOS 12 或 iPadOS 15才支援的 async/await 指令,所以請確認硬體規格(大約2014年以後)可以升級到新版作業系統。

關於範例程式,強烈建議讀者自己一行一行打,而不是整個複製剪貼,打字對於程式設計,就像學英語開口說一樣重要,打字過程會放慢速度,讓你有時間思考,可以注意到重要細節,甚至可能出現錯字漏打,讓你有機會除錯,熟悉正確語法。

程式設計的一些細節,特別是標點符號的使用:為什麼這裡要加句號 .,有些地方要用冒號 : 引號 " " 或括號( ) ,什麼時候要用大括號 { } ,這些都牽涉到程式的邏輯,非常重要,但初學者很容易疏忽或混淆,透過打字細心學習,可以得到正確的觀念。

只要一開始掌握正確的思路與方法,就能慢慢培養出程式設計的素養。

第1單元 Swift 程式語言基礎 https://www.mobile01.com/topicdetail.php?f=482&t=6402999
第2單元 SwiftUI 圖形介面基礎 https://www.mobile01.com/topicdetail.php?f=482&t=6424982
Swift Playgrounds App https://www.apple.com/tw/swift/playgrounds/
#2 第1課 什麼是URL ?

早期的程式語言如C或C++,寫網路程式是一件相當麻煩的事,如今用Swift寫網路程式,相對來說容易多了,幾乎就跟使用瀏覽器一樣簡單。

我們曾經在第2單元第7課提到下圖的概念:


右下角的使用者透過手機的瀏覽器或App連到網路上的主機(Host, 或稱為 Server 伺服器),這樣的基本架構稱為「Client-Server 模式」,Client 是指使用者(或稱客戶端)的App,Server 是指主機端的軟體。

Client-Server 模式的連線方式有好幾種,其中最基本的一種可簡化為兩個步驟:
(1) 由Client 主動發出請求(Request)訊息給Server,表明需要什麼資源或內容
(2) Server 根據請求去搜尋資料庫或檔案,然後回應(Response)內容給 Client

這樣的過程可稱為請求-反應(Request-Response)。可別小看這兩個簡單步驟,這是模擬低等動物(像水螅)的神經反應,也就是刺激-反應(Stimulus-Response),雖然簡單,但應用在電腦網路卻很有威力,是現在所有網站的主流方式。

Swift網路程式也採用請求-反應(Request-Response),先從App 發出一個「網路請求(Request)」到遠端伺服器(網站主機),伺服器根據請求回覆結果,內容統稱為「網路回應(Response)」,圖示如下:


App想要發出「網路請求(Request)」,有一個很關鍵的資訊稱為 "URL",原文是 Universal Resource Locator,字面上可譯為「通用資源定位器」,其實就是所謂的「網址」,網址並不只有伺服器位址,還包括其他部分,以"2-7 台灣特有種鳥類 - 2021.json" 所分享的網址為例,可分成4個部分,圖解如下:



第①部分是網路資源的種類,術語稱為 Scheme (格式、規格、規劃、方案...很多含義),原先這部分稱為網路協定(Network Protocol),最常見是 HTTP 或 HTTPS,HTTP 的連線過程沒有加密,比較節省CPU資源,HTTPS 則會加密,比較安全。

Scheme除了 HTTP/HTTPS 之外,還有 ftp, file, mailto, sms 等等3百多種,幾乎涵蓋目前網路能提供的服務或資源類別。

其中file 是用來存取本機(而非網路上的)檔案,這是為什麼2-7課讀取JSON檔案時,物件的方法(函式)稱為 url()的原因:
 let 檔案 = Bundle.main.url(forResource: 檔名, withExtension: "json") 

還有最近疫情期間的實聯制,用手機掃描QR Code之後,會轉到傳送簡訊畫面,其實就是利用 sms (簡訊)的URL網址。

可見 URL 雖然稱為「網址」,但未必都是在瀏覽器中使用,別的地方也會用到,的確可稱為通用(Universal)。

第②部分是主機(Host)位置,術語稱為Domain Name(主機的網域名稱),是代表網路上提供資源的主機地址。

這部分根據不同的資源類型(Scheme),會有不同的語法,例如簡訊 sms:1922,主機位址寫的是電話號碼。或是電子郵件 mailto:[email protected] 寫的是 email address。

第①、②部分的字母是不區分大小寫的,用大寫或小寫字母都代表同樣意思,但是都不允許空格或某些標點符號。

第③部分是網頁檔案或程式的路徑(Path),路徑(子目錄)的分隔用斜線 "/"。在此例路徑最後的 view (檢視)是查詢資料庫的程式或指令。

第④部分是給路徑末尾 view 指令的參數,通稱為查詢參數(Query),參數可以好幾組,每組為key=value形式,用 "&" 符號隔開,參數最前面要加問號 "?"。

第③、④部分的大小寫是有區別的,不可混淆。

這4個部分,在Swift程式語言中,分別對應URL物件的 scheme, host, path, query 等4個屬性。

以下第1個範例程式就先練習取用 Swift 的 URL 物件,只需導入 Foundation 物件庫。

// 3-1a URL
// Created by Heman, 2021/09/04
import Foundation

let 網址 = "https://drive.google.com/file/d/13g2sCz-zXK4uCesWeY4pPflowb06qdus/view?usp=sharing"
let myURL = URL(string: 網址)!

print("myURL:", myURL, "\n----")
print("scheme:", myURL.scheme)
print("host:", myURL.host)
print("path:", myURL.path)
print("query:", myURL.query)


執行結果如下:


注意主控台的輸出。從執行結果可以看出來,URL 就是將網址字串包裝為物件,另外加上分解後的屬性,包括 scheme (資源種類)、host (主機位址)、path (路徑)、query (查詢參數)等等,其中 scheme, host, query 都是 Optional 類型,表示有可能被省略,只有路徑一定可以初始化,若路徑是空字串,預設為根目錄 "/"。

所以在使用 URL 物件時,要注意 URL() 回傳的是一個 Optional 類型,當傳入的網址字串不符合 URL 規格時,就無法初始化 URL物件,會傳回 nil。
let myURL = URL(string: 網址)!

因此,在這行程式碼中,最後使用驚嘆號來強制取值,因為傳入的參數是我們提供的,不應該出錯。

註解
  1. Host 英文的原意是主人(相對於客人Guest而言)或(病毒、寄生蟲的)宿主,若用在電腦或網路的術語,通常稱為「主機」。主機是一種角色的泛稱,凡在網路上提供某些資源或服務的電腦都可稱為主機。
  2. 刺激-反應(Stimulus-Response)的行為模式不需要經過大腦,不僅在低等動物或植物看得到,在高等動物也保留這樣的本能反應,例如人類的反射動作。
  3. 完整的URL有8個部分,另外4個部分為 username, password, port, fragment,都可省略。
  4. 早期的URL經過Internet標準組織一般化並擴充之後,稱為 URI (Uniform Resource Identifier),但在Swift語言中,URL與URI並未明顯區別。有關URI詳細說明可以參考維基百科 https://en.wikipedia.org/wiki/Uniform_Resource_Identifier
  5. 完整的3百多種網路資源類別(URI scheme)可參考官方文件 https://www.iana.org/assignments/uri-schemes/uri-schemes.xhtml
  6. 上述3-1a範例程式,如果網址出現中文或某些不允許的標點符號,URL() 將無法初始化,會傳回 nil 導致程式閃退。後面課程會有解決方法。
#3 URLSession 網路連線


前一節提到了Swift網路程式採用 Client-Server模式,連線過程分為請求-反應(Request-Response) 兩步驟,完成這兩步驟,也就是完成一次連線過程,在 Swift 中稱為一個「工作(Task)」,而一個或多個工作合起來稱為工作階段或任務(Session)。

Swift 負責網路連線的物件稱為 URLSession (連線任務),每個 URLSession 可產生一個或多個連線工作(Task),每個連線工作(Task)都會進行請求(Request)跟反應(Response)兩個步驟,兩步驟都完成,這個Task才算成功,而所有Task完成,URLSession才算完成。

URLSession基本的用法如以下範例3-1b,這個範例可以正常執行,但是URLSession 任務不會完成,所以請特別注意執行所輸出到主控台的結果。

// 3-1b URLSession (NG)
// Created by Heman, 2021/09/04
import Foundation

let 網址 = "https://drive.google.com/file/d/13g2sCz-zXK4uCesWeY4pPflowb06qdus/view?usp=sharing"
let myURL = URL(string: 網址)!
URLSession.shared.dataTask(with: myURL) { data, response, error in
print(data)
print(response)
print(error)
}.resume()

print("Done")


先看執行的結果,注意圖右方主控台的輸出只有 "Done":

程式中有4個print()輸出指令,但只出現第④個輸出結果,而且沒有任何錯誤訊息,也就是說,語法是正確的,網址也確認沒錯,然而①到③ print()根本沒有執行,為什麼會這樣?

這是因為網路連線程式與前面兩單元所教程式的運作方式很不一樣,什麼地方不一樣呢?我們先看 URLSession 的基本語法:
URLSession.shared.dataTask(with: myURL) { data, response, error in
<匿名函式>
}.resume()

這裡用到匿名函式,還記得在第2單元2-6課說明過匿名函式,使用匿名函式的物件(如2-6課的父視圖)會負責傳遞參數給匿名函式。在本範例中,由URLSession.shared.dataTask() 負責傳遞參數給匿名函式,傳遞的參數共有3個,名稱可以隨便取,但是用途是固定的,依次分別是:

data -- Response 回傳的資料內容
response -- Response 的回應表頭Headers (或稱為回應碼)
error -- 如果任務中斷,或是伺服器發生錯誤,會傳回錯誤碼

回應(Response)拆成兩部分,即資料內容(data)與回應碼(response)。如果Response回傳成功,error 參數會是 nil;如果沒收到 Response,error 會包含錯誤代碼,而 data 與 response 則是 nil。

我們想要看看 data, response, error 的內容,因此在匿名函式中,分別用 print() 列印出來。如果print()參數是nil (未初始化),執行時會產生錯誤訊息而立刻退出,如果是空字串,至少會換行(空白行),但是都沒有,表示根本沒有執行。

為了解釋這個現象,我們需要進一步了解URLSession 的執行過程,圖解如下:



⑴首先,URLSession 物件有個「類別屬性」(type property),稱為 shared (共享)的物件實例,URLSession.shared 語法類似 Color.red,我們在第2單元2-5課說明過「類別屬性」。

URLSession.shared 物件實例有何用途呢?這是指作業系統的一個共享空間(在記憶體中),可以存放所有App提出的網路連線任務,因為所有的網路連線,實際上都是由作業系統統籌管理與執行的。所以 shared 就是使用共享連線任務空間的一個物件實例。

如果不想用共享的網路連線任務,可以用 URLSession(configuration: .default) 產出一個連線任務實例,作業系統會單獨分配一個專屬的記憶體空間給程式使用,在傳輸較大檔案時會較有效率。如果需要,程式碼可以改為:
URLSession(configuration: .default).dataTask(with: myURL) { data, response, error in
<匿名函式>
}.resume()

如此一來,雖然還是要交給作業系統負責實際連線,但不會和其他App共用任務空間。但就本範例來說,兩者效果是一樣的,所以我們用 URLSession.shared 即可。

⑵有了 URLSession.shared 任務實例之後,就可以呼叫產生「請求工作」的函式,目前共有5個方法可以產生不同的網路請求:

1. dataTask() -- 一般較短暫的資料下載
2. downloadTask() -- 較長時間的檔案下載
3. uploadTask() -- 上傳資料
4. streamTask() -- 音樂、語音或影片的串流資料下載
5. webSocketTask() -- 雙向(上傳、下載)資料交換

這5個方法都需用URL當作參數,也都會產出一個代表請求工作的物件實例(即請求-反應Request-Response過程的步驟一),但這時候還沒有真正連線。

⑶等到呼叫這個工作物件實例的 resume(),作業系統才會開始進行網路連線。

所以執行上述⑴⑵⑶之後,才算完成網路連線的第一個步驟(Request):
URLSession.shared.dataTask(with: myURL) { 匿名函式 }.resume()

⑷那麼中間的{ 匿名函式 } 做什麼用呢?最特別的地方就在這裡,這個匿名函式並不會馬上執行,要等到第二個步驟 Response 成功回傳之後,才會帶著3個參數進入匿名函式開始執行。

所以這個匿名函式術語稱為 "completion handler",主要目的就是在請求-反應(Request-Response)兩步驟都完成後,處理回傳資料,至於如何處理回傳資料,當然就是程式設計師的責任啦。

因此,dataTask() 執行網路連線的兩個步驟 Request-Response,可以圖解如下:

URLSession網路連線和其他指令不一樣的地方,就是執行過程的⑶、⑷之間(也就是Request-Response之間)並不連續,不是做完⑶馬上就執行⑷,因為在發出Request之後,什麼時候會得到Response並不知道,有可能很快,也可能永遠回不來(伺服器當機或網路中斷)。

所以當做完⑶,也就是呼叫resume()之後,程式會先執行 resume() 後面的程式碼,也就是:
print("Done")
等作業系統通知傳回 Response 之後,才回過頭執行匿名函式,這樣的運作方式稱為「非同步」(asynchronous)。

但問題是 print("Done") 之後已經到程式末尾,整個程式結束了,沒機會等到回應(Response)了,所以匿名函式裡面的3個 print() 根本就沒有機會執行。

怎麼辦?下一節會提出解決辦法。

本節的範例程式雖然不成功,但是帶出的觀念卻非常重要,尤其是非同步(asynchronous),是整個第3單元的核心觀念!所以請務必充分理解本節內容,包括以下註解。

註解
  1. Session 是從頭到尾完成一項任務的意思,任務中間可能分成若干工作或步驟,任務或工作不拘大小,可簡單可複雜。生活上,立法院的一個會期,或是學校的一學期課程,也可稱為一個 Session。
  2. Task 是較短的工作或差事,一個 Session 通常可分解為一到多個 Task。
  3. 因此URLSession 執行過程要先產出一個 URLSession() 任務實例,再產出一個或多個 dataTask()/downloadTask()/uploadTask() 工作實例。
  4. URLSession.shared 是一個作業系統預先產出的任務實例,在任何App或程式任何地方使用 URLSession.shared,都指向同一個任務實例,所以稱為 shared (共享)。
  5. resume 字面上是(暫停後)繼續、恢復、重新開始的意思,注意s發/z/音。當作名詞時(法文發音),是履歷、簡歷之意。
  6. synchronous 字面意思是在同一時間出現或以相同速度前進,中文稱「同步的」,而asynchronous則是相反,稱為「非同步」,特別指時間上無法協調或預期的事件。舉例來說,兩個人用手機通話,或是看直播影片,對人來說,這是「同步」的,因為必須在同一時間發生;而用 LINE 或 e-mail 溝通則是非同步的,因為對方什麼時候會讀取或回覆是無法預期的。
  7. 非同步事件(asynchronous event)是很重要的概念,可理解為「在某個無法預期的時間點會發生的事情」,對Client App來說,網路回應(Response)就是個非同步事件,而對Server來說,網路請求(Request)才是非同步事件。
#4 URLSession 正確用法

在前一節我們第一次使用 URLSession 進行網路連線,雖然語法正確,但是卻沒收到回傳資料,因為 URLSession網路連線是「非同步」的運作模式,發出請求(Request)之後,並不會在原地等待,而是往下繼續執行,太早結束來不及收到回傳資料。

所以如果想要等到回傳資料,直覺上的解決辦法,就是在 resume()後面設法等待。在第1單元1-10課曾經學過用 Date() 計算時間差,正好適合用來打發時間。

以下範例程式加了一個迴圈等待,最後成功收到回傳資料!
// 3-1c URLSession (Good)
// Created by Heman, 2021/09/04
import Foundation

let 網址 = "https://drive.google.com/file/d/13g2sCz-zXK4uCesWeY4pPflowb06qdus/view?usp=sharing"
var 回傳資料: Data?
let 計時開始 = Date()
var 時間差 = Date().timeIntervalSince(計時開始)

if let myURL = URL(string: 網址) {
print("送出訊息...")
URLSession.shared.dataTask(with: myURL) { data, response, error in
print("回傳資料:", data ?? "No data")
print("回應代碼:", response ?? "No response")
print("錯誤代碼:", error ?? "No error")
回傳資料 = data
}.resume()
}

while 回傳資料 == nil && 時間差 < 5.0 {
時間差 = Date().timeIntervalSince(計時開始)
}

print("花費時間(秒):", 時間差)

執行結果如下,網路連線前後花費約0.9秒:

這次我們在 resume() 後面加上一段while迴圈,因此在 URLSession.shared.dataTask().resume()發出請求之後,就會進入這個迴圈:
while 回傳資料 == nil && 時間差 < 5.0 {
時間差 = Date().timeIntervalSince(計時開始)
}

若是還未收到「回傳資料」而且「時間差」小於5.0秒,則一直反覆計算時間差,用來打發時間。5秒鐘是我們設定的逾時(timeout)門檻,如果超過5秒還未收到資料就脫離迴圈放棄等待,正常情況下,1秒鐘左右就會收到資料。

在執行迴圈的某一時刻,作業系統會通知程式已收到回傳資料,這時候會立刻「暫停」迴圈,回頭執行 URLSession.shared.dataTask()的匿名函式,匿名函式結束後再從迴圈暫停的地方繼續執行,但這時迴圈的條件 「回傳資料 == nil」已不成立,因此便會脫離迴圈。

這樣的方法有點笨,因為要一直去反覆確認「回傳資料 == nil」,相當於詢問「回傳資料收到沒」?結果在筆者電腦(mini Mac 2014)一共詢問了2萬7千多次,還好電腦不會煩,只是比較浪費CPU資源。

這次我們在使用 URL 物件時,不像之前用驚嘆號 ! 強制取用:
let myURL = URL(string: 網址)!

而是加上一個 if 條件句,如果 URL() 物件初始化成功,才進入 { } 內繼續執行,這樣的寫法比較安全,即使網址有中文字或特殊符號,無法轉換成 URL 物件,也不會讓程式發生錯誤而閃退。
if let myURL = URL(string: 網址) {
....
}

要注意 if 條件句的 { } 段落,若是在裡面定義新的變數或常數,其有效範圍(scope)是侷限在 { } 內的,不能拿到 { } 外面使用,與 for-in 迴圈變數的有效範圍類似。

同樣,在 URLSession.shared.dataTask() 匿名函式的3個參數 data, response, error,也無法帶出匿名函式之外,但是在後面我們又需要判斷資料收到沒,所以我們用一個全域變數「回傳資料」,將 data 指定給「回傳資料」,這樣就可間接將 data 帶出來。
    URLSession.shared.dataTask(with: myURL) { data, response, error in
print("回傳資料:", data ?? "No data")
print("回應代碼:", response ?? "No response")
print("錯誤代碼:", error ?? "No error")
回傳資料 = data
}.resume()

另外,注意這次 print() 內的用法,對於 data, response, error 這三個都是 Optional 資料類型的變數,最便捷的使用方式就是用 ?? 二選一,像這樣:
data ?? "No data"

語法上這相當於「如果 data 是 nil,就用 "No data" 字串,否則就取用 data 內容」:
data == nil ? "No data" : data!

兩相比較,使用 ?? 不需加驚嘆號強制取用(強制取用如果失敗會造成閃退),比較安全又方便。

最後執行結果顯示在主控台的內容如下:
送出訊息...
回傳資料: 73631 bytes
回應代碼: <nshttpurlresponse: 0x7fd17b62e0b0=""> { URL: https://drive.google.com/file/d/13g2sCz-zXK4uCesWeY4pPflowb06qdus/view?usp=sharing } { Status Code: 200, Headers {
"Alt-Svc" = (
"h3=\":443\"; ma=2592000,h3-29=\":443\"; ma=2592000,h3-T051=\":443\"; ma=2592000,h3-Q050=\":443\"; ma=2592000,h3-Q046=\":443\"; ma=2592000,h3-Q043=\":443\"; ma=2592000,quic=\":443\"; ma=2592000; v=\"46,43\""
);
"Cache-Control" = (
"no-cache, no-store, max-age=0, must-revalidate"
);
"Content-Encoding" = (
gzip
);
"Content-Type" = (
"text/html; charset=utf-8"
);
....<省略>
} }
錯誤代碼: No error
花費時間(秒): 0.8959380388259888

「回傳資料」的資料類型是 Data?(Data 加問號 ? 變成 Optional類型),Data 是沒有結構化的原始資料,所以 print() 出來時,「回傳資料」只能顯示 "73631 bytes",而不會告訴你是什麼內容。

回應代碼(response)則是結構化的資料類型,所以有一連串的 key-value 組合(參考第2單元2-8課末key path註解),各有其意義,在此我們不必理會,只要確認有內容即可。

最後的錯誤代碼(error)回傳 nil,表示連線正常。

註解
  1. Data是一個通用資料類型,我們在第2單元2-7課傑森解碼器曾經用過,從本地檔案讀取或網路傳回的資料,不管是文字、文件、圖片、JSON格式....等,在解碼或分解結構之前,都可一律塞在 Data 裡面,其實就是一連串的0與1,所以又稱為原始資料(raw data),英文 raw 是生的,沒有處理過的意思。
#5 第2課 網路抓圖

在如今數位時代,幾乎人人都會上網,但只有少數人能創作網路App,因為一方面要懂程式語言,另外一方面還須了解網路如何運作。如果你從第1單元按部就班學到現在,恭喜你,已經一腳踏入少數人能抵達的區域。

不過,網路世界非常遼闊,每天都有新的技術及應用出現,所以千萬不要自滿,即使學完第3單元,也只算懂一點皮毛而已,前方未探索的區域仍是無止盡。

在第1課學會 URLSession,能夠在文字模式開啓網路連線任務之後,本課開始將進入圖形模式,學習如何在圖形模式下連接網路,取得各類豐富的多媒體資料。

首先最基本的,如何從網路下載一張圖片並顯示出來。

我們要連結的網站 https://picsum.photos/ 是第2單元2-1課用過的 Unsplash 網站的延伸應用,只要在網址指定圖片的大小,網站就會隨機下載一張源自 Unsplash 自由授權的圖片。

例如,用瀏覽器打開這個網址:
 https://picsum.photos/720/1280

會隨機下載一張 720 (寬) x 1280 (高) 的照片,每次連接不同照片。下圖是本課範例程式的執行結果。

程式碼如下:
// 3-2a 讀取網路圖片(URLSession)
// Created by Heman, 2021/09/02
import PlaygroundSupport
import SwiftUI

let 網址 = "https://picsum.photos/720/1280"

struct 抓圖: View {
@State var 下載圖片: UIImage?
var body: some View {
if 下載圖片 == nil {
ProgressView()
.onAppear {
guard let myURL = URL(string: 網址) else { return }
URLSession.shared.dataTask(with: myURL) { 回傳資料, 回應碼, 錯誤碼 in
if let 圖檔 = UIImage(data: 回傳資料!) {
print(回應碼 ?? "No response")
下載圖片 = 圖檔
} else {
print(錯誤碼 ?? "No error")
}
}.resume()
}
} else {
Image(uiImage: 下載圖片!)
.resizable()
.scaledToFit()
}
}
}

PlaygroundPage.current.setLiveView(抓圖())


如果還記得上一課的內容,我們可先來看 URLSession 網路連線的部分:
URLSession.shared.dataTask(with: myURL) { 回傳資料, 回應碼, 錯誤碼 in
if let 圖檔 = UIImage(data: 回傳資料!) {
print(回應碼 ?? "No response")
下載圖片 = 圖檔
} else {
print(錯誤碼 ?? "No error")
}
}.resume()

匿名函式的參數名稱是可以隨意取的,我們改用中文「回傳資料」(data)、「回應碼」(response)、「錯誤碼」(error),只要順序不變即可。

還記得匿名函式必須等到 Response 回應收到以後,作業系統會通知程式,dataTask()才帶著3個參數進入匿名函式執行。所以匿名函式內的程式碼:
    let 圖檔 = UIImage(data: 回傳資料!)

就是將從回應(Response)得到的原始資料(raw data)轉成圖片物件,UIImage() 會自動判別圖片格式(如JPG, GIF 或 PNG)。上一課提過,回傳資料是 Data? (Optional) 資料類型,所以加驚嘆號強制取用。

另外,UIImage() 轉換結果也是 Optional 資料類型,這時候如果再強制取用,風險比較高,因為我們實在沒有十成把握網路回傳資料能否轉成圖形,所以整個指定句外面用 if 條件句包起來:
    if let 圖檔 = UIImage(data: 回傳資料!) {
print(回應碼 ?? "No response")
下載圖片 = 圖檔
} else {
print(錯誤碼 ?? "No error")
}

如果 UIImage() 順利獲得圖片物件,就列印出「回應碼」並將圖片物件指定給狀態變數「下載圖片」,否則就列印錯誤代碼。

為什麼要指定給「狀態變數」(@State var)呢?因為一方面可將資料帶出匿名函式,另一方面又可立刻更新整個View視圖,也就是會重新執行視圖主體(body)的程式碼,這時候在判斷以下條件句時,就會有不同的結果:
    var body: some View {
if 下載圖片 == nil {
ProgressView()
....<省略>
} else {
Image(uiImage: 下載圖片!)
.resizable()
.scaledToFit()
}
}

在網路回應(Response)之前,邏輯運算式「下載圖片 == nil」為true,所以會顯示 ProgressView(),可看到一直轉圈圈的小動畫;等收到回應並成功取得圖片之後,下載圖片就不再是 nil 了,顯示的就是 Image() 已經下載的圖片。

那麼,是如何發出網路請求的呢?因為我們知道一開始,顯示出來的一定是 ProgressView(),所以利用視圖修飾語 .onAppear { },只要 ProgressView() 開始出現在螢幕,就執行 { } 段落裡面的程式碼。
ProgressView()
.onAppear {
guard let myURL = URL(string: 網址) else { return }
URLSession.shared.dataTask(with: myURL) { 回傳資料, 回應碼, 錯誤碼 in
....<省略>
}.resume()
}

不過在這裡 { } 段落裡面,我們不像上一課用 if let myURL = URL() { },而是用 guard let myURL = URL(),有什麼差別呢?除了邏輯判斷稍不同之外,主要差異是 guard 所定義的變數或常數,有效範圍跟平常(沒有guard時)一樣,會繼續往下帶,所以 myURL 可以帶入下一句 URLSession.shared.dataTask() 的匿名函式中。

最後還有一個小問題:dataTask() 的 resume() 之後已經沒有程式碼可以執行,為什麼程式不會結束,就像上一課那樣?為什麼能等到 Response 回應而顯示出圖片?

主要的原因就在「圖形模式」。在文字模式時,執行完最後一行程式碼,程式就結束了;但在圖形模式下,並非如此,圖形模式在顯示視圖畫面之後,會一直等待使用者的互動,除非使用者主動關閉,否則程式不會主動停止。

程式執行的影片如下,注意一開始 ProgressView() 的顯示(不到一秒鐘),可看到一直旋轉的小動畫。


註解
  1. 網路程式特別要注意的一點,就是連接的遠端網站是否穩定。本課範例程式依賴 https://picsum.photos/ 的網路服務,如果網站中斷或某一天關閉了,程式碼就必須修改,否則就沒用了。
#6 ProgressView

上一節用到一個新的視圖 ProgressView,是第2單元沒有學過的,但其實用法非常簡單,直接看範例就懂。主要分成兩種,一種是圓形旋轉,一種是線性進度,以下是這兩種的使用範例。
// 3-2b ProgressView
// Created by Heman, 2021/09/13
import PlaygroundSupport
import SwiftUI

struct 等待: View {
var body: some View {
VStack {
ProgressView()
Spacer()
ProgressView("下載中...")
.scaleEffect(1.5)
Spacer()
ProgressView("等待中...")
.scaleEffect(2.0)
Spacer()
ProgressView("10%...", value: 0.1)
.padding()
Spacer()
ProgressView("200KB...", value: 200, total: 1000)
.font(.title2)
.padding()
}
}
}

PlaygroundPage.current.setLiveView(等待())

前三個是圓形旋轉,這是ProgessView()省略所有參數時的預設用法,可加上文字提示,或用 scaleEffect() 視圖修飾語加以放大(提示文字也會一起放大)。

後兩者是線性進度,根據目前進度值(value)參數的百分比加以顯示,所以通常將進度值定義為狀態變數(@State var)以更新進度圖。

除了 ProgressView 之外,範例中還使用另一個同樣超級簡單的視圖,就是 Spacer(),這是在各個視圖之間填補空間用的,否則這些 ProgressView 會擠在一起不美觀。

後面兩個 ProgressView 加了 padding() 修飾語,會在視圖四周留點空白。

注意 Spacer() 與 padding() 的區別,Spacer() 是一個視圖(View),而 padding() 是視圖修飾語(View Modifier),前者會佔一個視圖的空間(實際大小由上層父視圖決定),後者則是在視圖周圍留白。

顯示如下圖,前三個 ProgressView 是動態的圓形旋轉動畫,後兩個是靜態的顯示線性進度。


除了上述用法之外,SwiftUI還允許自行定義ProgressView的外觀風格,但本單元暫不介紹。

註解
  1. Progress 英文是進展、進度、進步的意思,如"Work-in-Progress (WIP)"是製造中、開發中或設計中的半成品。
#7 網路抓圖與手勢

本節我們將第2單元學過的手勢操作加進來,每點一次,就重複到 https://picsum.photos/720/1280 抓圖,這樣每次都可下載不同的圖片。

為了提示使用者可以輕點畫面,我們用 ZStack 在圖片上方,加上一段文字,設定透明度60%,如下圖顯示效果:


// 3-2c 手勢讀取網路圖片(URLSession + TapGesture)
// Created by Heman, 2021/09/12
import PlaygroundSupport
import SwiftUI

let 網址 = "https://picsum.photos/720/1280"

struct 抓圖: View {
@State var 下載圖片: UIImage?

func 下載() {
guard let myURL = URL(string: 網址) else { return }
URLSession.shared.dataTask(with: myURL) { 回傳資料, 回應碼, 錯誤碼 in
if let 圖檔 = UIImage(data: 回傳資料!) {
print(回應碼 ?? "No response")
下載圖片 = 圖檔
} else {
print(錯誤碼 ?? "No error")
}
}.resume()
}

var body: some View {
if 下載圖片 == nil {
ProgressView()
.onAppear {
下載()
}
} else {
ZStack(alignment: .top) {
Image(uiImage: 下載圖片!)
.resizable()
.scaledToFit()
.onTapGesture {
下載圖片 = nil
}
Text("Tap image to reload from: \n \(網址) ")
.multilineTextAlignment(.center)
.font(.title3)
.background(Color.gray)
.opacity(0.6)
.offset(y: 10)
}
}
}
}

PlaygroundPage.current.setLiveView(抓圖())

這次的程式碼,我們將 URLSession 連線的部分抽取出來,單獨寫成視圖的物件方法「下載()」。注意函式必須寫在視圖結構裡面,不能拿到視圖外面。

跟上一節一樣,在 URLSession 的匿名函式,會變更狀態變數(@State var),這樣抓到圖之後,才會更新視圖畫面。

以下部分將圖片下載的程式碼改成視圖物件的方法:
let 網址 = "https://picsum.photos/720/1280"

struct 抓圖: View {
@State var 下載圖片: UIImage?

func 下載() {
guard let myURL = URL(string: 網址) else { return }
URLSession.shared.dataTask(with: myURL) { 回傳資料, 回應碼, 錯誤碼 in
if let 圖檔 = UIImage(data: 回傳資料!) {
print(回應碼 ?? "No response")
下載圖片 = 圖檔
} else {
print(錯誤碼 ?? "No error")
}
}.resume()
}
....<省略>
}

如何讓圖片重新下載呢?其實很簡單,就是將狀態變數「下載圖片」重新指定為 nil 即可,所以放在 onTapGesture 點擊手勢的視圖修飾語裡面,非常簡單:
Image(uiImage: 下載圖片!)
.resizable()
.scaledToFit()
.onTapGesture {
下載圖片 = nil
}

至於提示的文字視圖,這是第2單元所教的內容,應該不成問題:
Text("Tap image to reload from: \n \(網址) ")
.multilineTextAlignment(.center)
.font(.title3)
.background(Color.gray)
.opacity(0.6)
.offset(y: 10)

執行時,最好將Swift Playgrounds的「啟用結果」關閉。整個執行過程的影片如下:


註解
  1. 為什麼可以將「下載圖片」重新指定為 nil 呢?之前提過,nil 是變數或物件未初始化的狀態,事實上,當我們將資料型態加上問號(問號之前不可有空白),例如 UIImage? ,這其實會包裝成另一個物件型態,名稱就稱為 Optional,也就是說 Optional 也是一種物件型態,只有兩個值(二選一),一個是 nil,代表原先的資料還未初始化,另一個是原來資料型態的值(代表已初始化)。
  2. 所以 Int 和 Int? 是不同的資料型態,兩者的變數不可混合運算,例如:
    var x: Int = 5
    var y: Int? = 10
    let z = x + y //Error!
    print(z) //無法執行

    let z = x + y! //OK
    print(z) //可執行

  3. 所以 Optional 類型的變數加上驚嘆號 ! ,其實就是剝掉 Optional 的外殼,還原回原來的資料型態,如果這時候真的沒有初始化,那事情就大條了,程式會閃退。
  4. 注意 Optional 類型變數的指定句:
    var x: Int = 5
    var y: Int? = 10
    x = y //Error!
    x = y! //OK
    y = x //OK
#8 定時自動抓圖

上一節我們利用手勢控制,每當輕點螢幕就到https://picsum.photos/720/1280 抓圖更新畫面,那何不設計一個定時抓圖的程式,省掉輕點螢幕的動作,讓程式每隔5秒自動抓圖更新,就像一個無止盡的相片播放器。

實現方式需要用到一個新物件「定時器 Timer」,這是 Foundation 裡的物件,此物件跟 URLSession 類似,都是由作業系統所控制,如果應用程式需要的話,必須跟作業系統登記,由作業系統統一分發。

登記使用定時器物件的方法如下:
let 定時器 = Timer.publish(every: 5.0, on: .main, in: .common).autoconnect()

Timer.publish() 是定時器物件類型的一個「類型方法」 "type method",就像上一課的 URLSession.shared 是個類型屬性 "type property",類型方法是整個類型適用的函式,publish() 這個函式會產出一個定時器的物件實例。

為什麼稱為 publish() 呢?publish 是發行、發布的意思,因為在此處,作業系統與App的溝通模式跟網路連線很類似,術語稱為發布-訂閱模式 "Publisher-Subscriber",圖解如下:


在範例中,作業系統控制的Timer是發布者,而App使用的物件實例「定時器」是訂閱者。

Timer.publish() 的三個參數:

every: 5.0
on: .main
in: .common

只有第一個參數 every 我們需要了解,是設定「每隔多久」發布一次通知,範例中為5.0秒。另外兩個 on: .main, in: .common 牽涉到作業系統的運作原理,我們暫不討論,但也不可省略。

這樣就會取得一個定時器的實例,完成登記,就像拿到一個號碼牌,每隔5.0秒鐘,作業系統會發一個「通知」給我們,不過這樣還沒有真的啟用,必須在最後一步使用物件方法 autoconnect() 來啟用:
let 定時器 = Timer.publish(every: 5.0, on: .main, in: .common).autoconnect()

這樣的過程是不是跟 URLSession.shared.dataTask().resume() 很類似?沒有錯,因為 Timer 和URLSession 一樣,都是非同步(asynchronous)的行為模式,我們看過範例程式後會更了解。

取得定時器的物件實例之後,如何使用呢?我們會放在顯示圖片的視圖中:
    Image(uiImage: 下載圖片!)
.onReceive(定時器) { 時間 in
下載圖片 = nil; 淡入 = false
}

與 URLSession 類似,程式收到作業系統定時器的通知後,會帶一個參數「時間」進入匿名函式,這個參數「時間」其實就是發布通知當下的時間 Date(),不過我們在範例中並未用到這個參數。

匿名函式中,我們只寫了兩個指定句,將「下載圖片」與「淡入」兩個狀態變數還原,這樣就會重新執行視圖的主體(body),再一次下載圖片並且產生淡入的效果。兩個短句可以寫在同一行,中間用分號 ; 隔開即可。

我們先來看看執行的影片,自動下載與淡入的效果似乎還不錯。


程式碼如下,除了定時器與淡入效果,其他都是上一節學過的內容,就不再重複解說。淡入效果在第2單元2-10課學過,忘記的同學可以到2-10課複習一下。
// 3-2d 定時反覆讀取網路圖片(URLSession + Timer)
// Created by Heman, 2021/09/12
import PlaygroundSupport
import SwiftUI

let 網址 = "https://picsum.photos/720/1280"

struct 抓圖: View {
@State var 下載圖片: UIImage?
@State var 淡入 = false
let 定時器 = Timer.publish(every: 5.0, on: .main, in: .common).autoconnect()

func 下載() {
guard let myURL = URL(string: 網址) else { return }
URLSession.shared.dataTask(with: myURL) { 回傳資料, 回應碼, 錯誤碼 in
if let 圖檔 = UIImage(data: 回傳資料!) {
print(回應碼 ?? "No response")
下載圖片 = 圖檔
} else {
print(錯誤碼 ?? "No error")
}
}.resume()
}

var body: some View {
if 下載圖片 == nil {
ProgressView()
.onAppear {
下載()
}
} else {
Image(uiImage: 下載圖片!)
.resizable()
.scaledToFit()
.opacity(淡入 ? 1.2 : 0.0)
.animation(.easeInOut(duration: 5.0))
.onAppear {
淡入 = true
}
.onReceive(定時器) { 時間 in
下載圖片 = nil; 淡入 = false
}
}
}
}

PlaygroundPage.current.setLiveView(抓圖())


註解
  1. 注意圖片淡入的設定,透明度 .opacity(淡入 ? 1.2 : 0.0) 為什麼設為 1.2 而不是 1.0 呢?其實 opacity 正常最大值就是1.0 (100%完全不透明),大於1.0的效果跟1.0是一樣的。
  2. 因為在 .animation(.easeInOut(duration: 5.0)) 動畫效果的設定,opacity 會在 5秒內按比例由 0.0 增加到 1.2,所以最大值設定為 1.2 是為了讓 opacity 停留在 ≥ 1.0 的時間多一點(約一秒鐘)。
  3. 可以試著改成 .opacity(淡入 ? 1.0 : 0.0) ,看看效果有何不同。
  4. 對App來說,.onAppear(), .onReceive(), .onTapGuesture() 其實都是非同步事件,所以用法都很類似。
#9 第3課 連接網路資料庫

在第2單元第2-7課曾提過,若程式學會解碼JSON格式,就像多出一隻手延伸到網路上的資料庫,可源源不絕取得資料更新網頁,本課就來實現這個想法。

當然,前提是網路上的伺服器必須開放權限,允許取用資料庫才行。

Apple公司的 iTunes 資料庫就是一個對任何人開放的音樂資料庫,可以傳回 JSON 格式的資料,再用程式加以解碼,就可獲得全世界音樂藝人的相關資料。例如,用瀏覽器連接網址:

https://itunes.apple.com/search?term=Justin+Bieber&media=music

就會在 iTunes 資料庫搜尋小賈斯汀 "Justin Bieber",傳回一個 JSON 格式的檔案,共有50筆資料,內容如下:

{
"resultCount":50,
"results": [
{
"wrapperType":"track",
"kind":"song",
"artistId":259760619,
"collectionId":359966550,
"trackId":359966562,
"artistName":"Sean Kingston & Justin Bieber",
"collectionName":"Eenie Meenie - Single",
"trackName":"Eenie Meenie",
"collectionCensoredName":"Eenie Meenie - Single",
"trackCensoredName":"Eenie Meenie",
"artistViewUrl":"https://music.apple.com/us/artist/sean-kingston/259760619?uo=4",
"collectionViewUrl":"https://music.apple.com/us/album/eenie-meenie/359966550?i=359966562&uo=4",
"trackViewUrl":"https://music.apple.com/us/album/eenie-meenie/359966550?i=359966562&uo=4",
"previewUrl":"https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview125/v4/bb/ce/30/bbce3088-f26c-f5e9-b8af-50344243726e/mzaf_17828995580283253999.plus.aac.p.m4a",
"artworkUrl30":"https://is3-ssl.mzstatic.com/image/thumb/Music125/v4/56/f9/aa/56f9aa10-f1f3-dd77-76d4-2d979030b6fd/source/30x30bb.jpg",
"artworkUrl60":"https://is3-ssl.mzstatic.com/image/thumb/Music125/v4/56/f9/aa/56f9aa10-f1f3-dd77-76d4-2d979030b6fd/source/60x60bb.jpg",
"artworkUrl100":"https://is3-ssl.mzstatic.com/image/thumb/Music125/v4/56/f9/aa/56f9aa10-f1f3-dd77-76d4-2d979030b6fd/source/100x100bb.jpg",
"collectionPrice":1.29,
"trackPrice":1.29,
"releaseDate":"2010-03-19T07:00:00Z",
"collectionExplicitness":"notExplicit",
"trackExplicitness":"notExplicit",
"discCount":1,
"discNumber":1,
"trackCount":1,
"trackNumber":1,
"trackTimeMillis":201880,
"country":"USA",
"currency":"USD",
"primaryGenreName":"Pop",
"isStreamable":true},
....<省略>
]
}

想要寫JSON程式,首先必須解析資料的欄位結構。還記得2-7課提到,在JSON格式中,大括號 { } 裡面是一個物件實例,物件實例的每個欄位為 key: value 的形式,欄位之間或陣列元素之間以逗號隔開。陣列則包含在中括號 [ ] 之中。

所以根據上面回傳的JSON內容,有兩層結構,外層是大的物件實例,只有兩個欄位:

struct 頁面結構 {
let resultCount: Int
let results: [單項]
}


第二個欄位 results 是一個陣列,陣列元素「單項」則是另一層資料結構,有31個欄位。根據這樣的欄位解析,就可以來寫一個傑森解碼器,透過網路抓取 iTunes 資料庫。程式範例如下:

// 3-3a JSON by URLSession
// Created by Heman, 2021/09/03
// Updated by Heman, 2023/10/03
// https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/index.html
import PlaygroundSupport
import SwiftUI

let 網址 = "https://itunes.apple.com/search?term=Justin+Bieber&media=music"

struct 頁面結構: Codable {
let resultCount: Int
let results: [單項]
}
struct 單項: Codable, Hashable {
let wrapperType: String //track, collection, artist
let kind: String //book, album, coached-audio, feature-movie,
//interactive-booklet, music-video, pdf podcast,
//podcast-episode, software-package,
//song, tv-episode, artist
let artistId: Int
let collectionId: Int
let trackId: Int
let artistName: String
let collectionName:String
let trackName:String
let collectionCensoredName: String
let trackCensoredName: String
let artistViewUrl: URL
let collectionViewUrl: URL
let trackViewUrl: URL
let previewUrl: URL?
let artworkUrl30: URL?
let artworkUrl60: URL?
let artworkUrl100: URL?
let collectionPrice: Double?
let trackPrice: Double?
let releaseDate: String
let collectionExplicitness: String
let trackExplicitness: String
let discCount: Int
let discNumber: Int
let trackCount: Int
let trackNumber: Int
let trackTimeMillis: Int?
let country: String
let currency: String
let primaryGenreName: String
let isStreamable: Bool?
}

struct 更新頁面: View {
@State var 歌曲列表: [單項]?

func 更新歌曲列表() {
guard let myURL = URL(string: 網址) else { return }
URLSession.shared.dataTask(with: myURL) { 回傳資料, 回傳碼 , 錯誤碼 in
if let 解碼資料 = 回傳資料 {
do {
let 解碼結果 = try JSONDecoder().decode(頁面結構.self, from: 解碼資料)
print(回傳碼 ?? "No response")
歌曲列表 = 解碼結果.results
} catch {
print("JSON解碼錯誤")
}
} else {
print(錯誤碼 ?? "No error")
}
}.resume()
}

var body: some View {
if 歌曲列表 == nil {
ProgressView()
.onAppear {
更新歌曲列表()
}
} else {
List(歌曲列表!, id: \.self) { 歌曲 in
Label(歌曲.trackName, systemImage: "music.note")
.font(.title)
.lineLimit(1)
}
}
}
}

PlaygroundPage.current.setLiveView(更新頁面())

本範例如果和上一課3-2c程式比較,會發現視圖結構非常類似,狀態變數的類型,從圖片 UIImage? 改成資料結構的陣列 [單項]?,也就是:
//3-2c
@State var 下載圖片: UIImage?
換成
//3-3a
@State var 歌曲列表: [單項]?

同樣是透過狀態變數來控制畫面的更新,網路下載的內容先導入傑森解碼器 JSONDecoder() 解碼,再將結果的陣列內容指定給狀態變數「歌曲列表」。網路連線的程式碼如下:
    func 更新歌曲列表() {
guard let myURL = URL(string: 網址) else { return }
URLSession.shared.dataTask(with: myURL) { 回傳資料, 回傳碼 , 錯誤碼 in
if let 解碼資料 = 回傳資料 {
do {
let 解碼結果 = try JSONDecoder().decode(頁面結構.self, from: 解碼資料)
print(回傳碼 ?? "No response")
歌曲列表 = 解碼結果.results
} catch {
print("JSON解碼錯誤")
}
} else {
print(錯誤碼 ?? "No error")
}
}.resume()
}

視圖主體(body)部分也跟 3-2c 差不多,同樣先用 ProgressView() 顯示下載中,等下載完資料並經過傑森解碼器解碼,陣列「歌曲列表」就用 List() 排版,顯示50筆歌曲名稱(trackName)。List 用法請參考第2單元2-9課。
    var body: some View {
if 歌曲列表 == nil {
ProgressView()
.onAppear {
更新歌曲列表()
}
} else {
List(歌曲列表!, id: \.self) { 歌曲 in
Label(歌曲.trackName, systemImage: "music.note")
.font(.title)
.lineLimit(1)
}
}
}

執行結果如下,記得先將Swift Playgrounds的「啟用結果」關閉再執行。


註解

  1. iTunes 資料庫的31個欄位有些是可以省略的(設為Optional),每個欄位的涵義可參考Apple 原廠文件
  2. 根據上述原廠文件,搜尋字串的空格,需改為加號 + ,因此網址的寫法:
    https://itunes.apple.com/search?term=Justin+Bieber&media=music
  3. 根據 JSONDecoder() 的需要,資料結構需符合 Codable 規範,而在 List() 使用上,需要符合 Hashable 規範,反映在資料結構的定義上:
    struct 頁面結構: Codable { }
    struct 單項: Codable, Hashable { }
#10 搜尋iTunes音樂資料庫

能夠使用JSON解碼器連接網路資料庫,對程式非常有用,因為網路上有非常多JSON格式的開放資料,這類網站提供公開的程式連結,通常稱為 "Open API"。

上一節我們用的「網址」其實就是 iTunes 提供的 Open API,透過這個 API,我們可以在程式中搜尋任意歌手的相關資料,本節我們就來試試看。

先看最後執行的結果影片:


跟上一節範例程式比較起來,想要搜尋任意歌手,還必須實現兩個新功能:
(1) 讓使用者輸入歌手名稱
(2) 將歌手名稱加入到API網址中

第一個新功能,用到一個視圖稱為 TextField,會顯示一個輸入字串的方框,用法如下:
TextField("歌手", text: $inputText) {
歌手 = inputText
歌曲列表 = nil
}

TextField視圖會顯示一個方框,讓使用者輸入字串,如下圖中間的深色框。


TextField() 有兩個參數,"歌手"是方框內的提示文字,第二個參數很關鍵,text: $inputText 是接受輸入值的變數,必須是一個狀態變數(用@State var宣告),使用者輸入的字串,會指定到 inputText 這個變數。

比較特別的地方,是這個變數必須加上金錢符號 $,主要目的是讓參數能夠帶值出來。因為平常沒有加 $ 的參數,只能帶值進去(術語稱為 "Call by value"),加上 $ 才變成 "Call by reference",能夠帶值出來。

此處也是目前唯一不能用中文命名的地方,可能是 Swift Playgrounds 的 bug,但目前就是這樣,所有要用 $ (Call by reference)的變數,只能用英文命名。

當使用者輸入歌手名稱的字串,並按下 ENTER 之後,就會進入 { } 段落裡面,因為鍵盤輸入也是非同步事件,所以 { } 其實是一個沒有參數的匿名函式。在匿名函式中,我們將輸入的字串值指定給「歌手」,並將「歌曲列表」重新設為未初始化的狀態(nil),這樣在更新畫面時才會重新下載歌手資料。

上圖整個橘色搜尋框,用SwiftUI視圖來表現的程式碼如下:
ZStack {
Rectangle()
.foregroundColor(.orange)
.frame(height: 60)
HStack {
Image(systemName: "magnifyingglass")
.font(.title)
TextField("歌手", text: $inputText) {
歌手 = inputText
歌曲列表 = nil
}
.font(.title)
.background(Color.secondary)
}
.padding()
}

第二個新功能是將歌手名稱加入到網址的一部分,需用到另一個 Foundation 的物件,稱為 URLComponents,其實就是將 URL 物件拆解開來,用法如下,非常直觀:
var myURLComponent = URLComponents()
myURLComponent.scheme = "https"
myURLComponent.host = "itunes.apple.com"
myURLComponent.path = "/search"
myURLComponent.query = "term=\(歌手)&media=music"
guard let myURL = myURLComponent.url else { return }

還記得在第一課學習過 URL 的4個部分:scheme(網路種類), host(主機名稱), path(路徑), query(查詢參數),在這個地方,就是依照這4個部分分別指定字串值,尤其是 query 的部分要插入歌手名稱。

此處query(查詢參數)有兩個參數,一是 term=歌手名稱,另一是固定的 media=music,參數之間用 & 符號隔開。在第一課曾經提過,整個字串不能有空格或中文等特殊符號,但是使用者可能輸入空格或查詢中文的歌手名稱怎麼辦?

幸好 URLComponents 會幫我們轉換,如果我們輸入歌手「徐佳瑩」,URLComponents 會轉成:

term=%E5%BE%90%E4%BD%B3%E7%91%A9

其中 % 開頭的編碼稱為 UTF-8 編碼格式,這是 Unicode 的編碼標準之一,也是網址能夠接受的格式。這樣就解決中文網址的問題了。

整個程式碼如下,其中「單項」的資料結構原本有31個欄位,我們只留部分可能會用到的欄位即可,這樣JSON解碼器同樣沒問題。
// 3-3b iTunes Search
// Created by Heman, 2021/09/05
// https://affiliate.itunes.apple.com/resources/documentation/itunes-store-web-service-search-api/
import PlaygroundSupport
import SwiftUI

struct 頁面結構: Codable {
let resultCount: Int
let results: [單項]
}
struct 單項: Codable, Hashable {
let artistName: String
let collectionName:String
let trackName:String
let artistViewUrl: URL
let collectionViewUrl: URL
let trackViewUrl: URL
let previewUrl: URL?
let artworkUrl30: URL?
let artworkUrl60: URL?
let artworkUrl100: URL?
}

struct 更新頁面: View {
@State var inputText = ""
@State var 歌手 = "Taylor Swift"
@State var 歌曲列表: [單項]?

func 更新歌曲列表() {
// let 網址 = "https://itunes.apple.com/search?term=Justin+Bieber&media=music"
// guard let myURL = URL(string: 網址) else { return }
var myURLComponent = URLComponents()
myURLComponent.scheme = "https"
myURLComponent.host = "itunes.apple.com"
myURLComponent.path = "/search"
myURLComponent.query = "term=\(歌手)&media=music"
guard let myURL = myURLComponent.url else { return }
URLSession.shared.dataTask(with: myURL) { 回傳資料, 回傳碼 , 錯誤碼 in
if let 解碼資料 = 回傳資料 {
do {
let 解碼結果 = try JSONDecoder().decode(頁面結構.self, from: 解碼資料)
print(回傳碼 ?? "No response")
歌曲列表 = 解碼結果.results
} catch {
print("JSON解碼錯誤")
}
} else {
print(錯誤碼 ?? "No error")
}
}.resume()
}

var body: some View {
VStack {
ZStack {
Rectangle()
.foregroundColor(.orange)
.frame(height: 60)
HStack {
Image(systemName: "magnifyingglass")
.font(.title)
TextField("歌手", text: $inputText) {
歌手 = inputText
歌曲列表 = nil
}
.font(.title)
.background(Color.secondary)
}
.padding()
}
if 歌曲列表 == nil {
ProgressView()
.onAppear {
更新歌曲列表()
}
} else {
List(歌曲列表!, id: \.self) { 歌曲 in
Label(歌曲.trackName, systemImage: "music.note")
.font(.title)
.lineLimit(1)
}
}
}
}
}

PlaygroundPage.current.setLiveView(更新頁面())


最後執行的畫面如下:


註解
  1. API 全名是 Application Programming Interface,中文可稱為「應用程式介面」,基本上就是程式與程式之間的溝通規格,例如作業系統與App之間或是Server程式與Client程式之間的溝通。目前大部分的API都已經物件化,物件內容透過JSON格式交換非常方便。
  2. UTF-8編碼在此不多做說明,有興趣者可參考維基百科 https://zh.wikipedia.org/wiki/UTF-8
  3. 本節範例程式有個小bug,如果輸入的歌手名稱搜尋不到任何資料,就會停留在 ProgressView,無法再搜尋。
  4. 狀態變數在宣告時就必須提供初始值,或是設為Optional,這樣作業系統才能觀察其「狀態」(變數值)是否有變化。
    @State var inputText = ""
    @State var 歌手 = "Taylor Swift"
    @State var 歌曲列表: [單項]?
關閉廣告
文章分享
評分
評分
複製連結

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