😶‍🌫️

Firestoreのsnapshotsをつかってはまった話

2021/11/02に公開
2

変更履歴

2021/11/3
コメントいただきタイトル変更・「はじめに」「終わりに」の部分を変更しました。
タイトル)
「FirestoreのsnapshotsをProviderの中でListenしない方が良さそうという話」

「FlutterでFirestoreのsnapshotsをつかってはまった話」

FirestoreのsnapshotsをProviderの中でListenしない方が良さそう。と書きましたが間違いになります。

はじめに

いろいろ理解不足ではまりにはまったことがあったので記事にしました。
同じようにはまった人用の記事です。

先に言っておくと、Firestoreのデータを取得する方法、getsnapshotsの違いがよく分かっている人は読まなくても大丈夫かと思います。

また、今回RiverpodのProviderの種類を「StateNotifier」→ 「StreamProvider」に変更することで、問題解決しています。
「StateNotifier」のままで(snapshotsをlistenして解決する方法)は載ってませんのでご注意ください。

利用しているライブラリとサービス

  • 状態管理 : Riverpod
    • flutter_hooks: ^0.17.0
    • freezed_annotation: ^0.14.2
    • hooks_riverpod: ^0.14.0+4
  • データベース : Firestore
  • 認証 : Firebase Authenticationの中のGoogle認証

何が起きたか

簡単にいうとログアウトしてログインし直すと、Firestoreのデータが取得できなくなりました。

取得しようとしたデータは?

userに紐づくデータです。
Firestoreのusersコレクションの中のデータで、認証情報を元に(例えばuid)で取得しているデータになります。
よって、「ログアウトするとデータが取得できなくなる」というのは正しい挙動なのですが、「ログインし直してもデータが取得できないまま」なのが問題点になります。

どんな実装なの?

Providerが以下のような実装になっています。
(説明のために雰囲気で書いているので悪しからず)

final listControllerProvider = StateNotifierProvider.autoDispose<ListController, ListState>(
  (ref) => ListController(ref.read),
);
// ListState
// SampleオブジェクトのListとlength(Sampleのlistの長さ)を持つViewModel
// freezedで自動生成している。

class ListController extends StateNotifier<ListState> {
    ListController(this._reader) : super(const ListState()) {
        fetch();
    }
    final Reader _reader;

    void fetch() {
        // authControllerProviderは認証情報を取得する別のprovider
        final user = _reader(authControllerProvider);
        if (user == null) {
          return;
        }
        // users/{user.id}/samples
        final collection = FirebaseFirestore.instance
            .collection('users')
            .doc(user.uid)
            .collection('samples')

        final listener = collection
            .snapshots()
            .map(
              (e) => e.docs
              .map((e) => Sample.fromJson(e.data())).toList(),
            ).listen((samples) {
                state = state.copyWith(samples: samples);
                state = state.copyWith(length: samples.length);
            }, onError: (Object e) {
              print('Catch an error on stream:$e');
            });
    }
}

見る人によっては不自然な実装だと思うかもしれません。
弁明すると、Firestoreから取ってきたデータをProviderの中で処理したくて、こんなことになりました。
(今回の説明用の実装だと、取得したデータの総数をListStateのlengthとして保持しようとしています)

エラーとか出てないの?

上記コードlistenの中のonErrorで拾っているエラーは以下です。

cloud_firestore/permission-denied ・・・

ログアウトのときに一回出て、(これはセキュリティルール上、納得のいく挙動ではありました)
ログインし直した後も同じエラーが出ていました

エラーからみてFirestoreのセキュリティルールが悪いんじゃ?

セキュリティルールは以下のような感じにしていました。
(これも説明上の雰囲気です)

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /users/{userId}/list/{listId} {
      // ユーザー認証されているか
      allow read: if request.auth != null;
    }
  }
}

解決方法としてはあり得ないのですが、
確かにrequest.auth != nullの部分を無くしたら、ログインし直してもデータを取得することはできました。

結局、何がいけなかったのか

snapshotsをProviderの中でlistenして、適切にcancelしていなかったことです。

私がわかっていなかったのかのは、
Firestoreのデータを取得する方法、getsnapshotsの違い(snapshotsの特徴について)でした。

ざっと書くと以下のような違いがあります。

get

snapshots

参考
https://zenn.dev/tsuruo/articles/a3d77c4854e108

私はsnapshotsを使っており、データをリアルタイムアップデート(購読)していました。
それをログアウトするときに「適切に」cancelできていなかったのが根本原因になります。

(先ほどのProviderのコードの抜粋・コメント追加)

        // ~ 省略 ~
        final listener = collection
            .snapshots()
            .map(
              (e) => e.docs
              .map((e) => Sample.fromJson(e.data())).toList(),
            ).listen((samples) {
                state = state.copyWith(samples: samples);
                state = state.copyWith(length: samples.length);
            }, onError: (Object e) {
              print('Catch an error on stream:$e');
            });

        // どこかで以下のように「listener」をキャセルしなければいけない。
        // listener.cancel();

