🍡

【Flutter】Provider + StatefulWidget というパターンの考察

2021/09/25に公開

provider パッケージを使って「状態」と UI を分離する際によく使われるパターンのひとつに 「画面を表す状態オブジェクトを作る」 というものがあるかと思います。

例えば「記事一覧画面」を「記事一覧画面を表す状態オブジェクト」を使って作る場合は以下のようなコードになります。

/// 記事一覧画面を表す Widget (ただし ArticleListPageState を _Screen が受け取れるようにしてあげるだけ)
class ArticleListPage extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return ChangeNotifierProvider(
      create: (context) => ArticleListPageState(),
      child: _Screen(),
    );
  }
}

/// 実際の UI が記述されている Widget
class _Screen extends StatelessWidget {
  
  Widget build(BuildContext context) {
    // context.watch で ArticleListPageState オブジェクトを取得(と同時に変化を検知できるようにする)
    final state = context.watch<ArticleListPageState>();
    
    // ArticleListPageState オブジェクトを使って ListView を構築
    return ListView.builder(
      itemBuilder: (context, index) => ArticleItem(state.items(index)),
      itemCount: state.items.length,
    );
  }
}

/// 記事一覧画面の状態クラス
class ArticleListPageState with ChangeNotifier {
  final items = <Article>[];
  
  // ...
  // 何か記事一覧を取得する処理。
  // 必要であればデータを取得したあとに UI で使うための加工を行う。
}

このパターンは Kotlin / Swift などネイティブでの開発でもよく用いられる MVVM パターン と同じ発想で役割分担ができるため、 Flutter アプリ開発においても(利用する状態管理パッケージによって細かな実装方法は異なるものの)よく見られるパターンだと思います。

しかし、このパターンは Flutter フレームワークの仕組みや特徴を考慮したときにいくつかの不都合や非効率があるように思います。

そして、その問題は Flutter フレームワークの考え方に従ってパターン自体を考え直すことで改善できる場合があります。

この記事では、 「1画面を表す状態オブジェクト」を作ることの問題点と StatefulWidget を使った改善方法 について考察し、そこから発見できた(たぶん)新しいパターンについて説明していきたいと思います。

「画面を表す状態オブジェクトを作る」ことの問題点

このパターンは先述した通り

  • 状態やロジックを担当するクラスと UI を構築するクラスの役割分担がしやすい
  • 他のプラットフォームと同じような考え方で分かりやすい

といういくつかのメリットがあり、それがこのパターンを採用する大きな理由になるのではないかと思いますが、一方でこのパターンを Flutter フレームワークの上で再現しようとすると以下のような問題が発生します。

  1. context の仕組み上 ArticleListPage_Screen という 2 つの StatelessWidget を定義しなければならない。
  2. 不必要にツリー上の Widget の数が増えてしまう

それぞれについて説明します。

2 つの StatelessWidget に分けなければならない問題

ProviderInheritedWidget のラッパーですので、 Provider によって提供される状態オブジェクト(ここでは ArticleListPageState)にアクセスする際は、 context.watch() などを使って ツリーの祖先から状態オブジェクトを探してくる 必要があります。そして、その起点となるのが context です。

context については拙著 「内側」から理解する Flutter 入門 の無料公開部分(チャプター1 ~ 3)で説明しているため詳しくはそちらを読んでいただければと思いますが、簡単にいうと context とは UI を構築するツリーにおける Widget の位置と、他の Widget へアクセスする方法を保持するオブジェクト です。

例えば、冒頭のサンプルコードをツリーの図に直すと以下のようになります。

左側の "Widget" の枠を見ると、ツリーの上(祖先)から順番に ArticleListPageChangeNotifierProvider_Screen と並んでいることがわかります。

ここで、試しに _Screen を用意せず以下のように ArticleListPagebuild() に UI の構築部分までまとめてしまう場合を考えると、そのコードは以下のようになります。

/// 記事一覧画面を表す Widget
class ArticleListPage extends StatelessWidget {
  
  Widget build(BuildContext context) {
    // context.watch で ArticleListPageState を取得したい
    final state = context.watch<ArticleListPageState>();
    
    return ChangeNotifierProvider(
      create: (context) => ArticleListPageState(),      
      child: ListView.builder(  // ArticleListPageState を使って ListView を構築したい
        itemBuilder: (context, index) => ArticleItem(state.items(index)),
        itemCount: state.items.length,
      ),
    );
  }
}

/// 記事一覧画面の状態クラス
class ArticleListPageState with ChangeNotifier {
  final items = <Article>[];
  
  ... 何か記事一覧を取得する処理
}

この場合 context.watch() で使っている contextArticleListPage のもの[1]ですので、そこから .watch で祖先をたどっても(つまり図を上方向にたどっても) ArticleListPageState を保持する ChangeNotifierProvider は見つけられずに ProviderNotFoundException が発生してしまいます。

そのため、 context.watch() で使う contextChangeNotifierProvider よりも下(子孫)にあるものにする ために、 別のStatelessWidget(ここでは _Screen) を定義し、そこでcontext.watch() による状態オブジェクトの取得と具体的な UI の構築をする必要があるわけです。

Flutter においては画面も Widget ですので、 画面を 1 つ追加したければ Widget を 1 つ追加するだけ にしたいところですが、このパターンでは画面を 1 つ追加するために ArticleListPage_Screen という 2 つの StatelessWidget が必要になっています。

1つか 2 つか、数だけ見ると細かな違いではありますが、一方でこの 2 つの StatelessWidget を必ず作り分けなければならないこと、またその際に他の画面での命名や作り方との一貫性に気をつけなければならないこと、などを考えながら画面を増やすのは不要な労力を必要とすることだと言えるでしょう。

不必要にツリー上の Widget の数が増えてしまう

数が増えるのは上述の StatelessWidget だけではありません。

状態オブジェクトを _Screen に渡すための ChangeNotifierProvider もやはり Widget で す。さらにこれは StatefulWidget のサブクラスで、 build() メソッドによってまた別の _InheritedProviderScope という Widget を生成しているため、実際のツリーは以下のように 4 つの Widget から構築されることになります。

つまり、 本来であれば「記事一覧画面」を表す ArticleListPage ひとつで済ませられる はずが、「画面を表す状態オブジェクト」を作って Provider で渡すパターンでは 4 つの Widget が必要になってしまう 、というわけです。

ただし注意したいのは、 Flutter フレームワークは Widget の数がひとつふたつ増えたからといって人間が気にするほどのパフォーマンス低下が発生しない最適化が施されている、ということです。そのため、パフォーマンスという観点ではこのような Widget の増加を気にする必要は無いといえば無いのですが、とはいえ可能であれば無駄な処理や無駄なコードは削減したい、という要求も一方であるはずです。

そこで使えるのが、 StatefulWidget です。

1画面の「状態」は State が管理する

Flutter の基本に立ち返ると、「状態」を管理できる Widget がまさに StatefulWidget です。

StatefulWidget が保持する State クラスに状態を表すデータを保持し、それを build() で使い、ユーザー操作等によって setState() で状態の変化とリビルドを発生させることで、そのときの状態に応じて構築し直した UI が画面に表示される、というのが StatefulWidget の本来の役割です。

素直に考えれば、冒頭の ArticleListPageStatefulWidgetState で「その画面の状態」を管理しない手はないでしょう。

/// 記事一覧画面を表す Widget
class ArticleListPage extends StatelefulWidget {
  
  _ArticleListPageState createState() => _ArticleListPageState();
}

/// 記事一覧の「状態」を使ってUIを構築する State
class _ArticleListPageState extends State<ArticleListPage> {

  final _articles = <Article>[];

  // ...
  // 何か記事一覧を取得する処理。
  // 必要であればデータを取得したあとに UI で使うための加工を行う。

  
  Widget build(BuildContext context) {
    // _articles を使って ListView を構築
    return ListView.builder(
      itemBuilder: (context, index) => ArticleItem(_articles(index)),
      itemCount: _articles.length,
    );
  }
}

とてもシンプルですね。ツリーの図も先ほどとは比べ物にならないほどスッキリしています。

単純に考えると、「記事一覧画面」を構築するだけであればこの StatefulWidget を使えばよいように思えます。

StatefulWidget を使う問題点

しかし当然、 StatefulWidget にも問題はあります。

  • _ArticleListPageState が肥大化する
  • 「状態」を他の Widget(他のページ) と共有できない

この2つが主な問題です。こちらも1つずつ説明します。

_ArticleListPageState が肥大化する

見ての通り、 Widget である ArticleListPage_ArticleListPageState を生成する以外何もしていません。

一方で、 _ArticleListPageState

  • 記事一覧データを取得する
  • UI 用に取得したデータを加工する
  • build() で UI を構築する

といった、「記事一覧画面」の構築に必要な全ての処理を担当する形になってしまっています。

もしこの画面を作るためにいろいろな API からデータを取得する必要があったり、また「絞り込み」や「並べ替え」といった機能で使う状態も保持する必要があったり、それを切り替える処理が必要だったり、という感じで画面の規模や要素が増えれば増えるほどこの _ArticleListPageState クラスが肥大化し、あっという間にコードが読めなくなってしまいます。

「状態」を他の Widget(他のページ) と共有できない

StatefulWidgetState クラスは Flutter フレームワークの扱いとしても基本的には「その Widget 専用」 であり、他の Widget(例えば他のページ)と共有することができません。[2]

そのため、たとえば記事一覧データの一部を編集(タイトルを変更など)した場合、その変更を同じデータを利用する他の画面に反映させるには何かしらの方法で「変更の検知」と「データの再取得」を行わなければいけません。

つまり、1画面に収まらない状態管理をしたくなったとたんに、 StatefulWidget では Flutter フレームワークの仕組み上「力不足」になってしまうと言えるでしょう。そして、そのような状況はアプリ開発をしていればいくらでも遭遇します。

Provider 本来の使い方と StatefulWidget との連携

ここで、 Provider(や、その元になっている InheritedWidget)の出番です。

Provider はもともと 「(子孫に存在する)任意の数・任意の位置の Widget にデータを提供する」役割 を持っています。

任意の Widget ですので、その Widget がどの画面なのか、そもそも画面を表す Widget なのか、などを Provider が気にする必要はありません。 「提供したいデータ」そのものに着目してツリーの子孫に受け渡すことに専念する のが Provider の本来の使い方であると考えられます。

例えば今回の例で言うと、 Provider が提供したいのは「記事データ」であって、「記事一覧画面を構築するためのデータ」ではありません。前者は「記事一覧画面」でも「記事詳細画面」でも「マイページ」でも使えますが、後者は「記事一覧画面」でしか使えません。

画面に依存しない「データそのもの」を Provider が提供し、それを StatefulWidget が各画面で受け取って必要に応じて UI 用に加工(絞り込んだり形式を変えたり)する という役割分担をすることで、先ほどの StatefulWidget を使う際の問題が解決できると考えられます。

また、このような作りにしておけば、 ChangeNotifierProviderArticleListPage は自然と離れた場所で全く別の Widget として定義することになりますので、 context の位置による ProviderNotFoundException も気にする必要がなくなるメリットも見込めます。

ここで、 ArticleListPageStatelessWidget ではなく StatefulWidget を継承しているのは、Provider から受け取ったデータを「加工」したものを State にキャッシュする必要があるためです。

ただし、「Provider からデータを受け取って UI を構築する」だけであればそれは StatelessWidget でも可能です。provider パッケージの Readme にもそのようなサンプルコードが載っています。

しかし、依存する状態オブジェクトが「記事データ」以外にも増えてくると(例えば「ログインユーザーデータ」など)、それら全てのデータの取得と加工をリビルドごとに行わなければならなくなり、「Provider から受け取ったデータを直接 build() で使う」という作戦は破綻します。

なぜなら、 build() が高速に何度も呼び出され得るのは以前 Qiita に書いた通りですので、

https://qiita.com/chooyan_eng/items/976adeea8eed1b5ebad4

build() の中で多くの処理をするのはパフォーマンスの面でも、また「役割分担」の観点でも望ましくありません。そのため、 受け取った状態オブジェクトを加工して「あとは使うだけ」のデータをキャッシュするために StatefulWidgetState が必要である  というわけです。

acceptable パッケージの紹介

さて、ここまで来たらあと一歩です。

「画面に依存しないデータ」を Provider がツリーの上の方から提供し、 StatefulWidget で作った各画面はそれを受け取り、加工し、 State にキャッシュした上で build() で使う、という流れと役割分担はここまでに説明した通りですが、最後にあとひとつ問題が残っています。

それが、 StatefulWidget において、状態オブジェクトの変更を検知したときに「どの」状態オブジェクトが変化したのかが分からない という問題です。

State クラスには、依存している状態オブジェクトの変更を検知したときに呼び出される didChangeDependencies() というメソッドが用意されています。

状態オブジェクトの変化に合わせて、最新のデータを加工して build() 用にキャッシュする処理はこの didChangeDependencies() で行えばよいように見えますが、実際はそうではありません。 didChangeDependencies() には引数がなく、「どの状態オブジェクトが変化したのか」という情報が渡ってこない ためです。

例えば、「記事一覧画面」が ①記事データ ②ログインユーザーデータ ③広告データ をそれぞれ持つ3つの状態オブジェクトに依存している場合、どのデータが変化したとしても等しく didChangeDependencies() が呼ばれ、その直後に build() が呼び出されて画面がリビルドされてしまいます。

つまり、 どれかの状態オブジェクトが変化したら、全ての状態オブジェクトからデータを取得し直し、加工し直さなければならない という問題が発生します。なぜなら、繰り返しですが、 StatefulWidget は「どの状態オブジェクトが変化したかが分からない」からです。

そこで、この問題を解決したのが acceptable パッケージです。

acceptable パッケージには、 StatefulWidget を継承した AcceptableStatefulWidget が用意されていて、先ほどの「どの状態オブジェクトが変化したのか」を判断する機能が備わっています。

例えば以下のように書くことで

class ArticleListPage extends AcceptableStatefulWidget {
  const ArticleListPage({Key? key}) : super(key: key);

  
  _ArticleListPageState createState() => _ArticleListPageState();
}

class _ArticleListPageState
    extends AcceptableStatefulWidgetState<ArticleListPage> {

  
  void acceptProviders(Accept accept) {
    accept<ArticleState, List<Article>>(
      watch: (state) => state.all, // ArticleState の all フィールドを監視
      apply: (articles) => _articles = articles.map((article) => article.title).toList(), // all が変化したら、それを検知してタイトルのみの一覧に加工
    );
  }

  /// 加工済みの記事タイトル一覧
  late List<String> _articleTitles;

  
  Widget build(BuildContext context) {
    return ListView.builder(
      itemBuilder: (context, index) => Text(_articleTitles[index],
      itemCount: _articleTitles.length,
    );
  }
}

ArticleState の中の「全ての記事」を表す all フィールドが変化した場合のみ、それに対応する apply の処理が呼び出され「タイトル一覧」を表す List<String> に変換し、 _articleTitles に代入した上で build() が呼び出される 、という流れが実現します。 all 以外のフィールドが変化しても、またはその他の状態オブジェクトが変化してもこの加工処理は呼び出されません。[3]

これにより、「その画面に必要な状態クラスの特定のフィールド」の変化のみを検知し、そのデータを加工した上で画面をリビルドできる ようになるわけです。

まとめ

長くなりましたが、 ProviderStatefulWidget の本来の仕組みを元に役割分担を整理し直し、あと一歩足りないところで acceptable パッケージを利用することで、最低限の Widget と最低限のコードで「元データの取得・更新」「画面ごとのデータの加工」「画面の構築」の3つの処理を分離することができるようになりました。

もちろん、この作りも依存する状態オブジェクトが増えてきたり加工処理が複雑になったりするにつれてどこかが肥大化して破綻することが予想されるため「銀の弾丸」とは言えませんが、小 ~ 中規模のアプリであればボイラープレートを少なく、最低限の役割分担とソースコードの整理ができるのではないかと思います。

acceptable パッケージはまだ開発スタートから2日と経っていない(2021.9.25現在)できたてのパッケージですが、ぜひ使ってみて、使い心地や改善点などのフィードバックをいただけたら幸いです。

https://pub.dev/packages/acceptable

脚注
  1. 実体は ArticleListPage とペアになっている StatelessElement。 Element についても詳細は 「内側」から理解する Flutter 入門 を参照してください。 ↩︎

  2. GlobalKey を使ったりと方法がないわけではありませんが、面倒です。 ↩︎

  3. 仕組みや実装は provider パッケージの context.select() を参考にしています。 ↩︎

Discussion