この記事はフューチャー 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アクセスなど非同期の処理を擬似的に表現したかったためです。
状態管理のパターンを比較するにあたって、コンポーネント(Widget)間でどのように状態を引き回すかという部分が大事な観点になってくるため、今回はコンポーネントを次のように分割して考えていきます。
なおソースコードはGitHubにアップしてますので実際に動かしたい場合は、下記を参照してください。
https://github.com/rhumie/flutter_statemanagement_samples
Stateful Widget Pattern
Flutterにおける状態管理で最もオーソドックスなのがこの Stateful Widget Pattern となります。
その名のとおりStatefulWidget
を継承したWidgetクラスと、その状態及び状態の操作を表現するStateクラスを用意します。
今回のサンプルアプリにおける状態とは「現在のカウンター値」と「ロード中かどうか」の2つとなります。
そして状態に対する操作としては「カウントをインクリメントする」操作が考えられます。
これらを念頭においてさっそくソースコードを見ていきましょう。
ソースコード
// ① 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)
],
);
}
}
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,
),
);
}
}
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:',
);
}
}
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));
}
}
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から直近の
InheritedWidget
にO(1)
でアクセスが可能 - 状態の変更によって、必要なWidgetのみリビルドを発生させることが可能
InheritedWidget
の詳細については下記の記事がとても参考になりました。
- https://medium.com/flutter-jp/inherited-widget-37495200d965
- https://zenn.dev/chooyan/articles/bd8b5990eb210f
説明だけではわかりづらいと思うので、ソースコードを見ていきましょう。
ソースコード
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;
}
}
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,
),
);
}
}
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:',
);
}
}
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));
}
}
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
)へのアクセスを提供します。(③) -
MyInheritedWidget
のupdateShouldNotify
メソッドによって子ウィジェットへの変更を通知するかどうか制御する。(④)
WidgetA
やWidgetC
を見るとわかりますが、Stateful Widget Pattern ではコンストラクタ経由で状態を受け取っていたのに対し、HomePage
のof
メソッド経由で状態を取得していることがわかります。
先述したとおり、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をより簡潔に記載できるようになります。
ソースコード
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();
});
}
}
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}'),
);
}
}
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:',
);
}
}
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));
}
}
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
から状態及びロジックを完全に分離することが可能になるため、HomePage
をStatelessWidget
として定義できるようになりました。
-
状態の受け渡しについては下記の通りとなります。
-
親Widgetで
ChangeNotifierProvider<T>
を使い、状態を子Widgetで受け取れるようにする。(①) -
子Widgetで
Provider.of<T>
を使い、状態を受け取る。(②) -
状態の更新については、
notifyListeners()
を呼び出すことで通知を行う。(③) -
状態が更新されたときにリビルドされる子Widgetは、
Provider.of<T>
呼び出し時にlisten: true
を指定した(デフォルト値はtrue)Widgetが対象となる。
そのためWidgetA
はリビルド対象(②)となり、WidgetC
はリビルド対象(②')とならない。
実装時ログ
HomePage
がStatelessWidget
になったため、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()
を利用して書くことで、よりリビルド回数を抑制することができます。
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,
),
);
}
}
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を利用するパターンです。
ソースコード
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);
});
}
}
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,
),
);
}
}
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:',
);
}
}
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));
}
}
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の上位互換となるパッケージといっても差し支えないかと思います。
ソースコード
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();
});
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,
),
);
});
}
}
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:',
);
}
}
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));
});
}
}
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引数で受けとったWidgetRef
のread
メソッドや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
ソースコード
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();
}
}
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,
);
}),
);
}
}
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:',
);
}
}
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));
}
}
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
を定義します。
HomePageLogic
はStreamController
を利用して、カウント値のStreamとローディング状態のStreamを保持します。(①) - 状態の受け渡しについては下記の通りとなります。
-
sink.add()
メソッドを利用してStreamに値を流し込みます。(②) -
StreamBuilder
Widgetを利用して、stream
をlisten
することで値を受け取ります。(③)
-
-
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という選択肢をとっていく形になるのではないかと考えています。