個人の意見です。 おそらく、下の3つのドキュメントの影響を強く受けています。
自分は最近ではWebもちょこちょこやっているけれど、AndroidやiOSのアプリを多く作っています。 主にFrontに分類される領域の経験が長く、それ以外だとFirebase FunctionsとかFirestore程度しか触っていません。 なので、意見を持っているのはFront側からの視点です。
(主に)モバイルアプリケーションを書いていると、いわゆる「状態」や「状態の管理」というワードを目にしたり口にしたりします。 いくつかのベストプラクティスと呼ばれるものや、フレームワークが推奨されるものなどがあるなか、結局自分達のチームで考察を深めなければならないものです。 今回は、自分が経験上「こうやって考え方を整理すると良いのでは」と思っていたり、レビューの時に「ここはこの視点で見るか」と思っているものを紹介する、みたいな記事にします。
その状態はどこが正か
まず、どういったアプリケーションのことを考えるか、から絞り込んでいきます。 自分の感覚になりますが、おおよそ、モバイルアプリケーションは次の3つに分類できます。
- 不揮発性記憶装置に置かれたデータを、ユーザーにいい感じに表示するもの
- ユーザーが操作する端末上で、ユーザーがデータを操作するもの
- ゲーム
今回話題にするのは、1つ目のものです。 *1 これらのアプリケーションは、言ってしまえば、JSON色付け係の世界です。 サーバーの奥にあるDBに保存されているデータを引っ張ってきて表示し、操作結果をDBになんやかんやして保存する世界です。
2つ目は、端末の機能や性能に依存するアプリケーションを想定しています。 *2 Telegramなんかはどうなんでしょう、みたいな話をするとアラが目立つので深入りはしませんが、1つ目のアプリケーションよりも数は少ないように思います。 3つ目はヘビーでもカジュアルでも、ゲームは纏めにくいが故にひとまとめにしておきます。
その状態は、Frontのものかそうではないか
アプリケーションの中で発生している状態は、結局「DBの(部分)コピー」と「UIの操作状態」、そして「DBの(部分)コピーをUI操作したもの」に分類でき(ると思い)ます。 「UIの操作状態」の具体例は「TextFieldの文字列」や「ドラッグ中の項目」、「DBの(部分)コピー」は「タイムラインのデータ」や「Push通知を受け取るかどうかの設定」などです。 混在状態としては「通知の受信設定を変更中」であったり、「過去の投稿を編集中」であったりを想定しています。
Flutterで状態管理と呼ぶ範囲を考えると、これら3つが混ざります。 この混ざった状態をどう分類するかの違いが、アーキテクチャの選択に繋がります。 それ故に、アプリケーションの設計から実装まで、アプリケーションにどういった状態が入り込むかが影響します。
Frontの状態をどう管理するか
Flutterの公式ドキュメントや、さまざまなサンプルコードで紹介される「状態」は、大抵の場合Frontの状態です。
別の言い方をすれば StatefulWidget
や StateNotifier
、 useState
のいづれを使うのかといった議論が綺麗に成り立つのは、Frontの状態のみに関心があるケースです。
もしかすると、ViewModel
を作って Provider
で画面ごとに管理するケースも、この分類をするべきかもしれません。
私は、setState
の影響を適切に管理することは難しい*3ため、StatefulWidget
の利用は避けたいという立場です。
Controllerの管理が苦手です。初期化と dispose
処理のタイミングを確認しつつ、setState
による build
メソッドの呼び出しがあるかないかなどを考慮するのが、煩わしいなと思っています。
これはnullableにしても、late
による遅延初期化をしても同じ問題があると思っています。
このためチームの状況が許すのであれば、 use
によるControllerの管理を第1の目的に、flutter_hooksを利用するのが良いのではないかと思っています。
Toggle Buttonのように、1つのWidgetの中で1つの状態を管理するだけであれば、StatefulWidget
や useState
のような、Widgetに強く結びつく状態管理が便利です。
しかし複数のWidgetで1つの状態を共有したり、あるWidgetで変更した状態が他のパラメーターに変更を与える場合、話が変わってきます。
例えば「居住地が海外になると、都道府県の項目が未選択になる」ような仕組みの実現です。
このとき、StatefulWidget
や useState
では、Navigator.push
の戻り値をWidget上でハンドリングすることとなり、拡張性が低く複雑な実装をすることになります。
こういった問題、つまり A Widget
の状態を B Widget
に適切に反映させるため、ProviderやRiverpodなどの仕組みを使うことになります。
自分の理解では、使うツールに対する意見に違いはあれど、この考え方自体は共有されています。
*4
この考え方を拡大していくと、アプリケーション全体の状態とパーツの状態との関係性を整理することになり、通信を行うインスタンスの管理などの話に進んでいきます。
Backの状態をどう管理するか
FirestoreのようなmBaaSを利用している場合を除くと、モバイルアプリケーションが直接DBを操作することはありません。 ここではHTTP GETを利用して、DBに保存されたデータをアプリケーションが取得するケースを考えます。 *5
「DBから必要なデータを必要なタイミングで、必ず取得する」ケースが、最もシンプルなパターンです。 RESTful APIで作られているHTMLアプリケーションのように、その表示するのに必要な情報だけが「画面レベル」で保持され、それ以上にBackの状態を保持しません。 このパターンは「画面が閉じられれば」データが破棄されることとなり、Backのデータは必要な時に必要なだけ保持されることになります。
あらゆる画面を開いた時に通信が発生したり、何かするたびにどこかしらが読み込み中になると、ユーザー体験が悪くなります。 このため、多くのアプリケーションでは、何らかの方法でBackの状態をモバイルアプリケーション側で保持します。 自分の理解では、画像のキャッシュが代表的な保持されているデータです。 これらのデータは、「通信処理を削減したり、レスポンスを高速化する」ためにキャッシュされるものとなり、DBに保存されているデータをそのまま保持します。
次の段階は、Backの状態を戦略的に保持することになります。 一度取得したら1年間は変更されないデータなのか、日付が変わるまでは有効なデータなのかなどの事情を考慮し、モバイルアプリケーション側に(Backの)状態を保持することになります。 そして、「どう保持するか」は「どう破棄するか」を考えることなります。
Androidのアーキテクチャでは、これらRemoteとLocaleのデータをViewModelが意識しなくて済むように、Repository層を置くことが多くあります。 これらのRepositoryでは、RoomやStoreなどを駆使することで、アプリケーションが利用しやすい状態でデータを保持します。
Androidの手厚い実装に比べると型落ち感は否めませんが、Flutterにおいてもdriftはhiveなどを利用することで、これらの処理を実現することはできます。
もしもRepositoryのような、UIから「そのデータがRemoteのものかLocaleのものか」意識しなくて済むようなData Layerを作っている場合、そのData Layerがキャッシュの取得と破棄を完璧に管理します。
このため、UIの表示のためにデータを取得する処理は、Repositoryのメソッドを Future
や Stream
の形式で呼び出せば十分になります。
一方で、上記のようなRepository層がない場合、APIリクエストとその結果のキャッシュを別の箇所で行う必要が生じます。 この対応を非常に便利にするのが、RiverpodのFutureProviderやStreamProviderです。 特にFutureProviderは一度取得したデータをキャッシュしたり、任意のタイミングでrefreshできます。 この処理はRefreshIndicatorと組み合わせるなど、UIの処理と任意の方法で組み合わせることもできます。
Frontの都合でBackの状態を変更して保持する
シンプルだった2つの領域を重ねると、話が複雑になります。 というのも、FrontとBackのそれぞれで「良い」と思う方向に寄せることもできれば、混合することもできるためです。 *6
例えば「ユーザーのPush通知受信設定の変更画面」のような、まずAPIリクエストで従来の設定を取得し、その値を初期値として編集画面を表示するケースです。 ユーザーが複数の端末を使っていたりすることを考えると、画面を開く時には必ず最新の情報を取得する必要があります。そして、場合によってはユーザーの操作時に、そのままデータをDBに保存します。 何かを新規作成するケースでは起こりにくいのですが、何かを編集したり更新したりするケースでは頻発します。
AsyncValue
と StateNotifier
の組み合わせは、こういったパターンで有用なケースです。
StateNotifier
を継承したクラスの生成時に AsynvValue.guard
を利用することで、多くのケースでシンプルな実装を行うことができます。
なお、Riverpod 2では AsyncStateNotifier
が作者により検討されています。
I'm thinking about reinventing StateNotifier a bit for Riverpod, and maybe adding an "AsyncStateNotifier"
— Remi Rousselet (@remi_rousselet) April 29, 2022
TL;DR here's a before/after pic.twitter.com/KAiq1MTFxA
AsyncValue
と StateNotifier
の利点は、StateNotifier
であるため、複数の状態の更新に対応しやすいという点です。
場合によっては、再度APIリクエストを行い、DBからデータを再取得することもできます。
また FutureProvider
と useState
を利用することで、それぞれのWidgetに状態を閉じつつ、しかしAPIレスポンスのキャッシュを利用した素早いレスポンスを実現することもできます。
これらの使い分けは「これが正解」というものがなく、難しいように思えるかもしれません。
このため、開発のリーダーやチーム、もしくはメンターなどに相談しながら設計を行う必要があります。
個人的な見解としては、これらの選択肢は、どれにも良い意味でメリットがあります。 それぞれのメリットを議論しながら選択していくことで、より良い選択ができるようになっていける状況は、開発者として好ましい状況ではないかなと感じています。
おわりに
会社のレビューなどで話す機会があったので、考えをまとめてみました。 特に結論があるわけではありませんが、こんな考えもあるんだな程度にみていただき、開発の参考にしてもらえれば嬉しいです。