FlutterとFirestoreを使い始めて一ヶ月がたった

# はじめに

こんにちは、 @tacksman です。
Diverse Advent Calendar 2019 22日目の記事です。

自分がDiverseにジョインしてから一ヶ月ほど経ったので、その間にFlutterやFirestoreを使っていて躓いた点などを紹介しようかと思います。

# 前提

自分はここ数年はKotlinでのAndroidアプリ開発がすべてでしたが、それより前には Cordovaとreact-redux
を使用したiOSアプリの開発に参加していたこともありました。
今回は非同期処理となるFirestoreへのアクセスにStreamBuilderを使用していますが、こちらの使い方の詳細などについては取り扱わないでいきます。

# FlutterとFirestoreの組み合わせ

おなじみのFirestoreをFlutterで扱う際のポイントです。
自分も久しぶりにFirestoreを扱うこともあり、Web上のドキュメントを読んだりしましたが、Flutter+Firestoreについて解説している日本語のドキュメントはあまりない上、公式のドキュメントを読むほうが参考になることが多い気がしています。
そこで、そういった部分であまりふれられていない箇所を中心に取り上げておきます。

### Firestoreからのデータの取得

ここではFlutterでの非同期処理に`Stream`と`StreamBuilder`を使用しています。
`StreamBuilder`を使用してFirestoreからデータを取得し、それをもとに表示するコンポーネントを作成するには以下のようになります。

```dart
StreamBuilder(
  stream: Firestore.instance.collection("messages").snapshots(),
  builder: (context, snapshot) {
    /// snapshot.dataでQuerySnapshotを取り出して求める型に置換したりする
  },
)
```

Repositoryパターンを使用しているので、画面側のロジックで型変換したくないよお……という方は、Repositoryで置換後にStreamに包んで返し、それをStreamBuilderのstreamにいれましょう。

### Firestoreからのデータ購読

次は購読です。
Firestore上のデータを購読するには以下のようなコードを実行することで、`/messages`のパスにあたるドキュメント(もしくはコレクション)の購読を開始できます。

```dart
Firestore.instance.collection("messages").snapshots().listen((data) {
  /// DocumentSnapshotな引数dataとして、購読したいドキュメントのスナップショットが落ちてくる
})
```

listen内のFunctionでスナップショットから実用する型へ変換することになります。
`listen()`は返り値として`StreamSubscription<QuerySnapshot>`を返しますが、こちらは購読の終了を行うときに`cancel()`をコールするためのものです。
購読を行っている画面を閉じるときなど、購読を止めたいタイミングで`cancel()`を実行するようにしておきましょう。

さて、それではどのように画面側へ購読したデータを届けましょうか。
`PublishSubject<T>`使用するなどいくつかの方法がありますが、`dispose`し忘れに注意をはらないといけなかったり、データの流れを追いにくいです。
しかし画面側でStreamBuilderを使っていると、`listen`を使わず素直なデータの流れで実装できます。

```dart

class Repository {
...

Stream<List<Message>> watch(String messageId) {
  Firestore.instance.document("messages/${messageId}")
    .snapshots()
    .map((querySnapshot) { // MessageのQuerySnapshot
      // List<Message>に変換する
    });
}

...
}


class MessagePage extends StatefulWidget {
  ...
}

class MessagePageState extends State<MessagePage> {
 String messageId;
 Repository repository = Repository;
 ...
 @override
 Widget build(BuildContext context) {
  repository.watch(messageId);
  return StreamBuilder(
   stream: repository.watch(),
   builder: ...
  );
 }

 ...
}

```

`Stream<QuerySnapshot>`に生えている`listen()`をコールして手動で購読を行ってもいいですが、そちらでは変換したデータを画面にもっていく部分や購読のキャンセルをしっかり行う必要が出てくるなど煩雑になってしまいます。
しかしStreamBuilderを使用しているときはStreamBuilder内部で`listen()`や`cancel()`を行ってくれるため、比較的簡単に購読を実装できます。


### Firestoreへのデータ追加

取得、購読ときたら次はFirestoreへのデータ追加についてです。
しかし`setData`するだけなら公式ドキュメントを読めばいいので、それ以外の部分について書いていきます。

データ取得のところで使ったMessageデータで考えてみましょう。
例えばチャットをする機能の場合、チャットルームに対してメッセージのリストを持つ形を考えて、以下のような構造にしてみます。
`lastUpdatedAt`は最後にチャットが投稿された日時を示すTimestampです。

```dart:ChatRoom
class ChatRoom {
 List<Message> messages;
 Timestamp lastUpdatedAt;
 ....
}

class Message {
 String text;
 ...
}
```

そして特定のチャットルームのFirestore上のパスを`"chatRooms/${chatRoomId}"`、特定のメッセージまでのパスを`"chatRooms/${chatRoomId}/messages/${messageId}"`としておきます。

上記の前提の場合だと、`lastUpdatedAt`の更新と`messages`への追加を同時に行わなければなりません。
このようなときは`batch`を使用しましょう。

```dart:async
final batch = Firestore.instance.batch();

batch.setData(
  Firestore.instance
    .document("chatRooms/${chatRoomId}")
    .collection("messages")
    .document(),
  {
    /// Messageを表現するMap
    ...
  }
);

batch.update(
  Firestore.instance
    .document("chatRooms/${chatRoomId}/lastUpdatedAt"),
  Timestamp.now() // Timestampにnow()は生えていないので雰囲気です
);

await batch.commit();
```

このあたりは公式のドキュメントにもありますが、`batch`の使い方は上記の通りです。
更新、追加、削除したいデータへの`DocumentReference`と追加、更新ならそれを行うデータの`Map`の組み合わせでリクエストを準備し、`batch.commit()`でまとめて実行します。

ところで、`messages`への追加をコレクションの新しいドキュメントとなるように行っているのがわかったでしょうか。
データの追加・更新時、`messages`をFirestore上においてList型のドキュメントとしているかコレクションとして扱っているかによって変わります。
`messages`をコレクションとして扱う利点は、クエリを引くことができるという一点が大きいです。
特定のboolパラメータがtrueになっているものはフィルタするなどの使い方がありますが、本記事の趣旨から外れるため割愛します。

本来`messages`は`ChatRoom`のもつリストですが、こちらをサブコレクションとしてFirestoreに登録するには、`ChatRoom`を作成するときに`batch`を用いて以下のようなリクエストを行いましょう。

```dart:async
final batch = Firestore.instance.batch();
Map<String, dynamic> chatRoom; // setDataするMap

chatRoom = {
 ... // コレクションとして登録するパラメータ以外をMapのkey, valueとして追加
}

final newRoomDocument = Firestore.instance
    .collection("chatRooms")
    .document():

batch.setData(newRoomDocument, chatRoom);

batch.setData(Firestore.instance
    .document("chatRooms/${newRoomDocument.documentId}")
    .collection("messages")
    .document(),
  {
    // ルーム作成時の最初のメッセージのデータを作成する
    ...
  }
);

await batch.commit();
```

このように、コレクションとして登録したいデータを抜いたMapの`setData`と別で、`messages`コレクションへのデータ作成という形でバッチに処理を依頼しましょう。
こういうときのために、Firebaseで使う型定義を行ったクラスにはMapに変換するメソッドを作成するなどすると便利ですね。


# おわりに

データの取得と購読、追加と更新までのあれこれを紹介しました。
ここまでのところがわかれば、Flutter+Firestoreを実践で使い始める障害はだいぶ消えるのではないでしょうか。

自分がFlutterとFirestoreを使い始めてから一ヶ月ほどたち、いろいろなドキュメントを探したりしましたが日本語でいい感じのドキュメントがあまりに少ないため(見出しよさそう……ってなってもspeakerdeckのスライドだったりして詳しく見たいとこが見れない)、おれたちが作っていくしかないんやという気持ちです。

みんなでいい感じにドキュメント整備していこうな！！

ありがとうございました。

### おまけ

12月初め頃、ようやくFlutterのFirestoreに`where-in`クエリが実装されました :dancer: 
FlutterのFirestoreプラグインはまだまだ整備途中な感じなので、コミットチャンスを狙ってみても面白いかなと思います。