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

#11 播放網路影音(AVKit)

iTunes 最早其實是 Apple 在 2001年推出的一款音樂播放軟體,比 iPhone 歷史還悠久,經過20年的發展,iTunes 已經成為全世界最大的音樂資料庫,每首歌曲都提供30秒左右的免費試聽,在上一節我們既然已經學會 iTunes 搜尋功能,當然也要來播放試聽內容。

用 SwiftUI 播放網路影音,比想像中簡單很多,只需用到 AVKit 物件庫中兩個物件:

1. AVPlayer(): 播放各種常見格式的音樂(Audio)或影片(Video),檔案來源可為網路或本機
2. VideoPlayer(): 提供SwiftUI相容的視圖外觀,控制AVPlayer播放、暫停、音量等

以小賈斯汀與Sean Kingston合作的歌曲"Eenie Meenie"為例,試聽檔的網址記錄在JSON結構的 "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

在程式中播放影音檔的一般方法如下,記得一開始要 import AVKit 匯入物件庫:
// 3-3c 播放網路影音
// Created by Heman, 2021/09/28
import PlaygroundSupport
import SwiftUI
import AVKit

let 網址 = "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview125/v4/bb/ce/30/bbce3088-f26c-f5e9-b8af-50344243726e/mzaf_17828995580283253999.plus.aac.p.m4a"
let myURL = URL(string: 網址)!
let 播放器 = AVPlayer(url: myURL)

struct 影音播放: View {
var body: some View {
VideoPlayer(player: 播放器)
.onAppear() {
播放器.play()
}
}
}

PlaygroundPage.current.setLiveView(影音播放())

程式分為兩個步驟:

第一部分,產出一個 AVPlayer() 物件實例,參數為影音檔案的網址(轉換為URL物件):
let 網址 = "https://audio-ssl.itunes.apple.com/itunes-assets/AudioPreview125/v4/bb/ce/30/bbce3088-f26c-f5e9-b8af-50344243726e/mzaf_17828995580283253999.plus.aac.p.m4a"
let myURL = URL(string: 網址)!
let 播放器 = AVPlayer(url: myURL)

第二部分,在視圖主體(body)中使用 VideoPlayer() 物件產生播放器外觀,參數為剛剛產出的 AVPlayer() 物件實例,並且呼叫 play() 開始播放。
VideoPlayer(player: 播放器)
.onAppear() {
播放器.play()
}

執行結果影片如下,記得打開聲音:
#12 iTunes 搜尋與播放試聽(NavigationView)

本單元到目前為止,我們已經學會不少網路程式功能,包括:

(1) 對 URL/URLComponents 網址的操作,就算包含中文也沒問題
(2) 熟悉 URLSession 連接網路的步驟
(3) 了解非同步(asynchronous)的運作模式
(4) 能夠下載圖片、音樂、JSON等檔案並加以運用
(5) 能夠連接 Open API,搜尋 iTunes 資料庫

本節就結合這幾個功能,做出一個App的雛型,能夠搜尋 iTunes 音樂,顯示搜尋的曲目列表,當使用者點選曲目時,可以顯示專輯封面並且播放試聽曲目。

先看下執行結果的影片,記得將聲音打開。本節範例程式在MacOS與iPadOS的執行畫面稍有不同,這是NavigationView 的特性,本影片是 MacOS 的範例:


為了達成這樣的操作,需要引進兩個搭配的視圖物件:NavigationView (導覽視圖)與NavigationLink(連結視圖),基本的用法如下圖:


在之前範例3-3a, 3-3b已經用過 List() 來產生列表,列表每個項目是 Label() 視圖。在此3-3d則是在最內層的 Label() 外面加一層 NavigationLink() ,讓每個 Label() 連結到右方「試聽頁面()」,也就是說,當使用者輕點 Label() 曲目時,右方會出現該曲目的試聽頁面。

而在 List() 外層,則是增加一層 NavigationView(),控制整個列表與試聽頁面之間的切換。

在 List() 底下有一個視圖修飾語,navigationTitle() 顯示整個列表的標題,另一行 Text() 則是在曲目尚未被點選之前,顯示在試聽頁面的提示文字。

因此,NavigationView 與 NavigationLink 的搭配方式,通常是由 NavigationLink 負責內圈個別項目的連接畫面,而NavigationView 則是控制整體的畫面切換。示意圖如下:


程式還需增加一個「試聽頁面」,畫面的主體(body)設計如下,其中用到第2課的圖片下載,以及上一節的音樂播放。


稍有不同的是,這裏試聽曲目的網址不是固定的,而是參數「歌曲」帶進來,無法直接用 let 指定網址,而必須像 body 一樣,使用 "computed property" 來定義,也就是將計算方式寫在大括號 { } 裡面:
var 播放器: AVPlayer {
AVPlayer(url: 曲目.previewUrl!)
}

其他部分,應該都是本單元前面課程學過的東西,完整的程式碼如下,篇幅較長,但大多是前面用過的程式段落。
// 3-3d iTunes Search & Play (NavigationView)
// Created by Heman, 2021/09/27
import PlaygroundSupport
import SwiftUI
import AVKit

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 下載圖片: UIImage?
var 曲目: 單項
init(_ p: 單項) { 曲目 = p }

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

var 播放器: AVPlayer {
AVPlayer(url: 曲目.previewUrl!)
}

var body: some View {
VStack {
Text(曲目.artistName)
.font(.largeTitle)
Text("專輯:\(曲目.collectionName)")
.font(.title)
.multilineTextAlignment(.center)
if 下載圖片 == nil {
ProgressView()
.onAppear { 下載() }
} else {
Image(uiImage: 下載圖片!)
.resizable()
.scaledToFit()
}
Text(曲目.trackName)
.font(.title2)
VideoPlayer(player: 播放器)
.onAppear() { 播放器.play() }
}
}
}

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

func 更新歌曲列表() {
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 {
NavigationView {
List(歌曲列表!, id: \.self) { 歌曲 in
NavigationLink(destination: 試聽頁面(歌曲)) {
Label(歌曲.trackName, systemImage: "music.note")
.font(.title)
.lineLimit(1)
}
}
.navigationTitle("\(歌手)@iTunes")
Text("⬅︎左方導覽頁面選擇試聽歌曲")
}
}
}
}
}

PlaygroundPage.current.setLiveView(更新頁面())
#13 第4課 Open API -- 芝加哥藝術博物館

開發一個App軟體,基本上需要兩個要素,一是程式碼,二是資料內容。

在過去寫程式最困難的地方,不是語法或演算法,而在於缺乏資料,大部分資料都是由企業、政府或機構所掌握,每筆資料都有產出成本,不會輕易與人分享,所以個人很難寫出有內容的軟體,就像俗話說「巧婦難為無米之炊」。現在拜 Open API 所賜,每個人從網路上都能免費取得豐富資料,這對程式設計者來說,是非常幸福的事。

在上一課,我們用 Open API 連接iTune 音樂資料庫,在本課則要練習連接「芝加哥藝術博物館」館藏資料庫。

芝加哥藝術博物館(Art Institute of Chicago)成立於1879年,是美國僅次於紐約大都會博物館的藝術殿堂,與著名的芝加哥藝術學院(SAIC, School of the Art Institute of Chicago)是聯屬機構。

博物館收藏超過30萬件藝術作品,著名館藏包括19世紀英國畫家William Turner與John Constable的風景畫,雷諾瓦、莫內等法國印象派畫家,還有秀拉、梵谷、塞尚、畢加索等著名歐洲畫家作品,當然也包括20世紀的美國現代藝術與其他各國藝術品,藏品非常豐富。

如果問說:「Apple iTune 的 5千萬首音樂,與芝加哥藝術博物館的30萬件作品,哪一個比較有價值」?答案肯定是芝加哥藝術博物館,因為每一件收藏都是獨一無二的真跡原件。

