🎉

【Flutter】色んな状態管理で作ってみよう ⑧StateNotifier編

2022/02/11に公開

※こちらの記事は【Flutter】色んな状態管理手法でカウンターアプリを作ってみるの一部として作成された記事です

今最もホットな状態管理手法 StateNotifier

さて今回は今最もホットな状態管理手法とも言えるStateNotifierでカウンターアプリを書いていきたいと思います。

使用するPackage:

概要

  • Provider、Freezed、Riverpodなどの作者でもあるRemi Rousselet氏が開発
  • 単一の値を保持し、その値の変更を通知するValueNotiiferの拡張版のようなクラス
  • RiverpodFreezedと併せて使う例がよく見られる
  • 初版リリースは2020年3月

全体像

特徴:

単一の状態変数を保持し、その変数の変更を自動で通知する

ChageNotifierが複数の変数を管理し、notifyListenersメソッドで明示的に変更を通知し、再描画などを促すのに対し、StateNotifierは単一の状態変数だけをスコープに持ち、その変数に変化があった場合、自動的に通知を飛ばす。

その為、複数の状態変数を管理したい場合は、状態クラスに複数のフィールドを持たせ管理する事になります。

実際には状態クラスが持つフィールドを直接変更する事も出来ますが、状態クラスはデータを保持するだけのクラスなのでなるべくimmutableな管理をする事でより堅牢な管理が可能になります。

immutableな管理を行う事が多い為、freezedなどimmutableなクラスを生成するツールと組み合わせる事が多いようです。

依存関係の注入:

依存関係の注入は同じ開発者が作成したProviderRiverpodどちらかのパッケージと組み合わせて行います。

今回はStateNotifierパッケージに用意されているStateNotifierProviderを使いますが、providerパッケージのProviderと同じです。Widgetツリーに沿って依存関係を下に流していきます。

キーとなるクラスやメソッド

  • StateNotifierクラス:状態管理クラスが継承するクラス
  • StateNotifierProviderクラス:状態管理クラスを注入するクラス

準備

具体的にカウンターアプリを例に見ていきましょう。
サンプルコードはこちら

1. Stateクラス

前述の通り、immutableな状態クラスを定義します。


class CounterState {
  CounterState({this.count});
  final int count;
}

freezedなどと組み合わせる事が多い様ですが、今回はシンプルにする為、そういったパッケージは用いません。

2. 状態管理クラス

  • StateNotifierクラスを継承する状態管理クラスを定義します。

  • StateNotifierが管理する状態クラスの型を明示します。(今回はCounterStateクラス)

  • stateで管理している状態変数にアクセスする事ができます。

  • コンストラクタで渡す初期値も、状態値を変更するメソッドでもstateに新しいCounterStateインスタンスを代入しています。

class CounterStateNotifier extends StateNotifier<CounterState> {
  CounterStateNotifier() : super(CounterState(count: 0));

  void increment() => state = CounterState(count: state.count + 1);
  void decrement() => state = CounterState(count: state.count - 1);
  void reset() => state = CounterState(count: 0);
}

3. 依存関係の注入

  • StateNotifierパッケージに用意されているStateNotifierProviderクラスを使って、依存関係を注入します。
  • とはいえ裏でproviderパッケージを使っているので実際には同じproviderです。
    • createフィールドで状態管理クラスCounterStateNotifierをインスタンス化
  • childフィールドに定義した_StateNotifierCounterPage widgetにインスタンスを注入
  
  Widget build(BuildContext context) {
    return StateNotifierProvider<CounterStateNotifier, CounterState>(
      create: (context) => CounterStateNotifier(),
      child: const _StateNotifierCounterPage(),
    );
  }

これで準備は整いました。

状態へのアクセス

前述の通り、裏でproviderを使ってるので状態へのアクセスもproviderと同じcontext.watchcontext.readを使いたいと思います。

状態値の監視

  • context.watch()で状態値の変更を監視し、変更に応じてwidgetを再描画します。
  • ただbuildメソッド内でcontext.watch()を定義してしまうと状態が変化する度にwidgetが丸ごと再描画されてしまいます。
  Widget build(BuildContext context) { 
    // ProviderCounterStateの値が変わる度にbuildメソッドが走る
    final ProviderCounterState state =
        context.watch<CounterStateNotifier>(); 
    return Scaffold(
      appBar: MainAppBar(
        title: 'StateNotifier x Provider',
        ...
  • これでは再描画する単位が大きくパフォーマンスが悪いので、変更に応じてラップしているwidgetだけを再描画するConsumerクラスを使用する事で再描画される範囲を限定する事が出来ます。
  Consumer<CounterState>(
    builder: (context, state, _) => Text(
      state.count.toString(),
      style: Theme.of(context).textTheme.headline4,
    ),
  ),
  • 実際に状態値を参照して描画しているText widgetだけをConsumerクラスでラップする事で状態値が変更しても再描画されるのはラップされたText Widgetだけになりました。

メソッドへのアクセス

状態変数を変更するメソッドへのアクセスは単発的に状態管理クラスにアクセスするcontext.read()を使います。

  FloatingActionButton(
    onPressed: () => context.read<CounterStateNotifier>().increment(),
    tooltip: 'Increment',
    heroTag: 'Increment',
    child: Icon(Icons.add),
  ),

全体

import 'package:flutter/material.dart';
import 'package:flutter_state_notifier/flutter_state_notifier.dart';
import 'package:provider/provider.dart';
import 'package:state_management_examples/widgets/main_appbar.dart';
import 'package:state_notifier/state_notifier.dart';

// 状態クラス

class CounterState {
  CounterState({this.count});
  final int count;
}

// 状態管理クラス
class CounterStateNotifier extends StateNotifier<CounterState> {
  CounterStateNotifier() : super(CounterState(count: 0));
  // when not using freezed, you need to substitute new State into managed state
  void increment() => state = CounterState(count: state.count + 1);
  void decrement() => state = CounterState(count: state.count - 1);
  void reset() => state = CounterState(count: 0);
}

// 依存関係の注入
class StateNotifierCounterPage extends StatelessWidget {
  const StateNotifierCounterPage({Key key}) : super(key: key);

  
  Widget build(BuildContext context) {
    return StateNotifierProvider<CounterStateNotifier, CounterState>(
      create: (context) => CounterStateNotifier(),
      child: const _StateNotifierCounterPage(),
    );
  }
}

// カウンター本体
class _StateNotifierCounterPage extends StatelessWidget {
  const _StateNotifierCounterPage({Key key}) : super(key: key);

  
  Widget build(BuildContext context) {
    print('rebuild!');

    return Scaffold(
      appBar: MainAppBar(
        title: 'StateNotifier x Provider',
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'You have pushed the button this many times:',
            ),
            // Consumer is also valid for stateNotifier solution to narrow the rebuild scope
            Consumer<CounterState>(
              builder: (context, state, _) => Text(
                state.count.toString(),
                style: Theme.of(context).textTheme.headline4,
              ),
            ),
          ],
        ),
      ),
      floatingActionButton: FittedBox(
        child: Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          children: <Widget>[
            FloatingActionButton(
              onPressed: () => context.read<CounterStateNotifier>().increment(),
              tooltip: 'Increment',
              heroTag: 'Increment',
              child: Icon(Icons.add),
            ),
            const SizedBox(width: 16),
            FloatingActionButton(
              onPressed: () => context.read<CounterStateNotifier>().decrement(),
              tooltip: 'Decrement',
              heroTag: 'Decrement',
              child: Icon(Icons.remove),
            ),
            const SizedBox(width: 16),
            FloatingActionButton.extended(
              onPressed: () => context.read<CounterStateNotifier>().reset(),
              tooltip: 'Reset',
              heroTag: 'Reset',
              label: Text('RESET'),
            ),
          ],
        ),
      ),
    );
  }
}

コード元

以上でした

いかがでしたでしょうか?

State Notifierを解説する記事はStateNotifier x Riverpod (x Freezed)という構成で書かれているが非常に多いですが、今回は「状態管理パッケージと依存注入パッケージ」は違うんだという事を分かってもらいたくて、あえてRiverpodを使わないで紹介してみました。

とはいえ強い人に状態管理と依存注入で併せて状態管理だろ、と言われたら「へへー(平伏)」となる程度に個人的な理解なので、実際に触ってみて各々のわかりやすい理解をしていって頂いたら良いかなと思います

参考

Discussion