在 Android 上启用离线功能

即使网络连接暂时中断,Firebase 应用仍可正常工作。此外,Firebase 还提供用于在本地持久保存数据、管理在线状态和应对延迟的工具。

磁盘持久化

Firebase 应用会自动处理临时性网络中断情况。 缓存的数据在离线状态下依然可用,而且 Firebase 会在网络连接恢复后重新发送中断期间的所有写入操作。

启用磁盘持久化后,您的应用会将数据写入到本地设备,以便在离线时保存状态数据。即使用户或操作系统重新启动应用,这些数据也不会丢失。

只需一行代码便可启用磁盘持久化。

Kotlin+KTX

Firebase.database.setPersistenceEnabled(true)

Java

FirebaseDatabase.getInstance().setPersistenceEnabled(true);

持久化行为

启用持久化后,Firebase Realtime Database 客户端在线期间会同步的所有数据都会持久保存在磁盘中,可供离线时使用。即使用户或操作系统重新启动应用,这些数据也不会丢失。也就是说,您的应用会使用存储在缓存中的本地数据,如同在线时一样正常运行。本地更新仍会触发监听器回调。

Firebase Realtime Database 客户端会自动将应用在离线状态下执行的所有写入操作维护到一个队列中。 启用持久化后,这个队列也会持久保存在磁盘中,这样,当用户或操作系统重启应用后,所有写入操作都不会丢失。当应用恢复连接时,所有操作都将发送到 Firebase Realtime Database 服务器。

如果应用使用 Firebase AuthenticationFirebase Realtime Database 客户端还会持久保留用户的身份验证令牌,即使应用重启也不受影响。如果身份验证令牌在应用处于离线状态时到期,客户端会暂停写入操作,直到应用对用户重新进行身份验证,否则写入操作可能会由于不符合安全规则而失败。

及时更新数据

Firebase Realtime Database 会同步数据并存储一份本地副本,以供处于活跃状态的监听器使用。此外,您还可以使特定位置中的数据保持同步。

Kotlin+KTX

val scoresRef = Firebase.database.getReference("scores")
scoresRef.keepSynced(true)

Java

DatabaseReference scoresRef = FirebaseDatabase.getInstance().getReference("scores");
scoresRef.keepSynced(true);

Firebase Realtime Database 客户端会自动下载这些位置中的数据并使其保持同步,即使引用中没有处于活跃状态的监听器也是如此。您可以使用下面这行代码停用同步。

Kotlin+KTX

scoresRef.keepSynced(false)

Java

scoresRef.keepSynced(false);

默认情况下,之前同步的数据中有 10 MB 会被写入缓存。这对大多数应用来说应该足够了。如果缓存数据的大小超过其配置的大小,Firebase Realtime Database 会清除最久未使用的数据。 保持同步的数据不会从缓存中清除。

离线查询数据

Firebase Realtime Database 会存储查询所返回的数据,供离线状态下使用。对于在离线状态下构建的查询,Firebase Realtime Database 会继续使用之前加载的数据。 如果请求的数据还未加载,Firebase Realtime Database 会从本地缓存中加载数据。在网络连接恢复后,应用会加载数据并给出查询结果。

例如,以下代码会查询 Firebase Realtime Database 中最后四条记录的分数:

Kotlin+KTX

val scoresRef = Firebase.database.getReference("scores")
scoresRef.orderByValue().limitToLast(4).addChildEventListener(object : ChildEventListener {
    override fun onChildAdded(snapshot: DataSnapshot, previousChild: String?) {
        Log.d(TAG, "The ${snapshot.key} dinosaur's score is ${snapshot.value}")
    }

    // ...
})

Java

DatabaseReference scoresRef = FirebaseDatabase.getInstance().getReference("scores");
scoresRef.orderByValue().limitToLast(4).addChildEventListener(new ChildEventListener() {
    @Override
    public void onChildAdded(@NonNull DataSnapshot snapshot, String previousChild) {
        Log.d(TAG, "The " + snapshot.getKey() + " dinosaur's score is " + snapshot.getValue());
    }

    // ...
});

假设用户连接中断,进入离线状态,然后重启了应用。应用在离线状态下从同一位置查询最后两条记录。此查询会成功返回最后两条记录,因为应用已经在上面的查询中加载过所有四条记录。

Kotlin+KTX

scoresRef.orderByValue().limitToLast(2).addChildEventListener(object : ChildEventListener {
    override fun onChildAdded(snapshot: DataSnapshot, previousChild: String?) {
        Log.d(TAG, "The ${snapshot.key} dinosaur's score is ${snapshot.value}")
    }

    // ...
})

Java

scoresRef.orderByValue().limitToLast(2).addChildEventListener(new ChildEventListener() {
    @Override
    public void onChildAdded(@NonNull DataSnapshot snapshot, String previousChild) {
        Log.d(TAG, "The " + snapshot.getKey() + " dinosaur's score is " + snapshot.getValue());
    }

    // ...
});

在上面的示例中,Firebase Realtime Database 客户端通过使用持久化缓存,针对“分数最高的两种恐龙”查询引发了“child added”事件。但它不会引发“value”事件,因为该应用在处于在线状态时未执行过该查询。

如果应用在离线状态下查询最后六条记录,则会立即针对已缓存的四条记录引发“child added”事件。设备恢复在线状态后,Firebase Realtime Database 客户端会与服务器同步,并为应用获取最后两条记录的“child added”事件以及“value”事件。

离线处理事务

应用处于离线状态时所执行的所有事务都会置于队列中。 当应用恢复在线状态后,这些事务将被发送到 Realtime Database 服务器。

管理在线状态

在实时应用中,通常需要检测客户端何时建立和断开连接。例如,当用户的客户端断开连接时,您可能需要将该用户标记为“离线”。

Firebase Database 客户端提供了一些简单的原语,在某个客户端与 Firebase Database 服务器断开连接时,可以使用这些原语向数据库写入数据。这些更新的完成情况与客户端是否正常断开连接无关,因此,即使连接突然中断或客户端崩溃,您仍可以依靠此类更新来清理数据。所有写入操作(包括设置、更新和移除)均可以在断开连接时执行。

在下面这个简单的示例中,我们使用 onDisconnect 原语在断开连接时写入数据:

Kotlin+KTX

val presenceRef = Firebase.database.getReference("disconnectmessage")
// Write a string when this client loses connection
presenceRef.onDisconnect().setValue("I disconnected!")

Java

DatabaseReference presenceRef = FirebaseDatabase.getInstance().getReference("disconnectmessage");
// Write a string when this client loses connection
presenceRef.onDisconnect().setValue("I disconnected!");

onDisconnect 的工作方式

当您建立 onDisconnect() 操作后,该操作会在 Firebase Realtime Database 服务器上驻留。该服务器会检查安全性,确保用户可以执行所请求的写入事件,并在该操作无效时通知您的应用。然后,服务器会监控连接状况。如果连接超时,或 Realtime Database 客户端主动关闭连接,服务器会再一次检查安全性(以确保操作仍有效),然后触发事件。

您的应用可以在写入操作中使用回调,以确保正确附加了 onDisconnect

Kotlin+KTX

presenceRef.onDisconnect().removeValue { error, reference ->
    error?.let {
        Log.d(TAG, "could not establish onDisconnect event: ${error.message}")
    }
}

Java

presenceRef.onDisconnect().removeValue(new DatabaseReference.CompletionListener() {
    @Override
    public void onComplete(DatabaseError error, @NonNull DatabaseReference reference) {
        if (error != null) {
            Log.d(TAG, "could not establish onDisconnect event:" + error.getMessage());
        }
    }
});

还可以调用 .cancel() 来取消 onDisconnect 事件:

Kotlin+KTX

val onDisconnectRef = presenceRef.onDisconnect()
onDisconnectRef.setValue("I disconnected")
// ...
// some time later when we change our minds
// ...
onDisconnectRef.cancel()

Java

OnDisconnect onDisconnectRef = presenceRef.onDisconnect();
onDisconnectRef.setValue("I disconnected");
// ...
// some time later when we change our minds
// ...
onDisconnectRef.cancel();

检测连接状态

对于许多与在线状态相关的功能,让您的应用了解自己处于在线还是离线状态非常有用。Firebase Realtime Database 提供了 /.info/connected 这个特殊位置,每当 Firebase Realtime Database 客户端的连接状态发生变化时,该位置都会更新。示例如下:

Kotlin+KTX

val connectedRef = Firebase.database.getReference(".info/connected")
connectedRef.addValueEventListener(object : ValueEventListener {
    override fun onDataChange(snapshot: DataSnapshot) {
        val connected = snapshot.getValue(Boolean::class.java) ?: false
        if (connected) {
            Log.d(TAG, "connected")
        } else {
            Log.d(TAG, "not connected")
        }
    }

    override fun onCancelled(error: DatabaseError) {
        Log.w(TAG, "Listener was cancelled")
    }
})

Java

DatabaseReference connectedRef = FirebaseDatabase.getInstance().getReference(".info/connected");
connectedRef.addValueEventListener(new ValueEventListener() {
    @Override
    public void onDataChange(@NonNull DataSnapshot snapshot) {
        boolean connected = snapshot.getValue(Boolean.class);
        if (connected) {
            Log.d(TAG, "connected");
        } else {
            Log.d(TAG, "not connected");
        }
    }

    @Override
    public void onCancelled(@NonNull DatabaseError error) {
        Log.w(TAG, "Listener was cancelled");
    }
});

/.info/connected 布尔值不会在 Realtime Database 客户端之间同步,因为该值取决于客户端的状态。换句话说,一个客户端的 /.info/connected 值为 false,并不代表另一个客户端的这个值也是 false。

在 Android 平台中,Firebase 可以自动管理连接状态以减少带宽和电池用量。如果客户端上没有处于活跃状态的监听器,没有等待处理的写入或 onDisconnect 操作,也没有通过 goOffline 方法明确断开连接,Firebase 会在不活跃状态持续 60 秒后关闭连接。

应对延迟

服务器时间戳

Firebase Realtime Database 服务器提供了一种机制,用于以数据形式插入服务器上生成的时间戳。这项功能与 onDisconnect 相结合,可让您轻松准确地记录 Realtime Database 客户端断开连接的时间:

Kotlin+KTX

val userLastOnlineRef = Firebase.database.getReference("users/joe/lastOnline")
userLastOnlineRef.onDisconnect().setValue(ServerValue.TIMESTAMP)

Java

DatabaseReference userLastOnlineRef = FirebaseDatabase.getInstance().getReference("users/joe/lastOnline");
userLastOnlineRef.onDisconnect().setValue(ServerValue.TIMESTAMP);

时钟偏差

虽然 firebase.database.ServerValue.TIMESTAMP 要准确得多,对大多数读写操作来说也更合适,但有时估算客户端相对于 Firebase Realtime Database 服务器的时钟偏差也很有用。您可以在 /.info/serverTimeOffset 这个位置附加回调函数来获取相应值(以毫秒为单位)。Firebase Realtime Database 客户端会将这个值与本地报告的时间(以毫秒为单位的纪元时间)相加,来估算服务器时间。请注意,这个时间差的准确性可能会受到网络延迟的影响,因此,它主要用于发现较大(> 1 秒)的时钟偏差。

Kotlin+KTX

val offsetRef = Firebase.database.getReference(".info/serverTimeOffset")
offsetRef.addValueEventListener(object : ValueEventListener {
    override fun onDataChange(snapshot: DataSnapshot) {
        val offset = snapshot.getValue(Double::class.java) ?: 0.0
        val estimatedServerTimeMs = System.currentTimeMillis() + offset
    }

    override fun onCancelled(error: DatabaseError) {
        Log.w(TAG, "Listener was cancelled")
    }
})

Java

DatabaseReference offsetRef = FirebaseDatabase.getInstance().getReference(".info/serverTimeOffset");
offsetRef.addValueEventListener(new ValueEventListener() {
    @Override
    public void onDataChange(@NonNull DataSnapshot snapshot) {
        double offset = snapshot.getValue(Double.class);
        double estimatedServerTimeMs = System.currentTimeMillis() + offset;
    }

    @Override
    public void onCancelled(@NonNull DatabaseError error) {
        Log.w(TAG, "Listener was cancelled");
    }
});

在线状态应用示例

将断开连接操作、连接状态监控与服务器时间戳相结合,可以构建一个用户在线状态系统。在这个系统中,每位用户都在数据库中的某个位置存储数据,来表明其 Realtime Database 客户端是否在线。客户端会在处于在线状态时将这个位置设为 true,并在断开连接时将其设置为时间戳。这个时间戳即代表该用户最后的在线时间。

请注意,在将用户标记为在线之前,您的应用应将断开连接操作加入队列,以免在因客户端网络连接中断而无法将两个命令都发送到服务器的情况下,出现竞态条件。

下方是一个简单的用户在线状态系统:

Kotlin+KTX

// Since I can connect from multiple devices, we store each connection instance separately
// any time that connectionsRef's value is null (i.e. has no children) I am offline
val database = Firebase.database
val myConnectionsRef = database.getReference("users/joe/connections")

// Stores the timestamp of my last disconnect (the last time I was seen online)
val lastOnlineRef = database.getReference("/users/joe/lastOnline")

val connectedRef = database.getReference(".info/connected")
connectedRef.addValueEventListener(object : ValueEventListener {
    override fun onDataChange(snapshot: DataSnapshot) {
        val connected = snapshot.getValue<Boolean>() ?: false
        if (connected) {
            val con = myConnectionsRef.push()

            // When this device disconnects, remove it
            con.onDisconnect().removeValue()

            // When I disconnect, update the last time I was seen online
            lastOnlineRef.onDisconnect().setValue(ServerValue.TIMESTAMP)

            // Add this device to my connections list
            // this value could contain info about the device or a timestamp too
            con.setValue(java.lang.Boolean.TRUE)
        }
    }

    override fun onCancelled(error: DatabaseError) {
        Log.w(TAG, "Listener was cancelled at .info/connected")
    }
})

Java

// Since I can connect from multiple devices, we store each connection instance separately
// any time that connectionsRef's value is null (i.e. has no children) I am offline
final FirebaseDatabase database = FirebaseDatabase.getInstance();
final DatabaseReference myConnectionsRef = database.getReference("users/joe/connections");

// Stores the timestamp of my last disconnect (the last time I was seen online)
final DatabaseReference lastOnlineRef = database.getReference("/users/joe/lastOnline");

final DatabaseReference connectedRef = database.getReference(".info/connected");
connectedRef.addValueEventListener(new ValueEventListener() {
    @Override
    public void onDataChange(@NonNull DataSnapshot snapshot) {
        boolean connected = snapshot.getValue(Boolean.class);
        if (connected) {
            DatabaseReference con = myConnectionsRef.push();

            // When this device disconnects, remove it
            con.onDisconnect().removeValue();

            // When I disconnect, update the last time I was seen online
            lastOnlineRef.onDisconnect().setValue(ServerValue.TIMESTAMP);

            // Add this device to my connections list
            // this value could contain info about the device or a timestamp too
            con.setValue(Boolean.TRUE);
        }
    }

    @Override
    public void onCancelled(@NonNull DatabaseError error) {
        Log.w(TAG, "Listener was cancelled at .info/connected");
    }
});