Cloud Firestore iOS 程式碼研究室

1. 總覽

目標

在本程式碼研究室中,您將在 iOS 上以 Swift 建構以 Firestore 為後端的餐廳推薦應用程式。您將學習下列內容:

  1. 從 iOS 應用程式讀取及寫入 Firestore 資料
  2. 即時監聽 Firestore 資料的變更
  3. 使用 Firebase 驗證和安全性規則保護 Firestore 資料
  4. 撰寫複雜的 Firestore 查詢

事前準備

開始本程式碼研究室之前,請先安裝下列項目:

  • Xcode 14.0 以上版本
  • CocoaPods 1.12.0 以上版本

2. 取得範例專案

下載程式碼

首先,請複製範例專案,然後在專案目錄中執行 pod update

git clone https://github.com/firebase/friendlyeats-ios
cd friendlyeats-ios
pod update

在 Xcode 中開啟 FriendlyEats.xcworkspace 並執行 (Cmd+R)。應用程式應該會正確編譯,但啟動時會立即異常終止,因為缺少 GoogleService-Info.plist 檔案。我們會在下一個步驟中修正這個問題。

3. 設定 Firebase

建立 Firebase 專案

  1. 使用 Google 帳戶登入 Firebase 控制台
  2. 按一下按鈕建立新專案,然後輸入專案名稱 (例如 FriendlyEats)。
  3. 按一下「繼續」
  4. 如果系統提示,請詳閱並接受 Firebase 條款,然後按一下「繼續」
  5. (選用) 在 Firebase 控制台中啟用 AI 輔助功能 (稱為「Gemini in Firebase」)。
  6. 本程式碼研究室不需要 Google Analytics,因此請關閉 Google Analytics 選項。
  7. 按一下「建立專案」,等待專案佈建完成,然後按一下「繼續」

將應用程式連結至 Firebase

在新的 Firebase 專案中建立 iOS 應用程式。

Firebase 主控台下載專案的 GoogleService-Info.plist 檔案,然後拖曳至 Xcode 專案的根目錄。再次執行專案,確認應用程式設定正確無誤,且啟動時不會再當機。登入後,您應該會看到空白畫面,如下列範例所示。如果無法登入,請確認您已在 Firebase 主控台的「Authentication」下方啟用「Email/Password」登入方式。

d5225270159c040b.png

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 資料分頁。現在餐廳集合中應該會顯示新項目:

Screen Shot 2017-07-06 at 12.45.38 PM.png

恭喜,您剛才已從 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.swiftRestaurantTableViewCell.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 讀取及寫入資料!

391c0259bf05ac25.png

6. 排序及篩選資料

目前我們的應用程式會顯示餐廳清單,但使用者無法根據需求篩選。在本節中,您將使用 Firestore 的進階查詢功能啟用篩選功能。

以下是擷取所有港式飲茶餐廳的簡單查詢範例:

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)
}

上述程式碼片段會新增多個 whereFieldorder 子句,根據使用者輸入內容建構單一複合查詢。現在查詢只會傳回符合使用者需求的餐廳。

執行專案,確認你可以依價格、城市和類別篩選 (請務必正確輸入類別和城市名稱)。測試期間,您可能會在記錄中看到類似下列的錯誤:

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 控制台會自動開啟索引建立使用者介面,並填入正確的參數。如要進一步瞭解 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」分頁中的「Database」>「Rules」

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,請參閱下列資源: