Cloud Firestore iOS 代码实验室

1. 概述

目标

在此 Codelab 中,您将在 iOS 上使用 Swift 构建 Firestore 支持的餐厅推荐应用。你将学到如何:

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

先决条件

在开始此代码实验室之前,请确保您已安装:

  • Xcode 8.3(或更高版本)
  • CocoaPods 1.2.1(或更高版本)

2. 创建 Firebase 控制台项目

将 Firebase 添加到项目

  1. 转至火力地堡控制台
  2. 选择创建新的项目和项目命名为“iOS版的FireStore程式码实验室”。

3. 获取示例项目

下载代码

通过克隆开始样品项目和运行pod update项目目录:

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

开放FriendlyEats.xcworkspace在Xcode并运行它(Cmd的+ R)。该应用程序应正确编译并立即崩溃的推出,因为它缺少GoogleService-Info.plist文件。我们将在下一步中更正。

设置 Firebase

按照该文件来创建一个新公司的FireStore项目。一旦你得到了你的项目,你的下载项目的GoogleService-Info.plist从文件火力地堡控制台,并拖动到Xcode项目的根。再次运行项目以确保应用程序配置正确并且在启动时不再崩溃。登录后,您应该会看到如下例所示的空白屏幕。如果您无法登录,请确保您已在 Firebase 控制台中的身份验证下启用电子邮件/密码登录方法。

10a0671ce8f99704.png

4. 将数据写入 Firestore

在本节中,我们将向 Firestore 写入一些数据,以便我们可以填充应用 UI。这可以通过手动调节来完成火力地堡控制台,但我们会在应用程序本身来证明一个基本的公司的FireStore写做。

我们应用程序中的主要模型对象是一家餐厅。 Firestore 数据分为文档、集合和子集合。我们将存储每个餐厅中称为顶级集合文件restaurants 。如果您想了解更多关于公司的FireStore数据模型,阅读有关文件和收藏的文件

在向 Firestore 添加数据之前,我们需要获取对餐厅集合的引用。下面添加到内中环RestaurantsTableViewController.didTapPopulateButton(_:)方法。

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 的最后,我们将讨论如何强化安全规则并限制意外读取和写入的可能性。

规则选项卡的火力地堡控制台添加下面的规则,然后单击发布

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

我们将在后面详细讨论安全规则,但是如果你在赶时间,看看在安全规则的文档

在运行应用程序和标志。然后在左上角,这将创建一个批处理文件餐馆的挖掘“填充”按钮,虽然你不会看到这个应用程序呢。

接下来,导航到公司的FireStore数据标签在火力地堡控制台。您现在应该会在餐厅集合中看到新条目:

屏幕截图 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 ),显示的数据只是一个分配的几个视图属性的问题。添加以下行来RestaurantTableViewCell.populate(restaurant:)RestaurantsTableViewController.swift

nameLabel.text = restaurant.name
cityLabel.text = restaurant.city
categoryLabel.text = restaurant.category
starsView.rating = Int(restaurant.averageRating.rounded())
priceLabel.text = priceString(from: restaurant.price)

此populate方法从表视图数据源的所谓tableView(_:cellForRowAtIndexPath:)方法,它负责从映射值类型的集合的前向个体表视图细胞。

再次运行应用程序并验证我们之前在控制台中看到的餐厅现在在模拟器或设备上是否可见。如果您成功完成了本部分,您的应用现在正在使用 Cloud Firestore 读取和写入数据!

2ca7f8c6052f7f79.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/testapp-5d356/database/firestore/indexes?create_index=..." 
UserInfo={NSLocalizedDescription=The query requires an index. You can create it here: https://console.firebase.google.com/project/project-id/database/firestore/indexes?create_index=...}

这是因为 Firestore 需要大多数复合查询的索引。在查询上需要索引可以使 Firestore 快速扩展。从错误信息打开链接就会自动打开与填入正确的参数的火力地堡控制台索引创建UI。要了解更多关于公司的FireStore索引,访问文件

7. 在事务中写入数据

在本节中,我们将添加用户向餐厅提交评论的功能。到目前为止,我们所有的写入都是原子的并且相对简单。如果其中任何一个出错,我们可能只会提示用户重试或自动重试。

为了给餐厅添加评级,我们需要协调多次读取和写入。首先必须提交评论本身,然后需要更新餐厅的评分计数和平均评分。如果其中一个失败,而另一个没有失败,我们就会处于不一致的状态,即数据库一部分中的数据与另一部分中的数据不匹配。

幸运的是,Firestore 提供了事务功能,让我们可以在单个原子操作中执行多次读取和写入,从而确保我们的数据保持一致。

添加以下代码下面的所有让利声明中RestaurantDetailViewController.reviewController(_:didSubmitFormWithReview:)

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 开始时编写的安全规则。打开火力地堡控制台并导航到数据库>规则在公司的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;
    }
  }
}

第一场比赛的语句相匹配的子集合命名的ratings属于任何文件的restaurants集合。将allow write那么有条件阻止任何审查,如果从审查的用户ID不匹配用户的提交。第二个 match 语句允许任何经过身份验证的用户将餐馆读取和写入数据库。

这对我们的评论非常有效,因为我们已经使用安全规则来明确声明我们之前写入我们的应用程序的隐含保证——用户只能写他们自己的评论。如果我们要为评论添加编辑或删除功能,这组完全相同的规则也将阻止用户修改或删除其他用户的评论。但 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. 结论

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

要了解有关 Firestore 的更多信息,请访问以下资源: