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. 创建 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 控制台的“Authentication”下启用了“电子邮件地址/密码”登录方法。

d5225270159c040b

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)

上面的代码会向餐馆集合添加一个新文档。文档数据来自从餐馆结构体获取的字典。

即将大功告成 - 在向 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;
    }
  }
}

我们稍后将详细讨论安全规则,但如果您很着急,请参阅安全规则文档

运行应用并登录。然后点按左上角的“Populate”(填充)按钮,这将创建一批餐馆文档,但您不会在应用中看到此项。

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

屏幕截图 2017 年 7 月 6 日中午 12.45.38 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

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 安全规则来保护数据。

首先,我们来深入了解一下我们在 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;
    }
  }
}

在这里,我们将写入权限拆分成了 create 和 update,以便更具体地说明应该允许哪些操作。任何用户都可以将餐馆写入数据库,并保留我们在 Codelab 开始时创建的“Populate”按钮的功能,但一旦写入餐馆,其名称、位置、价格和类别便无法更改。更具体地说,最后一条规则要求任何餐馆更新操作保留与数据库中已有字段相同的名称、城市、价格和类别。

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

9. 总结

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

如需详细了解 Firestore,请访问以下资源: