Firestore でリアルタイムアップデートxページングをする方法
Firestore でリアルタイムアップデート x ページングが欲しくなることがたまにあります。
代表的な例はチャットですが、他にもランキングや todo みたいなもののステータス一覧なんかも考えられます。
この実装方法が意外と悩みが多いので考えられる 3 つの方法をまとめて、それぞれのメリット、デメリット、適した利用シーンなどを考察してみました。
結論を最初に書いてしまうとコストが問題なければ方法 1 がおすすめです。
方法 1. limit を段階的に大きくしていく方法
これは最もシンプルな方法です。
1 ページに 10 件のドキュメントが必要な場合で考えてみます。
- まずは 1 ページ目用に
limit(10)
でデータリッスンします。 - 2 ページ目が必要になったら最初のスナップショットリスナーはキャンセルして最初のデータから
limit(20)
で取り直します。結果には 1 ページ目と 2 ページ目の両方の結果が入っています。 - さらに次のページが必要になったらさっきのスナップショットリスナーはキャンセルして最初のデータから
limit(30)
します。結果には 1,2,3 ページ目の結果が入っています。
つまり毎回追加される 10 件以外は重複するデータをとっています。
初めて見るとちょっとびっくりしますが実際にはこれが多くの場合使いやすい方法だと考えています。
この方法は Firebase の公式 Youtube でも言及されています。
また、firebase 公式の flutterfire_ui でもこの方法が採用されています。
良い点
考え方も実装もシンプルなことです。
管理するスナップショットリスナーは常に 1 つです。次のデータが必要なタイミングで新しいスナップショットリスナーに更新するだけです。
それだけでドキュメントの追加や削除、順序の入れ替えがあっても問題ありません。
その他の方法に比べるとこれだけで選ぶ価値があります。
悪い点
同じデータを重複して何度も取得するため他の方法に比べて余分な読み取りコストがかかる
1 ページ 10 件の場合、10 ページ目にいくまでに通常なら 100 件読み取りですむところが 550 件分ドキュメントを取得することになります。
ただし、これは慎重に設計すればそんなに問題にならない場合も多いです。
例えばほとんどのユーザーは最初の 100 件くらいまでしか見ないことが分かっているなら初回に取得するドキュメントを 100 件にしてしまえば OK です。
また 100 件以上みるユーザーは 500 件くらいまで見る傾向があるなら 2 回目以降は 500 件ずつ増やしていっても構いません。
(実際にリアルタイムな情報を何ページもページングするようなシーンはあまりないと思いますが。)
Firestore の東京リージョンの読み取りは 100,000 件で 5 円程度です。対象となるユースケースを元にうまく設計すればこのデメリットはマネージ可能です。
もし大量のデータを取得するのが不安な場合は この方法をベースにlimit
に上限を設定してもいいかもしれません。例えばlimit
が 1000 に達したらそれ以降はlimit
の数は増やさず、スタート地点をずらしていくというような方法です。ページを戻る際にもページ取得処理が必要になりますが、そこまで複雑にせずに実装できそうです。
1 クエリの取得データが大きくなりすぎて、パフォーマンスとコストが悪化する可能性がある
1 つのドキュメントの容量が大きい場合、一度のクエリで大量のドキュメントを取得すると単純にダウンロード容量が多くなりパフォーマンスが悪化する可能性があります。
500KB のドキュメントを 1000 件とれば 500MB のデータをダウンロードすることになります。
扱うデータを見ながら設計が必要です。
方法 2. limit + startAfter(DocumentSnapshot)でページごとにスナップショットリスナーを作る方法
この方法はリアルタイムアップデートでない方法で良く使う方法とアイデアは同じです。
- 1 ページ目は
limit
で指定した件数の最初のドキュメントを取得します。 - 2 ページ目以降は取得するドキュメントの件数を
limit
で指定しつつ、クエリのスタート地点はstartAfter(lastDoc)
で指定します。ここでlastDoc
は前回のクエリで取った結果に含まれる最後のドキュメントのDocumentSnapshot
です。
その後は各ページごとにスナップショットリスナーを維持して更新を反映していきます。
この方法を使うには多くの制限と工夫が必要になります。
良い点
たいていの場合で最も低い読み取り費用で実装可能です。
ドキュメントの追加や削除、順序の変動がない場合であれば採用は可能です。
悪い点
ドキュメントの追加や削除、順序の変動があるとデータの重複や不足が生じる
これが最大の問題です。
例えば何かしらの商品の売上ランキングを考えてみましょう。
下の画像の左側の 1 ページ目では商品をsales
順に 10 件取得しました。2 ページ目では追加で次の 10 件を取得しています。
ここで順位に変動が起こって新しい商品 NEW が商品 05 と商品 06 の間の売上まで急に伸びました。ページ 1 の結果は右側の表に変わります。sales
順に最初の 10 件を取得しているので、新しく商品 NEW が結果に入ると商品 10 はlimit(10)
に入らないためクエリ結果からは消えます。
一方 2 ページ目の結果は代わりません。そのため商品 10 は結果から漏れてしまい全体としては不正確なランキングになっています。
ドキュメントの削除の場合は同様の理由でドキュメントの重複がおきます。
順序の変動や、ドキュメントの追加・削除が起こるようなクエリにはおすすめできません。
ページ数(=スナップショットリスナーの数)が多いとパフォーマンスが悪化する
この方法はページ数の数だけスナップショットリスナーが増えていきます。
一方で Firestore のリアルタイムアップデートのスナップショットリスナーはクライアントごとに 100 件以下にすることが推奨されています。そのためアプリ内の他のリスナーの数も勘案して増えすぎないか注意が必要です。
3. 実装が面倒
考え方はリアルタイムでない場合と同じでシンプルですが実装は面倒です。順序の変動や、ドキュメントの追加・削除がない場合であってもリアルタイムでない場合と同じような実装にはできません。
まずページごとのスナップショットリスナーを適切に管理する必要があります。データが不要になった際にはそれら全てをアンサブスクライブしなければなりません。
また、スナップショットリスナーで受信した変更をデータに反映するためにデータの持ち方も工夫する必要があります。
ページ毎に個別の配列に保管して、それらをさらに配列に格納したり、あるいは ID をキーにした Map を作ってそこにドキュメントを入れていくのもいいかもしれません。更新時は ID から簡単に対象を探せる上、Map は順序も維持してくれます。
全ての結果を同じ配列にいれてしまうと更新時にいちいち対象ドキュメントの検索が必要になります。
方法 3. 変更だけを検知するスナップショットリスナーを作る方法
現在時刻時点でのデータはフェッチで取得し、現在時刻以降の変更は 1 つのスナップショットリスナーで取得する方法です。
具体的な実装には複数の方法が考えられますがどれも制限や工夫が必要です。
updated
フィールドを作る方法
ドキュメントにupdated
というフィールドを作って更新ごとにタイムスタンプを更新します。
現在時刻時点でのデータはフェッチで取得し、現在時刻以降の変更は updated
が現在時刻以降という条件のスナップショットリスナーで取得する方法です。
この方法では削除は検知できません。その他の変更は検知可能ですが、変更をフェッチで取得したデータに反映する処理は全て自前で実装する必要があり、かなり複雑になり得ます。また、フェッチで取得していない不要な変更も検知してしまいます。
実用的には 1 つのコレクションに格納されたチャットを表示する場合などには使えます。条件も不要で削除も不要であるか論理削除であるようなケースです。
方法 1 に比べてコストが少なく、方法 2 に比べてスナップショットリスナーが少なくてすみます。
全ての変更を記録するコレクションを作る場合
変更だけを記録するコレクションを作って変更検知にはそれをリッスンします。
削除を含めたどんな変更にも対応可能です。但し、変更をフェッチで取得したデータに反映する処理は全て自前で実装する必要があり、かなり複雑になり得ます。また、フェッチで取得していない不要な変更も検知してしまいます。
使うのはどうしても他の方法ではダメな時くらいでしょうか。
まとめ
最初 1 の方法を初めて見た時は少しびっくりしましたが、他の方法を検討するとその簡単さが際立ちます。
よほどの理由がない限りは 1 を採用したいです。
Discussion