より厳密にいうと、リッスンエラーの処理

エラーが発生すると、リスナーはそれ以上イベントを受信しなくなるため、リスナーをデタッチする必要はありません。

とあるので、
cloud_firestore/permission-deniedのエラーが起きてしまったがために、二度とリッスンしてくれなくなった

Firestoreのデータが取得できなくなった、
ということだったようです。

解決方法

原因からして適切にリスナーをcancelしたらいい、、ということになります。
さて、Providerの中でどこにcancelを書けばいいでしょうか?

最終的な話としては、「Providerの中でlistenする」こと自体をやめました。

つまり、
snapshotsを(コード例でよくあるように)StreamProviderでlistenしないまま取得し、画面側で値を処理する実装に変えました。

参考コード)
Provider

final listStreamProvider =
    StreamProvider.autoDispose<List<Sample>>((ref) {
  final user = ref.read(authControllerProvider);

  final collection = FirebaseFirestore.instance
      .collection('users')
      .doc(user!.uid)
      .collection('samples')

  final stream = collection.snapshots().map(
              (e) => e.docs
              .map((e) => Sample.fromJson(e.data())).toList(),
      );

  return stream;
});

画面


class SamplePage extends HookWidget {
  const SamplePage({Key? key}) : super(key: key);

  
  Widget build(BuildContext context) {

  final samples = useProvider(listStreamProvider);

  return Flexible(
    child: samples.when(
    loading: () => const CircularProgressIndicator(),
    error: (error, stack) => Text('Error: $error'),
    data: (samples) {
      final length = samples.length;
      return ListView.builder(
              itemCount: length,
              itemBuilder: (context, index) {
                final sample = samples[index];
                return ListTile(
                  title: Text('${index + 1}/$length ${sample.text}'),
                  );
              })
    }));
  }
}

AsyncValueとして画面で呼び出すことで、うまく必要のないときは(画面にWidgetが表示されていないとき?)リッスンしない(あるいはcancelしてくれている)ようです。
(cancelのされ方についてドキュメントでの明記部分は見つけられなかったです、すいません)

予想も入ってますが、この対応で「ログアウトしてログインし直すと、Firestoreのデータが取得できなくなる」挙動が解消できることを確認しています。

他、予期しない挙動(ログアウトして別のアカウントで入ると前のアカウントのデータが表示される、など)を防ぐためには
Providerに「.autoDispose」つけておくのもポイントです。

autoDispose → 使われなくなった Provider を自動的に破棄してくれます。

他試したこと

snapshotsからgetに書き換える。
以下のよいところ・よくないところがあり、私はリアルタイムアップデートの恩恵を享受したかったので採用しませんでした。

  • よいところ
    • istenのタイミングを自分で管理できるので「予期しない挙動」は少なくできる。なくせる。
  • よくないところ
    • リアルタイムアップデートの恩恵が得られない。
    • リアルタイムアップデートの挙動をgetで実現しようとすると処理が煩雑になる。

② ログアウト前にawait FirebaseFirestore.instance.terminate();を実行する。
FirebaseFirestore.instanceを無理矢理終了させる)

  • ログアウト → ログインし直してFirestoreのデータを取得するまでは成功しましたが、その後のリアルタイムアップデートが動かなくなりました。(原因不明)
  • またterminate()はテスト時に使うために用意されたメソッドのようなので、うまくいったとしても正攻法にはならなそうです。
    参考 : terminate()が記載されているGithubのコード部分、コメントメッセージ

試せていないが、うまくいくかも?

ブログ書くにあたって、いろいろ調査し直したのですが、
Providerに「.autoDispose」をつけて、ref.onDispose()でcancel処理がを書いたらうまくいくのかもしれません。
StreamProviderで書き換えてしまって、動作確認できていないので参考情報とさせてください。

参考)
https://zenn.dev/umatoma/articles/f9cfe3371a32f4#不要になったproviderを自動的に破棄する

終わりに

そもそも「データをリアルタイムアップデート(購読)したいのか、しなくてもいいのか」を考えるべきだったと思っています。
見よう見真似で実装してsnapshotsつかって、自滅したのが今回の内容です。

データをリアルタイムアップデート(購読)したい場合は、StreamProviderを使った方がコード例も多いのでハマることは少ないかもです。

似た現象に陥った人が検索で引っかかるかな? と思い、いろいろ書きましたが以上になります!

Discussion

monomono

FirestoreのsnapshotsをProviderの中でListenしない方が良さそう

いえ、正しく実装すれば全く問題ないです。記事のコードは断片なので、どこが問題かの指摘は困難ですが。

beeeyanbeeeyan

ご指摘を受けタイトルを変更させてもらいました。
「FirestoreのsnapshotsをProviderの中でListenしない方が良さそうという話」

「FlutterでFirestoreのsnapshotsをつかってはまった話」

断片のコードしか公開できない状態でして、お心遣いに添えず申し訳ありません。
ご指摘、本当にありがとうございます。