程式碼研究室簡介
1. 總覽
目標
在本程式碼研究室中,您將使用 Swift 在 iOS 上建構 Firestore 支援的餐廳推薦應用程式。您將學習下列內容:
- 從 iOS 應用程式讀取資料並寫入至 Firestore
- 即時監聽 Firestore 資料的變更
- 使用 Firebase 驗證和安全性規則保護 Firestore 資料
- 編寫複雜的 Firestore 查詢
事前準備
開始本程式碼研究室之前,請務必安裝:
- Xcode 14.0 以上版本
- CocoaPods 1.12.0 (或更高版本)
2. 建立 Firebase 控制台專案
將 Firebase 新增至專案
- 前往 Firebase 控制台。
- 選取「Create New Project」,並將專案命名為「Firestore iOS Codelab」。
3. 取得範例專案
下載程式碼
首先複製範例專案,然後在專案目錄中執行 pod update
:
git clone https://github.com/firebase/friendlyeats-ios cd friendlyeats-ios pod update
在 Xcode 中開啟 FriendlyEats.xcworkspace
並執行 (Cmd+R)。由於缺少 GoogleService-Info.plist
檔案,應用程式應該會正確編譯,並在啟動時立即當機。我們會在後續步驟中修正這個問題。
設定 Firebase
請按照說明文件建立新的 Firestore 專案。取得專案後,請從 Firebase 主控台下載專案的 GoogleService-Info.plist
檔案,然後將檔案拖曳至 Xcode 專案的根層級。再次執行專案,確認應用程式設定正確無誤,且不再在啟動時當機。登入後,畫面應會顯示空白畫面,如下方範例所示。如果無法登入,請確認您已在 Firebase 主控台的「驗證」下啟用電子郵件/密碼登入方式。
4. 將資料寫入 Firestore
在本節中,我們會將部分資料寫入 Firestore,以便填入應用程式 UI。您可以透過 Firebase 控制台手動執行這項操作,但我們會在應用程式中執行這項操作,以示範基本 Firestore 寫入作業。
應用程式的主要模型物件是餐廳。Firestore 資料會分為文件、集合和子集合。我們會將每間餐廳儲存為文件,並儲存在名為 restaurants
的頂層集合中。如要進一步瞭解 Firestore 資料模型,請參閱說明文件中的文件和集合。
在將資料新增至 Firestore 之前,我們需要取得餐廳集合的參照。將以下內容新增至 RestaurantsTableViewController.didTapPopulateButton(_:)
方法中的內部 for 迴圈。
let collection = Firestore.firestore().collection("restaurants")
有了集合參照,我們就可以寫入一些資料。在我們新增的最後一行程式碼後方加入以下內容:
let collection = Firestore.firestore().collection("restaurants")
// ====== ADD THIS ======
let restaurant = Restaurant(
name: name,
category: category,
city: city,
price: price,
ratingCount: 0,
averageRating: 0
)
collection.addDocument(data: restaurant.dictionary)
上述程式碼會在餐廳集合中新增文件。文件資料來自字典,而我們是從 Restaurant 結構體取得字典。
我們快完成了!在將文件寫入 Firestore 之前,我們需要開啟 Firestore 的安全性規則,並說明資料庫的哪些部分可供哪些使用者寫入。目前,我們只允許已驗證的使用者讀取及寫入整個資料庫。這對正式版應用程式來說過於寬鬆,但在應用程式建構過程中,我們希望能提供較寬鬆的設定,以免在實驗時不斷遇到驗證問題。在本程式碼研究室的最後,我們將說明如何強化安全性規則,並限制發生非預期讀取和寫入的可能性。
在 Firebase 主控台的「規則」分頁中新增下列規則,然後點選「發布」。
rules_version = '2'; service cloud.firestore { match /databases/{database}/documents { match /restaurants/{any}/ratings/{rating} { // Users can only write ratings with their user ID allow read; allow write: if request.auth != null && request.auth.uid == request.resource.data.userId; } match /restaurants/{any} { // Only authenticated users can read or write data allow read, write: if request.auth != null; } } }
我們稍後會詳細說明安全性規則,但如果您時間緊迫,請參閱安全性規則說明文件。
執行應用程式並登入帳戶。接著輕觸左上方的「Populate」按鈕,系統就會建立一批餐廳文件,但您還不會在應用程式中看到這些文件。
接著,前往 Firebase 控制台的 Firestore 資料分頁。餐廳集合項目中現在應該會顯示新的項目:
恭喜!您剛剛透過 iOS 應用程式將資料寫入 Firestore!在下一節中,您將瞭解如何從 Firestore 擷取資料,並在應用程式中顯示資料。
5. 顯示 Firestore 中的資料
在本節中,您將瞭解如何從 Firestore 擷取資料,並在應用程式中顯示資料。兩個重要步驟是建立查詢和新增快照監聽器。這個事件監聽器會收到通知,得知所有符合查詢的現有資料,並即時收到更新。
首先,我們要建立查詢,以便提供未篩選的預設餐廳清單。請查看 RestaurantsTableViewController.baseQuery()
的實作項目:
return Firestore.firestore().collection("restaurants").limit(to: 50)
這項查詢會擷取名為「restaurants」的頂層集合中的最多 50 間餐廳。有了查詢後,我們需要附加快照事件監聽器,才能將資料從 Firestore 載入應用程式。請在呼叫 stopObserving()
後,將下列程式碼新增至 RestaurantsTableViewController.observeQuery()
方法。
listener = query.addSnapshotListener { [unowned self] (snapshot, error) in
guard let snapshot = snapshot else {
print("Error fetching snapshot results: \(error!)")
return
}
let models = snapshot.documents.map { (document) -> Restaurant in
if let model = Restaurant(dictionary: document.data()) {
return model
} else {
// Don't use fatalError here in a real app.
fatalError("Unable to initialize type \(Restaurant.self) with dictionary \(document.data())")
}
}
self.restaurants = models
self.documents = snapshot.documents
if self.documents.count > 0 {
self.tableView.backgroundView = nil
} else {
self.tableView.backgroundView = self.backgroundView
}
self.tableView.reloadData()
}
上述程式碼會從 Firestore 下載集合,並將其儲存在本機的陣列中。addSnapshotListener(_:)
呼叫會在查詢中加入快照事件監聽器,每次伺服器上的資料變更時,就會更新檢視控制器。我們會自動取得更新,不必手動推送變更。請注意,這項快照事件監聽器隨時可能會因伺服器端變更而觸發,因此應用程式必須能夠處理變更。
將字典對應至結構體 (請參閱 Restaurant.swift
) 後,只要指派幾個檢視畫面屬性,即可顯示資料。在 RestaurantsTableViewController.swift
的 RestaurantTableViewCell.populate(restaurant:)
中新增下列行。
nameLabel.text = restaurant.name
cityLabel.text = restaurant.city
categoryLabel.text = restaurant.category
starsView.rating = Int(restaurant.averageRating.rounded())
priceLabel.text = priceString(from: restaurant.price)
這個填入方法會從資料表檢視資料來源的 tableView(_:cellForRowAtIndexPath:)
方法呼叫,該方法會負責將先前的值類型集合對應至個別資料表檢視儲存格。
再次執行應用程式,確認先前在控制台中看到的餐廳現在會顯示在模擬器或裝置上。如果您已順利完成本節,您的應用程式現在就能透過 Cloud Firestore 讀取及寫入資料!
6. 排序及篩選資料
目前應用程式會顯示餐廳清單,但使用者無法根據需求篩選餐廳。在本節中,您將使用 Firestore 的進階查詢功能啟用篩選功能。
以下是擷取所有 Dim Sum 餐廳的簡易查詢範例:
let filteredQuery = query.whereField("category", isEqualTo: "Dim Sum")
如其名稱所示,whereField(_:isEqualTo:)
方法會讓查詢只下載欄位符合所設定限制的集合成員。在這種情況下,系統只會下載 category
為 "Dim Sum"
的餐廳。
在這個應用程式中,使用者可以連結多個篩選器來建立特定查詢,例如「舊金山的披薩店」或「洛杉磯的海鮮餐廳 (依熱門程度排序)」。
開啟 RestaurantsTableViewController.swift
,並將下列程式碼區塊新增至 query(withCategory:city:price:sortBy:)
中間:
if let category = category, !category.isEmpty {
filtered = filtered.whereField("category", isEqualTo: category)
}
if let city = city, !city.isEmpty {
filtered = filtered.whereField("city", isEqualTo: city)
}
if let price = price {
filtered = filtered.whereField("price", isEqualTo: price)
}
if let sortBy = sortBy, !sortBy.isEmpty {
filtered = filtered.order(by: sortBy)
}
上述程式碼片段會新增多個 whereField
和 order
子句,根據使用者輸入內容建立單一複合查詢。這樣一來,查詢就只會傳回符合使用者需求的餐廳。
執行專案,確認您可以依價格、城市和類別進行篩選 (請務必輸入正確的類別和城市名稱)。測試期間,您可能會在記錄中看到類似以下的錯誤:
Error fetching snapshot results: Error Domain=io.grpc Code=9 "The query requires an index. You can create it here: https://console.firebase.google.com/project/project-id/database/firestore/indexes?create_composite=..." UserInfo={NSLocalizedDescription=The query requires an index. You can create it here: https://console.firebase.google.com/project/project-id/database/firestore/indexes?create_composite=...}
這是因為 Firestore 需要為大多數複合式查詢建立索引。要求在查詢中建立索引,可確保 Firestore 在大量規模下維持快速的速度。開啟錯誤訊息中的連結後,系統會自動在 Firebase 控制台中開啟索引建立 UI,並填入正確的參數。如要進一步瞭解 Firestore 中的索引,請參閱說明文件。
7. 在交易中寫入資料
在本節中,我們會新增使用者提交餐廳評論的功能。到目前為止,我們所有的寫入作業都是原子且相對簡單。如果其中任何一個發生錯誤,我們可能會提示使用者重試,或自動重試。
為了為餐廳新增評分,我們需要協調多個讀取和寫入作業。首先必須提交評論,然後更新餐廳的評分次數和平均評分。如果其中一個失敗,另一個卻沒有,系統就會處於不一致的狀態,也就是資料庫中某部分的資料與另一部分的資料不相符。
幸好,Firestore 提供交易功能,可讓我們在單一不可分割作業中執行多個讀取和寫入作業,確保資料保持一致。
在 RestaurantDetailViewController.reviewController(_:didSubmitFormWithReview:)
的所有 let 宣告下方加入以下程式碼。
let firestore = Firestore.firestore()
firestore.runTransaction({ (transaction, errorPointer) -> Any? in
// Read data from Firestore inside the transaction, so we don't accidentally
// update using stale client data. Error if we're unable to read here.
let restaurantSnapshot: DocumentSnapshot
do {
try restaurantSnapshot = transaction.getDocument(reference)
} catch let error as NSError {
errorPointer?.pointee = error
return nil
}
// Error if the restaurant data in Firestore has somehow changed or is malformed.
guard let data = restaurantSnapshot.data(),
let restaurant = Restaurant(dictionary: data) else {
let error = NSError(domain: "FireEatsErrorDomain", code: 0, userInfo: [
NSLocalizedDescriptionKey: "Unable to write to restaurant at Firestore path: \(reference.path)"
])
errorPointer?.pointee = error
return nil
}
// Update the restaurant's rating and rating count and post the new review at the
// same time.
let newAverage = (Float(restaurant.ratingCount) * restaurant.averageRating + Float(review.rating))
/ Float(restaurant.ratingCount + 1)
transaction.setData(review.dictionary, forDocument: newReviewReference)
transaction.updateData([
"numRatings": restaurant.ratingCount + 1,
"avgRating": newAverage
], forDocument: reference)
return nil
}) { (object, error) in
if let error = error {
print(error)
} else {
// Pop the review controller on success
if self.navigationController?.topViewController?.isKind(of: NewReviewViewController.self) ?? false {
self.navigationController?.popViewController(animated: true)
}
}
}
在更新區塊中,Firestore 會將我們使用交易物件執行的所有作業視為單一不可分割的更新。如果伺服器上的更新失敗,Firestore 會自動重試幾次。也就是說,我們的錯誤狀態很可能是單一錯誤重複發生,例如裝置完全離線,或是使用者沒有寫入權限。
8. 安全性規則
應用程式的使用者不應能夠讀取及寫入資料庫中的所有資料。舉例來說,每個人都應該可以查看餐廳的評分,但只有經過驗證的使用者才能發布評分。在用戶端編寫良好的程式碼還不夠,我們需要在後端指定資料安全性模型,才能確保完全安全。在本節中,我們將瞭解如何使用 Firebase 安全性規則保護資料。
首先,讓我們深入瞭解程式碼研究室一開始所撰寫的安全性規則。開啟 Firebase 控制台,然後依序前往「資料庫」>「Firestore 分頁中的規則」。
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /restaurants/{any}/ratings/{rating} {
// Users can only write ratings with their user ID
allow read;
allow write: if request.auth != null
&& request.auth.uid == request.resource.data.userId;
}
match /restaurants/{any} {
// Only authenticated users can read or write data
allow read, write: if request.auth != null;
}
}
}
規則中的 request
變數是所有規則中可用的全域變數,而我們新增的條件可確保在允許使用者執行任何操作前,先驗證要求。這可防止未經驗證的使用者使用 Firestore API 擅自變更您的資料。這麼做雖然不錯,但我們可以使用 Firestore 規則執行更強大的功能。
我們想限制評論寫入作業,讓評論的使用者 ID 必須與已驗證使用者的 ID 相符。這麼做可確保使用者無法冒用他人身分,並留下不實的評論。
第一個比對陳述式會比對屬於 restaurants
集合的任何文件的 ratings
子集合。如果評論的使用者 ID 與使用者不符,allow write
條件式會防止提交任何評論。第二個比對陳述式可讓任何已驗證的使用者讀取及寫入資料庫中的餐廳。
這對評論來說非常實用,因為我們使用安全性規則明確指出先前在應用程式中寫入的隱含保證,也就是使用者只能撰寫自己的評論。如果我們為評論新增編輯或刪除功能,同樣的規則也會禁止使用者修改或刪除其他使用者的評論。不過,您也可以使用 Firestore 規則,以更精細的方式限制寫入文件內部個別欄位,而非整個文件本身。我們可以利用這項功能,讓使用者只更新餐廳的評分、平均評分和評分數量,避免惡意使用者變更餐廳名稱或地點。
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
match /restaurants/{restaurant} {
match /ratings/{rating} {
allow read: if request.auth != null;
allow write: if request.auth != null
&& request.auth.uid == request.resource.data.userId;
}
allow read: if request.auth != null;
allow create: if request.auth != null;
allow update: if request.auth != null
&& request.resource.data.name == resource.data.name
&& request.resource.data.city == resource.data.city
&& request.resource.data.price == resource.data.price
&& request.resource.data.category == resource.data.category;
}
}
}
我們將寫入權限分為建立和更新,以便更明確地指出應允許哪些作業。任何使用者都可以將餐廳寫入資料庫,保留我們在程式碼研究室一開始建立的「填入」按鈕功能,但餐廳一旦寫入,其名稱、位置、價格和類別就無法變更。具體來說,最後一項規則要求任何餐廳更新作業都必須維持資料庫中現有欄位的名稱、城市、價格和類別。
如要進一步瞭解安全性規則的用途,請參閱說明文件。
9. 結論
在本程式碼研究室中,您學習了如何使用 Firestore 進行基本和進階讀取/寫入作業,以及如何透過安全性規則保護資料存取權。您可以在 codelab-complete
分支中找到完整的解決方案。
如要進一步瞭解 Firestore,請參閱下列資源: