オフライン機能の有効化

Firebase アプリケーションは、アプリのネットワーク接続が一時的に失われても機能します。さらに Firebase には、データのローカルへの保存、プレゼンスの管理、レイテンシの処理を行うためのツールが用意されています。

ディスクの永続性

Firebase アプリは、一時的なネットワークの中断を自動的に処理します。キャッシュされたデータはオフライン中にも使用でき、ネットワーク接続が回復すると書き込み内容が再送信されます。

ディスクの永続性を有効にすると、アプリのデータはデバイスにローカルに書き込まれるため、オフラインになってもアプリの状態を維持できます。これはユーザーまたはオペレーティング システムがアプリを再起動した場合でも変わりません。

ディスクの永続性は、わずか 1 行のコードで有効にできます。

FirebaseDatabase.instance.setPersistenceEnabled(true);

永続性の処理

永続性を有効にすると、オンライン中に Firebase Realtime Database クライアントによって同期されるすべてのデータがディスクに永続化され、オフラインになってもそれらのデータを使用できます。これはユーザーまたはオペレーティング システムがアプリを再起動した場合でも変わりません。つまり、アプリはキャッシュに保存されているローカルデータを使用して、あたかもオンラインであるかのように動作します。ローカルの更新に対してリスナーのコールバックは引き続き発生します。

Firebase Realtime Database クライアントは、アプリのオフライン中に実行されたすべての書き込みオペレーションのキューを自動的に保持します。永続性が有効になっている場合はこのキューもディスクに永続化されるため、ユーザーまたはオペレーティング システムがアプリを再起動したときにもすべての書き込みが維持されます。アプリの接続が回復すると、すべてのオペレーションが Firebase Realtime Database サーバーに送信されます。

アプリで Firebase Authentication を使用している場合、Firebase Realtime Database クライアントはアプリの再起動後もユーザーの認証トークンを保持します。アプリのオフライン中に認証トークンが期限切れになった場合、再認証が行われるまでクライアントは書き込みオペレーションを一時停止します。一時停止しないと、セキュリティ ルールによって書き込みオペレーションが失敗する可能性があります。

データを最新の状態に保つ

Firebase Realtime Database は、データを同期するとともに、そのデータのローカルコピーをアクティブ リスナーのために保存します。さらに、特定の場所を同期した状態に保つことができます。

final scoresRef = FirebaseDatabase.instance.ref("scores");
scoresRef.keepSynced(true);

これらの場所にあるデータは、参照にアクティブ リスナーが設定されていない場合でも自動的にダウンロードされ、データの同期が維持されます。次のコード行で同期を解除できます。

scoresRef.keepSynced(false);

デフォルトでは、すでに同期されているデータのうち 10 MB がキャッシュされます。ほとんどのアプリケーションでは、この量で十分です。キャッシュが構成済みのサイズより大きくなった場合は、最も長い間使われていないデータが消去されます。同期が維持されているデータは、キャッシュから消去されません。

オフラインでのデータのクエリ

Firebase Realtime Database は、オフラインのときでも使用できるように、クエリから返されたデータを保存します。オフライン中に作成されたクエリについては、それまでに読み込まれたデータを使用して引き続き動作します。要求したデータが読み込まれていない場合は、ローカル キャッシュからデータが読み込まれます。ネットワーク接続が回復すると、データが読み込まれ、クエリに反映されます。

たとえば次のコードは、スコアが下位から 4 番目までのアイテムをデータベースに照会します。

final scoresRef = FirebaseDatabase.instance.ref("scores");
scoresRef.orderByValue().limitToLast(4).onChildAdded.listen((event) {
  debugPrint("The ${event.snapshot.key} dinosaur's score is ${event.snapshot.value}.");
});

ユーザーが接続を失ってオフラインになり、アプリを再起動したとします。まだ接続が回復していないときにアプリが同じ場所から下位 2 つのアイテムを照会しました。上記のクエリで下位 4 つのアイテムがすべて読み込まれているため、このクエリは下位 2 つのアイテムを正常に返します。

scoresRef.orderByValue().limitToLast(2).onChildAdded.listen((event) {
  debugPrint("The ${event.snapshot.key} dinosaur's score is ${event.snapshot.value}.");
});

上記の例では、永続キャッシュを使用することで、最もスコアが高い 2 頭の恐竜の「child added」イベントが発生します。ただし、「value」イベントは発生しません。これは、オンライン中にそのクエリが実行されていないためです。

オフライン中にアプリが下位 6 つのアイテムをリクエストした場合、キャッシュに保存された 4 つのアイテムの「child added」イベントがすぐに取得されます。デバイスがオンラインに戻ると、データがサーバーと同期され、残り 2 つの「child added」イベントと「value」イベントが取得されます。

オフラインでのトランザクションの処理

アプリがオフラインの間に実行されたトランザクションはすべてキューに格納されます。ネットワーク接続が回復すると、それらのトランザクションは Realtime Database サーバーに送信されます。

Firebase Realtime Database には、オフラインのシナリオやネットワーク接続に対処するための機能が数多く用意されています。このガイドの残りの内容は、永続性を有効にしているかどうかに関係なく、アプリに適用されます。

プレゼンスの管理

リアルタイム アプリケーションでは、クライアントが接続または接続解除するタイミングを検出できると役に立つことがよくあります。たとえば、クライアントが接続を解除したときにそのユーザーを「オフライン」としてマークする場合などに有用です。

Firebase Database クライアントには、クライアントが Firebase Database サーバーから接続解除されたときにデータベースにデータを書き込むことができるシンプルなプリミティブが用意されています。これらの更新処理はクライアントが正常に接続を解除したかどうかにかかわらず行われるため、接続が失われた場合やクライアントがクラッシュした場合でもデータが確実にクリーンアップされます。また、設定、更新、削除を含むあらゆる書き込みオペレーションを接続の解除時に実行できます。

onDisconnect プリミティブを使用して接続の解除と同時にデータを書き込むシンプルな例を次に示します。

final presenceRef = FirebaseDatabase.instance.ref("disconnectmessage");
// Write a string when this client loses connection
presenceRef.onDisconnect().set("I disconnected!");

onDisconnect の仕組み

onDisconnect() オペレーションを確立すると、そのオペレーションは Firebase Realtime Database サーバーで継続的に実行されます。サーバーがセキュリティをチェックして、リクエストされた書き込みイベントを実行する権限がユーザーにあることを確認し、書き込みイベントが無効な場合はアプリに通知します。その後、サーバーが接続をモニタリングします。接続がタイムアウトした場合、または Realtime Database クライアントによって接続が能動的に閉じられた場合、サーバーはセキュリティをもう一度チェックして(オペレーションがまだ有効であることを確認して)からイベントを呼び出します。

try {
    await presenceRef.onDisconnect().remove();
} catch (error) {
    debugPrint("Could not establish onDisconnect event: $error");
}

.cancel() を呼び出して onDisconnect イベントをキャンセルすることも可能です。

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

接続状態の検出

プレゼンス関連の多くの機能において、アプリが現在オンラインであるかオフラインであるかがわかると便利です。Firebase Realtime Database には、クライアントの接続状態が変わるたびに更新される特別な場所(/.info/connected)が用意されています。以下に例を示します。

final connectedRef = FirebaseDatabase.instance.ref(".info/connected");
connectedRef.onValue.listen((event) {
  final connected = event.snapshot.value as bool? ?? false;
  if (connected) {
    debugPrint("Connected.");
  } else {
    debugPrint("Not connected.");
  }
});

/.info/connected はブール値です。この値はクライアントの状態に依存するため、異なる Realtime Database クライアント間では同期されません。言い換えると、あるクライアントで読み取った /.info/connected の値が false であったとしても、別のクライアントで読み取った値も false であるとは限らないということです。

レイテンシ対応

サーバーのタイムスタンプ

Firebase Realtime Database サーバーには、サーバー上で生成されたタイムスタンプをデータとして挿入するメカニズムが用意されています。この機能を onDisconnect と組み合わせることで、Realtime Database クライアントの接続が解除された日時を確実かつ簡単に記録できます。

final userLastOnlineRef =
    FirebaseDatabase.instance.ref("users/joe/lastOnline");
userLastOnlineRef.onDisconnect().set(ServerValue.timestamp);

クロックのずれ

ServerValue.timestamp は精度が高く、ほとんどの読み取りと書き込みのオペレーションに適していますが、一方で Firebase Realtime Database サーバーとクライアントの相対的なクロックのずれを推定することが有用な場合があります。場所 /.info/serverTimeOffset にコールバックをアタッチしてミリ秒単位の値を取得し、この値をローカルのレポート時刻(ミリ秒単位のエポックタイム)に加算すればサーバー時刻を推定できます。このオフセットの精度はネットワークのレイテンシによる影響を受ける可能性があるため、主にクロック時刻の大きな(1 秒を超える)不一致を検出するのに役立ちます。

final offsetRef = FirebaseDatabase.instance.ref(".info/serverTimeOffset");
offsetRef.onValue.listen((event) {
  final offset = event.snapshot.value as num? ?? 0.0;
  final estimatedServerTimeMs =
      DateTime.now().millisecondsSinceEpoch + offset;
});

プレゼンスのサンプルアプリ

接続解除オペレーションを接続状態のモニタリングやサーバーのタイムスタンプと組み合わせることで、ユーザー プレゼンス システムを構築できます。ユーザー プレゼンス システムでは、Realtime Database クライアントがオンラインであるかどうかを示すデータを、各ユーザーがデータベース上の特定の場所に保存します。クライアントは、オンラインになったときにこの場所を true に設定し、接続を解除したときにタイムスタンプを設定します。このタイムスタンプは、特定のユーザーがオンラインであった最後の時刻を示します。

両方のコマンドがサーバーに送信される前にクライアントのネットワーク接続が失われた場合に発生する競合状態を避けるため、ユーザーをオンラインとしてマークする前に接続解除オペレーションをキューに入れることをおすすめします。

// 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 myConnectionsRef =
    FirebaseDatabase.instance.ref("users/joe/connections");

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

final connectedRef = FirebaseDatabase.instance.ref(".info/connected");
connectedRef.onValue.listen((event) {
  final connected = event.snapshot.value as bool? ?? false;
  if (connected) {
    final con = myConnectionsRef.push();

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

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

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