读取和写入数据

(可选)使用 Firebase Emulator Suite 进行原型设计和测试

在介绍应用如何对 Realtime Database 进行数据读写之前,我们先介绍一套可用于对 Realtime Database 功能进行原型设计和测试的工具:Firebase Emulator Suite。如果您在尝试使用不同的数据模型、优化安全规则,或想要寻找经济的方式与后端进行交互,那么无需部署在线服务而能够在本地工作无疑是个好想法。

Realtime Database 模拟器是 Emulator Suite 的一部分,通过该模拟器,您的应用可以与模拟的数据库内容和配置进行交互,并可视需要与您的模拟项目资源(函数、其他数据库和安全规则)进行交互。

如需使用 Realtime Database 模拟器,只需完成几个步骤:

  1. 向应用的测试配置添加一行代码以连接到模拟器。
  2. 从本地项目的根目录运行 firebase emulators:start
  3. 照常使用 Realtime Database 平台 SDK 或使用 Realtime Database REST API,从应用的原型代码进行调用。

我们提供详细的 Realtime Database 和 Cloud Functions 演示。您还应该参阅 Emulator Suite 简介

获取 DatabaseReference

如需从数据库读取数据以及将数据写入数据库,您需要一个 DatabaseReference 实例:

DatabaseReference ref = FirebaseDatabase.instance.ref();

写入数据

本文将介绍读取和写入 Firebase 数据的基础知识。

Firebase 数据会被写入 DatabaseReference,并通过等待或监听该引用触发的事件进行检索。事件会针对数据的初始状态触发一次,以后只要数据发生变化就会再次触发。

基本写入操作

对于基本写入操作,您可以使用 set() 将数据保存至指定引用,替换该路径中的任何现有数据。您可以设置对以下类型的引用:StringbooleanintdoubleMapList

例如,您可以使用 set() 添加用户,如下所示:

DatabaseReference ref = FirebaseDatabase.instance.ref("users/123");

await ref.set({
  "name": "John",
  "age": 18,
  "address": {
    "line1": "100 Mountain View"
  }
});

以这种方式使用 set() 将重写指定位置(包括所有子节点)的数据。您也可以在不重写整个对象的情况下更新子节点。如果要允许用户更新其个人资料,您可按照如下所示更新用户名:

DatabaseReference ref = FirebaseDatabase.instance.ref("users/123");

// Only update the name, leave the age and address!
await ref.update({
  "age": 19,
});

update() 方法接受节点的子路径,可让您一次更新数据库上的多个节点:

DatabaseReference ref = FirebaseDatabase.instance.ref("users");

await ref.update({
  "123/age": 19,
  "123/address/line1": "1 Mountain View",
});

读取数据

通过监听值事件读取数据

如需读取路径中的数据并监听更改,请使用 DatabaseReferenceonValue 属性监听 DatabaseEvent

您可以使用 DatabaseEvent 来读取给定路径中的数据,因为这些数据在事件发生时就存在。此事件会在附加监听器时触发一次,以后会在每次数据(包括任何子节点数据)发生更改时再次触发。该事件有一个 snapshot 属性,其中包含该位置的所有数据(包括子数据)。如果没有任何数据,则快照的 exists 属性将为 false,其 value 属性将为 null。

以下示例演示了社交博客应用如何从数据库中检索博文详细信息:

DatabaseReference starCountRef =
        FirebaseDatabase.instance.ref('posts/$postId/starCount');
starCountRef.onValue.listen((DatabaseEvent event) {
    final data = event.snapshot.value;
    updateStarCount(data);
});

监听器接收到一个 DataSnapshot,其 value 属性中包含事件发生时数据库中指定位置存在的数据。

读取数据一次

读取一次使用 get()

此 SDK 旨在管理与数据库服务器的交互,无论您的应用是在线还是离线。

通常,您应该使用上述值事件方法读取数据,以接收后端发出的数据更新的通知。这些方法可减少使用量和费用,并经过优化,可在用户上线和离线时为他们提供最佳体验。

如果您只需要使用数据一次,可以使用 get() 从数据库获取数据的快照。如果 get() 由于任何原因无法返回服务器值,客户端将探测本地存储缓存,如果仍找不到该值,则返回错误。

以下示例演示了如何从数据库中检索用户的公开用户名一次:

final ref = FirebaseDatabase.instance.ref();
final snapshot = await ref.child('users/$userId').get();
if (snapshot.exists) {
    print(snapshot.value);
} else {
    print('No data available.');
}

不必要地使用 get() 会增加带宽使用量并且会导致性能损失,按照上文所述使用实时监听器可防止发生这种情况。

使用 once() 读取数据一次

在某些情况下,您可能希望立即返回本地缓存中的值,而不必检查服务器上更新后的值。在这些情况下,您可以使用 once() 立即从本地磁盘缓存获取数据。

对于只需加载一次且预计不会频繁变化或不需要主动监听的数据,这种方法非常有用。例如,上述示例中的博客应用就使用了此方法在用户开始撰写新博文时加载其个人资料:

final event = await ref.once(DatabaseEventType.value);
final username = event.snapshot.value?.username ?? 'Anonymous';

更新或删除数据

更新特定字段

如需同时向一个节点的某些特定子节点写入数据,而又不重写其他子节点,请使用 update() 方法。

调用 update() 时,您可以通过为键指定路径来更新较低层级的子节点值。如果为了更好地实现伸缩而将数据存储在多个位置,可以使用数据扇出更新这些数据的所有实例。例如,社交博客应用可能需要创建一篇博文,同时将其更新到近期活动 Feed 和发布用户的活动 Feed。为此,该博客应用需要使用如下代码:

void writeNewPost(String uid, String username, String picture, String title,
        String body) async {
    // A post entry.
    final postData = {
        'author': username,
        'uid': uid,
        'body': body,
        'title': title,
        'starCount': 0,
        'authorPic': picture,
    };

    // Get a key for a new Post.
    final newPostKey =
        FirebaseDatabase.instance.ref().child('posts').push().key;

    // Write the new post's data simultaneously in the posts list and the
    // user's post list.
    final Map<String, Map> updates = {};
    updates['/posts/$newPostKey'] = postData;
    updates['/user-posts/$uid/$newPostKey'] = postData;

    return FirebaseDatabase.instance.ref().update(updates);
}

此示例使用 push() 在包含所有用户博文的节点 (/posts/$postid) 创建了一篇博文,同时使用 key 检索相应键。接着,该键被用于在用户的博文列表 (/user-posts/$userid/$postid) 中创建第二个条目。

通过使用这些路径,只需调用 update() 一次即可同时更新 JSON 树中的多个位置,此示例就使用了这种方式在两个位置同时创建新博文。通过这种方式进行同时更新属于原子操作:所有更新要么全部成功,要么全部失败。

添加完成回调函数

如果您想知道数据是何时提交的,可以注册完成回调。set()update() 都会返回 Future,您可以在其中附加成功和错误回调,这些回调会在写入操作提交到数据库后以及调用不成功时调用。

FirebaseDatabase.instance
    .ref('users/$userId/email')
    .set(emailAddress)
    .then((_) {
        // Data saved successfully!
    })
    .catchError((error) {
        // The write failed...
    });

删除数据

删除数据最简单的方法是对数据所在位置的引用调用 remove()

您也可以指定 null 作为另一个写入操作(如 set()update())的值来进行删除。您可以将此方法与 update() 结合使用,在一次 API 调用中删除多个子节点。

将数据另存为事务

处理可能因并发修改而损坏的数据(例如,增量计数器)时,您可以通过将事务处理程序传递给 runTransaction() 来使用事务。事务处理程序会将数据的当前状态视为参数,并返回您要写入的新目标状态。如果另一个客户端在您成功写入新值前向该位置写入数据,则系统会使用新的当前值再次调用更新函数,然后重新尝试执行写入操作。

例如,在示例社交博客应用中,您可以允许用户对博文加星标和移除星标,并跟踪博文获得的星标数,如下所示:

void toggleStar(String uid) async {
  DatabaseReference postRef =
      FirebaseDatabase.instance.ref("posts/foo-bar-123");

  TransactionResult result = await postRef.runTransaction((Object? post) {
    // Ensure a post at the ref exists.
    if (post == null) {
      return Transaction.abort();
    }

    Map<String, dynamic> _post = Map<String, dynamic>.from(post as Map);
    if (_post["stars"] is Map && _post["stars"][uid] != null) {
      _post["starCount"] = (_post["starCount"] ?? 1) - 1;
      _post["stars"][uid] = null;
    } else {
      _post["starCount"] = (_post["starCount"] ?? 0) + 1;
      if (!_post.containsKey("stars")) {
        _post["stars"] = {};
      }
      _post["stars"][uid] = true;
    }

    // Return the new data.
    return Transaction.success(_post);
  });
}

默认情况下,每次运行事务更新函数时,系统都会触发事件,因此如果您多次运行该函数,可能会看到中间状态。您可以将 applyLocally 设置为 false 以抑制这些中间状态,并在事件触发之前等待事务完成:

await ref.runTransaction((Object? post) {
  // ...
}, applyLocally: false);

事务的结果是 TransactionResult,其中包含事务是否已提交等信息以及新快照:

DatabaseReference ref = FirebaseDatabase.instance.ref("posts/123");

TransactionResult result = await ref.runTransaction((Object? post) {
  // ...
});

print('Committed? ${result.committed}'); // true / false
print('Snapshot? ${result.snapshot}'); // DataSnapshot

取消事务

如果您想安全地取消事务,请调用 Transaction.abort() 以抛出 AbortTransactionException

TransactionResult result = await ref.runTransaction((Object? user) {
  if (user !== null) {
    return Transaction.abort();
  }

  // ...
});

print(result.committed); // false

原子化服务器端递增

在上述用例中,我们将两个值写入数据库:加星标/移除星标的用户的 ID,以及递增的星标数。如果我们已经知道用户正在为博文加星标,我们可以使用原子化递增操作,而不是事务。

void addStar(uid, key) async {
  Map<String, Object?> updates = {};
  updates["posts/$key/stars/$uid"] = true;
  updates["posts/$key/starCount"] = ServerValue.increment(1);
  updates["user-posts/$key/stars/$uid"] = true;
  updates["user-posts/$key/starCount"] = ServerValue.increment(1);
  return FirebaseDatabase.instance.ref().update(updates);
}

此代码不使用事务操作,因此它不会在更新有冲突时自动重新运行。但是,由于增量操作直接在数据库服务器上执行,因此不会发生冲突。

如果您希望检测并拒绝应用特有的冲突(例如,用户为之前他们已加星标的博文加星标),那么您应该为该使用场景编写自定义安全规则。

离线处理数据

如果客户端的网络连接中断,您的应用将继续正常运行。

对于所有有效数据,连接到 Firebase 数据库的每个客户端均维护着各自的内部版本。写入数据时,首先会写入这一本地版本。然后,Firebase 客户端会尽可能将这些数据与远程数据库服务器以及其他客户端同步。

因此,对数据库执行的所有写入操作会立即触发本地事件,然后数据才会写入服务器。这意味着应用仍将保持随时响应的状态,无论网络延迟或连接状况如何。

连接重新建立之后,您的应用将收到一系列相应的事件,以便客户端与当前服务器状态进行同步,而不必编写任何自定义代码。

我们将在详细了解在线和离线功能中详细介绍离线行为。

后续步骤