
The most impressive people I know spent their time with their head down getting shit down for a long, long time.
- Sam Altman
我們回顧一下歷史,2011 年蘋果年度全球開發者大會(Worldwide Developers Conference,簡稱 WWDC)上,史蒂夫.賈伯斯(Steve Jobs )介紹了 iCloud 作為 iOS 5 與 OS X Lion 的補充功能,儘管並非完全出乎意料,但是該聲明還是引起了廣泛的關注。透過 iCloud,App 與遊戲可以在雲端儲存資料,並在 Mac 與 iOS 裝置之間無縫同步資料。
然而,iCloud 在一個方面上存在不足,即作為雲端伺服器。開發者被限制使用 iCloud 來儲存可在使用者之間共享的公有資料,其功能僅限於促進同一使用者擁有的多個裝置之間的資料交換。為了說明這個限制,我們以 FoodPin App 為例,使用 iCloud 的經典版,你不能公開儲存你喜愛的餐廳,並將其提供給其他的 App 使用者,儲存在 iCloud 上的資料只能由你訪問,而不能與他人共享。
在那段期間,如果你想建立一個社群 App,以在使用者之間共享資料,則你有兩個選擇,第一個是開發你自己的自訂後端伺服器,並配備用於資料傳輸和使用者身分驗證的伺服端 API;第二個是你可以依賴 Firebase 和 Parse 等第三方雲端服務供應商。
Note: Parse 是當時非常流行的雲端服務,但是 Facebook 在2016年1月28日宣布要終止該服務。
然而,2014 年蘋果公司對 iCloud 的功能進行了重大的改進,為開發者與使用者提供了全新及增強的功能。CloudKit 的推出代表了對其前身的重大飛躍,並為開發者帶來了巨大的可能性。有了 CloudKit,開發社群網路App 或整合社群分享功能變得更加容易。
但是,如果你有一個網頁 App,並且想要存取儲存在 iCloud 上與你的 iOS App 相同的資料時,該怎麼辦呢? Apple 透過導入 CloudKit 網頁服務(也稱為 CloudKit JS ),讓 CloudKit 進一步發展。這個技術利用 JavaScript 函式庫,讓你開發一個網頁 App,以存取 iCloud 上與你的原生 App 相同的資料,如圖 23.1 所示。

在WWDC 2016 期間,Apple 發布了一個導入共享資料庫功能的重要聲明,此更新擴充了 CloudKit 的功能,讓開發者不僅可以公開或私密儲存資料,還可以與特定使用者群組共享資料。
CloudKit 消除了建立與維護自己的伺服器解決方案的需求,從而簡化了開發者的開發過程。透過最少的設定與編寫程式,CloudKit 使你的 App 能在雲端中儲存各種類型的資料, 包括結構式資料與素材。這種簡化的方法節省了時間和精力,使開發者能夠利用雲端儲存的強大功能,而無須進行大量的後端開發。
最重要的是,你可以免費開始使用 CloudKit(有限制)。一開始你可以使用:
這是大量的免費儲存空間,足以滿足絕大多數 App 的需求。根據 Apple 的說法,該儲存空間應可滿足大約 1000 萬免費使用者的需求。
T這是大量的免費儲存空間,足以滿足絕大多數 App 的需求。根據 Apple 的說法,該儲存空間應可滿足大約 1000 萬免費使用者的需求。
有了 CloudKit,我們能夠專注於建立 App,甚至可以加入一些額外的功能。
- Hipstamatic
在本章中,我的重點是指導你使用 CloudKit 框架來整合 iCloud,不過我們的重點只會放在公共資料庫。與上一章中討論的網頁視圖類似,SwiftUI 框架不提供特定的 CloudKit 元件,儘管如此,我將示範如何將 CloudKit API 整合到 SwiftUI 專案中。具體來說,將會學習如何存取及管理 iCloud 資料庫中的紀錄。我們會改進 App 來讓使用者匿名分享他們最喜愛的餐廳,並將這些餐廳上傳到 iCloud 的公共資料庫,然後所有的使用者可以在 Discover 標籤中查看這些共享收藏夾。
不過,這裡有一個問題,要存取 CloudKit 儲存器,你必須註冊 Apple 開發者計畫(每年費用99 美元),Apple 只讓付費的開發者使用 CloudKit,如果你真的想建立自己的 App, 那麼是時候註冊 Apple 開發者計畫,並開始建立一些基於 CloudKit 的 App。
CloudKit 不僅是儲存器,Apple 提供 CloudKit 框架來讓開發者能與 iCloud 進行互動。CloudKit 框架提供用於管理與 iCloud 伺服器之間的資料傳輸服務,作為將 App 資料從使用者裝置傳輸到雲端的機制。
需要注意的是,CloudKit 不提供任何本地持久性,只提供離線快取的最小支援。如果你需要快取來在本地儲存資料,則需要開發自己的解決方案。
在 CloudKit 框架中,容器與資料庫是基本元素。每個 App 都有自己的容器來管理其內容,預設設定是一個 App 與一個容器通訊,容器是以 CKContainer 類別表示。
在容器中有「公共資料庫」(Public Database)、「共享資料庫」(Shared Database)與「私有資料庫」(Private Database)等三種類型的資料庫。顧名思義,公共資料庫可供 App 的所有使用者存取,用來儲存共享資料。儲存在私有資料庫中的資料只能讓單一使用者檢視,而儲存在共享資料庫中的資料則可以在特定使用者群組之間共享,如圖 23.2 所示。
Apple 提供了選擇最適合你的 App 需求的資料庫類型的靈活性,例如:如果你正在開發一個類似 Instagram 的App,則可以使用公共資料庫來儲存使用者上傳的相片。另一方面,如果你正在建立一個待辦事項 App,那麼使用私有資料庫來儲存每個使用者的待辦事項會更適合。訪問公共資料庫不需要使用者擁有活躍的 iCloud 帳號,除非你需要將資料寫入公共資料庫,但是使用者必須先登入 iCloud,才能訪問私有資料庫。
在 CloudKit 框架中,資料庫是以 CKDatabase 類別表示,提供與特定資料庫型態互動所需的功能。

進一步進入層次結構,我們會遇到記錄區(Record Zone )的概念。CloudKit 透過將資料劃分為不同的記錄區,以結構化的方式組織資料。每個記錄區對應特定的資料類別或分區,根據資料庫的類型,它支援不同類型的記錄區。私有資料庫與公共資料庫都有一個預設區,這足以應付大多數的情況,但是如有必要,你可以靈活建立自訂區(Custom Zone )。在 CloudKit 框架中,記錄區是以 CKRecordZone 類別表示,如圖 23.3 所示。
資料結構的核心是紀錄( Record ),它是資訊的基本單位,以 CKRecord 類別表示。紀錄基本上是由鍵值對的集合組成,其中每個鍵代表一個特定的紀錄欄位,關聯的值代表該欄位的值。此外,每個紀錄都被指定一個記錄型別,記錄型別是由開發者在 CloudKit 儀表板中定義。如果這些術詞一開始看起來令人感到困惑,這是可以理解的,但不必擔心, 經過實際的示範後,它們的含義就會變得清晰。

現在你已經對 CloudKit 框架有了一些了解,我們開始建立 Discover 標籤。透過 App 與 CloudKit 的整合,你將學到:
假設你已經註冊了 Apple 開發者計畫,使用 CloudKit 的第一件事就是在 Xcode 專案中註冊你的帳號。在 Signing & Capabilities 標籤下,如果你尚未在 Signing 區塊指定開發者帳號,請點選「Add Account...」,並使用你的開發者帳號來登入。
Note: 在專案導覽器中選取「FoodPin」專案,然後選取 Targets 下的「FoodPin」。如果你的套件識別碼(Bundle Identifier)是使用「com.appcoda.FoodPin」,則需要改成其他名稱,例如:「[ 你的網域名稱].FoodPin」。若是你沒有網域,則可以使用「[ 你的名稱].FoodPin」,稍後 CloudKit 將使用套件識 別碼來產生容器,而由於容器的名稱空間對於所有的開發者來說是全域的,因此你必須要確認名稱是唯 一的。
在 Signing & Capabilities 標籤下,如果你尚未在 Signing 區塊指定開發者帳號,只需點選「Team」選項的下拉式選單,然後選擇「Add an account」,系統將提示你使用開發者帳號來登入。按照步驟操作,你的開發者帳號將出現在「Team」選項中,如圖 23.4 所示。

假設你已經設定了識別(Identity )以及套件識別碼(Bundle Identifier ), 則點選「+Capability」按鈕。要啟用 CloudKit,你所需要做的就是將 iCloud 模組加到你的專案中,然後在「Service」選項選擇「CloudKit」,如圖 23.5 所示。

而容器的部分,則在 Containers 區塊下點選「+」按鈕來建立新容器,命名規則(Naming Convention )是「iCloud.com.[bundle-ID] 」。
就我而言,我使用「iCloud.com.appcoda.FoodPinV6」作為識別碼。當你確認了識別碼, Xcode 就會自動在CloudKit 伺服器上建立對應的容器,並將所需的框架加到專案。請注意,Xcode 可能需要數分鐘的時間,才能完成雲端上的容器建立過程,如圖 23.6 所示。如果容器尚未準備就緒,則會顯示為紅色,你可以點選「Reload」按鈕來更新狀態,直到容器變成黑色。

Quick tip: 如果你有遇到像這樣的錯誤:「An App ID with identifier is not available. Please enter a different string.」,你可能需要選擇另一個套件 ID(Bundle ID )。
在我們可使用 CloudKit 儲存紀錄到雲端之前,我們必須使用 CloudKit 儀表板來設定紀錄。你可以點選「CloudKit Console」按鈕來開啟網頁版的儀表板,接著點選「CloudKit Database」按鈕,你應該會看到名稱為「iCloud」的 iCloud 容器,如圖 23.7 所示。對我而言,我的套件 ID 設定為「com.appcoda.FoodPinV6」,iCloud 的容器名稱是「iCloud. com.appcoda.FoodPinV6」。如果你看不到你選擇的雲端容器,則可以點選容器名稱旁邊的「v」圖示,並選擇正確的容器。

雲端容器有兩種環境:「開發」(Development )與「生產」(Production ),生產環境是你的 App 發布給公開使用者時使用的即時環境;顧名思義,開發環境是在開發 App 或測試時使用的環境。你應該根據開發目的選擇開發環境,如圖 23.8 所示。

這個儀表板可以讓你管理容器並執行各種操作,包括新增記錄型別與移除紀錄。
在你的 App 將餐廳紀錄儲存到雲端之前,你需要定義記錄型別。你是否還記得我們在運用 SwiftData 時建立了一個 Restaurant 模型類別?在 CloudKit 中,記錄型別對應於 SwiftData 中的模型類別。
要建立新的記錄型別,則導覽到儀表板的側邊欄選單,並選擇「Record Types」,然後點選「+」按鈕來建立新的記錄型別,並命名這個記錄型別為「Restaurant」。當你建立記錄型別後,CloudKit 儀表板會顯示某些系統欄位(如createdBy 與createdAt ),如圖 23.9 所示。

你可以為 Restaurant 記錄型別定義自己的欄位名稱與型別。CloudKit 支援各種屬性型別,例如:String、Data / Time、Double 與 Location。如果你需要儲存圖片等二進位資料, 則可使用 Asset 型別。
現在點選「Add Field」按鈕,並為 Restaurant 記錄型別新增以下的欄位名稱/型別:
| Field Name | Field Type |
|---|---|
| name | String |
| type | String |
| location | String |
| phone | String |
| description | String |
| image | Asset |
當你新增完自己的欄位後,不要忘記點選「Save Changes」按鈕來儲存這些變更,如圖 23.10 所示。

Note: CloudKit 使用 Asset 物件來合併外部檔案,例如:圖片、聲音、影片、文字與二進位資料檔。素材是以 CKAsset 類別表示,並與紀錄關聯。在儲存素材時,CloudKit 只儲存素材資料,並不能儲存檔 名。除了圖片之外,你可以對其餘欄位設定排序(Sort)、查詢(Query)與搜尋(Search)選項。
設定好記錄型別後,你的 App 現在可以將餐廳紀錄上傳到 iCloud。有兩種增加紀錄至資料庫的方式:
你可使用 CloudKit API 以程式設計方式建立這些紀錄。
或者透過 CloudKit 儀表板加入這些紀錄。
我們從使用儀表板來輸入一些紀錄開始。在側邊欄選單中,點選「Records」來導覽到紀錄窗格,請確認已選取「Public Database」選項,如圖 23.11 所示。

對於「Zone」選項,請確認選取「_defaultZone」,這表示你的公共資料庫的預設記錄區; 至於記錄型別,則設定為「Restaurant」。預設上,這個區域不包含任何紀錄,若要建立新紀錄,則點選「+」按鈕,輸入必要的詳細資訊,例如:名稱(Name)、型別(Type)、位置(Location )、電話(Phone )、描述(Description ),然後上傳你的圖片,最後點選「Save」按鈕來儲存資料。新建立的紀錄範例,如圖 23.12 所示。

現在你已經在雲端建立了一筆 Restaurant 紀錄,重複相同的步驟來建立至少十筆資料, 我們稍後會用到它們。
如果你試著查詢這些紀錄,你會得到一個錯誤訊息:「Queried type is not marked indexable.」。預設情況下,建立的記錄型別的所有元資料索引是停用的,因此允許你可以查詢紀錄之前,你必須向資料庫加入索引。
在選單列中點選「Schema」下的「Indexes」選項,選擇「Restaurant」,然後點選「+」按鈕來建立新索引。資料庫索引讓查詢有效率地從資料庫取得資料,你可以點選「Add Basic Index」按鈕來建立索引,我們將在 recordName 與 createdTimestamp 欄位上建立兩個索引。對於 recordName 欄位,索引型別設定為「Queryable」,即可以查詢紀錄。稍後, 我們將會以逆時序方式來取得紀錄,因此我們將 createdTimestamp 欄位的索引型別設定為「Sortable」,如圖 23.13 所示。

儲存變更之後, 返回「Records」窗格, 選擇「Public Database」, 並點選「Query Records」按鈕,你現在應該能夠取得餐廳紀錄了,請確認記錄型別設定為「Restaurant」, 如圖 23.14 所示。

CloudKit 框架為開發者提供兩種與 iCloud 互動的 API:便利型 API 與操作型,這兩種 API 都可以讓你從 iCloud 非同步儲存與取得資料,這表示資料傳輸是在後端進行。在本節中,我們從探索便利型 API 並實作 Discover 標籤開始,稍後我們將深入研究操作型 API。
顧名思義,便利型 API 讓你可以只用幾行程式碼就與 iCloud 互動。通常,下列的程式碼就足以從雲端取得Restaurant 紀錄:
let cloudContainer = CKContainer.default()
let publicDatabase = cloudContainer.publicCloudDatabase
let predicate = NSPredicate(value: true)
let query = CKQuery(recordType: "Restaurant", predicate: predicate)
do {
let results = try await publicDatabase.records(matching: query)
// Process the records
} catch {
// Handle the error
}
所提供的程式碼非常簡單。首先,我們取得 App 的預設 CloudKit 容器,然後取得預設的公共資料庫。要從公共資料庫取得 Restaurant 紀錄,我們使用 Restaurant 記錄型別和搜尋條件(稱為 predicate )來建立一個 CKQuery 物件。
你可能對述詞(predicate )這個觀念很陌生,iOS SDK 提供一個名為「NSPredicate」的基礎類別,可讓開發者指定資料篩選條件,若是你有資料庫的背景,則可將其視為 SQL 中的 WHERE 子句。執行 CKQuery 時,必須包含述詞,即使你希望查詢所有的紀錄而不進行任何特定的篩選,你仍然需要提供述詞。在本例中,我們初始化一個始終為true 的述詞,這表示沒有對查詢結果套用特定的排序或篩選。
最後,我們透過查詢來呼叫 CKDatabase 的 records 方法,然後 CloudKit 搜尋並回傳結果。搜尋與資料傳輸的操作是在背景執行(或非同步執行),以避免阻礙 UI 操作。
從 iOS 15 開始,Apple 導入一個名為「async / await」的功能來處理非同步操作,當我們需要處理背景操作時,此功能可以簡化程式碼。
如果你看一下 API 文件,records 方法是一個非同步函數,以 async 關鍵字表示:
func records(matching query: CKQuery, inZoneWith zoneID: CKRecordZone.ID? = nil, desiredKeys: [CKRecord.FieldKey]? = nil, resultsLimit: Int = CKQueryOperation.maximumResults) async throws -> (matchResults: [(CKRecord.ID, Result<CKRecord, Error>)], queryCursor: CKQueryOperation.Cursor?)
這表示此操作是非同步執行。當使用 async 關鍵字呼叫方法時,你需要在呼叫前面放置 await 關鍵字:
let results = try await publicDatabase.records(matching: query)
這就是使用非同步操作所需要做的全部工作。系統將會等待非同步操作完成後,再執行「// Process the records」下的程式碼。
請注意,try 關鍵字是用於捕捉紀錄取得過程中的任何錯誤。
很簡單,對吧?現在回到 FoodPin 專案並實作 Discover 標籤,這個標籤列出了從 iCloud 取得的餐廳清單。圖23.15 顯示了範例 UI。

我們將建立一個單獨的類別來提供與 iCloud 互動並儲存從雲端資料庫取得紀錄的常用功能。在專案導覽器中的「Model」群組上按右鍵來建立一個新檔案,然後選擇「Swift File」模板,將檔案命名為「RestaurantCloudStore.swift」。
檔案內容替換如下:
import CloudKit
import SwiftUI
@Observable class RestaurantCloudStore {
var restaurants: [CKRecord] = []
func fetchRestaurants() async throws {
// Fetch data using Convenience API
let cloudContainer = CKContainer.default()
let publicDatabase = cloudContainer.publicCloudDatabase
let predicate = NSPredicate(value: true)
let query = CKQuery(recordType: "Restaurant", predicate: predicate)
let results = try await publicDatabase.records(matching: query)
for record in results.matchResults {
self.restaurants.append(try record.1.get())
}
}
}
這個類別標註 @Observable,因為它需要在 restaurants 陣列更新時發出變更。要使用便利型 API 從 iCloud 取得紀錄,我們首先取得 App 的預設 CloudKit 容器,然後取得預設的公共資料庫。
CKQuery 實例指定要取得的記錄型別及排序方式(即 predicate )。當我們準備好查詢, 就可以呼叫資料庫的perform 方法來連結 iCloud,並以 CKRecord 陣列的形式取得紀錄。我們將回傳的紀錄儲存到 restaurants 陣列,因為這個類別標註 @Observable,因此每當 restaurants 陣列更新時,變更都會被發布到監聽變更的視圖。在本例中,視圖是我們巫實作的 DiscoverView。
現在於「View」資料夾上按右鍵,並選擇「New File...」,然後選取「SwiftUI View」模板,將檔案命名為「DiscoverView.swift」。
首先匯入 CloudKit 框架,然後宣告一個變數來存放 RestaurantCloudStore 實例:
@State private var cloudStore: RestaurantCloudStore = RestaurantCloudStore()
使用 @State 屬性包裹器,SwiftUI 只建立一次 RestaurantCloudStore 的新實例,當發布的屬性(即 restaurants )有了變更,SwiftUI 會更新受到變更影響的那些視圖。
接下來,更新 body 部分如下:
NavigationStack {
List(cloudStore.restaurants, id: \.recordID) { restaurant in
HStack {
AsyncImage(url: getImageURL(restaurant: restaurant)){ image in
image
.resizable()
.scaledToFill()
} placeholder: {
Color.purple.opacity(0.1)
}
.frame(width: 50, height: 50)
.clipShape(RoundedRectangle(cornerRadius: 10))
Text(restaurant.object(forKey: "name") as! String)
}
}
.listStyle(PlainListStyle())
.task {
do {
try await cloudStore.fetchRestaurants()
} catch {
print(error)
}
}
.navigationTitle("Discover")
.navigationBarTitleDisplayMode(.automatic)
}
你應該非常熟悉 List 與 NavigationStack 了,我們在清單視圖中顯示餐廳,並將其包裹在導覽視圖中。不過,有幾件事情可能會讓你感到困惑,讓我們逐行說明上列的程式碼。
首先是task 修飾器,當清單視圖出現時,我們呼叫 cloudStore.fetchRestaurants() 從 iCloud 資料庫中取得餐廳。這個操作將會更新雲端儲存的 restaurants 屬性,由於 fetchRestaurants() 方法是一個 async 操作,因此我們必須在呼叫之前放置 await 關鍵字。要捕捉任何錯誤, 我們使用 do-try-catch 語法。
當從雲端取得餐廳時,雲端儲存會通知清單視圖有關更新的資訊,然後清單視圖會自行更新,並以簡單的清單格式顯示餐廳。對於每一列,它都會顯示餐廳的小圖片與餐廳名稱。
由於 restaurant 型別是 CKRecord,因此你可以呼叫 .object(forKey:) 方法來取得特定值。在上列的程式碼中,我們使用 restaurant.object(forKey: "name") 來取得餐廳名稱,不過餐廳圖片又如何呢?什麼是 AsyncImage 呢?
到目前為止,我們只處過本地儲存在裝置上的圖片,我們可以使用 Image 視圖來輕鬆載入本地圖片。既然餐廳圖片都儲存在雲端上,那麼我們該如何從遠端載入呢?
Swift 框架有一個名為「AsyncImage」的視圖,它是一個用於非同步載入及顯示遠端圖片的內建視圖。你只需要告知它圖片的 URL 是什麼,然後 AsyncImage 便會處理從遠端取得圖片且顯示圖片於螢幕上的繁重工作。
這就是 getImageURL 方法的設計目的。我們還沒有實作這個方法,因此在 DiscoverView 中插入下列的程式碼:
private func getImageURL(restaurant: CKRecord) -> URL? {
guard let image = restaurant.object(forKey: "image"),
let imageAsset = image as? CKAsset else {
return nil
}
return imageAsset.fileURL
}
我們使用 restaurant.object(forKey: "image") 來取得餐廳圖片。由於圖片定義為素材類型,因此我們可以使用fileURL 屬性來取得圖片的 URL。
當你透過 AsyncImage 查看圖片的 URL,它就會連結 URL 並自動下載圖片。使用 AsyncImage 的最簡單方法如下:
AsyncImage(url: getImageURL(restaurant: restaurant))
那麼,為什麼我們要編寫這樣的程式碼呢?
AsyncImage(url: getImageURL(restaurant: restaurant)) { image in
image
.resizable()
.scaledToFill()
} placeholder: {
Color.purple.opacity(0.1)
}
.frame(width: 50, height: 50)
.clipShape(RoundedRectangle(cornerRadius: 10))
如果你只使用簡單的方式來初始化 AsyncImage,它會以固有大小顯示圖片。要自訂其大小,我們必須使用替代的init 方法,而另一種方式提供我們在閉包中的結果圖片,以供進一步的操作。搭配 frame 修飾器,我們可以應用resizable 與 scaledToFill 修飾器來將圖片縮放至想要的大小。
在測試 App 之前,切換到 MainView.swift,並更新 Discover 標籤為下列的程式碼:
DiscoverView()
.tabItem {
Label("Discover", systemImage: "wand.and.rays")
}
.tag(1)
現在於模擬器中執行 App, 並選取 Discover 標籤,你應該會看見到你已加入到 iCloud 中的紀錄。如果你有下載的問題,請確認有到「設定」,並登入你的 iCloud 帳號。圖 23.16 顯示了 Discover 標籤的範例截圖。
現在預覽窗格應該會顯示從 iCloud 取得的餐廳紀錄。圖 23.16 為 Discover 標籤的範例螢幕截圖。

你已經注意到使用便利型 API 的一個缺點,雖然便利型 API 適用於簡單的查詢,但並未對從雲端取得大量資料進行最佳化。當你呼叫 records 方法時,它會立即取得所有的餐廳紀錄,依據資料的大小,下載資料需要花費相當長的時間。
要解決這個問題,並減少 Discover 標籤的載入時間,我們將進行一些最佳化。首先, 我們改用操作型API,雖然用法與便利型 API 相似,但操作型 API 提供更大的靈活性。舉例而言,我們可以指定只取得必需的欄位,例如: name 與 image 欄位,而不是下載整個餐廳紀錄。此外,它允許我們控制要下載的最大紀錄數,透過取得更少的資料,我們可加快 Discover 標籤的載入時間。
現在再次開啟 RestaurantCloudStore.swift,並建立一個新方法,如下所示:
func fetchRestaurantsWithOperational() {
// 使用操作型 API 取得資料
let cloudContainer = CKContainer.default()
let publicDatabase = cloudContainer.publicCloudDatabase
let predicate = NSPredicate(value: true)
let query = CKQuery(recordType: "Restaurant", predicate: predicate)
// 以 query 建立查詢操作
let queryOperation = CKQueryOperation(query: query)
queryOperation.desiredKeys = ["name", "image"]
queryOperation.queuePriority = .veryHigh
queryOperation.resultsLimit = 50
queryOperation.recordMatchedBlock = { (recordID, result) -> Void in
if let restaurant = try? result.get() {
DispatchQueue.main.async {
self.restaurants.append(restaurant)
}
}
}
queryOperation.queryResultBlock = { result -> Void in
switch result {
case .success(let cursor): print("Successfully retrieve the data from iCloud.")
case .failure(let error): print("Failed to get data from iCloud - \(error.localizedDescription)")
}
}
// 執行查詢
publicDatabase.add(queryOperation)
}
前幾行程式碼保持不變。我們仍然會取得預設容器和公共資料庫,然後建立用於取得餐廳紀錄的查詢。
我們不呼叫 record 方法來取得紀錄,而是為查詢建立一個 CKQueryOperation 物件, 這也是 Apple 稱其為「操作型API」的原因。查詢操作物件為你的設定提供多個選項,desiredKeys 屬性可讓你指定要取得的欄位,你可使用這個屬性來取得 App 所需的欄位。在上列的程式碼中,我們告知查詢操作物件只需這些紀錄的 name 及 image 欄位。
除了 desiredKeys 屬性之外,你還可以使用 queuePriority 屬性來指定操作的執行順序, 並使用 resultsLimit 屬性來設定每次的最大紀錄數。
操作物件會在背景執行,它透過兩個回呼(Callback )來回報查詢操作的狀態,一個是 recordMatchedBlock, 另一個是 queryResultBlock。每次回傳紀錄時, 都會執行 recordMatchedBlock 內的程式碼區塊。在程式碼片段中,我們只需將每個回傳的紀錄加到 restaurants 陣列中,DispatchQueue.main.asyn 方法確保餐廳紀錄的插入是在主執行緒中執行。
另一方面,queryResultBlock 可以讓你指定在取得所有紀錄後執行的程式碼區塊,在本例中,我們請求表格視圖重新載入並顯示餐廳紀錄。
我再多說明一下 queryResultBlock,它提供一個游標物件(嵌入在 result 物件中)來指示是否有更多要取得的結果。還記得我們使用 resultsLimit 屬性來控制所取得的紀錄數量, App 可能無法在單個查詢中就取得所有資料,在這種情況下,CKQueryCursor 物件指示還有更多要取得的結果。另外,它標記了查詢的停止點以及取得剩餘結果的起點,例如:假設你總共有100 筆餐廳紀錄,對於每次的搜尋查詢,你最多可以取得 50 筆紀錄。在第一次查詢後,游標會指出你已經取得 1 至 50 筆紀錄,對於下一次的查詢,你應該從第 51 筆紀錄開始。如果你需要分批取得資料,則游標非常有用,這是取得大量資料的方式之一。
在 fetchRecordsFromCloud 方法的結尾處,我們呼叫 CKDatabase 類別的 add 方法來執行查詢操作。
在你測試 App 之前,切換到 DiscoverView.swift,並更新 task 修飾器從:
.task {
do {
try await cloudStore.fetchRestaurants()
} catch {
print(error)
}
}
改為:
.task {
cloudStore.fetchRestaurantsWithOperational()
}
在這個階段會有點麻煩,因為 Apple 正在從基於完成處理器的非同步 API 過渡到基於 async / await 的 API。我們在上一小節中使用的 API 是新的非同步 API,這就是我們使用 async / await 的原因。在本小節中,我們處理的 API 是基於完成處理器的非同步 API,你只要直接呼叫即可。
如果你再次在模擬器中執行 App,它應該會顯示餐廳紀錄,而結果應該和之前相同, 如圖 23.17 所示。但是,你在內部已經建立了一個自訂查詢來取得你需要的那些資料。

在討論效能優化時,重要的是不僅要考慮實際效能,還要考慮感知效能(Perceived Performance )。感知效能是指使用者感知你的 App 的速度,而不是實際的執行時間。
我來舉個例子,假設使用者點擊 Discover 標籤,它花了10 秒來載入餐廳紀錄,然後你優化圖片大小,將載入時間縮減為 6 秒,實際的效能提升 40%,從技術角度來看,這似乎是一個重大的改進,不過從使用者的角度來看,App 仍然感覺緩慢,因為它沒有即時回應。因此,在效能優化時,技術統計資料可能不是唯一的考量因素,優化感知效能來讓使用者覺得你的 App 速度很快,這一點至關重要。優化感知效能的一種方法是,在使用者切換至Discover 標籤時加入動態指示器。.

在 SwiftUI 中,有一個名為「ProgressView」的原生元件來顯示操作的狀態,你可以使用它作為動態指示器,如下所示:
ProgressView()
要在 Discover 標籤中使用動態指示器,則切換至 DiscoverView.swift,宣告一個狀態變數來控制載入指示器的外觀:
@State private var showLoadingIndicator = false
要顯示載入指示器的話,我們將List 視圖嵌入 ZStack 中,然後將 ProgressView 放在清單視圖的上方,如下所示:
ZStack {
List(cloudStore.restaurants, id: \.recordID) { restaurant in
.
.
.
}
.onAppear {
showLoadingIndicator = true
}
if showLoadingIndicator {
ProgressView()
}
}
當 List 視圖出現時,會顯示載入指示器。如果你在預覽窗格中執行 App,即使餐廳紀錄已經全部載入,載入指示器仍然會顯示,如圖 23.19 所示。

指示器不知道何時應該隱藏,因此我們必須在下載紀錄後隱藏指示器。修改 Restaurant CloudStore 中的 fetchRestaurantsWithOperational() 方法,以接收一個完成閉包:
func fetchRestaurantsWithOperational(completion: @escaping () -> ()) {
然後更新 queryResultBlock 屬性如下:
queryOperation.queryResultBlock = { result -> Void in
switch result {
case .success(let cursor): print("Successfully retrieve the data from iCloud.")
case .failure(let error): print("Failed to get data from iCloud - \(error.localizedDescription)")
}
DispatchQueue.main.async {
completion()
}
}
當取得紀錄時,我們呼叫 completion() 函數來執行呼叫者所指定的任何操作。由於 completion 操作與UI 相關,因此我們告知系統執行主執行緒中的程式碼。
現在切換回 DiscoverView.swift,將 cloudStore.fetchRestaurantsWithOperational() 替換為下列的程式碼:
cloudStore.fetchRestaurantsWithOperational {
showLoadingIndicator = false
}
當我們完成獲取操作時,我們將 showLoadingIndicator 的值設定為「false」,以隱藏載入指示器。現在再次測試 App 來看載入指示器是否運作,當載入紀錄後,指示器應該會消失。
實作所有的優化之後,Discover 標籤現在的功能應該明顯更好了,不過有一個限制:一旦載入餐廳紀錄,就沒有辦法取得更新。
現代 iOS App 大多數都包含一個名為「下拉更新」(pull-to-refresh )的功能來讓使用者重新更新其內容。下拉更新的互動最初是由 Loren Brichter 開發的,後來被許多 App 採用,包括 Apple 的郵件 App,用於更新內容。
事實上,Apple 已經為 SwiftUI 框架導入了一個標準的下拉更新控制元件,這個新增的功能使得使用內建的 refreshable 修飾器,即可以很簡單為你的 App 加入下拉更新的功能。
只需將 .refreshable 修飾器加入 DiscoverView 結構中的 List 視圖:
.refreshable {
cloudStore.fetchRestaurantsWithOperational() {
showLoadingIndicator = false
}
}
這會自動為你的 App 加入下拉更新功能,如圖 23.20 所示。當使用者下拉更新清單時, 將執行在閉包中定義的程式碼來取得最新的餐廳紀錄。

目前,當你從雲端更新資料時會有一個錯誤,即有些紀錄會重複,因為我們在將這些紀錄加入 restaurants 陣列之前,並沒有執行任何檢查。一個簡單的修復方式是在 fetchRestaurantsWithOperational 方法中更新查詢操作的 recordMatchedBlock:
queryOperation.recordMatchedBlock = { (recordID, result) -> Void in
if let _ = self.restaurants.first(where: { $0.recordID == recordID }) {
return
}
if let restaurant = try? result.get() {
DispatchQueue.main.async {
self.restaurants.append(restaurant)
}
}
}
在將紀錄加入 restaurants 陣列之前,我們使用紀錄 ID 來檢查該紀錄是否已經出現在陣列中。
現在我們已經介紹了資料查詢,接著我們進一步探索 CloudKit 框架,看看如何儲存資料到雲端。這一切都歸結於CKDatabase 類別提供的便利型API:
func save(_ record: CKRecord, completionHandler: @escaping (CKRecord?, Error?) -> Void)
save(_:completionHandler:) 方法帶入 CKRecord 物件,並將其上傳至 iCloud。操作完成後,它透過呼叫完成處理器來回報狀態,你可以檢查錯誤訊息並查看紀錄是否已成功儲存。
為了示範 API 的用法,我們會調整 FoodPin App 的加入餐廳功能。當使用者加入一間新餐廳時,紀錄除了儲存到本地資料庫之外,還會上傳到 iCloud。
現在開啟 RestaurantCloudStore.swift,並加入一個新函數來將紀錄上傳到雲端:
func saveRecordToCloud(restaurant: Restaurant) {
// 準備要儲存的紀錄
let record = CKRecord(recordType: "Restaurant")
record.setValue(restaurant.name, forKey: "name")
record.setValue(restaurant.type, forKey: "type")
record.setValue(restaurant.location, forKey: "location")
record.setValue(restaurant.phone, forKey: "phone")
record.setValue(restaurant.summary, forKey: "description")
// 調整圖片大小
let originalImage = restaurant.image
let scalingFactor = (originalImage.size.width > 1024) ? 1024 / originalImage.size.width : 1.0
guard let imageData = originalImage.pngData() else {
return
}
let scaledImage = UIImage(data: imageData, scale: scalingFactor)!
// 將圖片寫入本地端檔案,以供暫時使用
let imageFilePath = NSTemporaryDirectory() + restaurant.name
let imageFileURL = URL(fileURLWithPath: imageFilePath)
try? scaledImage.jpegData(compressionQuality: 0.8)?.write(to: imageFileURL)
// 建立要上傳的圖片素材
let imageAsset = CKAsset(fileURL: imageFileURL)
record.setValue(imageAsset, forKey: "image")
// 讀取iCloud 公共資料庫
let publicDatabase = CKContainer.default().publicCloudDatabase
// 儲存資料至iCloud
publicDatabase.save(record, completionHandler: { (record, error) -> Void in
if error != nil {
print(error.debugDescription)
}
// 移除暫存檔
try? FileManager.default.removeItem(at: imageFileURL)
})
}
要將餐廳紀錄儲存至雲端,我們先使用餐廳屬性準備一個 CKRecord 物件,而餐廳圖片需要做一些程序處理。首先,我們不想要上傳超高解析度相片,而是想要在上傳前先縮小。UIImage 類別可讓我們能夠建立一個具有特定比例因子的物件,在此情況下,任何寬度大於 1024 像素的圖片都會調整大小。
如你所知,你使用了 CKAsset 物件來表示雲端上的圖片。要建立 CKAsset 物件,我們必須提供縮圖的檔案 URL,因此我們將圖片儲存到暫存資料夾內,你可以使用 NSTemporary Directory 函數來取得暫時目錄的路徑。透過將路徑與餐廳名稱結合,我們就有了圖片的暫時檔案路徑,然後我們使用 UIImage 的jpegData(compressionQuality:) 函數來壓縮圖片資料,並呼叫 write 方法來將壓縮的圖片資料儲存為一個檔案。
縮圖準備好上傳後,我們可以使用檔案 URL 來建立 CKAsset 物件,最後我們取得預設的公共資料庫,並使用 CKDatabase 的 save 方法來將紀錄儲存到雲端。在完成處理器中, 我們清除了剛才建立的暫存檔案。
現在 saveRecordToCloud 方法已經準備好了,我們修改 NewRestaurantView.swift 來上傳新餐廳紀錄至雲端。
在 NewRestaurantView 的save() 方法中,於右括號前插入下列的程式碼:
let cloudStore = RestaurantCloudStore()
cloudStore.saveRecordToCloud(restaurant: restaurant)
你已經準備就緒,點擊「Run」按鈕並測試 App。點選「+」按鈕來加入一間新餐廳, 當你儲存餐廳後,導覽至Discover 標籤,你應該會看到其中列出的新餐廳。如果沒有立即出現,稍待幾秒鐘,然後對表格執行下拉更新;或者你可以訪問 CloudKit 儀表板來檢視新建立的紀錄。
如果在主控台中出現下列的錯誤,這表示你沒有儲存餐廳紀錄的「寫入」(Write)權限。
Optional(<CKError 0x6000031ac690: "Permission Failure" (10/2007); server message = "Operation not permitted"; uuid = C057A757-193A-4245-9E00-CEBA5D9E6EF5; container ID = "iCloud.com.appcoda.FoodPinV6">)
要修正這個問題,你必須更改雲端容器中更改 Restaurant 型別的權限,因此至 CloudKit 儀表板,並選取你的容器,在頂部選單中選擇「Schema」,然後選擇「Security Role」,如圖 23.21 所示。

接著,選擇「 _iCloud」(即已驗證的iCloud 使用者)來設定這個角色的權限。預設上, 只啟用了「建立」(Create )權限,要修正這個問題,你必須為已驗證的使用者啟用「讀取」(Read )與「寫入」(Write )權限。

當你儲存角色後,你可以再次測試 App。如果你已經在模擬器中登入 iCloud,則應該能夠將餐廳紀錄儲存到雲端了。
Discover 功能有一個問題是「餐廳資料沒有任何順序」。作為使用者,你可能希望檢視其他 App 使用者所分享的新餐廳,這表示我們需要將結果做逆時序排列。
排序已經內建於 CKQuery 類別中,它提供一個名為「sortDescriptor」的屬性來指定排序的順序。在RestaurantCloudStore 的 fetchRestaurants 方法(與 fetchRestaurantsWithOperational 方法)中,在CKQuery 的實例後插入下列的程式碼:
query.sortDescriptors = [ NSSortDescriptor(key: "creationDate", ascending: false) ]
這使用 creationDate 鍵(它是CkRecord 屬性)來建立一個 NSSortDescriptor 物件,並將順序設定為降冪排列。當 CloudKit 執行了搜尋查詢,它會依建立日期來對結果排序。你現在可以再次執行 App,並加入一間新餐廳,當儲存之後,至 Discover 標籤,可看到剛加入的餐廳出現在第一筆。
目前 Discover 標籤中的每一行只顯示餐廳的名稱與縮圖,請你修改這個專案,讓其顯示餐廳的位置與類型,圖 23.23 顯示了範例的螢幕截圖。

哇 !你已經製作了一個用於分享餐廳的社群網站 App。本章的內容很多,你現在應該了解 CloudKit 的基礎知識。有了 CloudKit,Apple 讓 iOS 開發者能更容易將他們的 App 與 iCloud 資料庫做整合,只要你註冊 Apple 開發者計畫(每年 99 美元),就能使用這個完全免費的服務。
隨著 CloudKit JS 的導入,你就能建立一個網頁 App,來讓使用者訪問和你的 iOS App 相同的容器,這對開發者而言是一件非常重大的事,也就是說,CloudKit 並不完美。CloudKit 是 Apple 的產品,我還沒有看到 Apple 願意將這樣的服務開放給其他平台的可能性。如果你要建立一個基於雲端的 App 給 iOS 及 Android 時,我想 CloudKit 不會是你的首選,你可能需要探索 Google 的 Firebase、Contentful、微軟的 Azure。
如果你的開發重點是 iOS 平台,那麼 CloudKit 對你及你的使用者而言有很大的潛力。我鼓勵你在下一個 App 中採用 CloudKit。
在本章所準備的範例檔中,有最後完整的 Xcode 專案 (http://www.appcoda.com/resources/swift59/swiftui-foodpin-cloudkit.zip](http://www.appcoda.com/resources/swift59/swiftui-foodpin-cloudkit.zip
)以及包含作業解答的完整專案(http://www.appcoda.com/resources/swift59/swiftui-foodpin-cloudkit-exercise.zip )可供你下載參考。