クラブハウスみたいなアプリをFlutter Riverpodを使って作ろう (Part 2/2)
なんと前回から3ヶ月も空いてしまいましたね。riverpodやagora_rtc_engineなどはマイナーなアップデートがあったのでGitHubレポジトリーにあるコードを修正しておきました。
Part 1 - ログイン、オンボーディング実装
Part 2 - Firestore、Agoraとの連携
さて前回(パート1/2)はログインを実装した時まででしたね。まだの方は是非お読みください。今回Part 2ではFirestoreと絡めたルームのリスト及び各ルームの詳細情報、(プロフィール画面は割愛)そしてAgora連携までをカバーします。かなり駆け足な解説なので詳細を知りたい方はレポジトリーのコードをご覧ください。
最後にRiverpodで実際に開発してみた総論を載せておきました。
Firestoreとの連携
前回でも説明しましたがFirestoreとはFirebaseで提供されているkey-value storeのデータベースサービスです。FirebaseとはGoogleによって提供されているバックエンドサービスの製品でさまざまな機能があります。FlutterもGoogleによって提供されているのでFlutterからFirebaseは非常に使いやすいです。このアプリではバックエンドのデータベースとしてFirestoreを使います。ご心配なくバックエンドのコードを書く必要はありません。全てFlutterのFirebaseパッケージから使えます。
Firebaseではすでにパート1でログインでFirebaseAuthを使っているのですがせっかくですからFirebaseを使い倒してデータはFirestoreに格納しておきます。格納するデータはユーザーデータとルームデータです。Flutter側からFirestoreにおいてあるデータに読み書きそして変更があったときに瞬時にそれをキャッチできるFirestoreServiceというクラスを作ります。
かなり長いクラスですが結構はしょりました。それでも長いですね。このクラスは個々の画面と密接に関わっているのでUIを作るごとに番号を振った行を解説します。端折ってないコードはレポジトリーからご覧ください。
まずUI描画前ですがコンストラクター(上記の// 1)ではFirestoreServiceのインスタンス作成のためにuidが必要となっています。これはこのアプリではユーザーがログイン状態でなければFirestoreには基本的にはアクセスできるデータがないからです。
しかしログイン前にもFirestoreにデータを書き込む必要がある場合が一箇所だけあります。それはPart1ではしょったEmailPasswordSignInScreenです。
case EmailPasswordSignInFormType.register
をみていただくとユーザーがregisterをタップした時点では当然ですがユーザーはFirestoreに存在していません。ここでfirebaseAuthを使いFirebaseAuthのバックエンドにユーザーを作り作成後に返り値としてuserCrendentialが戻ってきます。(16行目)userCredentialにはユーザー情報がありますのでそれをfirestore_service.dartの// 2と記されたstatic関数に渡しFirestoreにユーザーを作ります。そのあとは何もしなくてもtop_level_providers.dartのfirebaseAuthProvider及びauthStateChangesProviderがユーザーが作成された変化をキャッチしてauthStateChangesProviderに依存するdatabaseProvider再生成及びAuthWidget再描画がなされ,そのとき自動的にRoomsFeedが描画されます。
以下に画像で説明します。
かなり忙しない画像になってしまいましたがFirebaseAuth, Firestoreのみがバックエンドで長方形で囲ったものは全てアプリ内です。6, 7, 9は何が先に走るかは順不同ですがauth_widget.dartおよびその子孫たちを定義しているクラス達ではuserProviderおよびauthStateChangeProviderに依存しているProviderをウォッチしているので何か変化があるたびに再描画されます。数回の再描画の後もう少し下にあるルームのリスト画面のようになります。
ログイン済みユーザーありの場合のUI
このセクションでは前回も表示したロジック分岐の右側を解説します。
ルームリストの表示
ログイン後初めに見える画面はルームのリストです。以下になります。
この画面ではWatercooler1とTV Roomというルームが二つあり、Watercooler1には現在一人だけ(山県昌景さん)が参加しています。Watercoolerはアメリカのオフィスに良くある水サーバーのことです。右上は現在ログイン済みのユーザーのアイコン、タップするとユーザー情報編集画面に飛びます。下にはルーム作成のボタンです。
この画面を作るためにはバックエンドからルームのリストを取得しなければいけません。バックエンドにあるルームのリスト情報は上記のfirestore_service.dartの//5でマークされた関数で取得されます。下にまた掲載します。
RoomsFeedではdatabaseProviderをウォッチします。データベースの中のルームデータに変化があればStreamにその変化が行きます。帰ってきたルームそれぞれに対してRoomTileViewModelを作りRoomsFeedでそれを表示します。概念図としては以下となります。
例によってrooms_feed.dartのコード全体はGitHubからご覧になってください。説明は以下でします。
RoomsFeedでは二箇所Streamを使っています。
一つ目が今ログイン済みのユーザーを渡すStreamです。
現実的にはこの画面が表示されている間ユーザーが変わることはないのですがtop_level_providers.dartで定義されてるuserProviderがStreamProviderなので
userProviderをウォッチすると自動的にStreamが生成されます。Stream中のUserオブジェクトを拾うためには以下のようにwhenを使う必要があります。
二つ目の必要なStreamはroomsTileViewModelStreamです。
このStreamはバックエンドのデータが変わるたびに新しいRoomTileViewModelのリストを返します。RoomsFeed内には複数のRoomTileがありRoomTileのビューモデルがRoomTileViewModelです。
このRoomTileViewModelのリストもuserStreamと同じように使用してRoomTileのリストを作ります。
roomsTileViewModelStreamにデータがあるときにListViewを作成します。ルームからユーザーが入退室した時などにデータが変わるのでその都度新しいリストが再描画されます。
画面右上の現在ログイン済みユーザーのアイコンや下のルーム作成ボタンなどがありますが説明すると記事があまりにも長くなってしまうので割愛します。右上のアイコンはuserStreamから作られ下のボタンは普通のボタンです。興味のある方はロジックはGitHubからご覧ください。
ルーム詳細のUIの作成
ルームのリストから特定のルームを選択するとルームの詳細画面に移ります。その画面からルームに参加及び退出することができます。
この画面が依存している情報はルーム名、参加者リスト、そして現在使用中のユーザーが参加しているかどうかです。ルーム名が変更されることはありませんが参加者リスト及び自身の参加状態は随時変更しますのでルーム情報をFirestoreから常に参照して変化があればUIにそれを反映させなければいけません。
上記のfirestore_service.dartの//6とコメントされた部分で特定のルームに関するバックエンドにある情報をアプリ側に流します。
RoomDetailViewModelの全体ロジックは例の如くGitHubからご覧ください。以下に抜粋部分を掲載します。
RoomDetailViewModelに関しては二箇所説明します。
一つ目はisParticipatingの関数です。
この関数は現在アプリ使用中のユーザーが表示中のルームに参加中であればtrue,不参加であればfalseを返します。この返り値を用いて画面下方のボタンの文言を変えます。(Join roomもしくはLeave room)。
二つ目はjoinChannelの関数です。
これはアプリ内からAgoraとの接続を担当するAgoraServiceクラスのjoinChannelを呼び出します。AgoraServiceのコードは直接riverpodとは関係ないので割愛しますがagora.ioのバックエンドと繋がっていて表示中のルームの音声チャンネルに参加者の入退室、ミュート設定や現在発声中の参加者の特定などができます。参加者の入退室に成功もしくは失敗するとコールバックが呼び出されるのでroom_detail_view_model.dartでjoinChannelを呼び出すときにコールバックも設定します。AgoraServiceに関してはGitHubからご覧ください。
ルームに入室時のデータフローは以下の図のようになります。
解説すると上の画像での1はユーザーがボタンをタップすることによって開始されます。AgoraProviderはtop_level_provider.dartで定義されてますのでアプリ全体から呼び出せます。上の画像での4がなされた時にAgoraServiceに内包されているAgoraRTCEngineからの音の共有が開始されます。上の画像での9ではFirestoreのデータが改変されるので改変されたデータを反映する新たなRoomDetailViewModelのインスタンスが生成されそれによってRoomDetailScreenが再描画されます。
さてこのロジックフローの最後は実際に表示されるRoomDetailScreenですね。ファイル全体はGitHubからご覧ください。
ルームにいる参加者は刻々と変わっていると思われるので特にStreamとの相性がいいデータです。
RoomDetailScreenではコンストラクターでusersStreamProviderを作ってしまいます。usersStreamProviderはRoomDetailViewModelをバックエンドでのデータが変化するたびに提供します。RoomDetailViewModelは参加者のリストを内包しているので参加者の入退室のたびに新しいRoomDetailViewModelが再作成されます。ここでは解説しませんが参加者のミュート情報、発言状態などはfirestore_service.dartでの//7で示されたparticipantStreamで拾われてSpeakerTileViewModelに反映されます。
usersStreamProviderからきたRoomDetailViewModelは以下のようwhenを使いUIを作成します。
このRoomDetailScreenクラスではもう一つStreamを使います。
このStreamは二箇所で使われています。画面右上にプロフィール画面に行くボタンとルーム参加時に「Leave room」ボタンの右側に表示されるミュート情報です。プロフィール画像はあまり変わりませんがミュート情報などは刻一刻と変わりますのでこれもStreamで取得するのが適しています。幸いuserProviderはtop_level_providers.dartで定義されてるのでアプリのどこからでも使用することができます。
RoomDetailScreenの解説は以上です。
Agoraに関する問題など
これでAgoraとriverpodを合わせて利用する解説は概ね終わりです。まだ解説してないところもありますがGitHubでコードをご覧になれば理解できるかと思います。
agora_rtc_engineに関しては残念ながらいくつものバグがあり随時改善されているのですが現在のところでは以下の二点のバグを確認しています。
1. ミュートした時にAgoraからコールバックが呼び出されない - これによってRoomDetailScreenでのユーザー自身及び他の参加者のミュート表示がうまく動かなくなっています。ロジックはあるのでテストしたい時は直接SpeakerTileViewModelでisMuted関数から直接変更ください。
2. 自身及び他の参加者が喋ってるかの判定がなされるアゴラからのコールバックが呼び出されない - これによって発言者のタイルの右上に吹き出しが出るロジックがあるのですがこれもうまく動きません。テストのためには1と同じようにSpeakerTileViewModelから変更してください。
Riverpodに関する総論
ここからではriverpod, providerについて私見を述べていきます。パッケージ名としては全て小文字のriverpod, provider,フラター内で使用しているクラス名ではコードに準拠してProvider, Streamなど頭文字大文字で表記していきます。
riverpodは結論から言えば使いにくいです。私は今までフラターを使う時はproviderを使っていたのでriverpodへの移行は結構スムースに行く方だと思ったのですが苦戦しました。苦戦したポイントは以下です。
1. riverpodでは全てがStreamなので慣れるのが大変 - 基本的にアプリ使用中に変わる可能性のある値はStreamでラップしてある方がいいです。これは結構コードを書くときに慣れてない時は時間がかかります。しばらく経つと思考の中に自然にデータの値の変化からViewModelの変化、そしてUIの変化などが出てきますが...
2. riverpodではStreamから内部の値を剥がすのが面倒 - 例えば変化のない値を内包しているプロバイダーでさえウォッチして.whenで値の中身を見ないといけません。これは非常に面倒です。providerであればConsumerもしくはcontext.readを使えるのでだいぶ単純ですよね。
3. riverpodではProviderの合成が大変 - このコードベースでは以前はProviderの合成をやっていました。(Gitの履歴を見てください。)簡単にできればいいのですがProviderにはStreamProvider, Provider, StateNotifierProviderなど複数の種類があります。変化する値を内包するにはStreamProvider、あまり変化しないのであればProviderなどと使い分けられると思います。これらの合成はわざわざrxdartというパッケージを利用していちいちコールバック内で処理する必要があります。
この問題はprovider、riverpod共通です。Providerの種類が多くて何を使えばいいのかわからないということもあります。providerでは基本的にNotificationProviderだけ使ってればアプリはかけました。今回綺麗にriverpod使うのであればStreamProvider、Providerなど使い分ける必要があるのであれば悩みの種が一つ増えます。
難解なので前身のproviderの方が相当使いやすかったですね。これは慣れているからというよりはproviderはFlutterのウィジェットに組み込まれ直感的なので初回でもだいぶ楽だった気がします。というわけでriverpodはあまり主流のステート管理手法にはならないのでしょうか。しかしproviderは今後アップデートされないでriverpodへの移行が推奨されるのでしょうね。これはフラター全体にとってフラターの人気を左右する由々しき問題だと思うので誰かがproviderをメンテしてくれることを望みます。