Chapter 22

[v0.14.0以下版] StateNotifierProviderで画面に対するViewModelを作成する

村松龍之介
村松龍之介
2023.02.12に更新

WidgetをViewとした場合、Viewに対する状態(State)と処理(Presentation logic)を別々のクラスに分けたい、というニーズはあると思います。

MVC, MVP, MVVM などがありますが、StateNotifier を使うことで MVVM に近い構成にできるのではないでしょうか?

MVVMで例える Widget と StateNotifier(Notifier + State) の役割

Model: StateNotifierのState部分
View: StateNotifierを利用するWidget(Page)
ViewModel: StateNotifierのNotifier部分

  1. WidgetがNotifierにユーザーアクションを伝える
  2. Notifierは適宜別クラスとも連携して処理を行い、Stateを更新する
  3. Stateを watch しているWidgetが再構築される

という処理の流れになります。

試しに書いてみる

実際に書いた方がわかりやすいと思うので、一つずつ定義していきます。

サンプル画面の機能

  • 数字を0からカウントアップできる(メイン・サブの2つを用意)
  • アプリ次回起動時も前回のカウントから再開できる

非常にシンプルな画面なのでStateProviderでも作ることができそうですが、
実際のアプリではもっと複雑になるのが常なので、その構成の一端と捉えてもらえればと思います。

まず、ファイル及びクラス構成を考えましょう。

  • home_page.dart
    • HomePage (View = 画面)
  • home_page_state.dart
    • HomePageState (Model = 状態)
  • home_page_notifier.dart
    • HomePageNotifier (ViewModel = 操作)

ここでは、画面、状態、操作にファイル・クラスを分けていきます。
なお、各命名はあくまで一例で、完全に好みで分かれることになるかと思います。

ここでは分かりやすいように、1画面1VMという前提のもと、全てのクラスに Page という文字を入れています。

「HomePage」を HomeScreen にする人もいれば、 HomeView とする人もいます。
「HomePageNotifier」は、 HomePageController と命名しても分かりやすいと思います。

Stateクラスの実装

まずは、ViewにもNotifierにも依存しないStateから定義していきましょう。

Freezed[1] を使用して状態クラスを生成しています。

home_page_state.dart
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
// 生成されるファイル名を指定する( `生成元ファイル名.freezed.dart` )
part 'home_page_state.freezed.dart';


class HomePageState with _$HomePageState {
  const factory HomePageState({
    /// Main Count
    (0) int mainCount,

    /// Sub Count
    (0) int subCount,
  }) = _HomePageState;
}

StateNotifierクラスの定義

StateNotifierの持つ状態クラスには、前項で作成した HomePageState を指定します。

必須ではありませんが、ViewModelではサービスクラスやリポジトリなど他のクラスとやりとりすることも少なくないと思います。
他のProviderにアクセスするために必要な Reader を引数で渡しています。
これは、後ほどProviderをグローバルに定義するときに渡します。

home_page_notifier.dart
import 'package:hooks_riverpod/hooks_riverpod.dart';

import 'home_page_state.dart';

class HomePageNotifier extends StateNotifier<HomePageState> {
  HomePageNotifier(this._read) : super(const HomePageState());
  // Reader 型をフィールドに持っておくことで、HomePageNotifierから他のProviderを読み取ることができるようになります
  final Reader _read;

  // メインカウントを+1する
  void increaseMainCount() {
    state = state.copyWith(mainCount: state.mainCount + 1);
  }

  // サブカウントを+1する
  void increaseSubCount() {
    state = state.copyWith(subCount: state.subCount + 1);
  }

  // すべてのカウントを0に戻す
  void resetAllCount() {
    state = state.copyWith(
      mainCount: 0,
      subCount: 0,
    );
  }
}

メイン・サブのカウントを増やすメソッドと、リセットするメソッドを定義しました。
StateNotifierを使うと、このようにView関連のロジックを任すことができます。

Providerの定義

ViewModelとして使うStateNotifierの場合、画面の破棄によってProviderも破棄させたいので autoDispose 修飾子を付けています。
そうすることで、一度 homePageNotifierProvider を参照している画面を閉じてからもう一度その画面を開くと、Stateも正しくリセットされます。

home_page_notifier.dart
final homePageNotifierProvider =
  StateNotifierProvider.autoDispose<HomePageNotifier, HomePageState>((ref) {
    // Reader を渡しています(不要なら省略可能)
    return HomePageNotifier(ref.read);
});

Widgetの定義

CounsumerWidgetHookWidget 等を使って StateNotifierProvider を読み取りましょう。

home_page.dart
class HomePage extends ConsumerWidget {
  const HomePage({Key? key}) : super(key: key);

  
  Widget build(BuildContext context, ScopedReader watch) {
    // HomePageState
    final pageState = watch(homePageNotifierProvider);
    // HomePageNotifier
    final pageNotifier = watch(homePageNotifierProvider.notifier);

    return Scaffold(
      floatingActionButton: FloatingActionButton(
        onPressed: pageNotifier.resetAllCount,
        child: Icon(Icons.exposure_zero),
      ),
      body: ListView(
        children: [
          ListTile(
            title: Text('Main Count ${pageState.mainCount}'),
            onTap: pageNotifier.increaseMainCount,
          ),
          ListTile(
            title: Text('Sub Count ${pageState.subCount}'),
            onTap: pageNotifier.increaseSubCount,
          ),
        ],
      ),
    );
  }
}
  • Stateのメイン・サブのカウント数を表示
  • タップでそれぞれをカウントアップ
  • FloatingActionButton押下でカウントリセット

が行えるWidgetとStateNotifierProviderを作成できました🎉

脚注
  1. https://pub.dev/packages/freezed ↩︎