【Flutter】ProviderとConsumerを使った状態管理

投稿者: | 2019年12月30日

概要

Flutterにおいて、状態管理の方法の一つであるProviderConsumerの使い方について紹介する。
サンプルとして、前回の記事【Flutter】GestureDetectorとCustomPaintを使い、タッチした場所に図形を描画するで作成したアプリを使う。まずはこちらを読み、アプリの内容を把握しておいてほしい。

前回はStatefulWidgetとsetState()を使って状態を扱っていたが、それをProviderConsumerを使った方法に書き換えていく。

  • 作成したコードはこちら
    (pointer_drawing_lessonプロジェクトのstate_managementブランチ)

dependencyの追加

ProviderConsumerを使うため、pubspec.yamlのdependencies:にproviderを追加する。

pubspec.yaml

dependencies:
  # ...
  # 省略
  # ...
  provider: ^4.0.0

指定したバージョンは現時点での最新バージョンなので都度調整してほしい。

状態クラスの作成

このアプリでは、状態とはタッチされた点のリストである。そこで、以下のようなクラスを作る。ここでChangeNotifierを継承しているが、このクラスに用意されたnotifyListeners()メソッドを使うと状態の変化を通知することができる。


// 状態クラス タッチされた点を記録する
class PointState extends ChangeNotifier {
  final _points = List<Offset>();

  // 変更不能なリストのビュー
  UnmodifiableListView<Offset> get points => UnmodifiableListView(_points);

  void add(Offset offset) {
    _points.add(offset);
    notifyListeners();
  }

  void clear() {
    _points.clear();
    notifyListeners();
  }
}

上記のコードではadd()メソッドの中で、リストに値を追加した後notifyListeners()を呼び、このクラスを使っているWidgetに対して状態の変化を通知している。リストの内容を初期化するclear()メソッドも用意してある。

状態の供給

状態を供給するには、アプリ全体をProviderでラップする必要がある。コードは以下のようになる。


void main() => runApp(ChangeNotifierProvider(
      create: (_) => PointState(),
      child: MaterialApp(
        title: 'Pointer drawing lesson',
        theme: ThemeData(
          primarySwatch: Colors.blue,
        ),
        home: PointerDrawingWidget(title: 'Pointer drawing lesson'),
      ),
    ));

ここでは、runApp()にChangeNotifierProviderを渡している。ChangeNotifierProviderは状態を供給するWidgetの一つで、ChangeNotifierのインスタンスを下位の子Widgetへ供給する。
create:に状態を構築するための関数を渡す。このようにするとchild:以下のWidgetは、Widgetツリーのどの階層にいたとしても後述するProvider.of<T>()を使って状態にアクセスすることができるようになる。

描画Widgetの修正

修正後の描画Widgetは以下のようになる。


class PointerDrawingWidget extends StatelessWidget {
  PointerDrawingWidget({Key key, this.title}) : super(key: key);

  final String title;

  @override
  Widget build(BuildContext context) {
    // 状態の取得
    // 状態が変化したことにより、リビルドが必要なのはCustomPaintのみのため、
    // ここではlisten:falseを指定し、 Scaffold全体がリビルドされるのを回避する
    final pointState = Provider.of<PointState>(context, listen: false);

    return Scaffold(
      appBar: AppBar(
        title: Text(title),
      ),
      body: GestureDetector(
        // TapDownイベントを検知
        onTapDown: (TapDownDetails details) {
          // タッチされた点を追加して状態を更新する
          pointState.add(details.localPosition);
        },
        // 状態のConsumer
        // 状態の変化が通知されると、リビルドされる
        child: Consumer<PointState>(
          builder: (BuildContext context, PointState value, Widget _) {
            // カスタムペイント
            return CustomPaint(
              painter: MyPainter(value.points),
              // タッチを有効にするため、childが必要
              child: Center(),
            );
          },
        ),
      ),
      floatingActionButton: FloatingActionButton(
        // タッチした点をクリアする
        onPressed: pointState.clear,
        tooltip: 'Clear',
        child: Icon(Icons.clear),
      ),
    );
  }
}

いくつかのポイントについて説明する。

  • StatelessWidgetを継承するよう修正
    状態はProviderから取得できるため、Widgetで保持する必要が無くなった。そのため、StatelessWidgetへと修正した。
  • 状態の取得
    Widgetのbuild()メソッドの中でProvider.of<T>()メソッドを使い、状態を取得する。この時、オプション引数のlisten:にはfalseを指定している。これは、状態が変化した時(ChangeNotifierのnotifyListeners()が呼ばれた時)にWidgetをリビルドするかしないかを指定するフラグである。デフォルトはtrueであり、この場合はbuild()メソッド内で構築したUI全体がリビルドの対象となる。しかし、このWidget全体(Scaffold以下)の中にはタイトルバーやボタンなど、状態が変化しても表示内容は変わらないWidgetが含まれている。これらのWidgetまでリビルドされてしまうのは無駄である。そのため、それを避けるために明示的にfalseを指定している。
  • 状態の変化によってリビルドが必要なWidgetはConsumerで囲む
    タイトルバーやボタンと違い、CustomPaintは「タッチにより点が追加された」ことをトリガーにして表示を更新する必要がある。そのためにはConsumerをchildとし、そのbuilder:にWidget構築のロジックを書く関数を渡す。関数の第2引数に更新された状態が渡ってくるので、それを使って新しい画面を構築する。

CustomPainter

CustomPainterは前回と変わらない。このクラスは、渡された内容をただ描画することに集中する。


// 描画クラス
class MyPainter extends CustomPainter {
  final List<Offset> _points;
  final _rectPaint = Paint()..color = Colors.blue;

  MyPainter(this._points);

  @override
  void paint(Canvas canvas, Size size) {
    // 記憶している点を描画する
    _points.forEach((offset) => canvas.drawRect(
        Rect.fromCenter(center: offset, width: 20.0, height: 20.0),
        _rectPaint));
  }

  @override
  bool shouldRepaint(CustomPainter oldDelegate) {
    return true;
  }
}

以上で前回のアプリのリファクタリングが完了した。

参考

Simple app state management(公式ドキュメント・英語)

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です