Cloud Firestore iOS 程式碼實驗室

1. 概述

目標

在此 Codelab 中,您將使用 Swift 在 iOS 上建立一個由 Firestore 支援的餐廳推薦應用程式。你將學到如何:

  1. 從 iOS 應用程式讀取資料並將其寫入 Firestore
  2. 即時監聽 Firestore 資料的變化
  3. 使用 Firebase 驗證和安全規則來保護 Firestore 數據
  4. 編寫複雜的 Firestore 查詢

先決條件

在開始此 Codelab 之前,請確保您已安裝:

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

2.建立Firebase控制台項目

將 Firebase 新增到專案中

  1. 轉到Firebase 控制台
  2. 選擇“建立新專案”並將您的專案命名為“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)

上面的程式碼為餐廳集合新增了一個新文件。文件資料來自字典,我們從 Restaurant 結構中取得該字典。

我們快到了——在將文件寫入 Firestore 之前,我們需要打開 Firestore 的安全性規則並描述資料庫的哪些部分應該由哪些使用者寫入。目前,我們只允許經過身份驗證的使用者讀取和寫入整個資料庫。對於生產應用程式來說,這有點過於寬鬆,但在應用程式建置過程中,我們希望事情足夠寬鬆,這樣我們就不會在實驗時不斷遇到身份驗證問題。在本 Codelab 的最後,我們將討論如何強化安全規則並限制意外讀寫的可能性。

在 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 資料標籤。現在您應該在餐廳集合中看到新條目:

螢幕截圖 2017-07-06 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.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 讀取和寫入資料!

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 安全規則來保護我們的資料。

首先,讓我們更深入地了解我們在 Codelab 開始時編寫的安全規則。開啟 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的子集合相符。如果評論的使用者 ID 與使用者的 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;
    }
  }
}

在這裡,我們將寫入權限分為建立和更新,以便我們可以更具體地了解應該允許哪些操作。任何用戶都可以將餐廳寫入資料庫,保留我們在 Codelab 開始時創建的填充按鈕的功能,但是一旦寫入餐館,其名稱、位置、價格和類別就無法更改。更具體地說,最後一條規則要求任何餐廳更新作業都與資料庫中現有欄位保持相同的名稱、城市、價格和類別。

若要詳細了解可以使用安全規則執行哪些操作,請查看文件

9. 結論

在此 Codelab 中,您學習如何使用 Firestore 進行基本和進階讀寫,以及如何使用安全規則保護資料存取。您可以在codelab-complete分支上找到完整的解決方案。

要了解有關 Firestore 的更多信息,請訪問以下資源: