Studyplus Engineering Blog

スタディプラスの開発者が発信するブログ

Flutterの状態管理とViewの更新

こんにちは。モバイルクライアントグループの若宮(id:D_R_1009)です。 最近スタンディングデスクを導入しました。業務時間中はずーっとスタンディング状態で、疲れたら業務終了な感じでやってます。

スタディプラスでは一部のプロダクトでFlutterを採用しています。 社内では私がFlutterの開発経験が一番多く、また長くなっているので技術選択などを行っています。

先日、新たにFlutterのアーキテクチャを選びなおす機会がありました。 アーキテクチャを比較するにあたり、社内向けに書いたブログを一部編集して公開します。

はじめに

このブログでは、複数のアーキテクチャを比較しています。 しかし、「このアーキテクチャが最高」といった議論を目的とはしていません。

アーキテクチャはアプリケーションの実現したい事柄や、開発チームの構成、技術的な特性に強く依存します。 あくまでも、私が所属しているチームで、取り組んでいるアプリケーションでは今回の意見になったことを書いているに過ぎません。

Flutterは正式リリースから日が浅く、日進月歩で変化しているプラットフォームです。 そのため本ブログの意見は執筆当時、つまり2021年8月上旬の意見となります。 FlutterやDartの今後の発展によっては、全く異なる結論になる可能性もあります。

あらかじめ、上記の点についてご了承ください。


Flutterによる大規模なアプリを開発をする上で、避けて通れないのがアプリの状態管理です。 単純なアプリでは力技で解決できますが、大規模なアプリでは致命的なバグの原因となることもあります。 このため、力を入れて検討せざるを得ない領域です。

公式ドキュメントでも、もちろん状態管理について触れられています。 もしもFlutterの開発を初めて、そして一人で行うのであれば、まずこのドキュメント通りに実装してみるのが良さそうです。

flutter.dev

AndroidやiOSなど、その他のプラットフォームのように、Flutterでもさまざまな状態管理手法があります。 下記ページでは、そのうち代表的なものが網羅されています。*1

flutter.dev

今回は、このうち3つを取り上げます。

  1. StatefulWidget
  2. Provider (InheritedWidget)
  3. Riverpod

StatelessWidget

状態を扱う話をする前に、状態を扱わないWidgetを考えます。 状態のないWidget、つまりStatelessWidgetのことです。

Flutterでアプリを作るとき、StatelessWidgetを使わないということはまずありません。

api.flutter.dev

StatelessWidgetはその名の通り、状態を保持しません。 生成時に与えられた状態をただ描画するだけの、シンプルなWidgetです。 StatelessWidgetは自身、そして子要素の描画のみを考慮します。

なおFlutterにおける描画コストは、AndroidやiOSのView(のOpenGLキャッシュ)における描画コストの議論と同じになる印象です。 簡略化すれば、「再描画が最小限」であることと「再描画時にキャッシュが使えるなら使う」、この2点を考慮するだけです。

Widgetのレンダリングについて理解したい場合は、次の解説をお勧めします。 一読して完璧に理解するのは難しいので、期間をとったり、Flutterの開発経験を積んでから読み進めましょう。 *2

medium.com

なお、続いてStatefulWidgetの解説に進んでみると、Flutterに対する基本的な理解が深まります。 こちらも、時間をかけてしっかり理解しておきたい知見です。*3

medium.com


参照した解説にある通り「リビルド回数にこだわる」、「アーキテクチャに強く拘る」ことをしても、パフォーマンスが必ず上がるとは限りません。 現実的にはconstのつけ忘れを防ぐため、flutter_lintsを導入し、指摘箇所を全て対応する方が良いと断言できます。

私見ですが、アーキテクチャをなぜ慎重に選定するかと言えば、これらの事情を熟知してなくとも良いアプリケーションを作るためです。 このため、これからの記述は次の点を少しだけ意識しています。

  • WidgetのTreeに対して、どのような向き合い方をしているか
  • 多人数で開発した時に、どのようなメリットがあるか
  • アプリが(当初の)想定よりも育った時に、問題が起きないか

StatefulWidget

api.flutter.dev

flutter createした時に生成される、最も簡単な状態管理方法です。

StatefulWidgetはsetStateメソッドを呼び出すことで自身の再描画を要求できるWidget(意訳)です。 このとき、StatefulWidgetのプロパティは再描画前から引き継ぐことができるので、Viewの状態を保持できるという仕組みになります。 画面回転時に保持されるので、retainしたFragment(Android)やUIViewController(iOS)のようなイメージを持つと良さそうです。

アプリが単一の画面のみを持つ場合、StatefulWidgetは選択肢に入ります。 また「絶対にページ遷移しない」ような、末端のViewであればStatefulWidgetを利用しても良さそうです。 *4


などと断言したいのですが、現実ではStatefulWidgetを扱わなければならないシーンがあります。 だいたい、下記のようなケースです。

  1. StatefulWidgetにmixinするObserverが存在する
  2. TextEditingControllerやAnimationControllerといったViewを更新するControllerが存在する

StatefulWidgetにmixinするObserver

たいていWidgetsBindingObserverです。

FlutterのWidgetは、全画面を覆っていてもAndroidやiOSのようなライフサイクルを持ちません。 全画面を覆っていても、単なるStatefulwWidgetやStatelessWidgetであって、単なるFlutterのWidgetであるためです。

しかし、アプリをバックグラウンドに移動するなどの「アプリケーションの、スマートフォン的な状態」を確認する必要が発生します。 その時、StatefulWidgetを前提としたObserverが必要となってきます。

Viewを更新するController

リアルタイムで文字を入力したり(TextEditingController)、Tabにより画面を切り替えたり(TabController)するとき、Viewを更新するControllerが必要です。 このContollerは状態として管理する必要があり、最もシンプルに管理するケースではStatefulWidgetを利用します。


StatelessWidgetは「どのタイミングでもリビルドされても良い」ようにする必要があります。 というのも、StatelessWidgetのbuildメソッドは「1回しか呼ばれない」ことが保証されていないためです。

twitter.com

このため、プロパティで状態を保持できないStatelessWidgetでは前述のControllerを保持できません。 StatefulWidgetではbuildメソッドはsetStateが呼び出されるたびに実行されるものの、クラスのプロパティとしてControllerを保持できます。


なお「Viewを更新するController」について、明確に「このように対応できる」とされているアーキテクチャは少ない印象があります。

Provider (InheritedWidget)

pub.dev

blog.uhy.ooo

現在、Flutterのアーキテクチャガイドにおすすめとして掲載されているのがProviderです。 ProviderはInheritedWidgetのラッパーライブラリです。 そして、パフォーマンスの劣化が抑えられるように設計された機能が追加されています。

InheritedWidgetとは

InheritedWidgetは、FlutterのWidgetの1つです。 詳細は公式の動画を確認して欲しいのですが、Flutterにおいて「親の持つ要素に子がアクセスする」用途で生成されたWidgetになります。 Treeの深さによらず、アクセスした際の計算量はO(1)です。

Flutterにおける利用シーンは非常に多く、Theme.of(context)Navigator.of(context)のように、アプリ内ではさまざまな箇所で利用されています。 *5 InheritedWidgetを継承したクラスでは、自身が変更された際にViewを更新、つまりViewをリビルドさせるようにするよう通知できます。 この仕組みを利用することで、共有されている状態を更新した際、更新結果を表示に反映させることができます。

ProviderはInheritedWidgetのラッパーライブラリであるため、InheritedWidgetの特性をそのまま引き継ぎます。 その上でdispose処理やlazy-loadingなど、便利な要素が追加されています。

Providerにおけるアーキテクチャ

Providerは非常に評価が高いため、色々な資料があるのも魅力です。 今回は本当にシンプルにProviderを使用した例を考えてみます。

実装例です。

class App extends StatelessWidget {
  const App({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      home: ChangeNotifierProvider(
        create: (_) => HomeViewModel(),
        child: const HomePage(),
    );
  }
}

class HomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final viewModel = context.watch<HomeViewModel>();
    return Scaffold(
      appbar: Appbar(
        title: const Text('Sample'),
      ),
      body: Center(
        child: Text('count: ${viewModel.value}'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          context.read<HomeViewModel>().countUp();
        },
        child: const Icon(Icons.add),
      ),
    };
  }
}

class HomeViewModel extends ValueNotifier<int> {
  HomeViewModel(): super(0)
  
  void countUp() {
    value += 1;
  }
}

複数の状態に依存、もしくはHomeViewModelが何らかのRepositoryに依存する場合には、HomePageを括るProviderを追加することになります。 複数のProviderを提供するケース用にMultiProviderが用意されているので、そちらを利用します。


Providerの課題は、アプリ内で扱うModel層が大きくなったり、View層が増えたときにProvideする要素を記述する箇所が肥大化することです。 これを避けるため、Scaffoldに対応するようなロジックがまとめられた、Androidの文脈からViewModelと名付けられるクラスが作成されがちです。 Provideするクラスを増やすことを避けるため、小さなViewModelを作りたくなくなる、と表現するのが良いでしょうか。

Scaffoldをまとめるような状態は複雑になりがちです。 端的に言えば、ValueNotifierを利用できるシーンは、そう多くありません。 結果としてChangeNotifierを利用することとなります。

Providerのアーキテクチャとしては、Provider + StateNotifier + freezedが有名です。 全画面のView(Scaffoldを含むView)に対応するViewModelを生成し、その「画面の状態」を表現するデータクラスを作成します(freezedを利用)。 そして生成したデータクラスが更新された時、ViewModelから更新通知を扱う(state_notifierを利用)ようにすることで、ViewModelがViewの状態を管理できるようにするものです。

ViewModel内にViewで扱うロジックを閉じ込めることで、ViewがViewModelの影になるように実装します。 この辺りの切り分けは、AndroidのViewModelによる実装をした時とそう変わるものではありません。

ViewModelの再考

Model-View-ViewModelは、WPFで最初に提唱されたアーキテクチャです。 その後、AndroidのAndroid Architecture Componentsを経て、Jetpack推奨アーキテクチャとしてAndroidに導入されています。

AndroidにおけるViewModelは、筆者の理解では2つの点を解決しています。

  1. DataBindingによる、データの変更をViewに反映させる仕組み
  2. Activity/Fragmentのライフサイクルを再整理し、Viewの状態とロジックを切り離す仕組み

WPFにおいては、1はXAMLで解決されていました。 では、Flutterにおいてはどうでしょうか?

命令的UIであれば「ViewModelの状態が更新されれば、状態に合わせてViewが更新される」仕組みを採用することになります。 AndroidであればLiveDataDataBindingにより、ライブラリ側でデータ変更時のView更新をおこないます。 *6

宣言的UIであれば「ViewModelの状態が更新されれば、Viewのリビルドが走る(最新のViewが表示)」仕組みを採用することになります。 FlutterであればViewModelの通知に応じてbuildメソッドが走り、変更のないViewはキャッシュが使い回され、変更のあるViewは新規に生成されます。

Flutterは状態の更新がある時にViewを再生成する仕組みを備えているので、MVVMに切り分けた時のView更新を何らかのライブラリやアーキテクチャに頼る必要がありません。 また、状態をViewへ反映させる仕組みは命令的UIと全く異なったものになります。


2を考えるためには、FlutterとAndroidやiOSなどのプラットフォームとの関係を考える必要があります。 FlutterはAndroidであればActivity、iOSであればUIViewControllerを「全画面で覆った」Viewでしかありません。

画面回転やforeground/backgroundの状態遷移は、Flutterというフレームワークが解決しています。 そして(実の所)「あるWidgetを表示したとき」に「あるWidgetの状態が初期化される」ということはありません。 Providerであれば、Providerのdispose処理により実現されているだけとなります。


以上をまとめると「FlutterにおけるMVVMは、AndroidにおけるMVVMとは別物」となりますし、当然「iOSにおけるMVVMとは別物」となります。 ゆえに、AndroidにおけるベストプラクティスであるMVVMは、Flutterにおいてはアーキテクチャの1選択肢となります。

Riverpod

riverpod.dev

RiverpodはProviderの作者(freezed、state_notifierやその他多数)が開発したライブラリです。 Providerとの最大の違いは、ProviderがInheritedWidgetに依存していたのに対し、RiverpodはInheritedWidgetの仕組みを独自に作り直しているところです。 実装する上では、Provideしたい状態がProvideされているかどうかをコンパイル時にチェックできる点や、DartのクラスにDIするのがDartの世界で完結するなどの利点があります。

Riverpodの使用方法はProviderとほぼ変わりません。 とは言えRiverpod用のWidgetを継承する必要があったり、Provideするためのコードをglobal variableとして記述するなどの違いがあります。


実装例です。

void main() {
  runApp(
    ProviderScope(
      child: const App(),
    ),
  );
}

class App extends StatelessWidget {
  const App({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      home: const HomePage(),
    );
  }
}

class HomePage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final counter = ref.watch(counterProvider);
    return Scaffold(
      appbar: Appbar(
        title: const Text('Sample'),
      ),
      body: Center(
        child: Text('count: ${counter.value}'),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          ref.read(homeViewModelProvider).countUp();
        },
        child: const Icon(Icons.add),
      ),
    };
  }
}

final counterProvider = ChangeNotifierProvider(
  (_) => Counter(),
);

class Counter extends ValueNotifier<int> {
  Counter(): super(0)
  
  void countUp() {
    value += 1;
  }
}

依存関係を整理したいクラスをProviderではProviderMultiProviderで括りますが、RiverpodではProviderScopeで括ります。 そしてcounterProviderをglobal variableとして定義し、状態を取得しています。 Providerの役割が、2箇所に分岐するイメージです。

Riverpodにおけるアーキテクチャ

riverpod.dev

RiverpodのGetting startedページを見ると、flutter_hooksと組み合わせる方法が真っ先に紹介されています。 この理由について、筆者なりの解説をしていきます。


Hooks、ならびにflutter_hooks

medium.com

Hooks let us organize the logic inside a component into reusable isolated units:

Hooksの話を掴むためには、おおまかにReactにおいてHooksがどのような経緯で登場したか、把握しておく必要があります。

blog.uhy.ooo

Providerという名前、そして状態管理から、ReactにおけるContextとFlutterの状態管理が近いことに気づくのではないでしょうか? 複数の状態を管理すると、Providerが肥大化する問題点までほぼ同一です。*7


React Hooksが登場する以前では、ReactではFluxやReduxが状態管理としてよく採用されていた、と筆者は記憶しています。

facebook.github.io

codezine.jp

FluxやRedux(一説によればMVCですが)で実装すると、状態が一箇所に集約されます。 Viewからは状態を更新するためのActionを送信、送られたActionがDispatcherで変換、Dispatcherの結果がStoreに格納され、Storeの通知がViewを更新します。 Viewからロジックや状態が分離される一方、状態が巨大になることや、1つの処理を行うために大量のコードを書く必要がある、そんな問題がありました。

Hooksは、この問題を解決するために登場したものです。 Reactの関数型コンポーネントにロジックを適切に埋め込み、関数型コンポーネントを再利用可能にします。 この時、関数型コンポーネントはロジックや状態の管理にも対応します。

ja.reactjs.org

たとえばuseStateはステート(状態)を、関数型コンポーネント(状態を持たないコンポーネント)で扱えるようにするHookです。 useStateを利用すると、「タップのたびにカウントアップする、テキストボタン」のような関数型コンポーネントを作成できます。 関数型コンポーネントとして適切に切り出すことができれば、そのコンポーネントをさまざまな画面で使い回すことができます。このとき、コンポーネントの内部で処理が完結していれば副作用を気にする必要もありません。


flutter_hooksは、このReact Hooksの開発をFlutterに持ち込むためのものです。

再利用可能なStatelessWidget

ProviderでViewModelを利用した設計にした際、状態は「View全体」を管理していました。 これは、(可能ではありますが)ProviderではViewを構成するWidgetひとつひとつにViewModelを作成するのが困難なためです。 そして、この設計をAndroid/iOSを出身とするエンジニアが好まなかったためです。 *8

ここで、StatelessWidgetを再利用することを考えると、Widgetごとにロジックをまとめるクラスを作成した方が良いでしょう。 問題はどのレベルで状態をまとめるかであり、どのレベルでWidgetを再利用できるようにするかです。

