1. 概览
目标
在此 Codelab 中,您将使用 Swift 构建一个基于 Firestore 的 iOS 餐厅推荐应用。您将学习如何:
- 从 iOS 应用读取 Firestore 数据以及向 Firestore 写入数据
- 实时监听 Firestore 数据的变化
- 使用 Firebase Authentication 和安全规则保护 Firestore 数据
- 编写复杂的 Firestore 查询
前提条件
在开始此 Codelab 之前,请确保您已安装:
- Xcode 14.0 或更高版本
- CocoaPods 1.12.0(或更高版本)
2. 创建 Firebase 控制台项目
将 Firebase 添加到项目中
- 前往 Firebase 控制台。
- 选择创建新项目,然后将项目命名为“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”(身份验证)下启用“Email/Password”(电子邮件/密码)登录方法。
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)
上面的代码将一个新文档添加到餐馆集合。文档数据来自字典,我们从 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 数据标签页。现在,您应该会在餐馆集合中看到新条目:
恭喜,您刚刚从 iOS 应用向 Firestore 写入了数据!在下一部分中,您将学习如何从 Firestore 检索数据,并在应用中显示这些数据。
5. 显示来自 Firestore 的数据
在本部分,您将学习如何从 Firestore 检索数据,并在应用中显示这些数据。其中的两个关键步骤是创建查询和添加快照监听器。此监听器会收到与查询匹配的所有现有数据的通知,并实时接收更新。
首先,我们构建一个查询,它将提供默认的、未经过滤的餐馆列表。我们来看看 RestaurantsTableViewController.baseQuery()
的实现:
return Firestore.firestore().collection("restaurants").limit(to: 50)
此查询在名为“餐馆”的顶级集合中检索最多 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 读取和写入数据!
6. 对数据进行排序和过滤
目前,我们的应用显示了餐厅列表,但用户无法根据自己的需要进行过滤。在本部分中,您将使用 Firestore 的高级查询功能来启用过滤功能。
下面是一个提取所有点心餐馆的简单查询示例:
let filteredQuery = query.whereField("category", isEqualTo: "Dim Sum")
顾名思义,whereField(_:isEqualTo:)
方法可让我们的查询仅下载字段符合我们设置的限制的集合成员。在本示例中,它仅下载 category
为 "Dim Sum"
的餐馆。
在此应用中,用户可以链接多个过滤条件来创建特定查询,例如“Pizza in San Francisco”或“洛杉矶海鲜美食(按热门程度订购)”。
打开 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)
}
上面的代码段添加了多个 whereField
和 order
子句,以根据用户输入构建单个复合查询。现在,我们的查询将仅返回符合用户要求的餐馆。
运行您的项目,并验证您是否可以按价格、城市和类别进行过滤(请务必准确输入类别和城市名称)。在测试过程中,您可能会在日志中看到如下所示的错误:
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 控制台,然后前往 Database(数据库)> Firestore 标签页中的 Rules。
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 开始时设置的“填充”按钮的功能,但是一旦餐馆被写入,其名称、位置、价格和类别便无法更改。更具体地说,最后一条规则要求任何餐厅更新操作都必须保持与数据库中现有字段相同的名称、城市、价格和类别。
如需详细了解您可以使用安全规则执行的操作,请参阅文档。
9. 总结
在此 Codelab 中,您学习了如何使用 Firestore 进行基本和高级读写操作,以及如何使用安全规则来保护数据访问。您可以在 codelab-complete
分支中找到完整解决方案。
如需详细了解 Firestore,请参阅以下资源: