LoginSignup
158
89

【2021年版】Flutterの状態管理パターン総まとめ

Last updated at Posted at 2021-12-10

この記事はフューチャー Advent Calendar 2021の11日目の記事目です。

弊社のブログでもFlutter連載が行われているので、興味のある方はご一読ください。
https://future-architect.github.io/articles/20210510a/

はじめに

Flutterにおける状態管理の方法は様々なパターンが存在してますが、いい意味で非常に変化が激しく、確固たる状態管理のパターンというのはまだ確立されていません。

Flutterの状態管理に関する記事は調べれば多く出てきますが、1年前の記事ですら古い内容を含んでいる場合が多いので、自らのキャッチアップの意味も込めて、本記事では2021年12月時点での状態管理のパターンを整理したいと思います。
文章や図だけだと状態管理手法の違いについてイメージが湧きづらい方もいると思いますので、この記事ではソースコードレベルで比較していきたいと思います。

TL;DR

まとめ

状態管理とは

まずはじめに「状態管理」について順を追って説明していきます。

状態とは

SPAやモバイルアプリなど昨今のフロントエンド開発において、フロントエンド側は多くの「状態」を保持し、「状態」に応じてUIを変化させます。

状態とは一言で言えば「アプリケーションが保持するデータ」のことですが、

  • APIを通じて取得したサーバのデータ
  • フォームに入力した文字列
  • モーダルが開いている・閉じている

などサーバから取得した状態やUIに閉じた状態など様々なデータが含まれます。

状態の分類

状態の分類にはいくつかの考え方があります。

Flutterにおいては

  • 単一のコンポーネント(Widget)に閉じた状態を Ephemeral State
  • 複数のコンポーネント(Widget)で共有する状態を App State

と分類しており、状態管理の文脈においては主に後者の状態を管理対象としています。
https://docs.flutter.dev/development/data-and-backend/state-mgmt/ephemeral-vs-app

他にもAngular界隈では

  • Server State
  • Persistent State
  • URL State
  • Client State
  • Transient Client State
  • Local UI State

といったようにFlutterよりもより厳密に状態を定義する考え方もあります。
https://blog.nrwl.io/managing-state-in-angular-applications-22b75ef5625f

状態を「管理する」とはどういうことか

状態を管理するとは、上記の状態の整合性が破綻しないように、状態を「どういうフロー」で「どこに保持するか」そして「状態の変化をどのように検知するか」ということについて、ルールを決めて扱うということだと考えています。

このルールについてFlutterではいくつかのパターンが存在しており、それをこれから説明していきます。

状態管理パターン

サンプルアプリ

さまざまな状態管理のパターンを見ていくにあたって、下記のようなカウンターアプリの構築を例に考えていきます。
ボタンを押すとローダーが表示されて1秒経ってカウントがインクリメントされるといった具合です。
1秒後にインクリメントしているのは、サンプルとしてAPIアクセスなど非同期の処理を擬似的に表現したかったためです。

Flutter-Demo.gif

状態管理のパターンを比較するにあたって、コンポーネント(Widget)間でどのように状態を引き回すかという部分が大事な観点になってくるため、今回はコンポーネントを次のように分割して考えていきます。

なおソースコードはGitHubにアップしてますので実際に動かしたい場合は、下記を参照してください。
https://github.com/rhumie/flutter_statemanagement_samples

Stateful Widget Pattern

Flutterにおける状態管理で最もオーソドックスなのがこの Stateful Widget Pattern となります。
その名のとおりStatefulWidgetを継承したWidgetクラスと、その状態及び状態の操作を表現するStateクラスを用意します。

今回のサンプルアプリにおける状態とは「現在のカウンター値」と「ロード中かどうか」の2つとなります。
そして状態に対する操作としては「カウントをインクリメントする」操作が考えられます。

これらを念頭においてさっそくソースコードを見ていきましょう。

ソースコード

home_page.dart
// ① Stateful Widget
class HomePage extends StatefulWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  State<StatefulWidget> createState() {
    return HomePageState();
  }
}

// ② State
class HomePageState extends State<HomePage> {
  int _count = 0;
  bool _isLoading = false;

  void _increment() async {
    setState(() {
      _isLoading = true;
    });
    Future.delayed(const Duration(seconds: 1)).then((_) {
      // ④ setStateメソッドにより状態の変更を通知
      setState(() {
        _count++;
      });
    }).whenComplete(() {
      setState(() {
        _isLoading = false;
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    print('called HomePageState#build');
    return Stack(
      children: <Widget>[
        Column(
          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
          // ③ コンストラクタ経由で状態を受け渡し
          children: <Widget>[
            WidgetA(_count),
            const WidgetB(),
            WidgetC(_increment)
          ],
        ),
        LoadingWidget(_isLoading)
      ],
    );
  }
}
widget_a.dart
class WidgetA extends StatelessWidget {
  const WidgetA(this._count, {Key? key}) : super(key: key);

  final int _count;

  @override
  Widget build(BuildContext context) {
    print('called WidgetA#build');
    return Center(
      child: Text(
        '$_count',
        style: Theme.of(context).textTheme.headline4,
      ),
    );
  }
}
widget_b.dart
class WidgetB extends StatelessWidget {
  const WidgetB({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    print('called WidgetB#build');
    return const Text(
      'You have pushed the button this many times:',
    );
  }
}
widget_c.dart
class WidgetC extends StatelessWidget {
  const WidgetC(this._increment, {Key? key}) : super(key: key);

  final void Function() _increment;

  @override
  Widget build(BuildContext context) {
    print('called WidgetC#build');
    return ElevatedButton(
        onPressed: () {
          _increment();
        },
        child: const Icon(Icons.add));
  }
}
loading_widget.dart
class LoadingWidget extends StatelessWidget {
  const LoadingWidget(this._isLoading, {Key? key}) : super(key: key);

  final bool _isLoading;

  @override
  Widget build(BuildContext context) {
    print('called LoadingWidget#build');
    return _isLoading
        ? const DecoratedBox(
            decoration: BoxDecoration(color: Color(0x44000000)),
            child: Center(child: CircularProgressIndicator()))
        : const SizedBox.shrink();
  }
}

実装上のポイント

  • StatefulWidgetを継承したHomePageクラス(①)と対応する状態となるHomePageStateクラス(②)を定義します。
  • 子Widget(WidgetA, WidgetB, WidgetB, LoadingWidget)に対して、状態及び状態に対する操作はコンストラクタ経由で受け渡します。(③)
  • カウンターの値をインクリメントする_incrementメソッドではsetState()を呼び出しています(④)が、これにより状態の変化を通知し、HomePageWidgetを含む下位のWidgetのリビルドが実行されます。
    正確に言えば、全てのWidgetのリビルドが走るわけではなく、constを指定するなどして定義した同一インスタンスのWidgetはリビルドされません。

実行時ログ

setState()により状態が変更されたタイミングで、HopePage配下のWidgetのリビルドが実行されていることが確認できます。
上述のとおり、WidgetBはコンパイル時定数となるため、リビルドは実行されていません。
このように不要なリビルドを避けるためにも、constキーワードは可能な限り活用してくと良いでしょう。

# 初期表示
called HomePageState#build
called WidgetA#build
called WidgetB#build
called WidgetC#build
called LoadingWidget#build

# ボタン押下(ローダー表示)時
called HomePageState#build
called WidgetA#build
called WidgetC#build
called LoadingWidget#build

# インクリメント(ローダー非表示)時
called HomePageState#build
called WidgetA#build
called WidgetC#build
called LoadingWidget#build

問題点

最もベーシックでシンプルな状態管理方法となりますが、何が問題なのでしょうか。
具体的には下記のような点が問題と言われています。

  • コンストラクタ経由で状態が引き渡されるため、Widgetの階層が増えた場合や、引き渡す状態が増えた場合に、管理が煩雑になる。
  • 状態を変更する処理やそれに伴うロジックがUIと共に描かれることで、メンテナビリティやテスタビリティの低下を招く。
  • 効率的なリビルドが制御できない(しづらい)。
    これは上述の実行時ログを確認すればわかりますが、ローダーの表示・非表示、インクリメント処理のために2回リビルドが実行されており、さらに表示を変えないWidgetCについてもリビルドが実行されていることがわかります。

このような問題を解決するために、これから説明する別の状態管理手法が存在するのです。

Inherited Widget Pattern

Flutterにおける主要なWidgetの1つであるInheritedWidgetを利用する状態管理手法です。
InheritedWidgetは下記のような特徴を持ち、Stateful Widget Pattern による状態管理の問題点を解消します。

  • 下の階層のWidgetから直近のInheritedWidgetO(1)でアクセスが可能
  • 状態の変更によって、必要なWidgetのみリビルドを発生させることが可能

InheritedWidgetの詳細については下記の記事がとても参考になりました。

説明だけではわかりづらいと思うので、ソースコードを見ていきましょう。

ソースコード

home_page.dart
class HomePage extends StatefulWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  State<StatefulWidget> createState() {
    return HomePageState();
  }

  // ③ 状態へのアクセスを提供
  static HomePageState of(BuildContext context, {bool listen = true}) {
    if (listen) {
      return (context.dependOnInheritedWidgetOfExactType<MyInheritedWidget>())!
          .data;
    }
    return (context
            .getElementForInheritedWidgetOfExactType<MyInheritedWidget>()!
            .widget as MyInheritedWidget)
        .data;
  }
}

class HomePageState extends State<HomePage> {
  int count = 0;
  bool isLoading = false;

  void increment() {
    setState(() {
      isLoading = true;
    });
    Future.delayed(const Duration(seconds: 1)).then((_) {
      setState(() {
        count++;
      });
    }).whenComplete(() {
      setState(() {
        isLoading = false;
      });
    });
  }

  @override
  Widget build(BuildContext context) {
    print('called HomePageState#build');
    return MyInheritedWidget(
        child: Stack(
          children: <Widget>[
            Column(
              mainAxisAlignment: MainAxisAlignment.spaceEvenly,
              children: const <Widget>[
                WidgetA(),
                WidgetB(),
                WidgetC(),
              ],
            ),
            const LoadingWidget()
          ],
        ),
        data: this);
  }
}

// ① InheritedWidgetを継承したクラス
class MyInheritedWidget extends InheritedWidget {
  // ② 状態及び子要素をコンストラクタで受け取る
  const MyInheritedWidget({Key? key, required Widget child, required this.data})
      : super(key: key, child: child);

  final HomePageState data;

  // ④ 変更を通知するかしないかを制御
  @override
  bool updateShouldNotify(covariant MyInheritedWidget oldWidget) {
    return true;
  }
}
widget_a.dart
class WidgetA extends StatelessWidget {
  const WidgetA({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    print('called WidgetA#build');
    final HomePageState state = HomePage.of(context);
    return Center(
      child: Text(
        '${state.count}',
        style: Theme.of(context).textTheme.headline4,
      ),
    );
  }
}
widget_b.dart
class WidgetB extends StatelessWidget {
  const WidgetB({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    print('called WidgetB#build');
    return const Text(
      'You have pushed the button this many times:',
    );
  }
}
widget_c.dart
class WidgetC extends StatelessWidget {
  const WidgetC({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    print('called WidgetC#build');
    final HomePageState state = HomePage.of(context, listen: false);

    return ElevatedButton(
        onPressed: () {
          state.increment();
        },
        child: const Icon(Icons.add));
  }
}
loading_widget.dart
class LoadingWidget extends StatelessWidget {
  const LoadingWidget({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    print('called LoadingWidget#build');
    final HomePageState state = HomePage.of(context);
    return state.isLoading
        ? const DecoratedBox(
            decoration: BoxDecoration(color: Color(0x44000000)),
            child: Center(child: CircularProgressIndicator()))
        : const SizedBox.shrink();
  }
}

実装上のポイント

  • StatefulWidgetを継承したHomePageと、その状態及び状態の操作を表現するHomePageStateを用意するところはStateful Widget Patternと変わりありません。
  • InheritedWidgetを継承したMyInheritedWidgetを作成し、HomePageStateを保持します。(①)
  • MyInheritedWidgetはコンストラクタで子Widgetを受け取ります。(②)
  • HomePageにはofメソッドを用意し、MyInheritedWidget経由で状態(HomePageState)へのアクセスを提供します。(③)
  • MyInheritedWidgetupdateShouldNotifyメソッドによって子ウィジェットへの変更を通知するかどうか制御する。(④)

WidgetAWidgetCを見るとわかりますが、Stateful Widget Pattern ではコンストラクタ経由で状態を受け取っていたのに対し、HomePageofメソッド経由で状態を取得していることがわかります。
先述したとおり、O(1)でのアクセスが可能なので、これによりWidgetの階層がどんなに深くなったとしても、効率よく親Widgetの状態を取得できます。

ここで改めてofメソッドの中身を見てみましょう。

  static HomePageState of(BuildContext context, {bool listen = true}) {
    if (listen) {
      return (context.dependOnInheritedWidgetOfExactType<MyInheritedWidget>())!
          .data;
    }
    return (context
            .getElementForInheritedWidgetOfExactType<MyInheritedWidget>()!
            .widget as MyInheritedWidget)
        .data;
  }

まず前提知識として、InheritedWidgetを取得するメソッドを説明します。

メソッド 計算量 リビルド
dependOnInheritedWidgetOfExactType O(1)
getElementForInheritedWidgetOfExactType O(1) ×

どちらも直近のInheritedWidgetのサブクラスが戻り値となりますが、メソッドの呼び出し元のWidgetに変更を通知してリビルドを行うか行わないかというのが違いとなります。

もう少し詳しく説明すると、inheritFromWidgetOfExactTypeメソッドを呼び出した場合、InheritedWidgetが返却されるだけではなく、呼び出し元のElementオブジェクトがInheritedElementに保持されます。そしてInheritedWidgetに変更が入ったときに、保持しているElementオブジェクトに変更を通知してリビルド対象にするという動きになります。

ofメソッドの中身において、どちらのメソッドを呼び出すかは引数のlistenフラグで分岐をしていますが、これによりHomePageStateに変更が発生(setState)した場合に、WidgetA(listen: true)はリビルドを行い、WidgetC(listen: false)はリビルドを行わないという制御が可能になったのです。

またMyInheritedWidgetでoverrideしているupdateShouldNotifyメソッドでは、子Widgetに変更を通知する際の条件を細かく制御することが可能です。
例えば次のように書くことで、状態の変更前後でカウント値が変わった場合のみ、変更を通知するようになります。

@override
bool updateShouldNotify(covariant MyInheritedWidget oldWidget) {
  return data.count != oldWidget.data.count;
}

このようにInheritWidgetは、状態の変更に伴うリビルドについて、その子要素のリビルドを発生させるかどうか制御することができる(必要な状態の変更のみを伝播させる)ものと捉えると良いと思います。

実行時ログ

Stateful Widget Patternと比較するとWidgetCのリビルドが抑制されていることがわかります。

# 初期表示
called HomePageState#build
called WidgetA#build
called WidgetB#build
called WidgetC#build
called LoadingWidget#build

# ボタン押下(ローダー表示)時
called HomePageState#build
called WidgetA#build
called LoadingWidget#build

# インクリメント(ローダー非表示)時
called HomePageState#build
called WidgetA#build
called LoadingWidget#build

改善点

  • Stateful Widget Pattern の問題点であったコンストラクタ経由での状態の受け渡しから開放され、子Widgetからはofメソッド経由で状態にアクセスできるようになりました。

問題点

  • Stateful Widget Patternの課題でもあった状態とロジックとUIの分離については解消されていません。
  • ソースコードの記述量が多く、次に説明する Provide Pattern を使えば簡潔に書けるため、このパターンが採用されることはあまりありません。

Provider Pattern

Providerを利用して状態管理を行う手法です。
ProvierはInheritedWidgetのラッパーとなるライブラリで、これを利用することで先述したInherited Widget Patternをより簡潔に記載できるようになります。

ソースコード

home_page.dart
class HomePage extends StatelessWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    print('called HomePage#build');
    // ① ChangeNotifierProviderで変更通知可能な状態を、下位Widgetで受け取れるようにする。
    return ChangeNotifierProvider<HomePageState>(
      create: (context) {
        return HomePageState();
      },
      child: Stack(
        children: <Widget>[
          Column(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: const <Widget>[WidgetA(), WidgetB(), WidgetC()],
          ),
          const LoadingWidget(),
        ],
      ),
    );
  }
}

class HomePageState extends ChangeNotifier {
  int count = 0;
  bool isLoading = false;

  void increment() {
    isLoading = true;
    // ③ notifyListeners()により、状態の変更を通知する。
    notifyListeners();
    Future.delayed(const Duration(seconds: 1)).then((_) {
      count++;
    }).whenComplete(() {
      isLoading = false;
      notifyListeners();
    });
  }
}
widget_a.dart
class WidgetA extends StatelessWidget {
  const WidgetA({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    print('called WidgetA#build');
    // ② Provider.of<T>()を利用して状態を受け取る。引数のlistenを指定していないため、状態に変更があった時にリビルドが行われる。
    final state = Provider.of<HomePageState>(context);

    return Center(
      child: Text('${state.count}'),
    );
  }
}
widget_b.dart
class WidgetB extends StatelessWidget {
  const WidgetB({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    print('called WidgetB#build');
    return const Text(
      'You have pushed the button this many times:',
    );
  }
}
widget_c.dart
class WidgetC extends StatelessWidget {
  const WidgetC({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    print('called WidgetC#build');
    // ②' Provider.of<T>()を利用して状態を受け取る。引数のlistenにfalseを指定しているため、状態に変更があった時でもリビルドが行われない。
    final state = Provider.of<HomePageState>(context, listen: false);

    return ElevatedButton(
        onPressed: () {
          state.increment();
        },
        child: const Icon(Icons.add));
  }
}
loading_widget.dart
class LoadingWidget extends StatelessWidget {
  const LoadingWidget({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    print('called LoadingWidget#build');
    final state = Provider.of<HomePageState>(context);
    return state.isLoading
        ? const DecoratedBox(
            decoration: BoxDecoration(color: Color(0x44000000)),
            child: Center(child: CircularProgressIndicator()))
        : const SizedBox.shrink();
  }
}

実装上のポイント

Providerを利用することで、Inherited Widget Patternで記述していたボイラープレートが無くなった点に加え、HomePageから状態及びロジックを完全に分離することが可能になるため、HomePageStatelessWidgetとして定義できるようになりました。

  • 状態の受け渡しについては下記の通りとなります。

  • 親WidgetでChangeNotifierProvider<T>を使い、状態を子Widgetで受け取れるようにする。(①)

  • 子WidgetでProvider.of<T>を使い、状態を受け取る。(②)

  • 状態の更新については、notifyListeners()を呼び出すことで通知を行う。(③)

  • 状態が更新されたときにリビルドされる子Widgetは、Provider.of<T>呼び出し時にlisten: trueを指定した(デフォルト値はtrue)Widgetが対象となる。
    そのためWidgetAはリビルド対象(②)となり、WidgetCはリビルド対象(②')とならない。

実装時ログ

HomePageStatelessWidgetになったため、Inherited Widget Pattern と比較して、HomePageのビルド回数が抑制されています。

# 初期表示
called HomePageState#build
called WidgetA#build
called WidgetB#build
called WidgetC#build
called LoadingWidget#build

# ボタン押下(ローダー表示)時
called WidgetA#build
called LoadingWidget#build

# インクリメント(ローダー非表示)時
called WidgetA#build
called LoadingWidget#build

補足: 子Widgetでのデータの受け取り方

上記ではProvide.of<T>()を紹介しましたが、v4.1.0からBuildContextのextensionが追加されています。

メソッド 説明
context.read() Provider.of(context, listen: false) と同様です。
context.watch() Provider.of(context, listen: true) と同様です。
context.select() Provider.of(context, listen: true) と同様ですが、引数で指定した状態の特定の値が変更されたときだけリビルドを行います

WidgetA及びLoadingWidgetにおいてcount.select()を利用して書くことで、よりリビルド回数を抑制することができます。

widget_a.dart
class WidgetA extends StatelessWidget {
  const WidgetA({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    print('called WidgetA#build');
    // final state = Provider.of<HomePageState>(context);
    // return Center(
    //   child: Text(
    //     '${state.count}',
    //     style: Theme.of(context).textTheme.headline4,
    //   ),
    // );

    // countが変更された場合のみリビルドを実行
    final count = context.select<HomePageState, int>((state) {
      return state.count;
    });
    return Center(
      child: Text(
        '$count',
        style: Theme.of(context).textTheme.headline4,
      ),
    );
  }
}
loading_widget.dart
class LoadingWidget extends StatelessWidget {
  const LoadingWidget({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    print('called LoadingWidget#build');
    // final state = Provider.of<HomePageState>(context);
    // return state.isLoading
    //     ? const DecoratedBox(
    //         decoration: BoxDecoration(color: Color(0x44000000)),
    //         child: Center(child: CircularProgressIndicator()))
    //     : const SizedBox.shrink();

    // isLoadingが変更された場合のみリビルド実行
    final isLoading = context.select<HomePageState, bool>((state) {
      return state.isLoading;
    });
    return isLoading
        ? const DecoratedBox(
            decoration: BoxDecoration(color: Color(0x44000000)),
            child: Center(child: CircularProgressIndicator()))
        : const SizedBox.shrink();
  }
}

実行時のログは下記のようになり、ボタン押下時にWidgetAのリビルドが抑制されているのがわかります。

# ボタン押下(ローダー表示)時
called LoadingWidget#build

# インクリメント(ローダー非表示)時
called WidgetA#build
called LoadingWidget#build

なお、Provider v5.0未満のバージョンではcontext.read()buildメソッド内で利用できないという制約がありましたが、最新のバージョンでは解消されています。
https://minpro.net/context-read-can-now-be-used-in-the-build-method

改善点

  • Inherited Widget Patternと比較して、より簡潔なコードで状態管理を実現することができる。
  • 「View」と「状態 + ロジック」を完全に分離することができるため、StatelessWidgetを利用することで無駄なリビルドを避けることができる。

問題点

  • Providerで包まれたツリー以外から、状態にアクセスしようとすると 実行時に ProviderNotFoundExceptionが発生する。
  • Provider.of<T>では、直近の型を取得するため、同じ型の状態を複数同時に使用できない。
  • 「View」と「状態 + ロジック」を分離することはできたが、依然として「状態」と「ロジック」は分離されていない。

Provider + State Notifier Pattern

先述したProvider Patternの課題である「状態」と「ロジック」の分離を実現するために、Providerと合わせてState Notifierを利用するパターンです。

ソースコード

home_page.dart
class HomePage extends StatelessWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    print('called HomePage#build');
    // ③ StateNotifierProviderで変更通知可能な状態を、下位Widgetで受け取れるようにする。
    return StateNotifierProvider<HomePageStateNotifier, HomePageState>(
      create: (context) {
        return HomePageStateNotifier();
      },
      child: Stack(
        children: <Widget>[
          Column(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: const <Widget>[WidgetA(), WidgetB(), WidgetC()],
          ),
          const LoadingWidget(),
        ],
      ),
    );
  }
}

// ① 状態クラス
@immutable
class HomePageState {
  const HomePageState({
    this.count = 0,
    this.isLoading = false,
  });

  final int count;
  final bool isLoading;

  HomePageState copyWith({int? count, bool? isLoading}) {
    return HomePageState(
        count: count ?? this.count, isLoading: isLoading ?? this.isLoading);
  }
}

// ② StateNotifierを継承したロジッククラス
class HomePageStateNotifier extends StateNotifier<HomePageState> {
  HomePageStateNotifier() : super(const HomePageState());

  void increment() {
    // ⑤ 新しい状態をセットすることで変更が通知される。
    state = state.copyWith(isLoading: true);
    Future.delayed(const Duration(seconds: 1)).then((_) {
      state = state.copyWith(count: state.count + 1);
    }).whenComplete(() {
      state = state.copyWith(isLoading: false);
    });
  }
}
widget_a.dart
class WidgetA extends StatelessWidget {
  const WidgetA({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    print('called WidgetA#build');
    // ④ Provider.of<T>()を利用して状態を受け取る。引数のlistenを指定していないため、状態に変更があった時にリビル
    final state = Provider.of<HomePageState>(context);

    return Center(
      child: Text(
        '${state.count}',
        style: Theme.of(context).textTheme.headline4,
      ),
    );
  }
}
widget_b.dart
class WidgetB extends StatelessWidget {
  const WidgetB({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    print('called WidgetB#build');
    return const Text(
      'You have pushed the button this many times:',
    );
  }
}
widget_c.dart
class WidgetC extends StatelessWidget {
  const WidgetC({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    print('called WidgetC#build');
    // ④' Provider.of<T>()を利用して状態を受け取る。引数のlistenにfalseを指定しているため、状態に変更があった時でもリビルドが行われない。
    final state = Provider.of<HomePageStateNotifier>(context, listen: false);

    return ElevatedButton(
        onPressed: () {
          state.increment();
        },
        child: const Icon(Icons.add));
  }
}
loading_widget.dart
class LoadingWidget extends StatelessWidget {
  const LoadingWidget({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    print('called LoadingWidget#build');
    final state = Provider.of<HomePageState>(context);
    return state.isLoading
        ? const DecoratedBox(
            decoration: BoxDecoration(color: Color(0x44000000)),
            child: Center(child: CircularProgressIndicator()))
        : const SizedBox.shrink();
  }
}

実装上のポイント

  • 状態を保持するimmutableなクラス(①)及びStateNotifierを継承した状態を変更するロジッククラス(②)を作成します。
  • 状態の受け渡しについては下記の通りとなります。Provider Patternと基本的には同様ですが、ChangeNotifierProvider<T>の代わりにStateNotifierProvider<T, U>を使用する点が変更点となります。
  • 親WidgetでStateNotifierProvider<T, U>を使い、状態を子Widgetで受け取れるようにします。(③)
  • 子WidgetでProvider.of<T>を使い、状態を受け取ります。(④)
    Provider Patternで説明したcontext.read(),context.watch(),context.select()も利用可能です。
  • 状態の更新については、notifyListeners()を呼び出していましたが、新しい状態をセットするだけで変更が通知されます。(⑤)
    状態はimmutableであることが条件になるため、都度新しい状態インスタンスを生成してセットする必要があります。そのため、状態クラス(①)には@immutableアノテーションを付与することでimmutableであることを保証するのが良いでしょう。
    今回は自前でcopyWithメソッドを用意しましたが、freezedパッケージを利用して自動生成するアプローチが多いと思います。

実装時ログ

# 初期表示
called HomePageState#build
called WidgetA#build
called WidgetB#build
called WidgetC#build
called LoadingWidget#build

# ボタン押下(ローダー表示)時
called WidgetA#build
called LoadingWidget#build

# インクリメント(ローダー非表示)時
called WidgetA#build
called LoadingWidget#build

改善点

  • 「View」と「状態」と「ロジック」を完全に分離することができました。
  • Provider Patternにおいては、変更通知のために notifyListeners()を都度呼び出す必要があったが、これが不要になりました。
  • 状態クラスをimmutableにすることで予期せぬ値の変更によるバグをケアする必要がありません。

問題点

  • Provider Patternにおける「状態 + ロジック」の分離の問題は解消されたが、それ以外の問題は依然として残ったままです。
  • 状態クラスをimmutableにしたことで、都度新しい状態クラスを生成しなければならない。ただしこれはfreezedパッケージを利用することで容易になります。

Riverpod Pattern

Providerパッケージの問題点を解消したRiverpodパッケージを利用する状態管理手法です。
2021年11月6日にv1.0.0がリリースされ、今後標準的な状態管理パッケージとして利用されていくことが予想されます。

RiverpodはState Notifierパッケージに依存しているため、パッケージの追加なしでProvider + State Notifier Patternと同じ構成を実現できます。
RiverpodはProviderと同じ作者によって作成されており、Providerの上位互換となるパッケージといっても差し支えないかと思います。

ソースコード

home_page.dart
class HomePage extends StatelessWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    print('called HomePage#build');
    // ④ ProviderScopeを利用し、下位Widgetで状態を受け取れるようにする。
    return ProviderScope(
      child: Stack(
        children: <Widget>[
          Column(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: const <Widget>[WidgetA(), WidgetB(), WidgetC()],
          ),
          const LoadingWidget(),
        ],
      ),
    );
  }
}

// ① 状態クラス
@immutable
class HomePageState {
  const HomePageState({
    this.count = 0,
    this.isLoading = false,
  });

  final int count;
  final bool isLoading;

  HomePageState copyWith({int? count, bool? isLoading}) {
    return HomePageState(
        count: count ?? this.count, isLoading: isLoading ?? this.isLoading);
  }
}

// ② 状態を変更するロジッククラス
class HomePageStateNotifier extends StateNotifier<HomePageState> {
  HomePageStateNotifier() : super(const HomePageState());

  void increment() {
    // ⑥ 新しい状態をセットすることで変更が通知される。
    state = state.copyWith(isLoading: true);
    Future.delayed(const Duration(seconds: 1)).then((_) {
      state = state.copyWith(count: state.count + 1);
    }).whenComplete(() {
      state = state.copyWith(isLoading: false);
    });
  }
}

// ③ グローバルなProvider
final homePageProvider =
    StateNotifierProvider<HomePageStateNotifier, HomePageState>((ref) {
  return HomePageStateNotifier();
});
widget_a.dart
class WidgetA extends StatelessWidget {
  const WidgetA({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    // ⑤ Consumerを利用し、コールバックのrefから状態を取得する。watchメソッドの中でselectを利用しているため、countに変更があった場合のみリビルドが行われる。
    return Consumer(builder: (context, ref, child) {
      print('called WidgetA#build');
      final count = ref.watch(homePageProvider.select((state) {
        return state.count;
      }));
      return Center(
        child: Text(
          '$count',
          style: Theme.of(context).textTheme.headline4,
        ),
      );
    });
  }
}
widget_b.dart
class WidgetB extends StatelessWidget {
  const WidgetB({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    print('called WidgetB#build');
    return const Text(
      'You have pushed the button this many times:',
    );
  }
}
widget_c.dart
class WidgetC extends StatelessWidget {
  const WidgetC({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    // ⑤ Consumerを利用し、コールバックのrefから状態を取得する。readメソッドを利用しているため、状態に変更があった時でもリビルドは行われない。
    return Consumer(builder: (context, ref, child) {
      print('called WidgetC#build');
      final notifier = ref.read(homePageProvider.notifier);
      return ElevatedButton(
          onPressed: () {
            notifier.increment();
          },
          child: const Icon(Icons.add));
    });
  }
}
loading_widget.dart
class LoadingWidget extends StatelessWidget {
  const LoadingWidget({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Consumer(builder: (context, ref, child) {
      print('called LoadingWidget#build');
      final isLoading = ref.watch(homePageProvider.select((state) {
        return state.isLoading;
      }));
      return isLoading
          ? const DecoratedBox(
              decoration: BoxDecoration(color: Color(0x44000000)),
              child: Center(child: CircularProgressIndicator()))
          : const SizedBox.shrink();
    });
  }
}

実装上のポイント

  • Provider + State Notifier Patternと同様に状態を保持するimmutableなクラス(①)及びStateNotifierを継承した状態を変更するロジッククラス(②)を作成します。
  • グローバル変数としてProviderを定義します。(③)
  • 状態の受け渡しについては下記の通りとなります。
  • 親WidgetでProviderScopeを使い、状態を子Widgetで受け取れるようにします。(④)
  • 子WidgetでConsumerを使い、Consumerのコールバックの第2引数で受けとったWidgetRefreadメソッドやwatchメソッドで状態を受け取ります。
    watchメソッドでselect(myProvider.select((value) => ...))を利用することで、状態の特定の値が変更されたときだけリビルドを実行することができます。(⑤)
  • 状態の更新については、notifyListeners()を呼び出していましたが、新しい状態をセットするだけで変更が通知されます。(⑥)
    Provider + State Notifier Patternと同様にfreezedパッケージを利用して上体クラスを生成するアプローチが有効です。

実装時ログ

# 初期表示
called HomePageState#build
called WidgetA#build
called WidgetB#build
called WidgetC#build
called LoadingWidget#build

# ボタン押下(ローダー表示)時
called LoadingWidget#build

# インクリメント(ローダー非表示)時
called WidgetA#build
called LoadingWidget#build

改善点

  • Providerをグローバル定数として宣言するので確実にアクセスすることができ、Provider Patternの問題点だった実行時にProviderNotFoundExceptionが発生する課題が解消できました。

  • また同じ型のProviderを複数利用することが可能になります。

問題点

  • Riverpodの公式がFlutter Hooksとの併用を推している感があり、Riverpod + Flutter Hooksのパターンで状態管理を行う記事も多くあるが、基本的にクラスでWidgetを定義していくFlutterのスタイルでは現時点でそこまで恩恵があるとは筆者は考えていません。
    Flutter本家がHookスタイルに肯定的ではないとのこともあり、筆者としてFlutter Hooksの利用は現時点では見送っています。

BLoC Pattern

BLoC(Business Logic Component) Patternは2018年のDart Conferenceで発表されました。
一言で言えば、Stream/Sinkを入出力として状態の取得やロジックの実行を行うパターンとなります。
AngularでいうところのBehaviorSubjectを利用した状態管理と同様のそれです。Reactive Programingに馴染みがあれば理解が早いかと思われますが、馴染みない方はキャッチアップコストが大きいかもしれません。

BLoCの詳細な説明はここでは割愛するので別の記事などを参照してください。
https://qiita.com/kabochapo/items/8738223894fb74f952d3#%E3%81%84%E3%82%88%E3%81%84%E3%82%88bloc%E3%83%91%E3%82%BF%E3%83%BC%E3%83%B3

ソースコード

home_page.dart

class HomePage extends StatefulWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  State<StatefulWidget> createState() {
    return HomePageState();
  }
}

class HomePageState extends State<HomePage> {
  HomePageLogic? homePageLogic;

  @override
  void initState() {
    super.initState();
    homePageLogic = HomePageLogic();
  }

  @override
  Widget build(BuildContext context) {
    print('called _HomePageState#build');
    return Stack(
      children: <Widget>[
        Column(
          mainAxisAlignment: MainAxisAlignment.spaceEvenly,
          children: [
            // ④ Business Logicはコンストラクタで引き回している
            WidgetA(homePageLogic!),
            const WidgetB(),
            WidgetC(homePageLogic!)
          ],
        ),
        LoadingWidget(homePageLogic!)
      ],
    );
  }

  // ⑤ メモリリークを防ぐため、不要になった段階でstreamを閉じる。
  @override
  void dispose() {
    homePageLogic!.dispose();
    super.dispose();
  }
}

// ① Business Logic
class HomePageLogic {
  HomePageLogic() {
    _countController.sink.add(_count);
    _loadingController.sink.add(false);
  }

  final _countController = StreamController<int>();
  final _loadingController = StreamController<bool>();

  int _count = 0;

  Stream<int> get count {
    return _countController.stream;
  }

  Stream<bool> get isLoading {
    return _loadingController.stream;
  }

  Future<void> increment() {
    _loadingController.sink.add(true);
    return Future.delayed(const Duration(seconds: 1)).then((_) {
     // ② sink.addで値を流し込む
      _countController.sink.add(_count++);
    }).whenComplete(() {
      _loadingController.sink.add(false);
    });
  }

  void dispose() {
    _countController.close();
    _loadingController.close();
  }
}
widget_a.dart
class WidgetA extends StatelessWidget {
  const WidgetA(this.homePageLogic, {Key? key}) : super(key: key);

  final HomePageLogic homePageLogic;

  @override
  Widget build(BuildContext context) {
    return Center(
      // ③ streamから値を受け取る
      child: StreamBuilder<int>(
          stream: homePageLogic.count,
          builder: (context, snapshot) {
            print('called WidgetA#build');
            return Text(
              '${snapshot.data}',
              style: Theme.of(context).textTheme.headline4,
            );
          }),
    );
  }
}
widget_b.dart
class WidgetB extends StatelessWidget {
  const WidgetB({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    print('called WidgetB#build');
    return const Text(
      'You have pushed the button this many times:',
    );
  }
}
widget_c.dart
class WidgetC extends StatelessWidget {
  const WidgetC(this.homePageLogic, {Key? key}) : super(key: key);

  final HomePageLogic homePageLogic;

  @override
  Widget build(BuildContext context) {
    print('called WidgetC#build');
    return ElevatedButton(
        onPressed: () {
          homePageLogic.increment();
        },
        child: const Icon(Icons.add));
  }
}
loading_widget.dart
class LoadingWidget extends StatelessWidget {
  const LoadingWidget(this.homePageLogic, {Key? key}) : super(key: key);

  final HomePageLogic homePageLogic;

  @override
  Widget build(BuildContext context) {
    return StreamBuilder<bool>(
        stream: homePageLogic.isLoading,
        builder: (context, snapshot) {
          print('called LoadingWidget#build');
          return snapshot.data ?? false
              ? const DecoratedBox(
                  decoration: BoxDecoration(color: Color(0x44000000)),
                  child: Center(child: CircularProgressIndicator()))
              : const SizedBox.shrink();
        });
  }
}

実行時ログ

# 初期表示
called HomePageState#build
called WidgetA#build
called WidgetB#build
called WidgetC#build
called LoadingWidget#build

# ボタン押下(ローダー表示)時
called LoadingWidget#build

# インクリメント(ローダー非表示)時
called WidgetA#build
called LoadingWidget#build

実装上のポイント

  • BLoC Patternの肝となるビジネスロジックを保時するクラスとしてHomePageLogicを定義します。
    HomePageLogicStreamControllerを利用して、カウント値のStreamとローディング状態のStreamを保持します。(①)
  • 状態の受け渡しについては下記の通りとなります。
    • sink.add()メソッドを利用してStreamに値を流し込みます。(②)
    • StreamBuilder Widgetを利用して、streamlistenすることで値を受け取ります。(③)
  • HomePageLogicについては各Widgetのコンストラクタで引き回しています(④)が、これはStateful Widget Patternと同様、階層が深くなった場合に引き回しが煩雑になるので、Inherited Widget PatternやProvider Patternを組み合わせて、BLoCクラスを引き回すアプローチが一般的です。
  • メモリリークを防ぐため、Streamをクローズする処理を定義し、Streamが不要になった際に呼び出します。(⑤)

改善点

  • 「View」と「状態 + ロジック」を分離して扱うことができます。
  • Provider Pattern や Riverpod Patternと同様、リビルドを細かく制御することが可能です。

問題点

  • Reactive Programingに馴染みのない方にとってはキャッチアップコストが高いです。
  • 実際の開発でBLoC Patternを適用すると、どういう単位でBLoCを作成するかが結構悩ましくなってくるのではないかと思います。

Redux Pattern

Omitted.

まとめ

パターン名 ライブラリ 分離度 記述量 データの受け渡し キャッチアップコスト
Stateful Widget Pattern - View/State/Logicが同一 コンストラクタ経由
Inherited Widget Pattern - ViewとState/Logicが分離 コンストラクタ経由
Provider Pattern provider ViewとState/Logicが分離 Provider経由(下位Widget)
Provider + State Notifier Pattern provider
flutter_state_notifier
ViewとStateとLogicが分離 Provider経由(下位Widget)
Riverpod Pattern riverpod ViewとStateとLogicが分離 GlobalなProvider経由
BLoC Pattern - ViewとState/Logicが分離 Stream経由
Redux Pattern flutter_redux

ここまで色々な状態管理のパターンを見てきましたがいかがでしたでしょうか。
Flutterの状態管理の変遷として、Inherited Widget Pattern -> Provider Pattern -> Riverpod Patternというように移り変わってきています。2022年はまずRiverpod Patternがデファクトになっていくのではないでしょうか。
その上で、複雑な状態管理を必要としないシンプルなアプリなら Stateful Widget Pattern, ReactiveプログラムになれたメンバならBLoC Pattern, Reduxに慣れたメンバならReduxという選択肢をとっていく形になるのではないかと考えています。

158
89
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
158
89