有了整個博物館的完整資料,我們就能夠寫出豐富內涵的App軟體,如果有雄心壯志,更可以寫一個涵蓋全世界提供Open API的博物館,說不定會成為最完整的藝術作品App。

和上一課比較起來,視覺藝術作品與音樂曲目的呈現方式應該有所不同,不過最基本的,我們先從「搜尋」功能開始,將搜尋結果列表出來,以確認Open API連接與JSON解碼正常。

先用上一課3-3b iTune 搜尋的範例程式為藍本,改為搜尋芝加哥藝術博物館的館藏資料,需要修改的地方只有兩處:

1. Open API 網址,改成類似這樣: https://api.artic.edu/api/v1/artworks/search?q=van+gogh&limit=100
2. 根據回傳的JSON結構,修改 struct 型態宣告

注意上述網址的搜尋參數(query)為 "q=van+gogh&limit=100",包含兩個參數,q=van+gogh 是搜尋字串 "van gogh" 即梵谷,limit=100 是限制最多傳回100筆資料。

若直接用瀏覽器連結上述 Open API 網址,從回傳結果就可以看出 JSON 結構,下圖是透過 Chrome瀏覽器的外掛程式 JSONView 顯示的畫面:


根據上圖顯示的JSON結構,分為2層,外層有4個欄位:preference(未使用)、pagination(分頁資訊)、data(品項陣列)、info(未使用),我們需要的資料主要在data(品項陣列)中。依此定義struct資料類型如下:
struct 搜尋結果: Codable {
let pagination: 分頁資訊
let data: [搜尋品項]
}

struct 分頁資訊: Codable {
let total: Int
let limit: Int
let offset: Int
let total_pages: Int
let current_page: Int
}

struct 搜尋品項: Codable, Identifiable {
let api_link: URL?
let id: Int
let title: String
}

「搜尋結果」類型會用於 JSONDecoder(),所以要符合 Codable 規範,「搜尋品項」類型用於 List() 須符合 Identifiable (剛好有 id 欄位)。其他程式碼幾乎不需修改,只將變數名稱重新命名、搜尋列改為白底黑字、項目圖示改為方框,完整程式如下:
// 3-4a Open API: 芝加哥藝術博物館(https://api.artic.edu/docs/)
// Created by Heman, 2021/10/02
import PlaygroundSupport
import SwiftUI

struct 搜尋結果: Codable {
let pagination: 分頁資訊
let data: [搜尋品項]
}

struct 分頁資訊: Codable {
let total: Int
let limit: Int
let offset: Int
let total_pages: Int
let current_page: Int
}

struct 搜尋品項: Codable, Identifiable {
let api_link: URL?
let id: Int
let title: String
}

struct 更新頁面: View {
@State var inputText = ""
@State var 搜尋字串 = "van gogh"
@State var 作品列表: [搜尋品項]?

func 更新作品列表() {
// let 網址 = "https://api.artic.edu/api/v1/artworks/search?q=van+gogh&limit=100"
// guard let myURL = URL(string: 網址) else { return }
var myURLComponent = URLComponents()
myURLComponent.scheme = "https"
myURLComponent.host = "api.artic.edu"
myURLComponent.path = "/api/v1/artworks/search"
myURLComponent.query = "q=\(搜尋字串)&limit=100"
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")
作品列表 = 解碼結果.data
} 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("Search for artworks", text: $inputText) {
搜尋字串 = inputText
作品列表 = nil
}
.font(.title)
.foregroundColor(.black)
.background(Color.white)
}
.padding()
}
if 作品列表 == nil {
ProgressView()
.onAppear {
更新作品列表()
}
} else {
List(作品列表!) { 作品 in
Label(作品.title, systemImage: "rectangle.portrait")
.font(.title)
.lineLimit(1)
}
}
}
}
}

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

程式執行的影片如下:


註解
  1. 大部分提供 Open API 的機構,都會要求註冊,取得一個 "API Key" 才能連接。iTunes 與「芝加哥藝術博物館」是少數不用註冊也能連接的資料庫,但限制每分鐘最多連線60次,若需要更多,則必須先註冊取得 API Key。
  2. 芝加哥藝術博物館官網的藏品呈現方式可以提供我們學習模仿 https://www.artic.edu/collection
  3. 芝加哥藝術博物館提供的 Open API 官方文件參考網址 https://api.artic.edu/docs/
  4. Google Art & Culture 專案從2011年開始,與全球各大博物館合作,協助將館藏作品數位化,十年來合作機構已超過2,000家,但每家只選擇少部分(數百種)精品 https://artsandculture.google.com/
  5. JSONView 這類的瀏覽器外掛,是程式設計很方便的小工具,網址 https://chrome.google.com/webstore/detail/jsonview/chklaanhfefbnpoihckbnefhakgolnmc
  6. 2022年10月Apple官方宣布Apple Music(與iTune同一資料庫)已超過1億首歌曲。
#14 搜尋並顯示作品圖

上一節確認可連上芝加哥藝術博物館的Open API之後,我們真正想做的,當然是呈現博物館收藏的藝術品,芝加哥藝術博物館每份藏品都有超高解析度的圖片,但是高解析度意謂檔案較大,下載時間較長,幸好透過API,可以指定圖片的解析度(即寬x高尺寸)。

根據博物館Open API文件說明,從搜尋到下載圖片,需要經過以下幾個步驟:


上一節我們已經學會從搜尋字串得到「作品列表」。本節接下來要先從作品列表中的作品編號(artworks id)與 api_link 取得詳細的「作品資訊」,因為作品資訊裡面包含了一個重要欄位,就是 image_id (圖檔編號),這個過程圖解如下,也需要傑森解碼器(JSONDecoder)。


取得圖檔編號(image_id)之後,就可以組成「圖檔網址」,然後利用第2課的抓圖程式下載圖片顯示出來。

這裡的圖檔網址,用了一個學術機構、圖書館或博物館常用的圖片API 規範,稱為 IIIF,其實就是規定Open API的欄位,其中兩個欄位比較重要,即 image_id 與圖片尺寸,圖解如下:


在完整的程式碼之前,先看看程式執行的過程。由於博物館限制每分鐘最多連線60次,所以我們將搜尋筆數從100筆改為5筆,從這5筆「作品列表」會發出5次連線取得5筆「作品資訊」,然後再連線5次取得5張圖檔,所以每次搜尋到顯示圖片共連線11次,其實很容易就超過限制,如果要做成App,還是必須跟博物館申請 API Key,才能解開限制。


完整程式碼如下,下一節會再進一步討論:
// 3-4b 搜尋並顯示作品圖片(芝加哥藝術博物館)
// Created by Heman, 2021/10/05
import PlaygroundSupport
import SwiftUI

// Refer to https://api.artic.edu/api/v1/artworks/search?q=van+gogh&limit=100
struct 搜尋結果: Codable {
let pagination: 分頁資訊
let data: [搜尋品項]
}

struct 分頁資訊: Codable {
let total: Int
let limit: Int
let offset: Int
let total_pages: Int
let current_page: Int
}

struct 搜尋品項: Codable, Identifiable {
let api_link: URL?
let id: Int
let title: String
}

// Refer to https://api.artic.edu/api/v1/artworks/28560
struct 藝術作品: Codable {
let data: 作品資訊
let config: 配置資訊
}

struct 作品資訊: Codable, Identifiable {
let id: Int
let api_link: URL?
let title: String
let date_display: String
let artist_display: String
let artist_id: Int
let artist_title: String
let image_id: String
}

struct 配置資訊: Codable {
let iiif_url: URL?
let website_url: URL?
}

// 顯示「作品圖」陣列
struct 作品圖: Identifiable {
var id: Int = 0
var 作品名稱: String = ""
var 作者: String = ""
var 圖檔網址: URL?
}

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

init(_ p: URL) {myURL = p}

func 下載() {
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()
}
}
}

struct 芝加哥藝術博物館: View {
@State var inputText = "Van Gogh"
@State var 搜尋字串 = "van gogh"
@State var 作品列表: [搜尋品項]?
@State var 作品圖集: [作品圖] = []

// let 網址 = "https://api.artic.edu/api/v1/artworks/search?q=van+gogh&limit=5"
// guard let myURL = URL(string: 網址) else { return }
func 更新作品列表(_ 字串參數: String) {
var myURLComponent = URLComponents()
myURLComponent.scheme = "https"
myURLComponent.host = "api.artic.edu"
myURLComponent.path = "/api/v1/artworks/search"
myURLComponent.query = "q=\(字串參數)&limit=5"
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")
作品列表 = 解碼結果.data
for 作品 in 作品列表! {
取得作品資訊(作品.api_link!)
}
} catch {
print("JSON解碼錯誤")
}
} else {
print(錯誤碼 ?? "No error")
}
}.resume()
}

// let 網址 = "https://api.artic.edu/api/v1/artworks/28560"
// guard let myURL = URL(string: 網址) else { return }
// 圖檔網址 IIIF v2 standard format:
// https://www.artic.edu/iiif/2/25c31d8d-21a4-9ea1-1d73-6a2eca4dda7e/full/843,/0/default.jpg
func 取得作品資訊(_ myURL: URL) {
var 單項作品: 作品資訊?
var 目前作品 = 作品圖()
var myURLComponent = URLComponents()
URLSession.shared.dataTask(with: myURL) { 回傳資料, 回傳碼 , 錯誤碼 in
if let 待解碼資料 = 回傳資料 {
do {
let 尺寸 = "600,"
let 解碼結果 = try JSONDecoder().decode(藝術作品.self, from: 待解碼資料)
print(回傳碼 ?? "No response")
單項作品 = 解碼結果.data
目前作品.id = 單項作品!.id
目前作品.作品名稱 = 單項作品!.title
目前作品.作者 = 單項作品!.artist_display
myURLComponent.scheme = "https"
myURLComponent.host = "www.artic.edu"
myURLComponent.path = "/iiif/2/\(單項作品!.image_id)/full/\(尺寸)/0/default.jpg"
myURLComponent.query = ""
目前作品.圖檔網址 = myURLComponent.url
作品圖集 = 作品圖集 + [目前作品]
} catch {
print("JSON解碼錯誤")
}
} else {
print(錯誤碼 ?? "No error")
}
}.resume()
}

var body: some View {
VStack(spacing: 0) {
Text("Art Institute of Chicago")
.font(.title)
.bold()
ZStack {
Rectangle()
.foregroundColor(.orange)
.frame(height: 50)
HStack {
Image(systemName: "magnifyingglass")
.font(.title2)
TextField("Search for artworks", text: $inputText) {
搜尋字串 = inputText
作品列表 = nil
作品圖集 = []
}
.font(.title2)
.foregroundColor(.black)
.background(Color.white)
}
.padding()
}
if 作品列表 == nil {
ProgressView()
.onAppear {
更新作品列表(搜尋字串)
}
} else {
List(作品圖集) { 作品 in
VStack {
抓圖(作品.圖檔網址!)
HStack {
Text(作品.作品名稱)
.font(.title2)
.bold()
Spacer()
Text(作品.作者)
.font(.title3)
}
}
}
}
}
}
}

PlaygroundPage.current.setLiveView(芝加哥藝術博物館())
#15 範例程式3-4b解說

上一節的範例程式並未用到任何新語法或新物件,不過卻有點小複雜,困難之處不在於 JSON 解碼或 URLSession 語法,而在於網路程式的邏輯,因為「非同步」的特性,不論是撰寫或除錯,都比一般程式困難,因此,最好再詳細理解一番。

整個程式的流程從搜尋字串開始:


搜尋字串相關的程式碼如下,主要利用 TextField() 輸入字串,第一次輸入之前,我們先設好「搜尋字串」預設值 "van gogh",讓執行畫面一開始就有一些作品列表。
struct 芝加哥藝術博物館: View {
@State var inputText = "Van Gogh"
@State var 搜尋字串 = "van gogh"
@State var 作品列表: [搜尋品項]?
@State var 作品圖集: [作品圖] = []

var body: some View {
...
TextField("Search for artworks", text: $inputText) {
搜尋字串 = inputText
作品列表 = nil
作品圖集 = []
}

if 作品列表 == nil {
ProgressView()
.onAppear {
更新作品列表(搜尋字串)
}
...

當 TextField() 完成輸入(使用者按下ENTER),除了將輸入字串指定給「搜尋字串」之外,還將「作品列表」與「作品圖集」回復初始值,才會再呼叫「更新作品列表()」。

第二步是取得作品列表,即連接API網址,透過傑森解碼器 JSONDecoder() 取得「作品列表」陣列,接下來馬上利用 for 迴圈呼叫「取得作品資訊()」,參數為每個作品的 api_link (URL)欄位。


第三步與第四步是寫一個新的函式「取得作品資訊()」。因為在上一步我們已取得api_link的 URL,所以省掉 URLComponents 的組合,直接就能用URLSession連接API 網址。

第三步驟需要再次使用JSONDecoder() 解碼,這次獲得的是單一一筆「作品資訊」類型,指定給變數「單項作品」,再從「單項作品」取出我們需要的欄位,包括 id, title, artist_display,並利用 image_id 組成「圖檔網址」,這樣拿到我們所需要的結構類型「作品圖」的4個欄位資料,這是程式中唯一自行設計的struct類型:
struct 作品圖: Identifiable {
var id: Int = 0
var 作品名稱: String = ""
var 作者: String = ""
var 圖檔網址: URL?
}

在第二步驟的 for 迴圈呼叫「取得作品資訊」,將一個一個作品的 id, title, artist_display 以及圖檔網址收集起來,就成為「作品圖集」陣列了。


有了「作品圖集」的圖檔網址,就可以呼叫第五步實際去「抓圖()」並顯示出來,這修改自第二課的抓圖(),只是增添初始化函示 init() 傳進一個 URL參數。


抓圖()是一個視圖(View)物件,所以可組合在其他視圖主體(body)中。在「芝加哥藝術博物館()」的視圖主體,除了上方的標題與搜尋列之外,下方抓圖(URL)會顯示圖片,再用HStack 水平排列顯示作品名稱與作者,中間用 Spacer() 填滿多餘空間。
if 作品列表 == nil {
ProgressView()
.onAppear {
更新作品列表(搜尋字串)
}
} else {
List(作品圖集) { 作品 in
VStack {
抓圖(作品.圖檔網址!)
HStack {
Text(作品.作品名稱)
.font(.title2)
.bold()
Spacer()
Text(作品.作者)
.font(.title3)
}
}
}
}

整個分析之後,再重新看上一節的程式碼是不是就比較清楚了?
#16 網路的非同步(asynchronous)與並行(concurrent)特性

範例程式3-4b的影片中,有一個特別的細節,就是搜尋結果「作品列表」所顯示的5張圖片,並非按照順序,一張下載完再下載另一張,而是會5張圖片同時下載,哪張先下載完就先顯示。這個特性牽涉到「非同步(asynchronous)」與「並行作業(concurrent tasks)」,值得進一步說明,也跟後半單元課程有關。

在本單元第1課我們曾說過 URLSession 的執行過程,如下圖,第(3)步 dataTask() 在透過作業系統發出網路請求(Request)之後,就會直接返回,並不會在原處等待,當作業系統收到回應(Response),App才會回頭執行(4)匿名函式,這個特性稱為「非同步(asynchronous)」。


若使用迴圈連續執行 dataTask() 時,就會連續發出多個網路請求(Request),這些網路請求在作業系統中,會安排到不同的CPU核心(或稱為多執行緒)同時執行。這是非同步特性最大的好處之一,因為這些連線工作不需要特定的先後順序,所以可分散在多核心同時執行,這就稱為「並行作業」。

在3-4b程式碼當中,有兩段程式會連續發出這樣的非同步網路請求,包括:
for 作品 in 作品列表! {
取得作品資訊(作品.api_link!)
}

以及
List(作品圖集) { 作品 in
抓圖(作品.圖檔網址!)
}

這兩段程式都透過迴圈(for, List)呼叫網路函式,兩個函式(取得作品資訊、抓圖)用 URLSession.shared 同一個網路任務,產出多個 dataTask() 連線工作,這些工作在作業系統中會分散到多核心同時執行,就是並行作業,如下圖所示。



要注意 Concurrent 中文有「同時」的意思,但因為多個 Request 或 Response 並非同一瞬間發出或收到,彼此之間是有些微時間差,故稱「並行」或「並發」較接近實際的運作。

為了方便觀察,我們修改4-3b程式最後主體(body),將列表(List)改成用LazyVGrid雙欄排列,並且將圖片下載尺寸改為 "max" (最大畫素),搜尋列出8個作品,執行影片如下,請仔細觀察非同步(asynchronous)與並行(concurrent)的特性。

#17 第5課 下載大圖(downloadTask)

本單元第1課曾提過,一個 URLSession 連線任務可以發出一個或多個連線工作(task),連線工作則分為5種類型:

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

到目前為止,我們都只用到 dataTask(),不管是下載圖片或是JSON資料,dataTask() 都足以勝任。那什麼情況下才需要用 downloadTask() 或其他工作呢?

本課就先試用 downloadTask() 來下載大圖,看看與 dataTask() 有何不同。

芝加哥藝術博物館每個作品都提供高解析度的大圖,若要下載大圖,非常簡單,只要利用以下格式的網址:

https://www.artic.edu/iiif/2/cf50f037-5fb2-e197-0e56-3ae701edb3e2/full/max/0/default.jpg

在上一課曾提過,這個特別的網址格式是一種稱為 IIIF 的標準規範,其中規定API的欄位如下表說明。
欄位 用途 說明
1 https://www.artic.edu/iiif/2/ 基本網址(支援IIIFv2) 芝加哥藝術博物館API
2 cf50f037-5fb2-e197-0e56-3ae701edb3e2 圖片編號(image_id) 芝加哥藝術博物館API
3 full 圖片範圍(x1, y1, x2, y2)
“full” 表示全圖範圍
左上角座標(x1, y1)
右下角座標(x2, y2)
4 max 圖片大小(解析度)
“max” 代表最高解析度
寬(width), 高(height)
如 “843,”, “1920, 1080”
5 0 旋轉角度 0 ~ 360 (度)
6 default 影像處理
“default” 代表原圖
“gray” 灰階
“bitonal” 黑白(2色階)
7 jpg 圖檔格式 如 jpg, png, gif

對我們而言,只要知道作品的圖片編號(image_id),填入上述網址,其他欄位不變,就可抓到作品的大圖。

範例程式如下:
// 3-5a 抓大圖(downloadTask)
// Created by Heman, 2021/10/13
import PlaygroundSupport
import SwiftUI

struct 抓大圖: View {
@State var 下載圖片: UIImage?
var 圖片編號: String
init(_ p: String) {圖片編號 = p}

func 下載() {
let 網址 = "https://www.artic.edu/iiif/2/" + 圖片編號 + "/full/max/0/default.jpg"
guard let myURL = URL(string: 網址) else { return }
URLSession.shared.downloadTask(with: myURL) { 暫存檔, 回應碼, 錯誤碼 in
do {
let 回傳資料 = try Data(contentsOf: 暫存檔!)
if let 圖檔 = UIImage(data: 回傳資料) {
print(回應碼 ?? "No response")
下載圖片 = 圖檔
} else {
print(錯誤碼 ?? "No error")
}
} catch { print("Data I/O error") }
}.resume()
}

var body: some View {
if 下載圖片 == nil {
ProgressView()
.onAppear {
下載()
}
} else {
Image(uiImage: 下載圖片!)
.resizable()
.scaledToFit()
}
}
}

// let 網址 = "https://www.artic.edu/iiif/2/cf50f037-5fb2-e197-0e56-3ae701edb3e2/full/max/0/default.jpg"
let image_id = "cf50f037-5fb2-e197-0e56-3ae701edb3e2"
PlaygroundPage.current.setLiveView(抓大圖(image_id))

在此範例中,使用 downloadTask() 與原先的 dataTask() 不同的地方,只是在於「匿名函式」有一個參數不一樣。

原先 dataTask() 匿名函式的三個參數,分別為:

回傳資料(data) -- 回傳資料(如圖片或JSON),存放在記憶體中
回應碼(response) -- 回應的標頭(headers)
錯誤碼(error) -- 未獲得回應時的錯誤原因

而 downloadTask() 匿名函式的三個參數,分別為:

暫存檔(fileURL) -- 回傳的資料,存放在硬碟(暫存檔)中
回應碼(response) -- 回應的標頭(headers)
錯誤碼(error) -- 未獲得回應時的錯誤原因

因為 downloadTask() 會將回傳資料先存到暫存檔,所以我們仿照第2單元第7課從檔案讀入JSON資料的方式,來讀取暫存檔案,程式碼如下:
do {
let 回傳資料 = try Data(contentsOf: 暫存檔!)
if let 圖檔 = UIImage(data: 回傳資料) {
print(回應碼 ?? "No response")
下載圖片 = 圖檔
} else {
print(錯誤碼 ?? "No error")
}
} catch { print("Data I/O error") }

主要就是由 Data(contentsOf: 暫存檔!) 讀入檔案內容,再用 UIImage(data: 回傳資料) 轉成圖片。

執行的結果如下圖。


這樣看起來,downloadTask() 跟 dataTask() 功能似乎沒什麼差別啊,為什麼要用 downloadTask() 呢?有以下幾個原因:

1. 當檔案較大時,downloadTask() 因為有暫存檔,萬一傳輸中斷,可以在網路恢復後繼續傳檔。
2. downloadTask() 下載的檔案大小幾乎沒有限制
3. 下載途中,可以控制暫停(suspend)、繼續(resume)或放棄(cancel)
4. 必要時,可以設定下載工作在「背景」執行,不影響畫面操作
5. 使用者「另存檔案」比較方便

所以,什麼時候該用 dataTask(),什麼時候該用 downloadTask() 呢?這應該從「使用者體驗」的角度來看,如果資料量不大,下載時間只要幾秒鐘,就可用 dataTask(),不會讓使用者枯等,否則的話,若下載可能超過5秒鐘,則應改用 downloadTask()。

註解
  1. IIIF 各欄位詳細說明可參考原始文件 https://iiif.io/api/image/3.0/#41-region
  2. 螢幕的「解析度」有兩種意義,第一種代表螢幕的畫素(pixel, 或稱像素)多寡,例如 Full HD 代表 1920x1080,也就寬1920畫素,高1080畫素,兩數相乘等於2,073,600,大約200萬畫素。而所謂 8K 螢幕,是指寬7680畫素,高4320畫素,相乘等於33,177,600,約3300萬畫素。
  3. 「解析度」的第二種意義,是指畫素密度,通常用每英吋有多少畫素(dpi或ppi)來表示,例如 Apple 手機螢幕的解析度至少都 326dpi 以上,已超過眼睛所能辨識的最大密度。
  4. 圖片為11世紀印度濕婆神「宇宙之舞」銅像(Shiva as Lord of the Dance),濕婆是印度教三大主神中,力量最強大的,主導宇宙的滅亡與再生,有眾多化身。圖片來源及說明可參考: https://www.artic.edu/artworks/24548/shiva-as-lord-of-the-dance-nataraja
  5. 這張高解析度圖片尺寸為3480x3900,約1357萬畫素,檔案大小2.6MB,下載時間不長,用 dataTask() 或 downloadTask() 幾乎沒差別。
#18 拖曳手勢(DragGesture)

上一節我們用 downloadTask() 下載芝加哥博物館的高解析度藏品圖片,目的當然就是要仔細觀賞作品細節,本節我們利用「拖曳手勢」來放大、移動圖片,以便觀賞作品每處細節。

第2單元第2-6課曾列出SwiftUI 提供的手勢操作,拖曳手勢是其中相當重要的一種:

表2-6e 手勢操作元件
手勢操作元件 說明 學習地圖
1 Gesture 手勢規範(protocol) 3-5課
2 TapGesture 點按 2-10課
3 LongPressGesture 長壓 -
4 DragGesture 拖曳 3-5課
5 MagnificationGesture 放大 -
6 RotationGesture 旋轉 -
7 SequenceGesture 依序組合 -


之前我們只用過最簡單的點按手勢(TapGesture),並且是以視圖修飾語(View Modifier) .onTapGesture 的形式,不過除了 TapGesture 與 LongPressGesture 提供對應的修飾語之外,其他手勢則是透過一個通用的修飾語 .gesture() 來使用。

所謂「拖曳」,即按壓某個圖案並加以拖動的手勢,動作上分成兩步驟,第一步是按壓不放,第二步是移動到目的地然後放開。我們先來看拖曳手勢的基本用法:
struct 抓大圖: View {

var 拖曳: some Gesture {
DragGesture(minimumDistance: 0.0, coordinateSpace: .local) {
.onChanged { 拖曳參數 in
<匿名函式1>
}
.onEnded { 拖曳參數 in
<匿名函式2>
}
}
}

var body: some View {
Image(uiImage: 下載圖片!)
.offset(位移)
.gesture(拖曳)
}
}

首先,在某個視圖定義中,先用 var 定義一個「手勢變數」,類型是 "some Gesture",這個定義方式跟 body 很像,因為 Gesture 也是一種規範(Protocol),之前提過規範是一種大的類別,就像視圖(View)一樣,Gesture 規範包含了上表中的手勢物件類型,如下圖。


要產出 DragGesture 物件實例,可包含兩個參數(均可省略,因為都有預設值):
  • mininumDistance -- 手勢移動的最小距離,預設值為10 (點)

  • coordinationSpace -- 座標空間,預設為 .local

當按壓移動距離超過 minimumDistance 才會辨識為拖曳手勢,預設值為10點,意思是至少移動10點才視為拖曳手勢,但範例中我們將 minimumDistance 改為 0.0,即使只是按壓沒有移動,也會讓拖曳手勢的匿名函式處理。

DragGesture() 第二個參數是 coordinationSpace,用來選擇螢幕座標空間,若是 .global 表示全螢幕座標(原點為螢幕左上角),若為 .local 則表示視圖的區域座標(原點為視圖範圍左上角)。

參數之後緊接著兩個匿名函式,在手勢移動過程,會帶著「拖曳參數」執行 .onChanged 的匿名函式,當手指離開螢幕(手勢結束),則帶「拖曳參數」執行 .onEnded 的匿名函式。

這與 URLSession.shared.dataTask() 的匿名函式類似,都是用來處理非同步事件的,只有事件發生時,才會進入匿名函式,如 dataTask() 是在收到 Response 的時候,那拖曳手勢在什麼時間點產生非同步事件呢?

我們在範例程式的匿名函式中,寫了一些 print() 列印「拖曳參數」的位置(location)到主控台,如果觀察執行影片,就會發現拖曳過程,每秒鐘會產生數十筆 .onChanged 事件,可以仔細觀察這個輸出,以了解拖曳手勢的運作。

拖曳手勢要帶入匿名函式的「拖曳參數」是一個物件,包含以下屬性:
  • .startLocation -- 拖曳動作的起始位置座標

  • .location -- (拖曳過程)目前的位置座標

  • .translation -- (拖曳過程)目前的位移(相較於 .startLocation)

  • .time -- (拖曳過程)目前的時間(Date())

圖解如下:


定義好「手勢變數」之後,就可以用 .gesture(拖曳) 加入視圖主體(body)的圖片中。

在此處我們希望圖片隨著拖曳動作移動,用的是圖片的修飾語 .offset(位移) 來移動,因此匿名函式的任務就是要算出「位移」值,具體的程式碼如下:
    var 拖曳: some Gesture {
DragGesture(minimumDistance: 0.0, coordinateSpace: .local)
.onChanged { 拖曳參數 in
print("[onChanged]目前位置:", 拖曳參數.location)
// print("[onChanged]目前位移 上次位移", 拖曳參數.translation, 上次位移)
位移.height = 上次位移.height + 拖曳參數.translation.height
位移.width = 上次位移.width + 拖曳參數.translation.width

}
.onEnded { 拖曳參數 in
print("[onEnded]位移:", 拖曳參數.translation)
if 拖曳參數.translation.height < 3 && 拖曳參數.translation.width < 3 {
縮放.toggle()

位移 = CGSize.zero
上次位移 = CGSize.zero
}
上次位移.height += 拖曳參數.translation.height
上次位移.width += 拖曳參數.translation.width
}
}

當手勢結束,進入 .onEnded 匿名函式中,如果水平、垂直移動距離x, y均小於3點,則不移動圖片,而是縮放(1倍、5倍)。這樣其實就取代點按(TapGesture)的手勢。

圖片的縮放與移動,均透過狀態變數(@State var)來控制。

執行影片如下:


完整程式碼如下:
// 3-5b 拖曳手勢(DragGesture)
// Created by Heman, 2021/10/17
import PlaygroundSupport
import SwiftUI

struct 抓大圖: View {
@State var 下載圖片: UIImage?
@State var 縮放: Bool = false
@State var 位移: CGSize = .zero
@State var 上次位移: CGSize = .zero
var 圖片編號: String
init(_ p: String) {圖片編號 = p}
func 下載() {
let 網址 = "https://www.artic.edu/iiif/2/" + 圖片編號 + "/full/max/0/default.jpg"
guard let myURL = URL(string: 網址) else { return }
URLSession.shared.downloadTask(with: myURL) { 暫存檔, 回應碼, 錯誤碼 in
do {
let 回傳資料 = try Data(contentsOf: 暫存檔!)
if let 圖檔 = UIImage(data: 回傳資料) {
print(回應碼 ?? "No response")
下載圖片 = 圖檔
} else {
print(錯誤碼 ?? "No error")
}
} catch { print("Data I/O error") }
}.resume()
}

var 拖曳: some Gesture {
DragGesture(minimumDistance: 0.0, coordinateSpace: .local)
.onChanged { 拖曳參數 in
print("[onChanged]目前位置:", 拖曳參數.location)
// print("[onChanged]目前位移 上次位移", 拖曳參數.translation, 上次位移)
位移.height = 上次位移.height + 拖曳參數.translation.height
位移.width = 上次位移.width + 拖曳參數.translation.width
}
.onEnded { 拖曳參數 in
print("[onEnded]位移:", 拖曳參數.translation)
if abs(拖曳參數.translation.height) < 3 && abs(拖曳參數.translation.width) < 3 {
縮放.toggle()
位移 = CGSize.zero
上次位移 = CGSize.zero
}
上次位移.height += 拖曳參數.translation.height
上次位移.width += 拖曳參數.translation.width
}
}

var body: some View {
if 下載圖片 == nil {
ProgressView()
.onAppear {
下載()
}
} else {
Image(uiImage: 下載圖片!)
.resizable()
.aspectRatio(contentMode: .fit)
.scaleEffect(縮放 ? 5.0 : 1.0)
.offset(位移)
.gesture(拖曳)
}
}
}

//let 網址 = "https://www.artic.edu/iiif/2/cf50f037-5fb2-e197-0e56-3ae701edb3e2/full/max/0/default.jpg"
let image_id = "cf50f037-5fb2-e197-0e56-3ae701edb3e2"

PlaygroundPage.current.setLiveView(抓大圖(image_id))


註解
  1. 拖曳手勢與網路連線同樣都會產生非同步事件,不過兩者有很大不同,最大差異點在於拖曳手勢是第2課提到的 "Publisher-Subscriber" 模式,而網路連線是 "Request-Response" 模式,兩者的差異如下表。

    非同步
    溝通模式
    Request-Response
    「請求-回應」模式
    Publisher-Subscriber
    「發行-訂閱」模式
    通訊對象 Client端與Server端(多數情況為不同電腦)之間 App⇔OS或App⇔App(多數情況為相同電腦)之間
    主動(發起)方 由Client端主動發出請求,Server端被動回應

    Server端從不主動
    Subscriber須主動訂閱,獲得允許後加入訂閱名單

    Publisher若有新內容(或新事件)會主動通知訂閱者
    通訊次數 一次請求、一次回應 Subscriber 只需訂閱一次、Publisher 會持續通知,直到Subscriber停止訂閱或退出執行
    典型案例 網路連線(URLSession) 定時器(Timer)、手勢(Gesture)

  2. 發現一個bug,原先的寫法:
    if 拖曳參數.translation.height < 3 && 拖曳參數.translation.width < 3 {
    縮放.toggle()
    位移 = CGSize.zero
    上次位移 = CGSize.zero
    }
    會造成往左上方拖曳時也會縮放,位移須加上 abs() 變成「絕對值」才正確:
    if abs(拖曳參數.translation.height) < 3 && abs(拖曳參數.translation.width) < 3 {
    縮放.toggle()
    位移 = CGSize.zero
    上次位移 = CGSize.zero
    }

#19 整合(芝加哥藝術博物館)

上一節我們用拖曳手勢做兩種功能,放大與移動。接下來,我們將這個拖曳手勢的功能,整合到上一課範例3-4b程式中,並且將列表畫面改為兩欄,這樣就構成一個博物館App的雛型,具有以下功能:

1. 可搜尋藝術家或作品名稱(限英文)
2. 以兩欄顯示搜尋結果(圖片、作品名稱、作者)
3. 點選任何作品可顯示高解析度大圖
4. 大圖可放大、移動

先看看最後在 Swift Playgrounds 執行的結果:


這次視圖主體(body)的「階層」比較深,特別是最後的 NavigationView,為了用 LazyVGrid 改成兩欄,導致很多層的大括號,這種情況在程式設計中很常見,還有個特別的形容詞,稱為「毀滅金字塔」"Pyramid of Doom",並不是一個好詞,Doom有死亡、毀滅、末日的意思,就好比濕婆神降臨,宇宙即將毀滅的情境。
var body: some View {
VStack(spacing: 0) {
Text("Art Institute of Chicago")
.font(.title)
.bold()
ZStack {
Rectangle()
.foregroundColor(.orange)
.frame(height: 50)
HStack {
Image(systemName: "magnifyingglass")
.font(.title2)
TextField("Search for artworks", text: $inputText) {
搜尋字串 = inputText
作品列表 = nil
作品圖集 = []
}
.font(.title2)
.foregroundColor(.black)
.background(Color.white)
}
.padding()
}
if 作品列表 == nil {
ProgressView()
.onAppear {
更新作品列表(搜尋字串)
}
} else {
NavigationView {
ScrollView {
LazyVGrid(columns: 單欄) {
ForEach(作品圖集) { 作品 in
NavigationLink(destination: 抓大圖(作品.image_id)) {
VStack {
抓圖(作品.圖檔網址!)
HStack {
Text(作品.作品名稱)
.font(.body)
.bold()
Spacer()
Text(作品.作者)
.font(.callout)
}
}
}
}
}
}
.navigationTitle(搜尋字串)
Text("⬅︎左方導覽頁面選擇作品")
}
}
}
}

"Pyramid of Doom" 牽涉到程式的可讀性,有些人不喜歡這樣的風格,不過並沒有那麼嚴重,程式設計更重視的是「逐步改善」,先確定概念能夠實現,再去逐步改善。

在第2單元曾經提過非常重要的「視圖階層」觀念,SwiftUI每個視圖在螢幕的大小位置,是由上一層的「父視圖」所決定的,下圖就是3-5c的階層關係圖,將近10層,是不是有點誇張?這樣會不會影響執行的效能?想一想,有沒有辦法縮短?最後註解也提出一些問題,就留給讀者思考。


// 3-5c 整合(芝加哥藝術博物館)
// Created by Heman, 2021/10/13
import PlaygroundSupport
import SwiftUI

// Refer to https://api.artic.edu/api/v1/artworks/search?q=van+gogh&limit=100
struct 搜尋結果: Codable {
let pagination: 分頁資訊
let data: [搜尋品項]
}

struct 分頁資訊: Codable {
let total: Int
let limit: Int
let offset: Int
let total_pages: Int
let current_page: Int
}

struct 搜尋品項: Codable, Identifiable {
let api_link: URL?
let id: Int
let title: String
}

// Refer to https://api.artic.edu/api/v1/artworks/28560
struct 藝術作品: Codable {
let data: 作品資訊
let config: 配置資訊
}

struct 作品資訊: Codable, Identifiable {
let id: Int
let api_link: URL?
let title: String
let date_display: String
let artist_display: String
let artist_id: Int
let artist_title: String
let image_id: String
}

struct 配置資訊: Codable {
let iiif_url: String
let website_url: String
}

// 顯示「作品圖」陣列
struct 作品圖: Identifiable {
var id: Int = 0
var 作品名稱: String = ""
var 作者: String = ""
var image_id: String = ""
var 圖檔網址: URL?
}

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

init(_ p: URL) {myURL = p}

func 下載() {
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()
}
}
}

struct 抓大圖: View {
@State var 下載圖片: UIImage?
@State var 縮放: Bool = false
@State var 位移: CGSize = .zero
@State var 上次位移: CGSize = .zero
var 圖片編號: String
init(_ p: String) {圖片編號 = p}
func 下載() {
let 網址 = "https://www.artic.edu/iiif/2/" + 圖片編號 + "/full/max/0/default.jpg"
guard let myURL = URL(string: 網址) else { return }
URLSession.shared.downloadTask(with: myURL) { 暫存檔, 回應碼, 錯誤碼 in
do {
let 回傳資料 = try Data(contentsOf: 暫存檔!)
if let 圖檔 = UIImage(data: 回傳資料) {
print(回應碼 ?? "No response")
下載圖片 = 圖檔
} else {
print(錯誤碼 ?? "No error")
}
} catch { print("Data I/O error") }
}.resume()
}

var 拖曳: some Gesture {
DragGesture(minimumDistance: 0.0, coordinateSpace: .local)
.onChanged { 拖曳參數 in
print("[onChanged]目前位置:", 拖曳參數.location)
// print("[onChanged]目前位移 上次位移", 拖曳參數.translation, 上次位移)
位移.height = 上次位移.height + 拖曳參數.translation.height
位移.width = 上次位移.width + 拖曳參數.translation.width
}
.onEnded { 拖曳參數 in
print("[onEnded]位移:", 拖曳參數.translation)
if abs(拖曳參數.translation.height) < 3 && abs(拖曳參數.translation.width) < 3 {
縮放.toggle()
位移 = CGSize.zero
上次位移 = CGSize.zero
}
上次位移.height += 拖曳參數.translation.height
上次位移.width += 拖曳參數.translation.width
}
}

var body: some View {
if 下載圖片 == nil {
ProgressView()
.onAppear {
下載()
}
} else {
Image(uiImage: 下載圖片!)
.resizable()
.aspectRatio(contentMode: .fit)
.scaleEffect(縮放 ? 5.0 : 1.0)
.offset(位移)
.gesture(拖曳)
}
}
}

struct 芝加哥藝術博物館: View {
@State var inputText = "Van Gogh"
@State var 搜尋字串 = "van gogh"
@State var 作品列表: [搜尋品項]?
@State var 作品圖集: [作品圖] = []

// let 網址 = "https://api.artic.edu/api/v1/artworks/search?q=van+gogh&limit=100"
// guard let myURL = URL(string: 網址) else { return }
func 更新作品列表(_ 字串參數: String) {
var myURLComponent = URLComponents()
myURLComponent.scheme = "https"
myURLComponent.host = "api.artic.edu"
myURLComponent.path = "/api/v1/artworks/search"
myURLComponent.query = "q=\(字串參數)&limit=8"
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")
作品列表 = 解碼結果.data
for 作品 in 作品列表! {
取得作品資訊(作品.api_link!)
}
} catch {
print("JSON解碼錯誤")
}
} else {
print(錯誤碼 ?? "No error")
}
}.resume()
}

// let 網址 = "https://api.artic.edu/api/v1/artworks/28560"
// guard let myURL = URL(string: 網址) else { return }
// 圖檔網址 IIIF v2 standard format:
// https://www.artic.edu/iiif/2/25c31d8d-21a4-9ea1-1d73-6a2eca4dda7e/full/843,/0/default.jpg
func 取得作品資訊(_ myURL: URL) {
var 單項作品: 作品資訊?
var 目前作品 = 作品圖()
var myURLComponent = URLComponents()
URLSession.shared.dataTask(with: myURL) { 回傳資料, 回傳碼 , 錯誤碼 in
if let 待解碼資料 = 回傳資料 {
do {
let 尺寸 = "600,"
let 解碼結果 = try JSONDecoder().decode(藝術作品.self, from: 待解碼資料)
print(回傳碼 ?? "No response")
單項作品 = 解碼結果.data
目前作品.id = 單項作品!.id
目前作品.作品名稱 = 單項作品!.title
目前作品.作者 = 單項作品!.artist_display
目前作品.image_id = 單項作品!.image_id
myURLComponent.scheme = "https"
myURLComponent.host = "www.artic.edu"
myURLComponent.path = "/iiif/2/\(單項作品!.image_id)/full/\(尺寸)/0/default.jpg"
myURLComponent.query = ""
目前作品.圖檔網址 = myURLComponent.url
作品圖集 = 作品圖集 + [目前作品]
} catch {
print("JSON解碼錯誤")
}
} else {
print(錯誤碼 ?? "No error")
}
}.resume()
}

let 單欄 = [GridItem(.flexible()), GridItem(.flexible())]
var body: some View {
VStack(spacing: 0) {
Text("Art Institute of Chicago")
.font(.title)
.bold()
ZStack {
Rectangle()
.foregroundColor(.orange)
.frame(height: 50)
HStack {
Image(systemName: "magnifyingglass")
.font(.title2)
TextField("Search for artworks", text: $inputText) {
搜尋字串 = inputText
作品列表 = nil
作品圖集 = []
}
.font(.title2)
.foregroundColor(.black)
.background(Color.white)
}
.padding()
}
if 作品列表 == nil {
ProgressView()
.onAppear {
更新作品列表(搜尋字串)
}
} else {
NavigationView {
ScrollView {
LazyVGrid(columns: 單欄) {
ForEach(作品圖集) { 作品 in
NavigationLink(destination: 抓大圖(作品.image_id)) {
VStack {
抓圖(作品.圖檔網址!)
HStack {
Text(作品.作品名稱)
.font(.body)
.bold()
Spacer()
Text(作品.作者)
.font(.callout)
}
}
}
}
}
}
.navigationTitle(搜尋字串)
Text("⬅︎左方導覽頁面選擇作品")
}
}
}
}
}

PlaygroundPage.current.setLiveView(芝加哥藝術博物館())

註解

1. 目前僅列出搜尋結果的前8個作品,如何顯示更完整的搜尋結果呢?
2. 下載大圖有時會比較久,有沒有需要將 ProgressView 的等待圖案改成線性的進度條?
3. 目前圖片放大比例是固定1:5,能不能改成用「放大手勢」隨意縮放呢?
#20 導入Xcode (芝加哥藝術博物館)

從第一單元到目前為止,我們所有程式都是在 Swift Playgrounds 裡面執行,不過,若想開發跨產品(Mac, iPhone, iPad, Apple Watch, Apple TV)的程式,則必須使用 Xcode,這也是Apple陣營的程式設計師唯一選擇,那麼在 Swift Playgrounds 開發的程式,能不能用在 Xcode 呢?

答案是肯定的,本節我們將範例程式 3-5c 導入 Xcode 執行,看看有何差別。

Xcode 與 Swift Playgrounds 兩者的使用環境有所不同,下表是兩者的簡單比較。

表3-5 Xcode 與 Swift Playgrounds 差異
Xcode Swift Playgrounds
作業系統 只能在MacOS執行 MacOS 或 iPadOS
硬體設備 Mac (iMac, Macbook, Mac mini) Mac (iMac, Macbook, Mac mini), iPad
語言版本 只有英文版 支援14種語言(繁中、簡中、英、法、瑞典、義、荷、葡、西、土、德、日、韓、泰)
大小 12.4GB (v13.1) 232MB (v3.4.1)
產出App 所有Apple產品 僅iOS
費用 免費
上傳App Store須繳年費
免費
上傳App Store須繳年費
發布年代 2003年(v1.0) 2016年(v1.0)

所以需要一台Mac才能使用Xcode,本節範例筆者用Macbook Air (M1),花了將近12小時才升級完 MacOS 12.0.1 與 Xcode 13.1。

安裝好 Xcode 之後,依照以下步驟執行:

(一)執行 Xcode,選擇開啟新專案 "Create a new Xcode project":


(二)選擇 iOS App 或 Multiplatform (跨產品)


(三)輸入App名稱,選擇 SwiftUI 介面。注意App名稱只能用英文:


(四)選擇檔案存放目錄


(五)顯示 Hello World! 範例程式。


這是Xcode給每個新專案的範例程式,顯示兩個 struct 型態宣告,一個名稱 ContentView 是預設的 App 進入點,也就是程式開始執行的視圖;另一個名稱 ContentView_Previews,所屬規範是 PreviewProvider 而不是 View,只要是符合 PreviewProvider 規範,都可以在上圖程式碼右側欄位中「預覽」執行結果。
struct ContentView: View {
var body: some View {
Text("Hello, world!")
.padding()
}
}

struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

我們先按上圖預覽欄位的 "Resume" 按鈕來確認能否順利執行。

(六)按一下 "Resume" 開始預覽,正常結果如下圖


(七)更換為「芝加哥藝術博物館」程式

原來的 Hello world! 範例程式不要動,將範例 3-5c 程式碼完整拷貝到上方,將第一行與最後一行 "Remark" 起來(或刪除):
// import PlaygroundSupport
import SwiftUI
...
// PlaygroundPage.current.setLiveView(芝加哥藝術博物館())

原來我們在 Swift Playgrounds 寫的程式,第一行與最後一行只在 Swift Playgrounds 裡面才用得到,換到 Xcode 就不需要了。

然後更改 ContentView 主體,改成「芝加哥藝術博物館()」:
struct ContentView: View {
var body: some View {
芝加哥藝術博物館()
}
}

(八)執行預覽


以上執行過程的影片如下:


在 Xcode 上面的完整程式碼如下:
//
// ContentView.swift
// Shared
//
// Created by Heman Lu on 2021/10/29.
//
// 3-5c 整合(芝加哥藝術博物館)
// Created by Heman, 2021/10/13
// import PlaygroundSupport
import SwiftUI

// Refer to https://api.artic.edu/api/v1/artworks/search?q=van+gogh&limit=100
struct 搜尋結果: Codable {
let pagination: 分頁資訊
let data: [搜尋品項]
}

struct 分頁資訊: Codable {
let total: Int
let limit: Int
let offset: Int
let total_pages: Int
let current_page: Int
}

struct 搜尋品項: Codable, Identifiable {
let api_link: URL?
let id: Int
let title: String
}

// Refer to https://api.artic.edu/api/v1/artworks/28560
struct 藝術作品: Codable {
let data: 作品資訊
let config: 配置資訊
}

struct 作品資訊: Codable, Identifiable {
let id: Int
let api_link: URL?
let title: String
let date_display: String
let artist_display: String
let artist_id: Int
let artist_title: String
let image_id: String
}

struct 配置資訊: Codable {
let iiif_url: String
let website_url: String
}

// 顯示「作品圖」陣列
struct 作品圖: Identifiable {
var id: Int = 0
var 作品名稱: String = ""
var 作者: String = ""
var image_id: String = ""
var 圖檔網址: URL?
}

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

init(_ p: URL) {myURL = p}

func 下載() {
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()
}
}
}

struct 抓大圖: View {
@State var 下載圖片: UIImage?
@State var 縮放: Bool = false
@State var 位移: CGSize = .zero
@State var 上次位移: CGSize = .zero
var 圖片編號: String
init(_ p: String) {圖片編號 = p}
func 下載() {
let 網址 = "https://www.artic.edu/iiif/2/" + 圖片編號 + "/full/max/0/default.jpg"
guard let myURL = URL(string: 網址) else { return }
URLSession.shared.downloadTask(with: myURL) { 暫存檔, 回應碼, 錯誤碼 in
do {
let 回傳資料 = try Data(contentsOf: 暫存檔!)
if let 圖檔 = UIImage(data: 回傳資料) {
print(回應碼 ?? "No response")
下載圖片 = 圖檔
} else {
print(錯誤碼 ?? "No error")
}
} catch { print("Data I/O error") }
}.resume()
}

var 拖曳: some Gesture {
DragGesture(minimumDistance: 0.0, coordinateSpace: .local)
.onChanged { 拖曳參數 in
print("[onChanged]目前位置:", 拖曳參數.location)
// print("[onChanged]目前位移 上次位移", 拖曳參數.translation, 上次位移)
位移.height = 上次位移.height + 拖曳參數.translation.height
位移.width = 上次位移.width + 拖曳參數.translation.width
}
.onEnded { 拖曳參數 in
print("[onEnded]位移:", 拖曳參數.translation)
if abs(拖曳參數.translation.height) < 3 && abs(拖曳參數.translation.width) < 3 {
縮放.toggle()
位移 = CGSize.zero
上次位移 = CGSize.zero
}
上次位移.height += 拖曳參數.translation.height
上次位移.width += 拖曳參數.translation.width
}
}

var body: some View {
if 下載圖片 == nil {
ProgressView()
.onAppear {
下載()
}
} else {
Image(uiImage: 下載圖片!)
.resizable()
.aspectRatio(contentMode: .fit)
.scaleEffect(縮放 ? 5.0 : 1.0)
.offset(位移)
.gesture(拖曳)
}
}
}

struct 芝加哥藝術博物館: View {
@State var inputText = "Van Gogh"
@State var 搜尋字串 = "van gogh"
@State var 作品列表: [搜尋品項]?
@State var 作品圖集: [作品圖] = []

// let 網址 = "https://api.artic.edu/api/v1/artworks/search?q=van+gogh&limit=100"
// guard let myURL = URL(string: 網址) else { return }
func 更新作品列表(_ 字串參數: String) {
var myURLComponent = URLComponents()
myURLComponent.scheme = "https"
myURLComponent.host = "api.artic.edu"
myURLComponent.path = "/api/v1/artworks/search"
myURLComponent.query = "q=\(字串參數)&limit=8"
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")
作品列表 = 解碼結果.data
for 作品 in 作品列表! {
取得作品資訊(作品.api_link!)
}
} catch {
print("JSON解碼錯誤")
}
} else {
print(錯誤碼 ?? "No error")
}
}.resume()
}

// let 網址 = "https://api.artic.edu/api/v1/artworks/28560"
// guard let myURL = URL(string: 網址) else { return }
// 圖檔網址 IIIF v2 standard format:
// https://www.artic.edu/iiif/2/25c31d8d-21a4-9ea1-1d73-6a2eca4dda7e/full/843,/0/default.jpg
func 取得作品資訊(_ myURL: URL) {
var 單項作品: 作品資訊?
var 目前作品 = 作品圖()
var myURLComponent = URLComponents()
URLSession.shared.dataTask(with: myURL) { 回傳資料, 回傳碼 , 錯誤碼 in
if let 待解碼資料 = 回傳資料 {
do {
let 尺寸 = "600,"
let 解碼結果 = try JSONDecoder().decode(藝術作品.self, from: 待解碼資料)
print(回傳碼 ?? "No response")
單項作品 = 解碼結果.data
目前作品.id = 單項作品!.id
目前作品.作品名稱 = 單項作品!.title
目前作品.作者 = 單項作品!.artist_display
目前作品.image_id = 單項作品!.image_id
myURLComponent.scheme = "https"
myURLComponent.host = "www.artic.edu"
myURLComponent.path = "/iiif/2/\(單項作品!.image_id)/full/\(尺寸)/0/default.jpg"
myURLComponent.query = ""
目前作品.圖檔網址 = myURLComponent.url
作品圖集 = 作品圖集 + [目前作品]
} catch {
print("JSON解碼錯誤")
}
} else {
print(錯誤碼 ?? "No error")
}
}.resume()
}

let 單欄 = [GridItem(.flexible()), GridItem(.flexible())]
var body: some View {
VStack(spacing: 0) {
Text("Art Institute of Chicago")
.font(.title)
.bold()
ZStack {
Rectangle()
.foregroundColor(.orange)
.frame(height: 50)
HStack {
Image(systemName: "magnifyingglass")
.font(.title2)
TextField("Search for artworks", text: $inputText, onCommit: {
搜尋字串 = inputText
作品列表 = nil
作品圖集 = []
})
.font(.title2)
.foregroundColor(.black)
.background(Color.white)
}
.padding()
}
if 作品列表 == nil {
ProgressView()
.onAppear {
更新作品列表(搜尋字串)
}
} else {
NavigationView {
ScrollView {
LazyVGrid(columns: 單欄) {
ForEach(作品圖集) { 作品 in
NavigationLink(destination: 抓大圖(作品.image_id)) {
VStack {
抓圖(作品.圖檔網址!)
HStack {
Text(作品.作品名稱)
.font(.body)
.bold()
Spacer()
Text(作品.作者)
.font(.callout)
}
}
}
}
}
}
.navigationTitle(搜尋字串)
Text("⬅︎左方導覽頁面選擇作品")
}
}
}
}
}

// PlaygroundPage.current.setLiveView(芝加哥藝術博物館())
// import SwiftUI

struct ContentView: View {
var body: some View {
芝加哥藝術博物館()
}
}

struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}


註解
  1. 本課程並不推薦初學者用Xcode學習Swift/SwiftUI程式設計,希望還是推廣 Swift Playgrounds,不管是作為初學者的主要環境,或是程式設計師的測試環境,Swift Playgrounds都能勝任愉快。
  2. 所以本節內容,主要是讓讀者不必擔心程式碼無法轉到Xcode,後面課程仍會繼續以Swift Playgrounds作為主要開發環境。
  3. 為什麼在Xcode上面執行程式稱為「預覽」呢?因為未來產出的App可以在不同產品中執行,即使指定 iOS 平台,在不同型號的 iPhone 也會有不同解析度的情況,在Xcode上面可以指定iOS版本與iPhone型號,然後在該特定產品的模擬器中「預覽」執行結果,對調整UI畫面(視圖大小、位置、動態效果等)很有幫助。
  4. 所以Xcode最大的好處就是有完整Apple產品的「模擬器」(Simulator),這是Xcode大小超過12GB的原因,也是與Swift Playgrounds主要差異之一(Swift Playgrounds只含iOS模擬器)。
關閉廣告
文章分享
評分
評分
複製連結

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