Open17

Flutter+Firestoreで作るときのあれこれ(Riverpod+StateNotifierも)

welchiwelchi

FlutterでFirestoreを扱うときに使うパッケージ

Firestoreから取ってきたドキュメントを扱うときは、

https://resocoder.com/2020/02/11/freezed-data-class-union-in-one-dart-package/

welchiwelchi

Firstoreから受け取ったTimestamp型をDateTime型に変換する方法

FirestoreからTimestamp型フィールドを含むドキュメントを取ってくると、iOSではTimestamp型に、AndroidだとDateTime型にシリアライズされる。
json_serializableで変換用JsonKeyを作ったり、firestore_refでtimestampJsonKeyを使うと、両プラットフォームでDateTime型として扱えるようになる。
https://twitter.com/itometeam/status/1271097977600274432
https://twitter.com/_mono/status/1228974064879861760

welchiwelchi

Firestoreからデータを読み取る2つの方法

Firestoreでコレクションやドキュメントを取得する方法は2つ。

  1. Get → 指定したコレクション、ドキュメントを取得。返り値はFuture。
  2. Snapshot → コレクションやドキュメントの更新を随時受け取る。返り値はStream。
    https://speakerdeck.com/ryunosukeheaven/20201202-port-flutter-firebase-architecture?slide=12
    https://github.com/1amageek/Ballcap-iOS/blob/master/Ballcap/DataRepresentable.swift#L215-L282

迷ったら富豪的にSnapshotを使うと良いが、次の点に注意

  1. Read課金
  2. スナップショットリスナーが100を超えると通知レイテンシが増加する
    https://speakerdeck.com/ryunosukeheaven/20201202-port-flutter-firebase-architecture?slide=17
welchiwelchi

スナップショットをリッスンする場合の課金について

クエリの結果をリッスンする場合、結果セット内のドキュメントを追加または更新するたびに、1 回の読み取りとして課金されます。また、ドキュメントが結果セットから除去される場合も、ドキュメントが変更されることになるため、1 回の読み取りとして課金されます(これとは対照的に、ドキュメントが削除される場合、これは読み取りとして課金されません)。

リッスンしているドキュメントが更新された場合、変更点があったドキュメント数分Read課金が発生する。
変更がなかったドキュメントはキャッシュから読まれるので、Read課金対象ではない。
https://tenderfeel.xsrv.jp/javascript/firebase/4980/
https://firebase.google.com/docs/firestore/pricing?hl=ja
https://stackoverflow.com/questions/60062248/firebase-firestore-snapshot-read-count

welchiwelchi

FirestoreとRealtimeDatabaseとの使い分け

チャットくらいの、多少遅延があっても気にならない程度のリアルタイム性ならFirestoreで良い。。。とどこかで読んだけど忘れた。
(2021/01/05追記)
いや、公式ユースケースだと、チャットはRealTimeDatabaseを使っているっぽい?

Firebase Realtime Database で数百万人のユーザーのチャット メッセージを同期。

…と思ったけど、そもそもFirestoreの例がないのと、他の項目だとリンク先ではRaltimeDatabaseでなくFirestoreを使っていたり、このページ自体が古い。
Firebase Use Cases
むしろ料金サンプルだと、チャットでもFirestoreを使っている。
Cloud Firestore の料金サンプルを見る | Firebase

https://firebase.google.com/docs/database/rtdb-vs-firestore
https://firebase.google.com/docs/database

welchiwelchi

テスト環境用セキュリティルール

とりあえず、ログイン済ユーザなら全ドキュメントへアクセスを許す例。
本番環境でやってはいけない。

rules_version = '2';
service cloud.firestore {
  match /databases/{database}/documents {
    match /{document=**} {
      allow read, write: if
          request.auth.uid != null;
    }
  }
}

https://firebase.google.com/docs/firestore/security/get-started
https://zenn.dev/sgr_ksmt/books/2f83a604d636b241cf3c

welchiwelchi

Firestore+freezedでどうやってドキュメントIDを参照するか

freezedでエンティティを定義するとき、次のようにすると思う。


abstract class Todo with _$Todo {
  factory Todo({
     String title,
    () DateTime createdAt,
  }) = _Todo;
  factory Todo.fromJson(Map<String, dynamic> json) => _$TodoFromJson(json);
}

ただ、これを削除・更新したいとき、エンティティはDocumentIdを含んでない。
どのようにDocumentIdを参照するか?
TodoがDocumentIdを含むように拡張するのも気持ち悪い。
firestore_refを使い、エンティティの代わりにxxDocを使えば綺麗に書けそう?

