Cloud Firestore iOS Codelab

1. 概览

目标

在此 Codelab 中,您将使用 Swift 在 iOS 上构建一个由 Firestore 提供支持的餐厅推荐应用。您将学习如何:

  1. 从 iOS 应用读取数据并将数据写入 Firestore 中
  2. 实时监听 Firestore 数据的变化
  3. 使用 Firebase Authentication 和安全规则保护 Firestore 数据
  4. 编写复杂的 Firestore 查询

前提条件

在开始此 Codelab 之前,请确保您已安装:

  • 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. 在此 Codelab 中,您不需要使用 Google Analytics,因此请关闭 Google Analytics 选项。
  7. 点击创建项目,等待项目完成预配,然后点击继续

将您的应用关联至 Firebase

在新 Firebase 项目中创建 iOS 应用。

Firebase 控制台下载项目的 GoogleService-Info.plist 文件,并将其拖动到 Xcode 项目的根目录中。再次运行项目,确保应用配置正确,并且不再在启动时崩溃。登录后,您应该会看到一个空白屏幕,如下例所示。如果您无法登录,请确保您已在 Firebase 控制台的“身份验证”下启用“电子邮件/密码”登录方法。

d5225270159c040b.png

4. 将数据写入 Firestore

在本部分中,我们将一些数据写入 Firestore,以便填充应用界面。此操作可通过 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)

上述代码会向“restaurants”集合添加新文档。文档数据来自字典,该字典是从 Restaurant 结构体中获取的。

我们即将完成此步骤,但在将文档写入 Firestore 之前,我们需要打开 Firestore 的安全规则,并说明数据库的哪些部分应可由哪些用户写入。目前,我们仅允许通过身份验证的用户读取和写入整个数据库。对于生产应用而言,这有点过于宽松,但在应用构建过程中,我们希望宽松一些,这样在实验时就不会不断遇到身份验证问题。在此 Codelab 的最后,我们将讨论如何强化安全规则并限制意外读写操作的可能性。

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

我们稍后会详细讨论安全规则,但如果您时间紧迫,可以先参阅安全规则文档

运行应用并登录。然后,点按左上角的“填充”按钮,系统会创建一批餐厅文档,不过您暂时还不会在应用中看到这些文档。

接下来,前往 Firebase 控制台中的 Firestore 数据标签页。您现在应该会在“restaurants”集合中看到新条目:

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 加载到我们的应用中。将以下代码添加到 RestaurantsTableViewController.observeQuery() 方法中,紧跟在对 stopObserving() 的调用之后。

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" 的餐馆。

在该应用中,用户可以串连多个过滤条件来创建特定查询,例如“Pizza in San Francisco”或“Seafood in Los Angeles ordered by Popularity”。

打开 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 安全规则来保护我们的数据。

首先,我们来深入了解一下在此 Codelab 开头编写的安全规则。打开 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 的子集合。然后,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;
    }
  }
}

在此示例中,我们将写入权限拆分为创建权限和更新权限,以便更具体地说明应允许哪些操作。任何用户都可以将餐馆写入数据库,从而保留我们在 Codelab 开始时创建的“填充”按钮的功能,但一旦写入餐馆,其名称、位置、价格和类别就无法更改。更具体地说,最后一条规则要求任何餐厅更新操作都必须保持数据库中已存在字段的名称、城市、价格和类别不变。

如需详细了解安全规则的用途,请参阅相关文档

9. 总结

在此 Codelab 中,您学习了如何使用 Firestore 执行基本和高级读写操作,以及如何使用安全规则确保数据访问的安全性。您可以在 codelab-complete 分支中找到完整解决方案。

如需详细了解 Firestore,请参阅以下资源: