Google 致力于为黑人社区推动种族平等。查看具体举措

Cloud Firestore Android Codelab

目标

在此 Codelab 中,您将在 Android 上构建一个由 Cloud Firestore 支持的餐厅推荐应用。你将学到如何:

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

先决条件

在开始此 Codelab 之前,请确保您拥有:

  • Android Studio 4.0或更高版本
  • 一个安卓模拟器
  • Node.js 10或更高版本
  • Java 8或更高版本
  1. 使用您的 Google 帐户登录Firebase 控制台
  2. Firebase 控制台中,点击添加项目
  3. 如下面的屏幕截图所示,输入 Firebase 项目的名称(例如,“Friendly Eats”),然后点击Continue

9d2f625aebcab6af.png

  1. 您可能会被要求启用 Google Analytics,对于本 Codelab 而言,您的选择无关紧要。
  2. 大约一分钟后,您的 Firebase 项目将准备就绪。点击继续

下载代码

运行以下命令以克隆此 Codelab 的示例代码。这将在您的机器上创建一个名为friendlyeats-android的文件夹:

$ git clone https://github.com/firebase/friendlyeats-android

如果你的机器上没有 git,你也可以直接从 GitHub 下载代码。

将项目导入到Android的工作室。您可能会看到一些编译错误或有关缺少google-services.json文件的警告。我们将在下一节中更正此问题。

添加 Firebase 配置

  1. Firebase 控制台中,选择左侧导航栏中的项目概览。单击Android按钮以选择平台。当提示输入包名称时,请使用com.google.firebase.example.fireeats

73d151ed16016421.png

  1. 点击Register App ,按照说明下载google-services.json文件,移动到示例代码的app/文件夹中。然后单击下一步

在此 Codelab 中,您将使用Firebase Emulator Suite在本地模拟 Cloud Firestore 和其他 Firebase 服务。这提供了一个安全、快速且免费的本地开发环境来构建您的应用程序。

安装 Firebase CLI

首先,您需要安装Firebase CLI 。最简单的方法是使用npm

npm install -g firebase-tools

如果您没有npm或遇到错误,请阅读安装说明以获取适用于您平台的独立二进制文件。

安装 CLI 后,运行firebase --version应报告9.0.0或更高版本:

$ firebase --version
9.0.0

登录

运行firebase login将 CLI 连接到您的 Google 帐户。这将打开一个新的浏览器窗口以完成登录过程。确保选择之前创建 Firebase 项目时使用的帐户。

friendlyeats-android文件夹中运行firebase use --add将您的本地项目连接到您的 Firebase 项目。按照提示选择您之前创建的项目,如果要求选择别名,请输入default

现在是第一次运行 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 中打开文件FirebaseUtil.java 。此文件包含将 Firebase SDK 连接到您机器上运行的本地模拟器的逻辑。

在文件的顶部,检查这一行:

    /** Use emulators only in debug builds **/
    private static final boolean sUseEmulators = BuildConfig.DEBUG;

我们使用BuildConfig来确保我们只在我们的应用程序在debug模式下运行时连接到模拟器。当我们在release模式下编译应用程序时,此条件将为假。

现在看一下getFirestore()方法:

    public static FirebaseFirestore getFirestore() {
        if (FIRESTORE == null) {
            FIRESTORE = FirebaseFirestore.getInstance();

            // Connect to the Cloud Firestore emulator when appropriate. The host '10.0.2.2' is a
            // special IP address to let the Android emulator connect to 'localhost'.
            if (sUseEmulators) {
                FIRESTORE.useEmulator("10.0.2.2", 8080);
            }
        }

        return FIRESTORE;
    }

我们可以看到它正在使用useEmulator(host, port)方法将 Firebase SDK 连接到本地 Firestore 模拟器。在整个应用程序中,我们将使用FirebaseUtil.getFirestore()来访问FirebaseFirestore这个实例,因此我们确保在debug模式下运行时我们始终连接到 Firestore 模拟器。

运行应用程序

如果您已正确添加google-services.json文件,则该项目现在应该可以编译了。在 Android Studio 中单击Build > Rebuild Project并确保没有剩余的错误。

在 Android Studio 中,在您的 Android 模拟器上运行该应用程序。首先,您将看到一个“登录”屏幕。您可以使用任何电子邮件和密码登录该应用程序。此登录过程连接到 Firebase 身份验证模拟器,因此不会传输真实凭据。

现在通过在 Web 浏览器中导航到http://localhost:4000来打开 Emulators UI。然后单击“身份验证”选项卡,您应该会看到您刚刚创建的帐户:

Firebase 身份验证模拟器

完成登录过程后,您应该会看到应用主屏幕:

de06424023ffb4b9.png

很快我们将添加一些数据来填充主屏幕。

在本节中,我们将向 Firestore 写入一些数据,以便我们可以填充当前为空的主屏幕。

我们应用程序中的主要模型对象是一家餐厅(请参阅model/Restaurant.java )。 Firestore 数据分为文档、集合和子集合。我们会将每家餐厅作为文档存储在名为"restaurants"的顶级集合中。要了解更多关于公司的FireStore数据模型,阅读有关文件和收藏的文件

出于演示目的,当我们单击溢出菜单中的“添加随机项目”按钮时,我们将在应用程序中添加创建十个随机餐厅的功能。打开文件MainActivity.java ,填写onAddItemsClicked()方法:

    private void onAddItemsClicked() {
        // Get a reference to the restaurants collection
        CollectionReference restaurants = mFirestore.collection("restaurants");

        for (int i = 0; i < 10; i++) {
            // Get a random Restaurant POJO
            Restaurant restaurant = RestaurantUtil.getRandom(this);

            // Add a new document to the restaurants collection
            restaurants.add(restaurant);
        }
    }

关于上面的代码,有几个重要的事情需要注意:

  • 我们首先获得了对"restaurants"系列的引用。集合是在添加文档时隐式创建的,因此无需在写入数据之前创建集合。
  • 可以使用 POJO 创建文档,我们用它来创建每个 Restaurant 文档。
  • add()方法使用自动生成的 ID 将文档添加到集合中,因此我们不需要为每个餐厅指定唯一的 ID。

现在再次运行应用程序并单击溢出菜单中的“添加随机项目”按钮以调用您刚刚编写的代码:

95691e9b71ba55e3.png

现在通过在 Web 浏览器中导航到http://localhost:4000来打开 Emulators UI。然后单击Firestore选项卡,您应该会看到刚刚添加的数据:

Firebase 身份验证模拟器

此数据 100% 位于您的机器本地。事实上,您的真实项目甚至还没有包含 Firestore 数据库!这意味着可以安全地尝试修改和删除这些数据而不会产生任何后果。

恭喜,您刚刚将数据写入 Firestore!在下一步中,我们将学习如何在应用程序中显示这些数据。

在这一步中,我们将学习如何从 Firestore 检索数据并将其显示在我们的应用程序中。从 Firestore 读取数据的第一步是创建一个Query 。修改onCreate()方法:

        mFirestore = FirebaseUtil.getFirestore();

        // Get the 50 highest rated restaurants
        mQuery = mFirestore.collection("restaurants")
                .orderBy("avgRating", Query.Direction.DESCENDING)
                .limit(LIMIT);

现在我们想听查询,以便我们获得所有匹配的文档并实时收到未来更新的通知。因为我们的最终目标是将这些数据绑定到RecyclerView ,所以我们需要创建一个RecyclerView.Adapter类来监听数据。

打开FirestoreAdapter类,该类已经部分实现。首先,让适配器实现EventListener并定义onEvent函数,以便它可以接收 Firestore 查询的更新:

public abstract class FirestoreAdapter<VH extends RecyclerView.ViewHolder>
        extends RecyclerView.Adapter<VH>
        implements EventListener<QuerySnapshot> { // Add this "implements"

    // ...

    // Add this method
    @Override
    public void onEvent(QuerySnapshot documentSnapshots,
                        FirebaseFirestoreException e) {

        // Handle errors
        if (e != null) {
            Log.w(TAG, "onEvent:error", e);
            return;
        }

        // Dispatch the event
        for (DocumentChange change : documentSnapshots.getDocumentChanges()) {
            // Snapshot of the changed document
            DocumentSnapshot snapshot = change.getDocument();

            switch (change.getType()) {
                case ADDED:
                    // TODO: handle document added
                    break;
                case MODIFIED:
                    // TODO: handle document modified
                    break;
                case REMOVED:
                    // TODO: handle document removed
                    break;
            }
        }

        onDataChanged();
    }

  // ...
}

在初始加载时,侦听器将为每个新文档接收一个ADDED事件。随着查询结果集随时间变化,侦听器将收到更多包含更改的事件。现在让我们完成监听器的实现。首先添加三个新方法: onDocumentAddedonDocumentModifiedonDocumentRemoved

    protected void onDocumentAdded(DocumentChange change) {
        mSnapshots.add(change.getNewIndex(), change.getDocument());
        notifyItemInserted(change.getNewIndex());
    }

    protected void onDocumentModified(DocumentChange change) {
        if (change.getOldIndex() == change.getNewIndex()) {
            // Item changed but remained in same position
            mSnapshots.set(change.getOldIndex(), change.getDocument());
            notifyItemChanged(change.getOldIndex());
        } else {
            // Item changed and changed position
            mSnapshots.remove(change.getOldIndex());
            mSnapshots.add(change.getNewIndex(), change.getDocument());
            notifyItemMoved(change.getOldIndex(), change.getNewIndex());
        }
    }

    protected void onDocumentRemoved(DocumentChange change) {
        mSnapshots.remove(change.getOldIndex());
        notifyItemRemoved(change.getOldIndex());
    }

然后从onEvent调用这些新方法:

    @Override
    public void onEvent(QuerySnapshot documentSnapshots,
                        FirebaseFirestoreException e) {

        // ...

        // Dispatch the event
        for (DocumentChange change : documentSnapshots.getDocumentChanges()) {
            // Snapshot of the changed document
            DocumentSnapshot snapshot = change.getDocument();

            switch (change.getType()) {
                case ADDED:
                    onDocumentAdded(change); // Add this line
                    break;
                case MODIFIED:
                    onDocumentModified(change); // Add this line
                    break;
                case REMOVED:
                    onDocumentRemoved(change); // Add this line
                    break;
            }
        }

        onDataChanged();
    }

最后实现startListening()方法来附加监听器:

    public void startListening() {
        if (mQuery != null && mRegistration == null) {
            mRegistration = mQuery.addSnapshotListener(this);
        }
    }

现在该应用程序已完全配置为从 Firestore 读取数据。再次运行应用程序,您应该会看到您在上一步中添加的餐厅:

9e45f40faefce5d0.png

现在返回浏览器中的模拟器 UI 并编辑餐厅名称之一。您应该几乎立即在应用程序中看到它发生了变化!

该应用程序当前显示整个集合中评分最高的餐厅,但在真正的餐厅应用程序中,用户希望对数据进行排序和过滤。例如,应用程序应该能够显示“费城顶级海鲜餐厅”或“最便宜的披萨”。

单击应用程序顶部的白条会弹出一个过滤器对话框。在本节中,我们将使用 Firestore 查询来使此对话框工作:

67898572a35672a5.png

让我们编辑MainActivity.javaonFilter()方法。此方法接受一个Filters对象,它是我们创建的一个帮助对象,用于捕获过滤器对话框的输出。我们将更改此方法以从过滤器构造查询:

    @Override
    public void onFilter(Filters filters) {
        // Construct query basic query
        Query query = mFirestore.collection("restaurants");

        // Category (equality filter)
        if (filters.hasCategory()) {
            query = query.whereEqualTo("category", filters.getCategory());
        }

        // City (equality filter)
        if (filters.hasCity()) {
            query = query.whereEqualTo("city", filters.getCity());
        }

        // Price (equality filter)
        if (filters.hasPrice()) {
            query = query.whereEqualTo("price", filters.getPrice());
        }

        // Sort by (orderBy with direction)
        if (filters.hasSortBy()) {
            query = query.orderBy(filters.getSortBy(), filters.getSortDirection());
        }

        // Limit items
        query = query.limit(LIMIT);

        // Update the query
        mQuery = query;
        mAdapter.setQuery(query);

        // Set header
        mCurrentSearchView.setText(Html.fromHtml(filters.getSearchDescription(this)));
        mCurrentSortByView.setText(filters.getOrderDescription(this));

        // Save filters
        mViewModel.setFilters(filters);
    }

在上面的代码片段中,我们通过附加whereorderBy子句来匹配给定的过滤器来构建一个Query对象。

再次运行应用程序并选择以下过滤器以显示最受欢迎的低价餐厅:

7a67a8a400c80c50.png

您现在应该会看到过滤后的仅包含低价选项的餐厅列表:

a670188398c3c59.png

如果您已经做到了这一点,那么您现在已经在 Firestore 上构建了一个功能齐全的餐厅推荐查看应用程序!您现在可以实时对餐厅进行排序和过滤。在接下来的几个部分中,我们会发布应用的评论和安全性。

在本节中,我们将为应用添加评分,以便用户可以评论他们最喜欢(或最不喜欢)的餐厅。

集合和子集合

到目前为止,我们已将所有餐厅数据存储在名为“餐厅”的顶级集合中。当用户对餐厅进行Rating我们希望向餐厅添加一个新的Rating对象。对于此任务,我们将使用子集合。您可以将子集合视为附加到文档的集合。因此,每个餐厅文档都将有一个评级子集,其中包含所有评级文档。子集合有助于组织数据,而不会膨胀我们的文档或需要复杂的查询。

要访问子集合,请在父文档上调用.collection()

CollectionReference subRef = mFirestore.collection("restaurants")
        .document("abc123")
        .collection("ratings");

您可以像访问顶级集合一样访问和查询子集合,没有大小限制或性能变化。您可以在此处阅读有关 Firestore 数据模型的更多信息。

在事务中写入数据

Rating添加到适当的子集合只需要调用.add() ,但我们还需要更新Restaurant对象的平均评级和评级数量以反映新数据。如果我们使用单独的操作来进行这两个更改,则有许多竞争条件可能会导致过时或不正确的数据。

