へなちょこSEの考察

0x22歳のへなちょこSEが、日々思うことを考察します。自社内、金融系を経て現在法人系PKG開発に従事。

【React Native/firebase】firestoreのページネーションを使ってみた

作っている「セワシタ?」アプリでチャットルームの機能を提供しています。

ユーザー(家族)同士で会話したり、誰かがお世話を実施したことを登録すると、ペットが「お世話してくれてありがとう!」と言ってくれる機能です。

(将来的にはLINE連携にしたいのですが、今はまだできていません)

ページネーション(pagenation)とは

複数件のデータが取れる場面で、

  1. まずは最初の100件を取得し、
  2. ユーザーが希望したら(次へボタンを押す、ページの最後までくる、など)次の100件を表示する、
  3. それを繰り返すことで最初の取得件数を節約したり、ロード時間を短縮したり、

といったことをするための方法ですね。

これをチャットルームで、最新のメッセージ25件だけ表示し、過去分はボタンをクリックしたら表示する、という機能を追加しました。

firestoreでのページネーション

firestoreにはページネーションを実現するための機能が提供されています。

まずデータを取得する際に「limit」で件数を指定することができます。

今回のチャットアプリではメッセージの登録日時を持っているため、登録日時の新しい順に25件取得する、ということをします。

chatCollection.orderBy('createdAt', 'desc').limit(25).onSnapshot(async snapshot => {
            this.setState({
                lastMessage: snapshot.docs[snapshot.docs.length - 1] ,
            });

※最後の一件をstateに退避しておきます

その後、次の25件を取得したいとユーザーが指示した場合、先ほど取得した25件の最後のものを指定して、「startAfter」を使用します。

let next = chatCollection.orderBy('createdAt', 'desc')
            .startAfter(this.state.lastMessage!.data().createdAt)
            .limit(25);

stateに退避しておいたlastMessageのcreatedAtを指定して「startAfter」することで、指定された「createdAd」より後のデータが取得できます。

ちなみに今回は並べ替えのキーがcreatedAt(つまり日時)のみでしたが、複数項目で並べ替えをする場合は、startAfterにはorderByした項目をその順番で指定してやります。

複数指定については公式ドキュメントも参照してください。

クエリカーソルを使用したデータのページ設定  |  Firebase

snapshotを使った時の問題点

firestoreのsnapshotを使うと、データに変更があった際に通知してくれ、画面の更新ができます。

ただ、snapshotとlimitを同時に使うと問題も発生します。

はじめ、25件取得した後で、新しいメッセージが投稿されたとしましょう。

すると、新しく1件追加されると同時に、25件目だったデータは26件目になってしまうため取得されるデータから消えます。

これをそのまま画面に表示していると、一件新しいメッセージが来たら、一件古いメッセージが消えた、みたいなことになります。

また、追加の25件を取得していた場合も、うまく取得していないとあいだの一件が消えちゃったとか、そういうことになりかねません。

今回の「セワシタ?」アプリでは過去のメッセージの削除や更新は考慮せず、常に新しいメッセージの追加しかないという前提のため、以下のような仕組みとしました。

  1. snapshotについては最初の25件のみ設定する
  2. 追加のもの以降は常に通常のgetで取得する
  3. 取得したデータはstateにコピーを作成しそこに追記していく形とする
  4. snapshotで取得された新規メッセージはunshiftでstateのメッセージの1番前に追加する

最終的にはデータ取得する箇所はこんな感じになりました。

   /** チャットのメッセージ取得 */
    componentDidMount = async () => {
        // コレクションの取得
        const chatCollection = Fire.shared.chatCollection(store.familyId);

        // 最初の25件のみ取得、onSnapshotを設定し変更を受信する
        const chatUnsubscribe = chatCollection.orderBy('createdAt', 'desc').limit(25).onSnapshot(async snapshot => {

            // stateのメッセージをコピー
            const newMessages = this.state.messages.slice();

            // snapshotのうち変更のあった行のみ取得(docChanges)
            // 25件取得したものを逆順(reverse)に取得して一番前に追加(unshift)することで
            // 複数件更新を受信した場合にも対応する
            // (通信状態が悪く、一度に2件以上のメッセージを受信した場合を想定)
            snapshot.docChanges().reverse().forEach(async chatMessage => {

                // 追加されたメッセージのみ対象
                if (chatMessage.type === 'added') {
                    const message = new ChatMessage(chatMessage.doc);

                    // unshiftで一番前に追加
                    newMessages.unshift(message);
                }
            });

            this.setState({
                // 初回以降はonSnapshotの中では最後の一件を設定しない
                // pagenation対応のため
                lastMessage: this.state.firstFlg ? snapshot.docs[snapshot.docs.length - 1] : this.state.lastMessage,
                // チャットのメッセージ配列を設定
                messages: newMessages,
                // 最初の一回の時のみtrue、以降はコンポーネントが再ロードされるときまでずっとfalse
                firstFlg: false,
            });
        });
        Fire.shared.unsubscribeFunctions.push(chatUnsubscribe);
    }

    /** 過去のメッセージを読み込む */
    getOlderMessage = () => {

        // react-native-gifted-chat用の設定
        this.setState({ isLoadingEarlier: true });

        const chatCollection = Fire.shared.chatCollection(store.familyId);

        let next = chatCollection.orderBy('createdAt', 'desc')
            .startAfter(this.state.lastMessage!.data().createdAt) // 前回の最後のデータの日時
            .limit(25);

        // 過去のデータの更新はないため、通常のgetで取得する(onSnapshotしない)
        next.get().then((snapshot) => {
            const newMessages = this.state.messages.slice();
            snapshot.forEach(async chatMessage => {
                const message = new ChatMessage(chatMessage);
                newMessages.push(message);
            });
            this.setState({
                lastMessage: snapshot.docs[snapshot.docs.length - 1], // 新しい最後の一件を設定
                messages: newMessages,
                isLoadingEarlier: false, // react-native-gifted-chat用の設定
            });

        })
    }

ちなみに、チャット部分にはreact-native-gifted-chatを使ってます。

すごく簡単にチャット機能作れちゃうのすごいんですが、ちょっと癖がある・・・。

github.com

チャット機能だとたぶんこんな感じで最初の部分だけonSnapshot設定して追加されたデータは自分でマージって感じにするのが、データのロード量とかも少なく抑えられていいのかなぁという感じ。

もっといい方法思いつくかもしれませんが。

チャットみたいに常に最新のみ追加されるわけじゃないときのページネーションはとりあえず今は考えたくない。

本当にキレイに表示するなら、データのマージとかをゴリゴリ自分で頑張るしかないんだろうなぁ。。。