使用 Firestore 构建排行榜

1. 简介

上次更新时间:2023 年 1 月 27 日

如何构建排行榜?

从本质上讲,排行榜只是一个分数表,但有一个复杂因素:要读取任何给定分数的排名,都需要按某种顺序了解所有其他分数。此外,如果您的游戏大受欢迎,排行榜会变得很大,并且会被频繁读取和写入。为了打造成功排行榜,它需要能够快速处理此排名操作。

构建内容

在此 Codelab 中,您将实现各种不同的排行榜,每种排行榜都适合不同的场景。

学习内容

您将学习如何实现四种不同的排行榜:

  • 一种使用简单记录计数来确定排名的简单实现
  • 一个便宜且会定期更新的排行榜
  • 一个实时排行榜,上面有一些关于树的无聊内容
  • 一种随机(概率性)排行榜,用于对庞大的玩家群进行近似排名

您需要满足的条件

  • 最新版 Chrome(107 或更高版本)
  • Node.js 16 或更高版本(如果您使用的是 nvm,请运行 nvm --version 查看版本号)
  • 付费 Firebase Blaze 方案(可选)
  • Firebase CLI v11.16.0 或更高版本
    如需安装该 CLI,您可以运行 npm install -g firebase-tools,也可以参阅 CLI 文档了解更多安装选项。
  • 了解 JavaScript、Cloud Firestore、Cloud Functions 和 Chrome 开发者工具

2. 准备工作

获取代码

我们已将您完成此项目所需的一切都放入一个 Git 代码库中。首先,您需要获取相关代码,然后在您常用的开发环境中将其打开。在此 Codelab 中,我们使用了 VS Code,但任何文本编辑器都可以。

,然后解压下载的 ZIP 文件。

或者,克隆到您选择的目录中:

git clone https://github.com/FirebaseExtended/firestore-leaderboards-codelab.git

我们从何处入手?

我们的项目目前是一张白板,包含一些空函数:

  • index.html 包含一些粘合脚本,可让我们从开发者控制台调用函数并查看其输出。我们将使用此工具与后端进行交互,并查看函数调用的结果。在实际场景中,您需要直接从游戏中发出这些后端调用。我们之所以未在此 Codelab 中使用游戏,是因为每次想向排行榜添加得分时都玩游戏会花费太长时间。
  • functions/index.js 包含我们的所有 Cloud Functions 函数。您将看到一些实用函数,例如 addScoresdeleteScores,以及我们将在本 Codelab 中实现的函数,这些函数会调用另一个文件中的辅助函数。
  • functions/functions-helpers.js 包含我们将实现的空函数。对于每个排行榜,我们将实现读取、创建和更新功能,您将了解我们的实现选择如何影响实现的复杂性和扩展性能。
  • functions/utils.js 包含更多实用函数。在此 Codelab 中,我们不会修改此文件。

创建并配置 Firebase 项目

  1. Firebase 控制台中,点击添加项目
  2. 如需创建新项目,请输入要使用的项目名称。
    这还会将项目 ID(显示在项目名称下方)设置为基于项目名称的某个值。您可以视需要点击项目 ID 上的修改图标,进一步自定义项目 ID。
  3. 如果看到相关提示,请查看并接受 Firebase 条款
  4. 点击继续
  5. 选择为此项目启用 Google Analytics 选项,然后点击继续
  6. 选择要使用的现有 Google Analytics 账号,或选择创建新账号以创建新账号。
  7. 点击 Create project
  8. 项目创建完毕后,点击继续
  9. 构建菜单中,点击函数,然后根据提示将项目升级为使用 Blaze 结算方案。
  10. 构建菜单中,点击 Firestore 数据库
  11. 在随即显示的创建数据库对话框中,选择以测试模式开始,然后点击下一步
  12. Cloud Firestore 位置下拉菜单中选择一个区域,然后点击启用

配置并运行排行榜

  1. 在终端中,前往项目根目录并运行 firebase use --add。选择您刚刚创建的 Firebase 项目。
  2. 在项目根目录中,运行 firebase emulators:start --only hosting
  3. 在浏览器中,前往 localhost:5000
  4. 打开 Chrome 开发者工具的 JavaScript 控制台并导入 leaderboard.js
    const leaderboard = await import("http://localhost:5000/scripts/leaderboard.js");
    
  5. 在控制台中运行 leaderboard.codelab();。如果您看到欢迎消息,就说明一切就绪!如果不是,请关闭模拟器并重新运行第 2-4 步。

我们先来实现第一个排行榜。

3. 实现简单的排行榜

学完此部分后,我们将能够向排行榜添加得分,并让排行榜显示我们的排名。

在开始之前,我们先来了解一下此排行榜实现的工作原理:所有玩家都存储在一个集合中,获取玩家的排名是通过检索该集合并统计有多少玩家排在该玩家前面来完成的。这样一来,插入和更新得分就变得非常简单。如需插入新得分,只需将其附加到集合中;如需更新得分,请过滤当前用户,然后更新生成的文档。我们来看看代码中的效果。

functions/functions-helper.js 中,实现 createScore 函数,该函数非常简单明了:

async function createScore(score, playerID, firestore) {
  return firestore.collection("scores").doc().create({
    user: playerID,
    score: score,
  });
}

对于更新得分,我们只需要添加一个错误检查,以确保要更新的得分已存在:

async function updateScore(playerID, newScore, firestore) {
  const playerSnapshot = await firestore.collection("scores")
      .where("user", "==", playerID).get();
  if (playerSnapshot.size !== 1) {
    throw Error(`User not found in leaderboard: ${playerID}`);
  }
  const player = playerSnapshot.docs[0];
  const doc = firestore.doc(player.id);
  return doc.update({
    score: newScore,
  });
}

最后,我们简单但可伸缩性较差的排名函数:

async function readRank(playerID, firestore) {
  const scores = await firestore.collection("scores")
      .orderBy("score", "desc").get();
  const player = `${playerID}`;
  let rank = 1;
  for (const doc of scores.docs) {
    const user = `${doc.get("user")}`;
    if (user === player) {
      return {
        user: player,
        rank: rank,
        score: doc.get("score"),
      };
    }
    rank++;
  }
  // No user found
  throw Error(`User not found in leaderboard: ${playerID}`);
}

让我们来测试一下!在终端中运行以下命令来部署函数:

firebase deploy --only functions

然后,在 Chrome 的 JS 控制台中,添加一些其他分数,以便我们查看自己在其他玩家中的排名。

leaderboard.addScores(); // Results may take some time to appear.

现在,我们可以添加自己的得分了:

leaderboard.addScore(999, 11); // You can make up a score (second argument) here.

写入完成后,您应该会在控制台中看到“乐谱已创建”的响应。看到的是错误消息?通过 Firebase 控制台打开函数日志,查看出了什么问题。

最后,我们可以获取并更新得分。

leaderboard.getRank(999);
leaderboard.updateScore(999, 0);
leaderboard.getRank(999); // we should be last place now (11)

不过,此实现在获取给定得分的排名时,会产生不理想的线性和时间内存需求。由于函数执行时间和内存都有限,因此这不仅意味着我们的提取速度会越来越慢,而且在排行榜中添加足够多的得分后,我们的函数会在返回结果之前超时或崩溃。显然,如果我们想吸引更多玩家,就需要更好的解决方案。

如果您是 Firestore 爱好者,可能知道 COUNT 聚合查询,这种查询可以大幅提升排行榜的性能。您说得对!对于 COUNT 查询,这种方法在用户数量低于 100 万左右时效果不错,但其性能仍呈线性。

不过,您可能会想,如果我们无论如何都要枚举集合中的所有文档,那么可以为每个文档分配一个排名,这样一来,当我们需要提取文档时,提取操作的时间和内存复杂度将为 O(1)!这就引出了我们的下一种方法,即定期更新的排行榜。

4. 实现定期更新的排行榜

此方法的关键在于将排名存储在文档本身中,这样一来,提取文档即可获得排名,无需额外操作。为此,我们需要一种新的函数。

index.js 中添加以下代码:

// Also add this to the top of your file
const admin = require("firebase-admin");

exports.scheduledFunctionCrontab = functions.pubsub.schedule("0 2 * * *")
    // Schedule this when most of your users are offline to avoid
    // database spikiness.
    .timeZone("America/Los_Angeles")
    .onRun((context) => {
      const scores = admin.firestore().collection("scores");
      scores.orderBy("score", "desc").get().then((snapshot) => {
        let rank = 1;
        const writes = [];
        for (const docSnapshot of snapshot.docs) {
          const docReference = scores.doc(docSnapshot.id);
          writes.push(docReference.set({rank: rank}, admin.firestore.SetOptions.merge()));
          rank++;
        }
        Promise.all(writes).then((result) => {
          console.log(`Writes completed with results: ${result}`);
        });
      });
      return null;
    });

现在,我们的读取、更新和写入操作都非常简单。写入和更新均保持不变,但读取变为(在 functions-helpers.js 中):

async function readRank(playerID, firestore) {
  const scores = firestore.collection("scores");
  const playerSnapshot = await scores
      .where("user", "==", playerID).get();
  if (playerSnapshot.size === 0) {
    throw Error(`User not found in leaderboard: ${playerID}`);
  }

  const player = playerSnapshot.docs[0];
  if (player.get("rank") === undefined) {
    // This score was added before our scheduled function could run,
    // but this shouldn't be treated as an error
    return {
    user: playerID,
    rank: null,
    score: player.get("score"),
  };
  }

  return {
    user: playerID,
    rank: player.get("rank"),
    score: player.get("score"),
  };
}

很遗憾,您必须先向项目添加结算账号,才能部署和测试此应用。如果您有结算账号,请缩短预定函数的间隔,然后观看您的函数神奇地为排行榜得分分配排名。

如果不是,请删除已调度的函数,然后跳至下一个实现。

点击分数集合旁边的三点状图标,删除 Firestore 数据库中的分数,为下一部分做好准备。

启用“删除集合”功能的 Firestore 评分文档页面

5. 实现实时树状排行榜

此方法通过将搜索数据存储在数据库集合本身中来实现。我们的目标不是拥有统一的集合,而是将所有内容存储在一棵树中,我们可以通过在文档中移动来遍历该树。这样一来,我们就可以针对给定得分的排名执行二元(或 n 元)搜索。这会是什么样子?

首先,我们需要能够将得分分配到大致均匀的桶中,这就需要了解用户记录的得分值;例如,如果您要为竞技游戏中的技能等级构建排行榜,那么用户的技能等级几乎总是呈正态分布。我们的随机得分生成函数使用 JavaScript 的 Math.random(),这会产生近似均匀的分布,因此我们将均匀划分各个分桶。

在此示例中,为简单起见,我们将使用 3 个存储分区,但您可能会发现,如果在实际应用中使用此实现,更多存储分区会产生更快的结果 - 树越浅,平均而言,集合提取次数越少,锁定争用也越少。

玩家的排名是得分高于该玩家的玩家数量与 1(玩家本人)之和。scores 下的每个集合将存储三个文档,每个文档都包含一个范围、每个范围下的文档数量,然后是三个对应的子集合。为了读取排名,我们将遍历此树,搜索得分并跟踪较大得分的总和。找到得分后,我们也会得到正确的总和。

写作要复杂得多。首先,我们需要在事务中进行所有写入操作,以防止在同时进行多次写入或读取时出现数据不一致的情况。在遍历树以写入新文档时,我们还需要保持上述所有条件。最后,由于我们采用了这种新方法,树的复杂性有所增加,并且需要存储所有原始文档,因此存储费用会略有增加(但仍为线性)。

functions-helpers.js 中:

async function createScore(playerID, score, firestore) {
  /**
   * This function assumes a minimum score of 0 and that value
   * is between min and max.
   * Returns the expected size of a bucket for a given score
   * so that bucket sizes stay constant, to avoid expensive
   * re-bucketing.
   * @param {number} value The new score.
   * @param {number} min The min of the previous range.
   * @param {number} max The max of the previous range. Must be greater than
   *     min.
   * @return {Object<string, number>} Returns an object containing the new min
   *     and max.
   */
  function bucket(value, min, max) {
    const bucketSize = (max - min) / 3;
    const bucketMin = Math.floor(value / bucketSize) * bucketSize;
    const bucketMax = bucketMin + bucketSize;
    return {min: bucketMin, max: bucketMax};
  }

  /**
   * A function used to store pending writes until all reads within a
   * transaction have completed.
   *
   * @callback PendingWrite
   * @param {admin.firestore.Transaction} transaction The transaction
   *     to be used for writes.
   * @return {void}
   */

  /**
   * Recursively searches for the node to write the score to,
   * then writes the score and updates any counters along the way.
   * @param {number} id The user associated with the score.
   * @param {number} value The new score.
   * @param {admin.firestore.CollectionReference} coll The collection this
   *     value should be written to.
   * @param {Object<string, number>} range An object with properties min and
   *     max defining the range this score should be in. Ranges cannot overlap
   *     without causing problems. Use the bucket function above to determine a
   *     root range from constant values to ensure consistency.
   * @param {admin.firestore.Transaction} transaction The transaction used to
   *     ensure consistency during tree updates.
   * @param {Array<PendingWrite>} pendingWrites A series of writes that should
   *     occur once all reads within a transaction have completed.
   * @return {void} Write error/success is handled via the transaction object.
   */
  async function writeScoreToCollection(
      id, value, coll, range, transaction, pendingWrites) {
    const snapshot = await transaction.get(coll);
    if (snapshot.empty) {
      // This is the first score to be inserted into this node.
      for (const write of pendingWrites) {
        write(transaction);
      }
      const docRef = coll.doc();
      transaction.create(docRef, {exact: {score: value, user: id}});
      return;
    }

    const min = range.min;
    const max = range.max;

    for (const node of snapshot.docs) {
      const data = node.data();
      if (data.exact !== undefined) {
        // This node held an exact score.
        const newRange = bucket(value, min, max);
        const tempRange = bucket(data.exact.score, min, max);

        if (newRange.min === tempRange.min &&
          newRange.max === tempRange.max) {
          // The scores belong in the same range, so we need to "demote" both
          // to a lower level of the tree and convert this node to a range.
          const rangeData = {
            range: newRange,
            count: 2,
          };
          for (const write of pendingWrites) {
            write(transaction);
          }
          const docReference = node.ref;
          transaction.set(docReference, rangeData);
          transaction.create(docReference.collection("scores").doc(), data);
          transaction.create(
              docReference.collection("scores").doc(),
              {exact: {score: value, user: id}},
          );
          return;
        } else {
          // The scores are in different ranges. Continue and try to find a
          // range that fits this score.
          continue;
        }
      }

      if (data.range.min <= value && data.range.max > value) {
        // The score belongs to this range that may have subvalues.
        // Increment the range's count in pendingWrites, since
        // subsequent recursion may incur more reads.
        const docReference = node.ref;
        const newCount = node.get("count") + 1;
        pendingWrites.push((t) => {
          t.update(docReference, {count: newCount});
        });
        const newRange = bucket(value, min, max);
        return writeScoreToCollection(
            id,
            value,
            docReference.collection("scores"),
            newRange,
            transaction,
            pendingWrites,
        );
      }
    }

    // No appropriate range was found, create an `exact` value.
    transaction.create(coll.doc(), {exact: {score: value, user: id}});
  }

  const scores = firestore.collection("scores");
  const players = firestore.collection("players");
  return firestore.runTransaction((transaction) => {
    return writeScoreToCollection(
        playerID, score, scores, {min: 0, max: 1000}, transaction, [],
    ).then(() => {
      transaction.create(players.doc(), {
        user: playerID,
        score: score,
      });
    });
  });
}

这肯定比我们上次的实现复杂得多,上次的实现只包含一个方法调用和六行代码。实现此方法后,尝试向数据库添加一些分数,并观察生成的树的结构。在 JavaScript 控制台中:

leaderboard.addScores();

生成的数据库结构应如下所示,其中树形结构清晰可见,树的叶表示各个得分。

scores
  - document
    range: 0-333.33
    count: 2
    scores:
      - document
        exact:
          score: 18
          user: 1
      - document
        exact:
          score: 22
          user: 2

现在,我们已经完成了最难的部分,接下来可以按照之前所述的方式遍历树来读取得分。

async function readRank(playerID, firestore) {
  const players = await firestore.collection("players")
      .where("user", "==", playerID).get();
  if (players.empty) {
    throw Error(`Player not found in leaderboard: ${playerID}`);
  }
  if (players.size > 1) {
    console.info(`Multiple scores with player ${playerID}, fetching first`);
  }
  const player = players.docs[0].data();
  const score = player.score;

  const scores = firestore.collection("scores");

  /**
   * Recursively finds a player score in a collection.
   * @param {string} id The player's ID, since some players may be tied.
   * @param {number} value The player's score.
   * @param {admin.firestore.CollectionReference} coll The collection to
   *     search.
   * @param {number} currentCount The current count of players ahead of the
   *     player.
   * @return {Promise<number>} The rank of the player (the number of players
   *     ahead of them plus one).
   */
  async function findPlayerScoreInCollection(id, value, coll, currentCount) {
    const snapshot = await coll.get();
    for (const doc of snapshot.docs) {
      if (doc.get("exact") !== undefined) {
        // This is an exact score. If it matches the score we're looking
        // for, return. Otherwise, check if it should be counted.
        const exact = doc.data().exact;
        if (exact.score === value) {
          if (exact.user === id) {
            // Score found.
            return currentCount + 1;
          } else {
            // The player is tied with another. In this case, don't increment
            // the count.
            continue;
          }
        } else if (exact.score > value) {
          // Increment count
          currentCount++;
          continue;
        } else {
          // Do nothing
          continue;
        }
      } else {
        // This is a range. If it matches the score we're looking for,
        // search the range recursively, otherwise, check if it should be
        // counted.
        const range = doc.data().range;
        const count = doc.get("count");
        if (range.min > value) {
          // The range is greater than the score, so add it to the rank
          // count.
          currentCount += count;
          continue;
        } else if (range.max <= value) {
          // do nothing
          continue;
        } else {
          const subcollection = doc.ref.collection("scores");
          return findPlayerScoreInCollection(
              id,
              value,
              subcollection,
              currentCount,
          );
        }
      }
    }
    // There was no range containing the score.
    throw Error(`Range not found for score: ${value}`);
  }

  const rank = await findPlayerScoreInCollection(playerID, score, scores, 0);
  return {
    user: playerID,
    rank: rank,
    score: score,
  };
}

更新留作额外练习。尝试在 JavaScript 控制台中使用 leaderboard.addScore(id, score)leaderboard.getRank(id) 方法添加和获取得分,然后在 Firebase 控制台中查看排行榜的变化。

不过,通过这种实现方式,我们虽然实现了对数级性能,但也付出了一些代价。

  • 首先,此排行榜实现可能会遇到锁争用问题,因为事务需要锁定对文档的读取和写入,以确保文档保持一致。
  • 其次,Firestore 规定了子集合深度限制为 100,这意味着您需要避免在 100 个分数相同的情况下创建子树,而此实现方式并未做到这一点。
  • 最后,只有在树平衡的理想情况下,此排行榜才会以对数方式进行扩缩;如果树不平衡,此排行榜的最差性能再次为线性。

完成后,通过 Firebase 控制台删除 scoresplayers 集合,然后继续进行最后一个排行榜实现。

6. 实现随机(概率性)排行榜

运行插入代码时,您可能会注意到,如果并行运行的次数过多,您的函数将开始失败,并显示与事务锁定争用相关的错误消息。虽然有一些方法可以解决此问题,但我们不会在此 Codelab 中探讨这些方法。如果您不需要精确的排名,则可以放弃之前方法的所有复杂性,转而采用更简单、更快速的方法。我们来看看如何返回玩家得分的估计排名(而非确切排名),以及这会如何改变我们的数据库逻辑。

对于此方法,我们将排行榜划分为 100 个存储分区,每个存储分区大约代表我们预期会收到的分数中的 1%。即使不知道分数分布情况,这种方法也能奏效。在这种情况下,我们无法保证整个分桶中的分数分布大致均匀,但如果我们知道分数将如何分布,就能更精确地进行近似计算。

我们的方法如下:与之前一样,每个桶存储得分的数量和得分范围。插入新得分时,我们会找到该得分对应的分桶并增加其计数。在获取排名时,我们只需将前面的分桶相加,然后在我们的分桶中进行近似计算,而无需进一步搜索。这样一来,我们就可以实现非常出色的常数时间查找和插入,并且需要的代码也少得多。

首先,插入:

// Add this line to the top of your file.
const admin = require("firebase-admin");

// Implement this method (again).
async function createScore(playerID, score, firestore) {
  const scores = await firestore.collection("scores").get();
  if (scores.empty) {
    // Create the buckets since they don't exist yet.
    // In a real app, don't do this in your write function. Do it once
    // manually and then keep the buckets in your database forever.
    for (let i = 0; i < 10; i++) {
      const min = i * 100;
      const max = (i + 1) * 100;
      const data = {
        range: {
          min: min,
          max: max,
        },
        count: 0,
      };
      await firestore.collection("scores").doc().create(data);
    }
    throw Error("Database not initialized");
  }

  const buckets = await firestore.collection("scores")
      .where("range.min", "<=", score).get();
  for (const bucket of buckets.docs) {
    const range = bucket.get("range");
    if (score < range.max) {
      const writeBatch = firestore.batch();
      const playerDoc = firestore.collection("players").doc();
      writeBatch.create(playerDoc, {
        user: playerID,
        score: score,
      });
      writeBatch.update(
          bucket.ref,
          {count: admin.firestore.FieldValue.increment(1)},
      );
      const scoreDoc = bucket.ref.collection("scores").doc();
      writeBatch.create(scoreDoc, {
        user: playerID,
        score: score,
      });
      return writeBatch.commit();
    }
  }
}

您会注意到,此插入代码顶部有一些用于初始化数据库状态的逻辑,并附带一条警告,提示不要在生产环境中执行类似操作。初始化代码完全不受竞态条件的保护,因此如果您这样做,多个并发写入操作会因产生大量重复的存储分区而损坏数据库。

继续部署函数,然后运行一次插入操作,以将所有桶的初始计数设为零。它会返回一个错误,您可以放心地忽略该错误。

leaderboard.addScore(999, 0); // The params aren't important here.

现在,数据库已正确初始化,我们可以运行 addScores,并在 Firebase 控制台中查看数据结构。虽然表面上看起来相似,但生成的结构比我们上次的实现扁平得多。

leaderboard.addScores();

现在,我们来解读一下得分:

async function readRank(playerID, firestore) {
  const players = await firestore.collection("players")
      .where("user", "==", playerID).get();
  if (players.empty) {
    throw Error(`Player not found in leaderboard: ${playerID}`);
  }
  if (players.size > 1) {
    console.info(`Multiple scores with player ${playerID}, fetching first`);
  }
  const player = players.docs[0].data();
  const score = player.score;

  const scores = await firestore.collection("scores").get();
  let currentCount = 1; // Player is rank 1 if there's 0 better players.
  let interp = -1;
  for (const bucket of scores.docs) {
    const range = bucket.get("range");
    const count = bucket.get("count");
    if (score < range.min) {
      currentCount += count;
    } else if (score >= range.max) {
      // do nothing
    } else {
      // interpolate where the user is in this bucket based on their score.
      const relativePosition = (score - range.min) / (range.max - range.min);
      interp = Math.round(count - (count * relativePosition));
    }
  }

  if (interp === -1) {
    // Didn't find a correct bucket
    throw Error(`Score out of bounds: ${score}`);
  }

  return {
    user: playerID,
    rank: currentCount + interp,
    score: score,
  };
}

由于我们已让 addScores 函数生成均匀分布的分数,并且我们在各个分桶内使用线性插值,因此我们将获得非常准确的结果,排行榜的性能不会随着用户数量的增加而下降,并且我们在更新计数时不必担心(太多)锁定争用。

7. 附录:作弊

请稍等,您可能会想,如果我通过浏览器标签页的 JS 控制台将值写入我的 Codelab,那么我的任何玩家岂不是都可以向排行榜谎报自己获得了高分,而实际上他们并没有公平地获得该分数?

可以。如果您想防止作弊,最可靠的方法是通过安全规则停用客户端对数据库的写入权限,安全访问 Cloud Functions,以便客户端无法直接调用它们,然后在服务器上验证游戏内操作,再将得分更新发送到排行榜。

请务必注意,此策略并非防止作弊的万灵药。在足够大的奖励面前,作弊者可以找到绕过服务器端验证的方法,许多大型成功视频游戏都在不断与作弊者玩猫捉老鼠的游戏,以识别新的作弊行为并阻止其蔓延。这种现象带来的一个严重后果是,每款游戏的服务器端验证本质上都是定制的;虽然 Firebase 提供了 App Check 等反滥用工具,可防止用户通过简单的脚本客户端复制您的游戏,但 Firebase 并未提供任何可实现全面反作弊的服务。

如果未进行服务器端验证,那么对于足够热门的游戏或作弊门槛足够低的游戏,排行榜上的最高分将全部由作弊者获得。

8. 恭喜

恭喜,您已成功在 Firebase 上构建了四种不同的排行榜!您可以根据游戏对精确度和速度的需求,选择一款适合自己且费用合理的模型。

接下来,请查看游戏的学习路线