class TodosRef extends CollectionRef<Todo, TodoDoc, TodoRef> {
  TodosRef() : super(FirebaseFirestore.instance.collection('todos'));
  
  Map<String, dynamic> encode(Todo data) =>
      replacingTimestamp(json: data.toJson());

  
  TodoDoc decode(DocumentSnapshot snapshot, TodoRef docRef) {
    assert(docRef != null);
    return TodoDoc(
      docRef,
      Todo.fromJson(snapshot.data()),
    );
  }

  
  TodoRef docRef(DocumentReference ref) => TodoRef(
        ref: ref,
        todosRef: this,
      );
}

class TodoRef extends DocumentRef<Todo, TodoDoc> {
  const TodoRef({
     DocumentReference ref,
     this.todosRef,
  }) : super(ref: ref, collectionRef: todosRef);
  final TodosRef todosRef;
}

class TodoDoc extends Document<Todo> {
  const TodoDoc(
    this.todoRef,
    Todo entity,
  ) : super(todoRef, entity);
  final TodoRef todoRef;
}

mono0926/flutter_firestore_ref: Cross-platform(including web) Firestore type-safe wrapper.

welchiwelchi

StateNotifier+Firestoreでスナップショットを使う

Firestoreでsnapshots()を使いつつ、リアルタイムにstateを更新するにはsnapshots().listen()内へ処理を書く。

class TodoController extends StateNotifier<TodoState> {
  TodoController(this._read) : super(TodoState()) {
     todoQuery.snapshots().listen((snapshot) {
        final newTodos = snapshot.docs
            .map(
              (doc) => Todo.fromJson(
                doc.data(),
              ),
            )
            .toList();
        state = state.copyWith(todos:newTodos);
      });
  }
}

firestore_refを使うなら

todoDocStream = todosRef.documents(
      (r) => r
          .where(
            'userId',
            isEqualTo: 'hogefuga',
          )
    );
todoDocStream.listen((docs){
  state=state.copyWith(todos:docs);
});
welchiwelchi

Firestoreで読み取り回数が異様に多い

アクティブな接続は1、スナップショットリスナー2。
ドキュメントの総数も対して多くないのに、急に100くらい読み取り回数が増えることがある。
アプリからドキュメント操作してみて、ドキュメント追加だと読み取り回数+1、削除だと読み取り回数に変化ないことは確認していて、実装ミスはないっぽい。
で、気づいたが、ブラウザでFirestoreコンソール>データを開くときに、ドキュメント読み取りカウントをされているっぽい。
とりあえず _という空のコレクションを作り、デフォルトでそっちを読み込むようにしてみる。

welchiwelchi

The member 'state' can only be used within instance members of ...

RiverpodでStateNotifierを使っていて、hogeController.state的な形でstateへアクセスすると次の警告が出る。

info: The member 'state' can only be used within instance members of subclasses of 'package:state_notifier/state_notifier.dart'. (invalid_use_of_protected_member at [hoge_fuga] lib/ui/pages/home/home_page.dart:122)

Remiさん曰く

No this is expected
It allows reading the notifier without listening to the state, to avoid pointless rebuilds.

(stateをリッスンしないことで)リビルドを避けながら、StateNotifier(hogeController)を読むのが正しいとのこと。

つまり

final hogeController = useProvider(hogeControllerProvider);
hogeController.state.name;

ではなく、

final hogeController = useProvider(hogeControllerProvider);
final hogeState = useProvider(hogeControllerProvider.state);
hogeState.name;

として、状態を読むのが正しい。
https://github.com/rrousselGit/river_pod/issues/52

welchiwelchi

freezed中でインスタンスを使う

@lateをつければ、freezed中でも、インスタンスが使える

welchiwelchi

セキュリティルールで get() exists() getAfter() を使った場合の課金額

セキュリティルールを評価する際に、get() exists() を使うと、Firestore内の別ドキュメントを使ったルール評価ができる。

https://firebase.google.com/docs/firestore/security/rules-conditions#access_other_documents

ただ、get()exists()を使うと、ドキュメントに対する読み取りコストも発生する。
なので、例えば collection('post').get() で100個ドキュメントを読み取った際、セキュリティルール評価にget()を使ってるから、さらに100回追加で読み取りコストがかかる、だと嫌だなと思った。

ただ、実際には、get() 等でセキュリティルールを評価する際に発生する読み取りコストは、1リクエストあたり1回らしい。
(上記例だと、100+1回分の読み取りコストで済む)
https://firebase.google.com/docs/firestore/pricing#firestore-rules