Riverpodは、前述のReactの設計においてはRecoilに近いと筆者は考えています。 それはTreeの末端側から、任意のサイズで状態を持たせる形にComponent(Widget)を切り出し、管理していく仕組みだからです。Riverpodでは、globalなProviderの形で(そしてimport文による読み込み先の指定で)実現されます。

Viewを更新するController

flutter_hooksには、Viewを更新するControllerを提供する仕組みがあります。 特にFlutterのSDKに含まれているようなTabControllerやTextEditingControllerは、flutter_hooksでHookとして実装済みです。

Riverpodはflutter_hooksと合わせて利用することで、「Viewを更新するControllerの管理」の問題に対応できます。 少し甘めに解釈して良いのであれば、Riverpodはflutter_hooksとセットであることを主張しているので、この課題に対応していると言えます。

状態管理について

本項のまとめと、意見です。

Flutterにおける状態管理は多数あり、その選択は多様です。 はじめに書いたように、どれも「実現するべきアプリ」や「開発チーム」によって選択されるべきです。 もしもReactとReduxによる開発に慣れているチームであれば、Flutter向けのReduxを採用するでしょう。

今のところ、Flutterではデファクトスタンダード(Provider)はあれど、それ以上はありません。 *9


筆者の意見としては、まずはデファクトスタンダードを検討するのが一番です。 それは経験者が多く、既知の問題への修正やワークアラウンドが多いためです。

しかしながら、Providerで大規模なアプリを作ると、途中でProvideするクラスを追加するのが辛くなります。 このためTreeの末端に位置するような機能を独立して作る際には、Providerは最適と判断します。 一方で、アプリ全体をProviderで対応するのは避けたいと感じています。

アプリ全体を管理した方が良いのはRiverpodである、というのが今の意見です。 Riverpodをflutter_hooksと組み合わせ、再利用可能なStatelessWidgetを中心に添えた設計をするのが、最も効果的だと判断しています。

アプリケーションの開発においてレイヤードアーキテクチャも採用します。 これはモバイルアプリの開発経験からくるものです。 AndroidやiOSは、端末内のFileシステム上に作るDBを利用することが多く、その関心の分離にはレイヤードアーキテクチャが武器となるためです。

アプリの中に複数のRepositoryがあるとき、そのRepositoryへの処理をViewで直接記述するのはどうでしょうか? Viewで複雑な処理を書くと、Viewの改修時にロジック(ビジネスロジック)への影響を考慮する必要が出てしまいます。


これらの事情を踏まえ、FlutterのアプリケーションでMVPアーキテクチャを参考にするのが良いと判断しました。 簡単に記すと、下記のような整理です。

  • Model
    • Entity
    • Repository
    • (Firebaseなどの)外部SDK
  • View
    • StatelessWidget + flutter_hooks
  • Presenter
    • Model層の操作(ユースケース)を実装
    • アナリティクス送信などのロジックを実装
    • Viewの操作に対応するビジネスロジックを実装

現実的には、実装する機能によってこの構成にならない箇所が発生します。 その場合は、時々に応じて制約を緩めていくのが良いと考えています。 参考までに、簡単なサンプルを作りました。 一例として、参考になれば嬉しいです。

github.com

おわりに

非常に長いブログとなってしまいました。 最後まで読んでいただき、ありがとうございます。

本ブログに対するコメント、お待ちしております!

*1:全部を試すのは、ちょっと大変そうです。

*2:難しかったです。

*3:Androidでいう、Androidを支える技術のようなものです。

*4:Stateful Widget のパフォーマンスを考慮した正しい扱い方に「Stateful Widget の不適切な使い方はパフォーマンス劣化や思わぬバグを招きます」とある通りです。

*5:確かに、Themeをどこからでも定数時間で取得できないと、確かに描画が遅くなりそうです。

*6:この時、Viewのインスタンスは更新されません

*7:あくまで私見です。

*8:最近ではWebやDesktopもサポートしていますが、それまではAndroidやiOSのアプリを作るためのものだったためです。

*9:このブログの公開日に、新しい話が起きても不思議ではありません。