【Flutter】状態管理:StatefulWidget + setStateからproviderへ移行とメリット

この記事は約14分で読めます。

Flutterにおける状態管理の進化

Flutterでは、StatefulWidgetを使用して内部状態を持つことができる。また、ProviderやRiverpod、Blocなどのライブラリを使用してアプリケーション全体の状態管理を行う。

本記事では、statefulWidget + setStateから、次のステップであるProviderまでをみていく。別記事でRiverpodについて触れる予定。

StatefulWidget + setState

StatefulWidget を使用すると、StatelessWidgetと異なり、ウィジェットがアプリケーションの実行中に変更することができる内部状態を持つことができる。そして、setState メソッドは、この内部状態が変更された際にウィジェットを再構築(再描画)するために使用される。

カウンターアプリなど、ユーザーのアクションに応じて状態が変化する画面を作成したい場合にFlutterを始めた初心者の段階で、まず勉強する組み合わせである。

例)カウンターアプリ

ユーザーがボタンを押すと、カウンターが増加し、画面上のカウントが更新される。

Dart
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ウィジェットに表示されるカウントの値が更新される。

Dart
        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の情報を複数の画面で利用したい場合利用することができない。画面が多ければ多いほど同じような冗長なソースを実装しなければならなくなり、保守性が悪くなる。

出典:https://kimuralog.com/?p=1914

ビジネスロジックとUIの分離が困難→メンテナンスしにくい

setStateを使うと、ビジネスロジックとUIロジックが密接に結びついてしまいがち。状態管理をWidgetの外部に分離することで、コードの再利用性やテストの容易さが向上する。

というわけで、上記setStateの課題を解決すべく、Providerという概念が誕生した。

Providerを導入して、カウンターアプリを実装

providerパッケージを導入

provider install | Flutter package
A wrapper around InheritedWidget to make them easier to use and more reusable.

カウント状態を保持するCounterModelクラスを作成

状態を保持するクラスで、ChangeNotifierをMixinしている。ChangeNotifierは、状態が変更されたことをリスナーに通知する機能を提供する。

notifyListeners()は、ChangeNotifier mixinに定義されているメソッド。notifyListeners()が呼び出されると、そのオブジェクト(CountModelクラスから生成されるインスタンスを指しており、この場合はChangeNotifierProvidercreate関数を通じて生成されるCountModelのインスタンス)をリッスンしているすべてのウィジェットが再構築され、これにより、状態の変更がUIに反映されるようになる。

Dart
// 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

ChangeNotifierProviderCountModelのインスタンスを提供し、そのインスタンスに変更があった場合にリスナー(Consumerウィジェットなど)に通知する役割を持っている。

ChangeNotifierProvider がなければ、 Consumer ウィジェットは CountModel のインスタンスを監視することができず、notifyListeners()が呼ばれても、変更に基づいてUIを更新することができない。

modelの値変更に応じて再描画したい箇所をConsumer Widgetで囲む

Consumer<CountModel>ウィジェット内でbuilder関数が呼び出されるとき、この関数にはcontextmodelchildという3つの引数が渡される。

  • context: 現在のBuildContextを表し、Flutterのウィジェットツリー内でウィジェットの位置を示す
  • model: Consumerによってリッスンされているオブジェクト、このケースではCountModelのインスタンスを指す。このオブジェクトを通じて、状態が保持され、変更がウィジェットに通知される。modelを使用することで、CountModelのプロパティやメソッドにアクセスし、UIを適切に更新することができる。
  • child: childは、Consumerウィジェットが再構築されるたびに再構築されないウィジェットツリーを提供する。これは、パフォーマンス最適化のために使用される。builderが再実行されるたびに再構築すべきでない、つまり状態の変更に依存しないウィジェットは、childパラメータを通じて提供される。今回のケースでは使用されていない。
Dart
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パッケージを使った状態管理の流れ

  1. ボタンの押下: ユーザーがFloatingActionButtonをタップ。
  2. 状態の更新: FloatingActionButtononPressedコールバックがmodel.incrementCounter()を実行する。これにより、CountModelクラス内のincrementCounterメソッドが呼び出され、カウンターがインクリメントされる。
  3. リスナーへの通知: incrementCounterメソッド内でnotifyListeners()が呼び出されると、CountModelクラスのインスタンスに変更があったことがすべてのリスナー(この場合はConsumer<CountModel>ウィジェットやChangeNotifierProvider)に通知される。
  4. ウィジェットの再構築: ChangeNotifierProviderは提供しているCountModelインスタンスの変更を検知すると、Consumer<CountModel>ウィジェットを再構築する。Consumerウィジェットのbuilder関数が再度呼び出され、ここでmodel.counterの最新の値を参照して、Textウィジェットが更新される。

ChangeNotifierProviderはインスタンス自体(CountModel())を提供しており、通知そのものには関わっていない。しかし、インスタンスの提供がないとインスタンスの状態変更(notifyListeners() )をそもそも検知できないのでUI際描画(Consumer<CountModel>ウィジェットの再描画)ができない。

例えば、ある家族が一つのテレビを共有している例で考えてみる。このテレビがChangeNotifierProviderで、テレビ番組がCountModelに相当する。家族のメンバー(子ウィジェット)はリビングルーム(ウィジェットツリー内の適切な場所)に行くことでいつでもテレビを見ることができる。

  1. テレビの提供: リビングルームにテレビが置いてある(ChangeNotifierProviderCountModelを提供)。
  2. 番組の選択: 家族の誰かがリモコン(アクション)を使ってチャンネルを変更する(CountModelの状態を変更)。
  3. 番組の通知: テレビ(CountModel)は新しいチャンネル(状態の変更)を表示し、リビングルームにいる全員がそれを見ることができる(notifyListeners()がリスナーに通知)。

この例で言うと、ChangeNotifierProviderはテレビがリビングルームにあることを保証し、家族の誰もがアクセスできるようにする。そして、チャンネルが変わる(CountModelが変更される)と、テレビは新しい番組(新しい状態)を表示し、それをリビングルームにいる人(Consumerやその他のリスナー)は見ることができる。

Providerを使うメリット

再利用性の向上:

Providerを使えば親Widgetで取得した内容を子Widgetに受け渡すことがができるので、ステート情報を複数の画面で使用できる。

Provider をアプリケーションのトップレベル(main.dart 内の MaterialApp をラップする位置)に配置すると、アプリケーション内のすべての下層でステート情報にアクセスできるようになる。一方で、Provider を特定の StatelessWidget または StatefulWidget 内に配置すると、その Provider はそのウィジェットとその子ウィジェットのスコープ内でのみアクセス可能となる。

つまり、Provider の配置場所によって、提供されるステートのスコープを調整できる。

出典:https://kimuralog.com/?p=1914

状態のカプセル化:

Providerを使用すると、状態を特定のクラスにカプセル化できる。これにより、状態のロジックとUIのロジックを明確に分離でき、コードの読みやすさと保守性が向上する。

効率的なUI更新:

notifyListeners()が呼ばれると、Consumerの中身だけ再描画される。

つまりProviderを使うと、Consumerを使うことで再描画の範囲を開発者側が明示的に限定でき、不要なウィジェットの再構築を避けることが可能になる。

様々なproviderのmodelの呼び出し方

Provider.of:notifiListeners()で変更されたら呼ばれる。

Dart
final model = Provider.of<CountModel>(context);

context.watch:notifiListeners()で変更されたら呼ばれる。

Dart
final model = context.watch<CountModel>();

context.read:1回しか呼ばれない。notifiListeners()で変更されても呼ばれない。

Dart
final model = context.read<CountModel>();

context.select:特定のpropertyが更新された時だけ呼ばれる

Dart
final count = context.select<CountModel, int>(
      (CountModel model) => model.count,
    );

とはいえ、Providerでもコンテキスト依存・不変性・初期化の複雑さなどの問題が残る。この課題を解決したのがRiverpod。ProviderからRiverpodへの流れに関してはこちらの記事で記載。

コメント

タイトルとURLをコピーしました