Flutterにおける状態管理の進化
Flutterでは、StatefulWidget
を使用して内部状態を持つことができる。また、ProviderやRiverpod、Blocなどのライブラリを使用してアプリケーション全体の状態管理を行う。
本記事では、statefulWidget + setStateから、次のステップであるProviderまでをみていく。別記事でRiverpodについて触れる予定。
StatefulWidget + setState
StatefulWidget
を使用すると、StatelessWidget
と異なり、ウィジェットがアプリケーションの実行中に変更することができる内部状態を持つことができる。そして、setState
メソッドは、この内部状態が変更された際にウィジェットを再構築(再描画)するために使用される。
カウンターアプリなど、ユーザーのアクションに応じて状態が変化する画面を作成したい場合にFlutterを始めた初心者の段階で、まず勉強する組み合わせである。
例)カウンターアプリ
ユーザーがボタンを押すと、カウンターが増加し、画面上のカウントが更新される。
import 'package:flutter/material.dart';
class Counter extends StatefulWidget {
// このクラスは状態の設定です。
// 親によって提供され、Stateのbuildメソッドで使われる値を保持する。
// Widgetサブクラスのフィールドは常に"final"としてマークされる。
const Counter({super.key});
@override
State<Counter> createState() => _CounterState();
}
class _CounterState extends State<Counter> {
int _counter = 0;
void _increment() {
// setStateの呼び出し: setStateが呼び出されると、
// Flutterは該当のStateオブジェクトが保持する
// buildメソッドを再実行するようスケジュールする。
setState(() {
_counter++;
});
}
@override
Widget build(BuildContext context) {
// このメソッドはsetStateが呼ばれるたびに再実行される。
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
ElevatedButton(
onPressed: _increment,
child: const Text('Increment'),
),
const SizedBox(width: 16),
Text('Count: $_counter'),
],
);
}
}
void main() {
runApp(
const MaterialApp(
home: Scaffold(
body: Center(
child: Counter(),
),
),
),
);
}
_increment
が呼ばれると、_counter
の値が増加し、setState
によってbuild
メソッドが再実行され、Text
ウィジェットに表示されるカウントの値が更新される。
MaterialApp
|
Scaffold
|
Center
|
Counter (StatefulWidget)
|
_CounterState (State)
|
Row
/
ElevatedButton Text
(Increment) (Count: x)
Flutterのbuild
メソッドは、setState
が呼ばれるたびに再実行され、その結果としてWidget全体が再構築される。build
メソッド内で新しいウィジェットツリーが生成されるが、実際には、Flutterフレームワークは差分(diffing)アルゴリズムを使用して、前回のビルドと比較し、実際に必要な変更のみをUIに適用する。
この例では、_counter
変数が更新されると、この変数を含むText
ウィジェットのみが再描画される。しかし、ElevatedButton
やその他のウィジェットは再構築の必要がないため、変更されない。
このプロセスは、Reactの仮想DOMと類似しており、不要なUIの再描画を最小限に抑えるように設計されている。
setStateの2つの課題
離れたWidget間で状態を共有するのが困難
setState
は基本的にローカル状態(単一のStatefulWidget
内で管理される状態)の管理に適している。StatefulWidget
と State
は1:1で紐づくため、State
の情報を複数の画面で利用したい場合利用することができない。画面が多ければ多いほど同じような冗長なソースを実装しなければならなくなり、保守性が悪くなる。
ビジネスロジックとUIの分離が困難→メンテナンスしにくい
setState
を使うと、ビジネスロジックとUIロジックが密接に結びついてしまいがち。状態管理をWidgetの外部に分離することで、コードの再利用性やテストの容易さが向上する。
というわけで、上記setStateの課題を解決すべく、Providerという概念が誕生した。
Providerを導入して、カウンターアプリを実装
providerパッケージを導入
https://pub.dev/packages/provider/install
カウント状態を保持するCounterModelクラスを作成
状態を保持するクラスで、ChangeNotifier
をMixinしている。ChangeNotifier
は、状態が変更されたことをリスナーに通知する機能を提供する。
notifyListeners()
は、ChangeNotifier
mixinに定義されているメソッド。notifyListeners()
が呼び出されると、そのオブジェクト(CountModel
クラスから生成されるインスタンスを指しており、この場合はChangeNotifierProvider
でcreate
関数を通じて生成されるCountModel
のインスタンス)をリッスンしているすべてのウィジェットが再構築され、これにより、状態の変更がUIに反映されるようになる。
// lib/count_model.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; //Providerをimport
class CountModel with ChangeNotifier {
// 別ファイルから呼び出せるように_(プライベート化)を取って記載
int counter = 0;
void incrementCounter() {
counter++;
notifyListeners();
}
}
ChangeNotifierProviderで囲んでmodelをinitialize
ChangeNotifierProvider
はCountModel
のインスタンスを提供し、そのインスタンスに変更があった場合にリスナー(Consumer
ウィジェットなど)に通知する役割を持っている。
ChangeNotifierProvider
がなければ、 Consumer
ウィジェットは CountModel
のインスタンスを監視することができず、notifyListeners()
が呼ばれても、変更に基づいてUIを更新することができない。
modelの値変更に応じて再描画したい箇所をConsumer Widgetで囲む
Consumer<CountModel>
ウィジェット内でbuilder
関数が呼び出されるとき、この関数にはcontext
、model
、child
という3つの引数が渡される。
- context: 現在のBuildContextを表し、Flutterのウィジェットツリー内でウィジェットの位置を示す
- model:
Consumer
によってリッスンされているオブジェクト、このケースではCountModel
のインスタンスを指す。このオブジェクトを通じて、状態が保持され、変更がウィジェットに通知される。model
を使用することで、CountModel
のプロパティやメソッドにアクセスし、UIを適切に更新することができる。 - child:
child
は、Consumer
ウィジェットが再構築されるたびに再構築されないウィジェットツリーを提供する。これは、パフォーマンス最適化のために使用される。builder
が再実行されるたびに再構築すべきでない、つまり状態の変更に依存しないウィジェットは、child
パラメータを通じて提供される。今回のケースでは使用されていない。
class MyHomePage extends StatelessWidget {
MyHomePage({Key key, this.title}) : super(key: key);
final String title;
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider(
// CountModelクラスをcreate関数でインスタンス化
create: (_) => CountModel(),
child: Scaffold(
appBar: AppBar(
title: Text(title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
Text(
'You have pushed the button this many times:',
),
Consumer<CountModel>(
builder: (context, model, child) {
return Text(
'${model.counter}',
style: Theme.of(context).textTheme.headline4,
);
},
),
],
),
),
floatingActionButton: Consumer<CountModel>(
builder: (context, model, child) {
return FloatingActionButton(
onPressed: () => model.incrementCounter(),
tooltip: 'Increment',
child: Icon(Icons.add),
);
},
),
),
);
}
}
providerパッケージを使った状態管理の流れ
- ボタンの押下: ユーザーが
FloatingActionButton
をタップ。 - 状態の更新:
FloatingActionButton
のonPressed
コールバックがmodel.incrementCounter()
を実行する。これにより、CountModel
クラス内のincrementCounter
メソッドが呼び出され、カウンターがインクリメントされる。 - リスナーへの通知:
incrementCounter
メソッド内でnotifyListeners()
が呼び出されると、CountModel
クラスのインスタンスに変更があったことがすべてのリスナー(この場合はConsumer<CountModel>
ウィジェットやChangeNotifierProvider
)に通知される。 - ウィジェットの再構築:
ChangeNotifierProvider
は提供しているCountModel
インスタンスの変更を検知すると、Consumer<CountModel>
ウィジェットを再構築する。Consumer
ウィジェットのbuilder
関数が再度呼び出され、ここでmodel.counter
の最新の値を参照して、Text
ウィジェットが更新される。
ChangeNotifierProviderはインスタンス自体(CountModel())を提供しており、通知そのものには関わっていない。しかし、インスタンスの提供がないとインスタンスの状態変更(notifyListeners()
)をそもそも検知できないのでUI際描画(Consumer<CountModel>
ウィジェットの再描画)ができない。
例えば、ある家族が一つのテレビを共有している例で考えてみる。このテレビがChangeNotifierProvider
で、テレビ番組がCountModel
に相当する。家族のメンバー(子ウィジェット)はリビングルーム(ウィジェットツリー内の適切な場所)に行くことでいつでもテレビを見ることができる。
- テレビの提供: リビングルームにテレビが置いてある(
ChangeNotifierProvider
がCountModel
を提供)。 - 番組の選択: 家族の誰かがリモコン(アクション)を使ってチャンネルを変更する(
CountModel
の状態を変更)。 - 番組の通知: テレビ(
CountModel
)は新しいチャンネル(状態の変更)を表示し、リビングルームにいる全員がそれを見ることができる(notifyListeners()
がリスナーに通知)。
この例で言うと、ChangeNotifierProvider
はテレビがリビングルームにあることを保証し、家族の誰もがアクセスできるようにする。そして、チャンネルが変わる(CountModel
が変更される)と、テレビは新しい番組(新しい状態)を表示し、それをリビングルームにいる人(Consumer
やその他のリスナー)は見ることができる。
Providerを使うメリット
再利用性の向上:
Providerを使えば親Widget
で取得した内容を子Widgetに受け渡す
ことがができるので、ステート情報を複数の画面で使用できる。
Provider
をアプリケーションのトップレベル(main.dart
内の MaterialApp
をラップする位置)に配置すると、アプリケーション内のすべての下層でステート情報にアクセスできるようになる。一方で、Provider
を特定の StatelessWidget
または StatefulWidget
内に配置すると、その Provider
はそのウィジェットとその子ウィジェットのスコープ内でのみアクセス可能となる。
つまり、Provider
の配置場所によって、提供されるステートのスコープを調整できる。
状態のカプセル化:
Provider
を使用すると、状態を特定のクラスにカプセル化できる。これにより、状態のロジックとUIのロジックを明確に分離でき、コードの読みやすさと保守性が向上する。
効率的なUI更新:
notifyListeners()が呼ばれると、Consumerの中身だけ再描画される。
つまりProviderを使うと、Consumerを使うことで再描画の範囲を開発者側が明示的に限定でき、不要なウィジェットの再構築を避けることが可能になる。
様々なproviderのmodelの呼び出し方
Provider.of:notifiListeners()で変更されたら呼ばれる。
final model = Provider.of<CountModel>(context);
context.watch:notifiListeners()で変更されたら呼ばれる。
final model = context.watch<CountModel>();
context.read:1回しか呼ばれない。notifiListeners()で変更されても呼ばれない。
final model = context.read<CountModel>();
context.select:特定のpropertyが更新された時だけ呼ばれる
final count = context.select<CountModel, int>(
(CountModel model) => model.count,
);
とはいえ、Providerでもコンテキスト依存・不変性・初期化の複雑さなどの問題が残る。この課題を解決したのがRiverpod。ProviderからRiverpodへの流れに関してはこちらの記事で記載。
コメント