Provider + ChangeNotifierでシンプルに状態管理する方法
私はこれまでにさまざまな方法でFlutterの状態管理を試してきました。
StatefulWidget、Provider、Bloc、Riverpodなどは実際にプロジェクトで利用しましたし、他にもいくつかの方法を試してきました。
実際にアプリを数年間運用していく中で、依存パッケージに破壊的な変更があり、パッケージのアップデートに苦労した経験もあります。
状態管理パッケージはプレゼンテーション層全体に強い依存性があるため、なるべくシンプルで安定したものに依存する方が良いと考えました。
そのため現在では、FlutterのデフォルトであるChangeNotifierと、安定していて更新頻度の少ないProviderパッケージを組み合わせて、シンプルな状態管理を行っています。
状態管理の方針
基本的に、状態管理はWidgetと、その状態を持つControllerを用いて管理する方針を取っています。
図にすると以下のようになります。
Widgetが初期化されると、ControllerのonInit
が呼ばれて初期化され、Widgetが破棄されると、ControllerのonDispose
が呼ばれます。
仕様によっては、Widgetが初期化される前にControllerを初期化したい場合もあります。そのような場合には、Widgetの引数にControllerを渡し、TextFieldに対するTextFieldControllerのように使うこともありますが、可能であればWidget内で完結することが推奨されます。
またもう一つのルールとして、「親のWidgetのControllerは子のWidgetから参照しても良い」という方針で運用しています。例えば、
このような関係であれば、ChildWidgetはParentControllerを参照することができます。
基本的にController同士が依存関係を持つことは避けるというルールを設けています。Controller同士が互いに呼び出し合うとアプリが複雑になりがちだからです。Controller同士が呼び出し合いたい場合は、設計を見直すサインかもしれません。
私のアプリでは、Controller同士の連携が必要な場合、主にWidget内で行います。例えば、ChildControllerのStreamやメソッドの結果をbool
やResult
型で受け取り、ParentControllerのメソッドを呼び出すといった形です。
Controllerの実装
Controllerは前述の通り、ChangeNotifierで実装します。ValueNotifierやStateNotifierを使うこともできますが、私はChangeNotifierをよく使っています。
その理由は、Widgetの状態管理では、ローディングやエラーハンドリングなど、さまざまな状態を持ちたいことが多く、そのたびにイミュータブルなクラスを定義するのが手間だからです。
イミュータブルにすること自体は重要ですが、それらをまとめて1つのクラスにすることにはあまり価値を感じておらず、ChangeNotifierで十分だと考えています。もっとも、最近ではRecord型を使うのも良いかもしれないと思っているので、今後は変更する可能性もあります。
以下は、CounterControllerのChangeNotifierによる実装例です。
class CounterController with ChangeNotifier {
int counter = 0;
void onInit() {
reset();
}
void onDispose() {}
void increment() {
counter += 1;
notifyListeners();
}
void reset() {
counter = 0;
notifyListeners();
}
}
onInit
はWidgetの初期化時に呼ばれるので、ビジネスロジックを実行するのに便利です。たとえば、データをフェッチして、フェッチ中はローディング状態にし、フェッチが完了したらローディングを終了する、といった使い方ができます。onInit
でStreamをリッスンし、onDispose
で開放するなど、さまざまな場面で利用できます。
なお、ビジネスロジックはControllerに直接書かず、Usecaseなどに切り出してGetIt等のDIコンテナで注入するようにしていますが、ここではその話は割愛します。
Widgetの実装
WidgetにはProviderパッケージのChangeNotifierProviderを使用し、先ほど作成したCounterControllerを注入するためのWidgetを実装します。このWidgetはStatefulWidgetとして実装し、Controllerの初期化、Providerを使ったControllerの注入、Controllerの破棄をWidgetのライフサイクルに沿って行います。
実装は以下の通りです。
class CounterWidget extends StatefulWidget {
const CounterWidget({super.key});
State<CounterWidget> createState() => _CounterWidgetState();
}
class _CounterWidgetState extends State<CounterWidget> {
late final CounterController controller;
void initState() {
super.initState();
controller = CounterController();
WidgetsBinding.instance.addPostFrameCallback((_) {
controller.onInit();
});
}
void dispose() {
controller.onDispose();
super.dispose();
}
Widget build(BuildContext context) {
return ChangeNotifierProvider.value(
value: controller,
child: const _CounterWidget(),
);
}
}
このCounterWidgetはCounterControllerのスコープとなり、CounterWidgetの子WidgetでのみCounterControllerを利用できます。
ControllerはProviderを通してコンテキストに注入されているため、Providerパッケージの方法で取得可能です。たとえば、context.watch<CounterController>()
を使用すれば、notifyListeners()
された際にWidgetが更新されますし、context.read<CounterController>().increment()
を使えばカウンターを増加させることができます。
実際にCounterControllerを利用したWidgetの構築は、_CounterWidgetに委譲されます。実装は次の通りです。
class _CounterWidget extends StatelessWidget {
const _CounterWidget({super.key});
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text('You have pushed the button this many times:'),
Text(
'${context.select<CounterController, int>((value) => value.counter)}',
style: Theme.of(context).textTheme.displayLarge,
),
ElevatedButton(
onPressed: () {
context.read<CounterController>().increment();
},
child: const Text('Increment'),
),
ElevatedButton(
onPressed: () {
context.read<CounterController>().reset();
},
child: const Text('Reset'),
),
],
);
}
}
パッケージ化
このような方法で状態管理を行うことで、かなり効果的に状態管理ができます。ただし、CounterWidgetの実装ではStatefulWidgetを毎回書く必要があり、少し冗長に感じる部分もあります。
そこで、ボイラープレートを減らして使いやすくするために、Flutterのパッケージとして実装し、公開しました。
lifecycle_controllerというパッケージです。
このパッケージはProviderパッケージのラッパーであり、この記事で説明した使い方をする際に、できるだけボイラープレートを減らすことを目的としています。
カウンターアプリの場合、次のようにControllerとWidgetを定義できます。
CounterControllerではChangeNotifierの代わりにLifecycleControllerを継承します。使い方は基本的に同じで、Controller内にメソッドを定義し、notifyListeners()
を呼び出すだけです。
class CounterController extends LifecycleController {
int _counter = 0;
int get counter => _counter;
void increment() {
// asyncRunを使うことで、ローディング状態を自動で管理できる
// 1秒後にカウンターをインクリメントし、
// インクリメントされるまではロード中とする
asyncRun(() async {
await Future.delayed(const Duration(seconds: 1));
_counter++;
notifyListeners(); // UIの再構築を通知
});
}
void reset() {
_counter = 0;
notifyListeners();
}
void onInit() {
super.onInit();
// 初期化処理
print('CounterController initialized');
}
void onDispose() {
super.onDispose();
// クリーンアップ処理
print('CounterController disposed');
}
}
Widget側では、StatefulWidgetの代わりにLifecycleWidgetを継承したWidgetを作成します。このWidgetでは、createController
メソッドを実装して、Controllerをどのように作成するか決定し、build
メソッドでUIを構築します。
class CounterWidget extends LifecycleWidget<CounterController> {
const CounterWidget({Key? key}) : super(key: key);
CounterController createController() => CounterController();
Widget build(BuildContext context, CounterController controller) {
return _CounterWidget();
}
}
ちなみに、LifecycleControllerにはasyncRun
というメソッドがあり、非同期処理中のローディング状態を自動で管理し、ロード画面を表示してくれます。
これはLifecycleController独自のもので、他にもWidgetの状態管理で便利な機能がいくつか実装されています。
LifecycleControllerに実装されている機能は、現時点で以下の通りです。
- ローディング
- エラー表示
- StreamSubscriptionの追加と自動開放
- ナビゲーションのポップ時のコールバック
- アプリ終了時のコールバック
- デバウンスとスロットリング
詳細はパッケージのREADMEに記載しているので、興味のある方は確認してみてください。
まとめ
私はこれまでFlutterでの状態管理を試行錯誤し、最終的にシンプルで安定した管理方法にたどり着きました。特に、ChangeNotifierとProviderの組み合わせは、更新が少なく、安定して運用できる点で優れていると感じています。また、今回ご紹介したライフサイクルに基づいたControllerの実装方法は、冗長さを減らし、直感的でメンテナンスがしやすい状態管理を実現するものです。
状態管理はアプリケーション全体の設計に大きな影響を与えるため、シンプルでわかりやすく、さらに将来的な変更にも耐えられる構造にすることが重要だと思います。今回紹介した方法をさらに効率化し、パッケージ化した「lifecycle_controller」は、その一助となるものです。
Flutterでの状態管理に悩んでいる方や、もっとシンプルで堅牢な設計を目指している方に、この方法が役立つことを願っています。興味のある方はぜひ、パッケージを試してみてください。
Discussion