为确保正确添加评分,我们将使用事务向餐厅添加评分。此事务将执行一些操作:

  • 阅读餐厅当前的评分并计算新的评分
  • 将评分添加到子集合
  • 更新餐厅的平均评分和评分数量

打开RestaurantDetailActivity.java并实现addRating函数:

    private Task<Void> addRating(final DocumentReference restaurantRef,
                                 final Rating rating) {
        // Create reference for new rating, for use inside the transaction
        final DocumentReference ratingRef = restaurantRef.collection("ratings")
                .document();

        // In a transaction, add the new rating and update the aggregate totals
        return mFirestore.runTransaction(new Transaction.Function<Void>() {
            @Override
            public Void apply(Transaction transaction)
                    throws FirebaseFirestoreException {

                Restaurant restaurant = transaction.get(restaurantRef)
                        .toObject(Restaurant.class);

                // Compute new number of ratings
                int newNumRatings = restaurant.getNumRatings() + 1;

                // Compute new average rating
                double oldRatingTotal = restaurant.getAvgRating() *
                        restaurant.getNumRatings();
                double newAvgRating = (oldRatingTotal + rating.getRating()) /
                        newNumRatings;

                // Set new restaurant info
                restaurant.setNumRatings(newNumRatings);
                restaurant.setAvgRating(newAvgRating);

                // Commit to Firestore
                transaction.set(restaurantRef, restaurant);
                transaction.set(ratingRef, rating);

                return null;
            }
        });
    }

addRating()函数返回一个代表整个交易的Task 。在onRating()函数中,将侦听器添加到任务中以响应事务的结果。

现在再次运行应用程序并单击其中一家餐厅,这将显示餐厅详细信息屏幕。单击+按钮开始添加评论。通过选择一些星星并输入一些文本来添加评论。

78fa16cdf8ef435a.png

点击提交将启动交易。交易完成后,您将在下方看到您的评论以及餐厅评论计数的更新:

f9e670f40bd615b0.png

恭喜!您现在拥有一个基于 Cloud Firestore 构建的社交、本地、移动餐厅评论应用。我听说这些天这些很受欢迎。

到目前为止,我们还没有考虑过这个应用程序的安全性。我们怎么知道用户只能读写正确的自己的数据? Firestore 数据库由名为Security Rules的配置文件保护

打开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 与登录用户匹配时才能创建评级,从而防止欺骗。

要阅读有关安全规则的更多信息,请访问文档

您现在已经在 Firestore 之上创建了一个功能齐全的应用程序。您了解了最重要的 Firestore 功能,包括:

  • 文件和集合
  • 读取和写入数据
  • 使用查询进行排序和过滤
  • 子集
  • 交易

学到更多

要继续了解 Firestore,以下是一些入门的好地方:

此代码实验室中的餐厅应用程序基于“Friendly Eats”示例应用程序。您可以在此处浏览该应用程序的源代码。

可选:部署到生产

到目前为止,此应用仅使用了 Firebase Emulator Suite。如果您想了解如何将此应用部署到真正的 Firebase 项目,请继续执行下一步。

到目前为止,这个应用程序完全是本地的,所有数据都包含在 Firebase 模拟器套件中。在本节中,您将学习如何配置 Firebase 项目,以便此应用在生产环境中运行。

Firebase 身份验证

在 Firebase 控制台中,转到Authentication部分并导航到Sign-in Providers 选项卡

启用电子邮件登录方法:

334ef7f6ff4da4ce.png

火店

创建数据库

导航到控制台的Firestore部分,然后单击Create Database

  1. 当系统提示安全规则选择以锁定模式启动时,我们将很快更新这些规则。
  2. 选择要用于应用的数据库位置。请注意,选择数据库位置是一个永久性决定,要更改它,您必须创建一个新项目。有关选择项目位置的更多信息,请参阅文档

部署规则

要部署您之前编写的安全规则,请在 codelab 目录中运行以下命令:

$ firebase deploy --only firestore:rules

这会将firestore.rules的内容部署到您的项目中,您可以通过导航到控制台中的Rules选项卡来确认。

部署索引

FriendlyEats 应用程序具有复杂的排序和过滤功能,需要大量自定义复合索引。这些可以在 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 控制台中监控进度。

配置应用程序

FirebaseUtil类中,我们将 Firebase SDK 配置为在调试模式下连接到模拟器:

public class FirebaseUtil {

    /** Use emulators only in debug builds **/
    private static final boolean sUseEmulators = BuildConfig.DEBUG;

    // ...
}

如果您想使用真实的 Firebase 项目测试您的应用,您可以:

  1. 在发布模式下构建应用程序并在设备上运行它。
  2. 暂时将sUseEmulators更改为false并再次运行该应用程序。

请注意,您可能需要退出应用程序并重新登录才能正确连接到生产。