Riverpod + StateNotifier + FreezedでState管理

  • #妊活ボイス
  • #Flutter
  • #StateNotifier
  • #Freezed
  • #Dart

Flutter アプリでの State 管理の話

こんにちは!CURUCUR の笠原です!

CURUCURU で運営している妊活ボイスにはアプリがあり、アプリは Flutter で開発しています。

「Flutter」「状態管理」などと検索すると多くの情報がヒットします。

  • StatefulWidget パターン
  • InheritedWidget パターン
  • Provider パターン
  • Provider + StateNotifier パターン
  • Riverpod パターン

この中で最もオーソドックスなのが StatefulWidget パターンです。

class CounterPage extends StatefulWidget {
	const CounterPage({Key? key}) : super(key:key);
	@override
	CounterPageState createState() => CounterPageState();
}

class CounterPageState extends State<CounterPage> {
	int _count = 0;

	@override
	Widget build(BuildContext context) => Column(
		children: [
			Text(_counter),
			OutlinedButton(onPressed: () => _increment(), child: Text('+')),
		],
	);

	void _increment() {
		setState(() => _count += 1);
	}
}

setState() を使ってフィールドの値を更新することで CounterPage 以下の Widget が再描画されます。 とてもシンプルで使え、簡単なアプリであればこれで十分な場合もあり妊活ボイスも当初は setState で State を管理していました。

しかし、シンプルすぎるゆえにいくつかの問題が発生しました。

  • const を指定しない全ての子 Widget が再描画されるので描画が遅くなることがある
  • コンストラクタの引数で state を渡していくやり方なので、引数が増えると管理が面倒
  • ロジックと View が同じファイルに書かれるため可読性が悪くなる

Provider の場合

InheritedWidget は祖先 Widget の状態を効率的に取得でき、Provider を使えば内部的に InheritedWidget を使うことになります。

Provider は ChangeNotifier 状態クラスを作成することでロジックと View を分離することができ、ソースの可読性などの問題が改善されています。 例えば以下のようなコードになります。

class CounterStore with ChangeNotifier {
	var count = 0;
	void increment() {
		count += 1;
		notifyListeners();
	}
}

これはかなり便利で一時期は flutter の公式も押していましたがこちらにもいくつかの問題があり、 特に変更のたびに notifyListeners() を呼び出すというのが面倒でした。

Riverpod の登場

setState() による問題を改善した Provider、さらにその Provider の問題を解決するべく登場したのが Riverpod です。 Riverpod は Provider の作者自身が開発しています。

使い方も簡単で

  1. State をラップした Provider を global に定義
  2. Widget 側から Provider を指定して read or watch で State を取得

プロバイダについて詳しい説明はこちらを参照 https://riverpod.dev/ja/docs/concepts/providers

StateNotifierProvider で State 管理

StateNotifierProvider は Riverpod が推奨する State 管理のソリューションです。 StateNotifierProvider を使うことで得られるメリットは2つです。

  1. Immutable な State を公開する(イベントによって変わることはある)
  2. State を変更するためのロジックを一元管理で保守性が高まる

StateNotifierProvider は StateNotifier という state を immutable に関するための状態管理クラスを使います。 Provider の項目で登場した ChangeNotifier とは異なり変更時に明示的な通知が不要です。

Riverpod とStateNotifierProvider でロジックと View の分離、そして簡潔なコードが書けるようになりました。 しかし新たな問題として state の更新のたびに新たな state オブジェクトを作成し、プロパティを変更して書き込みという手順が必要になりました。

Freezed で immutable なモデルの生成

Freezed は Riverpod と同じ作者が開発している immutable オブジェクトのコードジェネレータです。 build_runner と合わせて使うことで immutable モデルを定義し、JSON converter も自動で生成することができます。

実際に使ってみる

Riverpod + StateNotifier + Freezed を使えばもろもろの問題が解決することがわかったところで、実際にコードを書いてみます。

ディレクトリ構成

myapp
├── build.yaml
└── lib
    ├── app.dart
    ├── main.dart
    ├── models
    │   └── todo_state.dart
    └── notifiers
        └── todos_notifier.dart

build.yaml

  • build_runner を動かすための設定
  • include と exclude を設定することで高速化できる
  • json_serializable でシリアライズする設定を行っている
targets:
  $default:
    builders:
      freezed:
        enabled: true
        generate_for:
          exclude:
            - test
          include:
            - lib/**/*state.dart
      json_serializable:
        enabled: true
        generate_for:
          exclude:
            - test
          include:
            - lib/**/*state.dart
        options:
          # Options configure how source code is generated for every
          # `@JsonSerializable`-annotated class in the package.
          #
          # The default value for each is listed.
          #
          # For usage information, reference the corresponding field in
          # `JsonSerializableGenerator`.
          any_map: false
          checked: true
          create_factory: true
          create_to_json: true
          disallow_unrecognized_keys: false
          explicit_to_json: true
          field_rename: pascal
          generic_argument_factories: false
          ignore_unannotated: false
          include_if_null: true

lib/models/todo_state.dart

// freezed によって生成されるファイル filename.freezed.dart で宣言
part 'todo_state.freezed.dart';
// json serialize が必要なら指定 filename.g.dart で宣言
part 'todo_state.g.dart';

@freezed
class TodoState with _$TodoState {
  const TodoState._();
  const factory TodoState({
    @Default('') String id,
    @Default('') String text,
    @Default(false) bool done,
  }) = _TodoState;

  factory TodoState.fromJson(Map<String, dynamic> json) =>
		_$TodoStateFromJson(json);
}

ファイル作成時には *.freezed.dart と *.g.dart ファイルが存在しないのでエラーが発生するが、build_runner を実行すればエラーは解消される

$ flutter packages pub run build_runner build --delete-conflicting-outputs

lib/notifiers/todos_notifier.dart

final todosProvider =>
    StateNotifierProvider<TodosNotifier, List<TodoState>>((ref) =>
       TodosNotifier(ref.read)
    });

class TodosNotifier extends StateNotifier<List<TodoState>> {
  TodosNotifier(this.read) : super([]);

  void set(List<TodoState> items) {
	  state = items;
  }

  void add(TodoState item) {
	  state = [...state, item];
  }

  void remove(String id) {
	  state = state.where((t) => t.id != id).toList();
  }

  void toggleDone(String id) {
	  state = state
	  	.map((t) => t.id == id ? t.copyWith(done: !t.done) : t)
		.toList();
  }
}

lib/main.dart

Future<void> main() async {
	// ~~~ 初期化処理 ~~~
	runApp(
		// ProviderScope 以下の Widget で Riverpod が有効になるになる
		ProviderScope(child: App()),
	);
}

lib/app.dart

class App extends StatelessWidget {
	const App({Key? key}) : super(key:key);

	@override
	Widget build(BuildContext context) => Column(
		children, [
			TodoForm(),
			Expanded(child: TodoItems()),
		],
	);
}

class TodoForm class ConsumerStatefulWidget {
	const TodoForm({Key? key}) : super(key:key);
	@override
	TodoFormState createState() => TodoFormState();
}

class TodoFormState class ConsumerState<TodoForm> {
	String text;

	@override
	Widget build(BuildContext context) => Row(
		children: [
			TextField(onChange: handleChange),
			OutlineButton(
				onPressed: () => submit(),
				child: Text('送信'),
			)
		],
	);

	void submit() {
		final newTodo = TodoState(id: 'id0', text: text, done: false);
		// providerName.notifier で notifier のメソッド呼び出し
		ref.read(todosProvider.notifier).add(newTodo);
		setState(() => text = '');
	}

	void handleChange(String s) {
		setState(() => text = s);
	}
}

class TodoItems extends ConsumerWidget {
	const TodoItems({Key? key}) : super(key:key);

	@override
	Widget build(BuildContext context, WidgetRef ref) {
		// watch で変更を検知してこの Widget 以下の Widget を再描画
		// watch をどこで宣言するかによって再描画する範囲を制御できる
		ref.watch(todosProvider);
		return ListView.builder(
			itemBuilder: TodoItemBuilder, // Todo item Widget を削除したり done できる
			itemCount: todosProvider.length,
		),
	}

}

省いている部分もありますが以上の方法で効率的に State を管理でき、ソースも見やすくなります。 妊活ボイスでは主要ページから Riverpod を使うように書き直している最中です。

最後に

CURUCURU では新しい技術やサービス作りが好きなエンジニアを募集中です! 「ITで女性のライフスタイルを豊かにする」というミッションの元やりがいのあるサービス作りに携わる事ができます。

CURUCURU でエンジニアとして働くことに興味がある方はよければオンラインでカジュアルにお話しましょう! https://www.wantedly.com/companies/curucuru/projects