👩‍💻

Flutter: Riverpod(v2)を使ったViewModelの作り方

2023/09/02に公開

概要

Flutterで riverpod という状態管理ライブラリを使って、MVVMアーキテクチャにおけるViewModelを作成する。

riverpodは、v2になり色々と作成方法も変わったので、基本v2でアノテーションによるコード生成を使う前提で記載。

使用するライブラリ

freezedはViewModelがViewに対して公開する状態を管理するために使用。
(Stateはimmutableであることが好ましいため。)

Flutterで新規プロジェクト作成時のカウンターアプリ

簡単なサンプルとして、Flutterのプロジェクト作成時に生成されているカウンターアプリの機能をViewModelを使って実装する。

sample_image

[初期状態のコード]

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key, required this.title});
  final String title;

  
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  int _counter = 0;

  void _incrementCounter() {
    setState(() {
      _counter++;
    });
  }

  
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(widget.title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$_counter',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: _incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ), // This trailing comma makes auto-formatting nicer for build methods.
    );
  }
}

ディレクトリ構成

lib/
├── main.dart
└── widget/
    ├── app.dart
    └── pages/
        └── home/
            ├── home_page.dart
            ├── view_model.dart
            └── ui_state.dart

pages/ ディレクトリを作成し、各ページごとに一つのディレクトを作成する。

  • home_page.dart: 実際のページウィジェット(View)
  • view_model.dart: ViewModel
  • ui_state.dart: ViewModelがViewに公開する状態

※ view_model.dart, ui_state.dartについては、コード生成により、生成される <file名>.g.dart も一緒に作成される。

状態ファイルの作成

import 'package:freezed_annotation/freezed_annotation.dart';

part 'ui_state.freezed.dart';


class UiState with _$UiState {
  const factory UiState({
    required int counter,
  }) = _UiState;
}

Viewに公開したい情報は、インクリメントされるカウンターの数値のためこれをプロパティとして持つUiStateというクラスを作成する。

freezedによりコード生成するため、@freezed アノテーションと ui_state.freezed.dart というファイルを part で読み込む必要がある。

dart run build_runner build

というコマンド実行で、freezedの生成ファイル(ui_state.freezed.dart)が生成される。

ViewModelファイルの作成

import 'package:riverpod_annotation/riverpod_annotation.dart';

import 'ui_state.dart';

part 'view_model.g.dart';


final class ViewModel extends _$ViewModel {
  
  UiState build() {
    return const UiState(
      counter: 0,
    );
  }
}

RiverpodでViewModelを作るときは、NotifierProviderを使う。

NotifierProviderを自動生成するときは、build というメソッドをoverrideして、ここで先程のUIStateを初期化する。

freezedと同様にこちらも、コード生成するため、 @riverpodpart 'view_model.g.dart'; を記載して、

dart run build_runner build

でコード生成する。

ViewModelファイルに incrementCounter メソッドを追加する


final class ViewModel extends _$ViewModel {
  
  UiState build() {
    return const UiState(
      counter: 0,
    );
  }

  void incrementCounter() {
    state = state.copyWith(counter: state.counter + 1);
  }
}

ViewからViewModelに対して、カウンター増加を通知するためのメソッドを追加する。

stateプロパティから、現在管理しているUiStateインスタンスにアクセスでき更新が可能。

freezedは、一部のプロパティを更新した新しいインスタンスを copyWith というメソッドをもっていて、これを使うことで、counterを更新した新しいUiStateのインスタンスを生成して、stateを更新することができる。

state = state.copyWith(counter: state.counter + 1);

ViewModelを利用するPageウィジェットの完成コード

class HomePage extends ConsumerWidget {
  const HomePage({super.key, required this.title});
  final String title;

  
  Widget build(BuildContext context, WidgetRef ref) {
    final viewModel = ref.read(viewModelProvider.notifier);
    final counter = ref.watch(viewModelProvider);
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(title),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            const Text(
              'You have pushed the button this many times:',
            ),
            Text(
              '$counter',
              style: Theme.of(context).textTheme.headlineMedium,
            ),
          ],
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: viewModel.incrementCounter,
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}

Riverpodを利用するウィジェットは ConsumerWidget を継承する必要がある。

NotifierProviderからViewModelを参照する場合、 viewModelProvider.notifier のようにnotifierをreadすればよい。
(viewModelは変更を受け取る必要がないため、ref.readを使う)

NotifierProviderからUiStateを参照する場合、viewModelProviderをref.watchを使って参照する。
(UiStateは変更を検知する必要があるため、watchを使う)

実際のコード

https://github.com/morooka-akira/flutter_architecture

Discussion