1. 概览
目标
在此 Codelab 中,您将在 Android 上构建一个由 Cloud Firestore 支持的餐厅推荐应用。您将学习如何:
- 从 Android 应用读取数据并将数据写入 Firestore
- 实时监听 Firestore 数据的变化
- 使用 Firebase Authentication 和安全规则来保护 Firestore 数据
- 编写复杂的 Firestore 查询
前提条件
在开始此 Codelab 之前,请确保:
- Android Studio Flamingo 或更高版本
- 搭载 API 19 或更高级别的 Android 模拟器
- Node.js 版本 16 或更高版本
- Java 版本 17 或更高版本
2. 创建 Firebase 项目
- 使用您的 Google 账号登录 Firebase 控制台。
- 在 Firebase 控制台中,点击添加项目。
- 如下面的屏幕截图所示,为您的 Firebase 项目输入一个名称(例如“Friends Eats”),然后点击继续。
- 系统可能会要求您启用 Google Analytics,在此 Codelab 中,您的选择无关紧要。
- 大约一分钟后,您的 Firebase 项目将准备就绪。点击继续。
3. 设置示例项目
下载代码
运行以下命令以克隆此 Codelab 的示例代码。这将在您的机器上创建一个名为 friendlyeats-android
的文件夹:
$ git clone https://github.com/firebase/friendlyeats-android
如果您的机器上没有 git,也可以直接从 GitHub 下载代码。
添加 Firebase 配置
- 在 Firebase 控制台中,选择左侧导航栏中的项目概览。点击 Android 按钮,选择平台。当系统提示您输入软件包名称时,请使用
com.google.firebase.example.fireeats
- 点击 Register App(注册应用),然后按照说明下载
google-services.json
文件,并将其移至您刚刚下载的代码的app/
文件夹中。然后点击下一步。
导入项目
打开 Android Studio。点击文件 >新建 >Import Project,然后选择 culturaleats-android 文件夹。
4. 设置 Firebase 模拟器
在此 Codelab 中,您将使用 Firebase Emulator Suite 在本地模拟 Cloud Firestore 和其他 Firebase 服务。这提供了一个安全、快速且免费的本地开发环境,方便您构建应用。
安装 Firebase CLI
首先,您需要安装 Firebase CLI。如果您使用的是 macOS 或 Linux,可以运行以下 cURL 命令:
curl -sL https://firebase.tools | bash
如果您使用的是 Windows,请参阅安装说明,获取独立二进制文件或通过 npm
进行安装。
安装 CLI 后,运行 firebase --version
应报告 9.0.0
或更高版本:
$ firebase --version 9.0.0
登录
运行 firebase login
以将 CLI 关联到您的 Google 账号。系统随即会打开一个新的浏览器窗口,供您完成登录流程。请务必选择您之前创建 Firebase 项目时所用的账号。
关联您的项目
在 friendlyeats-android
文件夹中运行 firebase use --add
,将本地项目关联到 Firebase 项目。按照提示选择您之前创建的项目,如果系统要求选择别名,请输入 default
。
5. 运行应用
现在,是首次运行 Firebase Emulator Suite 和 FriendlyEats Android 应用。
运行模拟器
在您的终端中,从 friendlyeats-android
目录运行 firebase emulators:start
以启动 Firebase 模拟器。您应该会看到如下日志:
$ firebase emulators:start i emulators: Starting emulators: auth, firestore i firestore: Firestore Emulator logging to firestore-debug.log i ui: Emulator UI logging to ui-debug.log ┌─────────────────────────────────────────────────────────────┐ │ ✔ All emulators ready! It is now safe to connect your app. │ │ i View Emulator UI at http://localhost:4000 │ └─────────────────────────────────────────────────────────────┘ ┌────────────────┬────────────────┬─────────────────────────────────┐ │ Emulator │ Host:Port │ View in Emulator UI │ ├────────────────┼────────────────┼─────────────────────────────────┤ │ Authentication │ localhost:9099 │ http://localhost:4000/auth │ ├────────────────┼────────────────┼─────────────────────────────────┤ │ Firestore │ localhost:8080 │ http://localhost:4000/firestore │ └────────────────┴────────────────┴─────────────────────────────────┘ Emulator Hub running at localhost:4400 Other reserved ports: 4500 Issues? Report them at https://github.com/firebase/firebase-tools/issues and attach the *-debug.log files.
现在,您的机器上已经运行了一个完整的本地开发环境!请确保在 Codelab 的其余部分保持运行此命令,您的 Android 应用需要连接到模拟器。
将应用连接到模拟器
在 Android Studio 中打开 util/FirestoreInitializer.kt
和 util/AuthInitializer.kt
文件。这些文件包含在应用启动时将 Firebase SDK 连接到您机器上运行的本地模拟器的逻辑。
在 FirestoreInitializer
类的 create()
方法中,检查这段代码:
// Use emulators only in debug builds
if (BuildConfig.DEBUG) {
firestore.useEmulator(FIRESTORE_EMULATOR_HOST, FIRESTORE_EMULATOR_PORT)
}
我们使用 BuildConfig
来确保仅在应用在 debug
模式下运行时连接到模拟器。当我们在 release
模式下编译应用时,此条件将为 false。
我们可以看到,它正在使用 useEmulator(host, port)
方法将 Firebase SDK 连接到本地 Firestore 模拟器。在整个应用中,我们将使用 FirebaseUtil.getFirestore()
访问 FirebaseFirestore
的这个实例,以便确保在 debug
模式下运行时,我们始终会连接到 Firestore 模拟器。
运行应用
如果您已正确添加 google-services.json
文件,项目现在应该可以编译了。在 Android Studio 中,点击 Build >重新构建项目并确保不再有错误。
在 Android Studio 中,在 Android 模拟器上运行应用。首先,系统会显示“登录”屏幕。您可以使用任何电子邮件地址和密码登录该应用。此登录流程正在连接到 Firebase Authentication 模拟器,因此系统不会传输任何真实凭据。
现在,在网络浏览器中导航到 http://localhost:4000,以打开模拟器界面。然后点击 Authentication(身份验证)标签,您应该会看到刚刚创建的账号:
完成登录流程后,您应该会看到应用主屏幕:
我们很快就会添加一些数据来填充主屏幕。
6. 将数据写入 Firestore
在本部分中,我们将一些数据写入 Firestore,以填充目前空白的主屏幕。
我们应用中的主要模型对象是餐厅(请参阅 model/Restaurant.kt
)。Firestore 数据分为文档、集合和子集合。我们将每家餐馆以文档形式存储在名为 "restaurants"
的顶级集合中。如需详细了解 Firestore 数据模型,请参阅文档中的文档和集合。
为便于演示,我们将在应用中添加一项功能,点击“Add Random Items”(添加随机项目)按钮,创建 10 个随机餐厅。按钮。打开文件 MainFragment.kt
,并将 onAddItemsClicked()
方法中的内容替换为:
private fun onAddItemsClicked() {
val restaurantsRef = firestore.collection("restaurants")
for (i in 0..9) {
// Create random restaurant / ratings
val randomRestaurant = RestaurantUtil.getRandom(requireContext())
// Add restaurant
restaurantsRef.add(randomRestaurant)
}
}
关于上述代码,请注意以下几点:
- 我们首先获取了对
"restaurants"
集合的引用。集合是在添加文档时隐式创建的,因此在写入数据之前无需创建集合。 - 您可以使用 Kotlin 数据类创建文档,我们将使用这些数据类创建每个“餐厅”文档。
add()
方法会将文档添加到具有自动生成 ID 的集合,因此我们无需为每家餐厅指定唯一 ID。
现在,再次运行应用,然后点击“Add Random items”(添加随机商品)按钮(位于右上角)调用您刚刚编写的代码:
现在,在网络浏览器中导航到 http://localhost:4000,以打开模拟器界面。然后点击 Firestore 标签页,您应该会看到刚刚添加的数据:
这些数据完全位于您的计算机本地。实际上,您的真实项目还没有包含 Firestore 数据库!这意味着,您可以放心地尝试修改和删除这些数据,而不会产生任何后果。
恭喜,您刚刚向 Firestore 写入了数据!在下一步中,我们将了解如何在应用中显示这些数据。
7. 显示来自 Firestore 的数据
在此步骤中,我们将学习如何从 Firestore 检索数据并在应用中显示这些数据。从 Firestore 读取数据的第一步是创建 Query
。打开 MainFragment.kt
文件,并将以下代码添加到 onViewCreated()
方法的开头:
// Firestore
firestore = Firebase.firestore
// Get the 50 highest rated restaurants
query = firestore.collection("restaurants")
.orderBy("avgRating", Query.Direction.DESCENDING)
.limit(LIMIT.toLong())
现在,我们要监听查询,以便获取所有匹配的文档并在将来有更新时实时收到通知。由于我们的最终目标是将这些数据绑定到 RecyclerView
,因此我们需要创建一个 RecyclerView.Adapter
类来监听数据。
打开已部分实现的 FirestoreAdapter
类。首先,让我们让适配器实现 EventListener
并定义 onEvent
函数,以便它可以接收 Firestore 查询的更新:
abstract class FirestoreAdapter<VH : RecyclerView.ViewHolder>(private var query: Query?) :
RecyclerView.Adapter<VH>(),
EventListener<QuerySnapshot> { // Add this implements
// ...
// Add this method
override fun onEvent(documentSnapshots: QuerySnapshot?, e: FirebaseFirestoreException?) {
// Handle errors
if (e != null) {
Log.w(TAG, "onEvent:error", e)
return
}
// Dispatch the event
if (documentSnapshots != null) {
for (change in documentSnapshots.documentChanges) {
// snapshot of the changed document
when (change.type) {
DocumentChange.Type.ADDED -> {
// TODO: handle document added
}
DocumentChange.Type.MODIFIED -> {
// TODO: handle document changed
}
DocumentChange.Type.REMOVED -> {
// TODO: handle document removed
}
}
}
}
onDataChanged()
}
// ...
}
在初始加载时,监听器将针对每个新文档收到一个 ADDED
事件。随着查询的结果集随时间变化,监听器会收到更多包含更改的事件。现在,我们来完成监听器的实现。首先,添加三个新方法:onDocumentAdded
、onDocumentModified
和 onDocumentRemoved
:
private fun onDocumentAdded(change: DocumentChange) {
snapshots.add(change.newIndex, change.document)
notifyItemInserted(change.newIndex)
}
private fun onDocumentModified(change: DocumentChange) {
if (change.oldIndex == change.newIndex) {
// Item changed but remained in same position
snapshots[change.oldIndex] = change.document
notifyItemChanged(change.oldIndex)
} else {
// Item changed and changed position
snapshots.removeAt(change.oldIndex)
snapshots.add(change.newIndex, change.document)
notifyItemMoved(change.oldIndex, change.newIndex)
}
}
private fun onDocumentRemoved(change: DocumentChange) {
snapshots.removeAt(change.oldIndex)
notifyItemRemoved(change.oldIndex)
}
然后,从 onEvent
调用这些新方法:
override fun onEvent(documentSnapshots: QuerySnapshot?, e: FirebaseFirestoreException?) {
// Handle errors
if (e != null) {
Log.w(TAG, "onEvent:error", e)
return
}
// Dispatch the event
if (documentSnapshots != null) {
for (change in documentSnapshots.documentChanges) {
// snapshot of the changed document
when (change.type) {
DocumentChange.Type.ADDED -> {
onDocumentAdded(change) // Add this line
}
DocumentChange.Type.MODIFIED -> {
onDocumentModified(change) // Add this line
}
DocumentChange.Type.REMOVED -> {
onDocumentRemoved(change) // Add this line
}
}
}
}
onDataChanged()
}
最后,实现 startListening()
方法以附加监听器:
fun startListening() {
if (registration == null) {
registration = query.addSnapshotListener(this)
}
}
现在,应用已完全配置为从 Firestore 读取数据。再次运行应用,您应该会看到在上一步中添加的餐厅:
现在,返回浏览器中的模拟器界面,然后修改其中一个餐厅名称。你应该很快就会在应用中看到它的变化!
8. 对数据进行排序和过滤
该应用目前会显示整个集合中评分最高的餐厅,但在真实的餐厅应用中,用户会希望对数据进行排序和过滤。例如,应用应能够显示“费城热门海鲜餐厅”或“最便宜的披萨”。
点击应用顶部的白色栏会调出过滤器对话框。在本部分中,我们将使用 Firestore 查询来实现此对话框:
我们来修改 MainFragment.kt
的 onFilter()
方法。此方法接受 Filters
对象,该对象是我们创建的辅助对象,用于捕获过滤器对话框的输出。我们将更改此方法,以根据过滤条件构造查询:
override fun onFilter(filters: Filters) {
// Construct query basic query
var query: Query = firestore.collection("restaurants")
// Category (equality filter)
if (filters.hasCategory()) {
query = query.whereEqualTo(Restaurant.FIELD_CATEGORY, filters.category)
}
// City (equality filter)
if (filters.hasCity()) {
query = query.whereEqualTo(Restaurant.FIELD_CITY, filters.city)
}
// Price (equality filter)
if (filters.hasPrice()) {
query = query.whereEqualTo(Restaurant.FIELD_PRICE, filters.price)
}
// Sort by (orderBy with direction)
if (filters.hasSortBy()) {
query = query.orderBy(filters.sortBy.toString(), filters.sortDirection)
}
// Limit items
query = query.limit(LIMIT.toLong())
// Update the query
adapter.setQuery(query)
// Set header
binding.textCurrentSearch.text = HtmlCompat.fromHtml(
filters.getSearchDescription(requireContext()),
HtmlCompat.FROM_HTML_MODE_LEGACY
)
binding.textCurrentSortBy.text = filters.getOrderDescription(requireContext())
// Save filters
viewModel.filters = filters
}
在上面的代码段中,我们通过附加 where
和 orderBy
子句来构建 Query
对象,以匹配给定的过滤条件。
再次运行应用,然后选择以下过滤条件以显示最受欢迎的低价餐厅:
您现在应该会看到过滤后的餐厅列表,其中仅包含低价位选项:
到目前为止,您已经在 Firestore 上构建了一个功能齐全的餐厅推荐查看应用!您现在可以实时对餐厅进行排序和过滤。在接下来的几个部分中,我们将为餐馆添加评价,并为应用添加安全规则。
9. 在子集合中整理数据
在此部分中,我们将为应用添加评分,以便用户评价他们喜爱(或最不喜欢)的餐馆。
合集和子合集
到目前为止,我们已将所有餐厅数据存储在名为“restaurants”的顶级集合中。当用户对餐馆评分时,我们希望为餐馆添加新的 Rating
对象。对于此任务,我们将使用子集合。您可以将子集合视为附加到文档的集合。因此,每个餐厅文档都会有一个包含评分文档的评分子集合。子集合有助于整理数据,而不会使文档变得臃肿或需要进行复杂查询。
如需访问子集合,请对父文档调用 .collection()
:
val subRef = firestore.collection("restaurants")
.document("abc123")
.collection("ratings")
您可以像访问顶级集合一样访问和查询子集合,并且没有大小限制或性能变化。您可以点击此处详细了解 Firestore 数据模型。
在事务中写入数据
若要将 Rating
添加到相应的子集合,只需调用 .add()
,但我们还需要更新 Restaurant
对象的平均评分和评分数量,以反映新数据。如果我们使用不同的操作来进行这两项更改,则会出现多种竞态条件,这可能会导致数据过时或错误。
为确保正确添加评分,我们将使用事务为餐厅添加评分。此事务将执行以下操作:
- 读取餐厅的当前评分并计算新评分
- 将评分添加到子合集
- 更新餐厅的平均评分和评分数量
打开 RestaurantDetailFragment.kt
并实现 addRating
函数:
private fun addRating(restaurantRef: DocumentReference, rating: Rating): Task<Void> {
// Create reference for new rating, for use inside the transaction
val ratingRef = restaurantRef.collection("ratings").document()
// In a transaction, add the new rating and update the aggregate totals
return firestore.runTransaction { transaction ->
val restaurant = transaction.get(restaurantRef).toObject<Restaurant>()
?: throw Exception("Restaurant not found at ${restaurantRef.path}")
// Compute new number of ratings
val newNumRatings = restaurant.numRatings + 1
// Compute new average rating
val oldRatingTotal = restaurant.avgRating * restaurant.numRatings
val newAvgRating = (oldRatingTotal + rating.rating) / newNumRatings
// Set new restaurant info
restaurant.numRatings = newNumRatings
restaurant.avgRating = newAvgRating
// Commit to Firestore
transaction.set(restaurantRef, restaurant)
transaction.set(ratingRef, rating)
null
}
}
addRating()
函数会返回一个表示整个事务的 Task
。在 onRating()
函数中,将监听器添加到任务以响应事务结果。
现在,再次 Run 该应用,然后点击其中一家餐馆,这应该会打开餐馆详情屏幕。点击 + 按钮开始添加评价。选择若干星级并输入一些文字,即可添加评价。
点击提交将启动交易。交易完成后,您会在下方看到自己的评价,以及餐厅评价数量的更新:
恭喜!现在,您已经拥有一个基于 Cloud Firestore 构建的本地移动餐厅评价社交应用。我听说这些设备现在很受欢迎。
10. 保护您的数据
到目前为止,我们还没有考虑过该应用的安全性。我们如何知道用户只能读取和写入正确的数据?Firestore 数据库由名为安全规则的配置文件提供保护。
打开 firestore.rules
文件,您应该会看到以下内容:
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.rules
文件并将内容替换为以下代码:
rules_version = '2';
service cloud.firestore {
match /databases/{database}/documents {
// Determine if the value of the field "key" is the same
// before and after the request.
function isUnchanged(key) {
return (key in resource.data)
&& (key in request.resource.data)
&& (resource.data[key] == request.resource.data[key]);
}
// Restaurants
match /restaurants/{restaurantId} {
// Any signed-in user can read
allow read: if request.auth != null;
// Any signed-in user can create
// WARNING: this rule is for demo purposes only!
allow create: if request.auth != null;
// Updates are allowed if no fields are added and name is unchanged
allow update: if request.auth != null
&& (request.resource.data.keys() == resource.data.keys())
&& isUnchanged("name");
// Deletes are not allowed.
// Note: this is the default, there is no need to explicitly state this.
allow delete: if false;
// Ratings
match /ratings/{ratingId} {
// Any signed-in user can read
allow read: if request.auth != null;
// Any signed-in user can create if their uid matches the document
allow create: if request.auth != null
&& request.resource.data.userId == request.auth.uid;
// Deletes and updates are not allowed (default)
allow update, delete: if false;
}
}
}
}
这些规则用于限制访问,以确保客户端仅进行安全的更改。例如,对餐厅文档的更新只能更改评分,不能更改名称或任何其他不可变数据。只有当用户 ID 与登录的用户相匹配时,才能创建评分,这样可以防止仿冒攻击。
如需详细了解安全规则,请参阅相关文档。
11. 总结
现在,您已基于 Firestore 创建了一个功能齐全的应用。您已了解 Firestore 最重要的功能,包括:
- 文档和集合
- 读取和写入数据
- 使用查询进行排序和过滤
- 子集合
- 事务
了解详情
如需继续了解 Firestore,您可以参阅以下一些很好的入门资源:
此 Codelab 中的餐馆应用基于“Kind Eats”示例应用。您可以点击此处浏览该应用的源代码。
可选:部署到生产环境
到目前为止,此应用仅使用了 Firebase Emulator Suite。如果您想要了解如何将此应用部署到真实的 Firebase 项目,请继续下一步。
12. (可选)部署应用
到目前为止,此应用完全在本地运行,所有数据都包含在 Firebase Emulator Suite 中。在本部分中,您将了解如何配置 Firebase 项目,以使此应用在生产环境中运行。
Firebase Authentication
在 Firebase 控制台中,前往身份验证部分,然后点击开始。前往登录方法标签页,然后从原生提供方中选择电子邮件地址/密码选项。
启用电子邮件地址/密码登录方法,然后点击保存。
Firestore
创建数据库
转到控制台的 Firestore Database 部分,然后点击创建数据库:
- 当系统提示您选择安全规则时,请选择以生产模式开始,我们很快就会更新这些规则。
- 选择要为应用使用的“数据库位置”。请注意,选择数据库位置是一个永久性决策,如需更改,您必须创建一个新项目。如需详细了解如何选择项目位置,请参阅文档。
部署规则
如需部署您之前编写的安全规则,请在 Codelab 目录中运行以下命令:
$ firebase deploy --only firestore:rules
这会将 firestore.rules
的内容部署到您的项目中,您可以通过前往控制台中的规则标签页来确认这一点。
部署索引
FriendlyEats 应用具有复杂的排序和过滤功能,需要使用多个自定义复合索引。这些 API 可以在 Firebase 控制台中手动创建,但更简单的做法是将其定义写入 firestore.indexes.json
文件中,并使用 Firebase CLI 进行部署。
如果打开 firestore.indexes.json
文件,您会看到已提供所需的索引:
{
"indexes": [
{
"collectionId": "restaurants",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "city", "mode": "ASCENDING" },
{ "fieldPath": "avgRating", "mode": "DESCENDING" }
]
},
{
"collectionId": "restaurants",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "category", "mode": "ASCENDING" },
{ "fieldPath": "avgRating", "mode": "DESCENDING" }
]
},
{
"collectionId": "restaurants",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "price", "mode": "ASCENDING" },
{ "fieldPath": "avgRating", "mode": "DESCENDING" }
]
},
{
"collectionId": "restaurants",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "city", "mode": "ASCENDING" },
{ "fieldPath": "numRatings", "mode": "DESCENDING" }
]
},
{
"collectionId": "restaurants",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "category", "mode": "ASCENDING" },
{ "fieldPath": "numRatings", "mode": "DESCENDING" }
]
},
{
"collectionId": "restaurants",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "price", "mode": "ASCENDING" },
{ "fieldPath": "numRatings", "mode": "DESCENDING" }
]
},
{
"collectionId": "restaurants",
"queryScope": "COLLECTION",
"fields": [
{ "fieldPath": "city", "mode": "ASCENDING" },
{ "fieldPath": "price", "mode": "ASCENDING" }
]
},
{
"collectionId": "restaurants",
"fields": [
{ "fieldPath": "category", "mode": "ASCENDING" },
{ "fieldPath": "price", "mode": "ASCENDING" }
]
}
],
"fieldOverrides": []
}
如需部署这些索引,请运行以下命令:
$ firebase deploy --only firestore:indexes
请注意,索引创建过程不是即时的,您可以在 Firebase 控制台中监控进度。
配置应用
在 util/FirestoreInitializer.kt
和 util/AuthInitializer.kt
文件中,我们配置了 Firebase SDK,以便在调试模式下连接到模拟器:
override fun create(context: Context): FirebaseFirestore {
val firestore = Firebase.firestore
// Use emulators only in debug builds
if (BuildConfig.DEBUG) {
firestore.useEmulator(FIRESTORE_EMULATOR_HOST, FIRESTORE_EMULATOR_PORT)
}
return firestore
}
如果您想使用真实的 Firebase 项目测试应用,可以执行以下任一操作:
- 在发布模式下构建应用,并在设备上运行该应用。
- 暂时将
BuildConfig.DEBUG
替换为false
,然后再次运行应用。
请注意,您可能需要退出应用,然后重新登录,才能正确连接到正式版应用。