Cloud Firestore iOS 程式碼研究室

1. 總覽

目標

在這個程式碼研究室中,您將在 Swift 以 iOS 裝置建構支援 Firestore 的餐廳推薦應用程式。您將學習下列內容:

  1. 從 iOS 應用程式讀取資料並寫入 Firestore
  2. 即時監聽 Firestore 資料異動內容
  3. 使用 Firebase 驗證和安全性規則保護 Firestore 資料
  4. 編寫複雜的 Firestore 查詢

事前準備

開始本程式碼研究室之前,請確認您已安裝:

  • Xcode 14.0 以上版本
  • CocoaPods 1.12.0 (或更高版本)

2. 建立 Firebase 控制台專案

將 Firebase 新增至專案

  1. 前往 Firebase 控制台
  2. 選取「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 控制台的「驗證」下方,啟用電子郵件/密碼登入方式。

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)

以上程式碼會將新文件新增到餐廳集合。文件資料來自餐廳結構體提供的字典。

快要大功告成了。在將文件寫入 Firestore 之前,我們必須開啟 Firestore 的安全性規則,並說明哪些使用者應寫入資料庫的哪些部分。目前我們僅允許經過驗證的使用者讀取和寫入整個資料庫。對正式版應用程式而言,這個要求過於寬鬆,但在建構應用程式的過程中,我們希望達到一點寬鬆的效果,以免在進行實驗時持續遇到驗證問題。在本程式碼研究室的結尾,我們會說明如何強化安全性規則,並限制發生非預期讀取和寫入的可能性。

在 Firebase 控制台的「規則」分頁新增下列規則,然後按一下「發布」

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      //
      // WARNING: These rules are insecure! We will replace them with
      // more secure rules later in the codelab
      //
      allow read, write: if request.auth != null;
    }
  }
}

稍後我們會進一步討論安全性規則。如果您需要趕時間,請參閱安全性規則說明文件

執行應用程式並登入。然後輕觸 [填入]按鈕,上方將建立一批餐廳文件,不過應用程式目前尚未提供這項功能。

接著,前往 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)

此查詢會擷取名為「餐廳」的頂層集合中最多 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 控制台開啟索引建立 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 /{document=**} {
      // Only authenticated users can read or write data
      allow read, write: if request.auth != null;
    }
  }
}

上述規則中的 request 變數是所有規則皆可使用的全域變數,而我們新增的條件可確保要求已通過驗證,再讓使用者執行任何操作。防止未經驗證的使用者透過 Firestore API 在未經授權的情況下變更您的資料。這是好的開始,不過我們可以使用 Firestore 規則執行更實用的工作。

我們來限制評論寫入次數,確保評論的使用者 ID 必須與已驗證使用者的 ID 相符。這可確保使用者無法冒用他人身分,並留下不實評論。請將安全性規則替換為下列內容:

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

第一個比對陳述式會比對所有屬於 restaurants 集合的文件,且名為 ratings 的子集合。接著,allow write 條件式會禁止評論的使用者 ID 與使用者 ID 不符,因而無法提交任何評論。第二個比對陳述式可讓任何經過驗證的使用者讀取資料,並將餐廳寫入資料庫。

這種做法很適合審查,因為我們已使用安全性規則明確指出我們先前在應用程式中撰寫的默示保證,讓使用者只能撰寫自己的評論。如果我們要新增編輯或刪除評論的函式,同一組規則也能防止使用者修改或刪除其他使用者客戶也會提出評論不過,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;
    }
  }
}

我們在此將寫入權限分割為建立及更新權限,以便透過更具體的方式說明允許哪些作業。所有使用者都能將餐廳寫入資料庫,保留我們在程式碼研究室一開始製作的「Populate」按鈕功能;不過,餐廳寫出名稱、地點、價格和類別後,就無法變更。更具體來說,最後一項規則需要更新所有餐廳資料,以便保留資料庫中既有欄位的相同名稱、城市、價格和類別。

如要進一步瞭解安全性規則的用途,請參閱說明文件

9. 結論

在本程式碼研究室中,您學到瞭如何使用 Firestore 執行基本與進階的讀取與寫入作業,以及如何使用安全性規則保護資料存取安全。您可以在 codelab-complete 分支版本上找到完整解決方案。

如要進一步瞭解 Firestore,請參閱下列資源: