Android でのオフライン機能の有効化

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

ディスクの永続性

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

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

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

Kotlin+KTX

Firebase.database.setPersistenceEnabled(true)

Java

FirebaseDatabase.getInstance().setPersistenceEnabled(true);

永続性の処理

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

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

アプリで Firebase Authentication を使用している場合、Firebase 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);

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

Kotlin+KTX

scoresRef.keepSynced(false)

Java

scoresRef.keepSynced(false);

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

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

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

たとえば次のコードは、スコアが下位から 4 番目までのアイテムを 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());
    }

    // ...
});

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

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());
    }

    // ...
});

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

オフライン中にアプリが下位 6 つのアイテムをリクエストした場合、キャッシュに保存された 4 つのアイテムの「child added」イベントがすぐに取得されます。デバイスがオンラインに戻ると、データがサーバーと同期され、残り 2 つの「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)が用意されています。次に例を示します。

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 にコールバックをアタッチしてミリ秒単位の値を取得し、この値をローカルのレポート時刻(ミリ秒単位のエポックタイム)に加算すればサーバー時刻を推定することができます。このオフセットの精度はネットワークのレイテンシによる影響を受ける可能性があるため、主にクロック時刻の大きな(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");
    }
});