torikatsu.dev

Flutterとかプログラミングとかガジェットとか書きます

【Flutter】MVVMなアーキテクチャで初期化処理をしたい!

こんにちは、とりかつ(@torikatsu923)です。 私は趣味でFlutterアプリの開発をしていて、MVVMライクなアーキテクチャをriverpod+StateNotifier+freezedで状態管理をしています。

この状態管理手法は非常に便利ですが、初期化処理をする際にすこし詰まりました。 そこで、今回はMVVM的なアーキテクチャを目指すときの画面の初期化処理のやり方について書いていきます。

はじめに

Flutterでアプリを開発していると、「画面表示時にサーバからデータの取得をしつつ初期ローディング画面を表示したい」みたいなケースが結構あります。 このケースの例としてYoutubeアプリが挙げられます。 Youtubeアプリはアプリを起動するとホームが表示されます。 最初にホームが表示されるときは初期ローディングエフェクト(Shimmer)が表示されて、データの取得が終わると動画一覧が表示されます。

f:id:torikatsu923:20210530004824p:plain
初期ローディング(shimmer)

f:id:torikatsu923:20210530004850p:plain
初期データ取得後画面

大したことなさそうに見えます。 とりあえず動くものを作ろうとすると、FutureBuilder使って初期ローディングを行うだけです。

一見問題なさそうに見えますが、MVVM的なアーキテクチャを目指そうとした時、以下のような課題感があると思いました。

  • StateNotifierを経由せずにUIの更新を行っている
  • UIにエラーハンドリングの詳細ロジックが漏れる

StateNotifierを経由せずにUIの更新を行っている

UIは常にStateNotifierが持っている状態をもとにUIのレンダリングを行うべきです。 もう少し抽象的な言い方をすると、UIは状態を引数に取る関数によって構築されるべきです。 この考えはFlutterのdocsでも述べられています。

https://flutter.dev/assets/development/data-and-backend/state-mgmt/ui-equals-function-of-state-54b01b000694caf9da439bd3f774ef22b00e92a62d3b2ade4f2e95c8555b8ca7.png

上記の例ではStateNotifierの状態の変更とFutureBuilderのfutureの結果の2つの要素によってUIが構築されます。 上記の例は状態によってUIを管理できておらず、将来的にアプリケーションの状態管理が複雑になることが考えられます。

UIにエラーハンドリングの詳細ロジックが漏れる

上記の例ではinitialize()がどういった値を返すのか、どういったエラーをハンドリングする必要があるのかをUI側のコードに記述する必要があります。 これは「画面の初期化処理」というイベントのロジックそのものと考えられます。 StateNotifierを採用してMVVM的なアーキテクチャを目指す一つの理由に、UIとロジックの分離が挙げられます。 これより、「画面の初期化処理」はViewModel側(StateNotifier)に記述し、「初期化処理」によって生み出された「状態」によって以下の画面を出し分ける必要があります。

  • 初期ローディング画面
  • 初期化処理完了後画面
  • エラー画面

課題解決の方法

前述の課題を解決するためには以下を達成する必要があります。

  • 画面の出しわけを状態に基づくようにする
  • 初期化処理をUI構築時に呼び出せるようにする

これらを実装に反映させていきます。

画面の出しわけを状態に基づくようにする

初期ローディングはbool initializingという状態として考えることができます。 なので、StateNotifierが管理する状態にinitializingというフィールドを追加し、UI側を以下のように変更します。 また、エラーは「エラーが起きている」という状態として考えることができます。 なので、Error errorというフィールドを追加します。 そしてUI側のコードを以下のように変更します。

UI側のコードが状態飲みに依存するようになり、どのような状態の時にどのようなUIが構築されるのか見通しがよくなりました。

ここでは2つのフラグによってUIを管理していますが、enumでUIの状態の種類を定義し、switchを使用する方法もあると思います。 このとき、それぞれのフラグの概念をenumにまとめていいかどうか注意する必要があります。

初期化処理をUI構築時に呼び出せるようにする

初期化処理をUI構築時に呼び出す方法にはいくつかあります。 とりあえず以下のような方法を思いつきました。 (これらは全てアンチパターンです。課題感の前提を共有するためにあえて流れを追うように書いていきます。)

  • buildの最初に呼び出す
  • StatelessWidgetのコンストラクタで呼び出す
  • StatefulWidgetのinitStateで呼び出す

しかし、これらは全て失敗します。 まず、buildの最初に呼び出す方法ですが、これだとWidgetがrebuildされるたびに初期化処理が走ります。 そのため、状態に変更が入るたびに初期化処理が発火してしまいます。 初期化処理はウィジェットのライフサイクルに合わせたいため、この方法では目的が達成できません。

次にStatelessWidgetのコンストラクタで呼び出す方法を試しました。 コンストラクタ内でinitializeを呼び出す時ProviderContainerを使用してStateNotifierを取得します。 しかし、コンストラクタでProviderContainerが参照するStateNotifierとbuild内でcontextを使用して参照するStateNotifierは別のインスタンスです。 コンストラクタ内で初期化処理を行ってもbuild内で参照するStateNotifierは初期化処理が行われていないため、失敗します。

次にStatefulWidgetのinitStateで呼び出す方法を試しました。 initState内ではcontextを参照することができません。それゆえ、この方法は失敗します。 contextを参照したいときはinitState直後に呼ばれるdidChangeDependenciesを利用するそうです。

以上の失敗を踏まえ、以下2つの方法が候補として考えられます。

  • StateNotifierのコンストラクタで呼び出す
  • didChangeDependenciesで呼び出す

StateNotifierのコンストラクタで呼び出す

以下のようにして呼び出します。

class ViewModel extends StateNotifier<SomeState> {
  ViewModel(): super(SomeState()) {
    _initialize();
  }

  Future<void> _initialize() async {
    ...
  }
}

これはうまく機能します。 初期化処理はViewModelのライフサイクルの開始時に行いたいため、StateNotifierのコンストラクタで初期化処理を行うのは理にかなっています。 また、初期化処理をViewModel内に閉じ込めることもできます。 ですが、コンストラクタ内で非同期に状態を変更する関数を呼び出すため、ユニットテストがしづらくなります。

didChangeDependenciesで呼び出す

didChangeDependenciesではcontextを参照できるため以下のように初期化処理を行うことができます。 これにより、ページのライフサイクルの開始時に合わせて初期化処理を発火させることができます。 さらに、ViewModelのユニットテストが容易になります。 ですが、ViewModelの初期化処理を外部に公開する必要があります。

この2つの方法はどちらもメリットでメリットがあるので、どちらを選択するかは好みかと思います。 私はユニットテストをしやすくしたいため、didChangeDependenciesで初期化処理を呼び出す方が好みです。

これで以下の課題を解決することができました🎉

  • StateNotifierを経由せずにUIの更新を行っている
  • UIにエラーハンドリングの詳細ロジックが漏れる

おわりに

今回の記事では、MVVM的なアーキテクチャを目指すときにどのように初期化処理を行うかということについて述べました。 目指すアーキテクチャの姿は人それぞれであり、正解はありません。 もしこっちの方法がスマートになる!みたいなものがあればコメントにて教えていただけると幸いです。

それではよい開発ライフを!