[Flutter]riverpodによる状態管理
はじめに
MVVMとRepositoryパータンを使うことを前提として、Riverpod、StateNotifier、Freezedと組み合わせてFlutterでの状態管理についてまとめてみました。
MVVM
MVVM(Model-View-ViewModel)アーキテクチャは、アプリケーションをModel(モデル)、View(ビュー)、ViewModel(ビューモデル)の3つの主要なコンポーネントに分割するデザインパターンです。
Repositoryパータン
Repositoryパターンは、データの永続化(データベース、外部APIなど)に関する操作を抽象化することを目的としています。このパターンは、データの取得や保存、更新などの責務を、高レベルのビジネスロジックから分離することで、システム全体の保守性と拡張性を向上させます。
Riverpod
Riverpodは、Flutterでの状態管理と依存性の注入のためのパッケージです。Providerベースの状態管理として知られており、ウィジェットツリー全体で状態を提供および消費することができます。Riverpodは、ウィジェットの再構築を最小限に抑えながら状態の変更をリッスンできるようにします。
StateNotifier
StateNotifierは、Flutterでの状態管理のためのクラスです。StateNotifierを使用すると、状態を保持し、変更通知を行うことができます。Flutterアプリのビジネスロジックや状態管理の中心となるコンポーネントとして使用されます。StateNotifierを継承したクラスを作成し、そのクラスのインスタンスをRiverpodプロバイダを介してウィジェットツリーに提供します。
Freezed
Freezedは、イミュータブルなデータクラスを簡単に生成するためのパッケージです。Freezedを使用すると、データのイミュータブル性を簡単に実現でき、エクイタブル(Equatable)なクラス、JSONシリアライズ、デシリアライズのサポートなどが提供されます。これにより、状態オブジェクトを効果的に作成し、ミュータブルなデータと比較して安全かつ保守的なコーディングを実現することができます。
apiから取得したチャット一覧をサイドバーに表示し、riverpodによる状態管理を使ったチャット一覧のUIの再描画を行う例になります。
ディレクトリ構造
.
├── Makefile
├── README.md
├── analysis_options.yaml
├── android
├── assets
├── build
├── build.yaml
├── ios
├── lib
│ ├── entity
│ │ ├── chat
│ │ │ ├── chat.dart
│ ├── env
│ ├── firebase_options.dart
│ ├── js
│ ├── main.dart
│ ├── model
│ ├── repository
│ │ ├── chat_repository.dart
│ ├── state
│ │ ├── side_bar
│ │ │ ├── side_bar_state.dart
│ │ │ └── side_bar_state.freezed.dart
│ ├── utility
│ ├── view_model
│ │ ├── side_bar_view_model.dart
│ └── widget
│ ├── components
│ │ ├── side_bar
│ │ │ │ ├── side_bar.dart
├── linux
├── macos
├── pubspec.lock
├── pubspec.yaml
├── test
├── web
└── windows
tl;dr
- パッケージをインストールする
-
ProviderScope
を追加する - modelを定義する
- apiをリクエストするメソッドを作成する
- stateを定義する
- view modelを作成する
- viewを作成する
パッケージをインストールする
flutter pub add flutter_riverpod
flutter pub add riverpod_annotation
flutter pub add hooks_riverpod
flutter pub add dev:riverpod_generator
flutter pub add dev:build_runner
flutter pub add dev:custom_lint
flutter pub add dev:riverpod_lint
flutter pub get
を実行してパッケージをインストールします。
ProviderScope
を追加する
アプリのルートにProviderScope
を追加します。
そうすることで、アプリ全体でRiverpodが有効になります。
void main() {
runApp(
ProviderScope(
child: MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp();
}
}
Chatモデルを定義する
APIから受け取るデータのモデルを定義する必要があります。
このモデルには、JSONオブジェクトをDartクラスのインスタンスにパースします。
一般的に、JSONのデコードを処理するために、Freezed
やjson_serializable
のようなコードジェネレータを使用することが多いです。
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import '../message/message.dart';
part 'chat.freezed.dart';
part 'chat.g.dart';
/// `GET /api/chats` エンドポイントのレスポンス。
/// `freezed` と `json_serializable` を用いて定義する
class Chat with _$Chat {
const factory Chat({
required int id,
required int userId,
required String createdAt,
required List<Message>? messages,
}) = _Chat;
/// JSONオブジェクトを[Chat]インスタンスに変換する
/// これにより、APIレスポンスのタイプセーフな読み込みが可能になる
factory Chat.fromJson(Map<String, dynamic> json) =>
_$ChatFromJson(json);
}
flutter pub run build_runner build
を実行します。
apiをリクエストするメソッドを作成する
import 'dart:async';
import 'dart:convert';
import 'package:http/http.dart' as http;
import '../../entity/chat/chat.dart';
class ChatRepository {
Future<List<Chat>> getChats() async {
final url = //endpoint;
try {
final response = await http.get(Uri.parse(url));
if (response.statusCode >= 400) {
throw CustomErrorException(code: response.statusCode);
} else {
final List<dynamic> responseJson = json.decode(utf8.decode(response.bodyBytes));
final List<Chat> chatThread = responseJson
.map((json) => Chat.fromJson(json))
.toList();
return chatThread;
}
} catch (e) {
rethrow;
}
}
}
stateを定義する
stateはアプリケーションの状態を表すもので、通常は UI の表示や動作に関連するデータを保持します。
View Modelに持たせたい状態(プロパティ群)を内包したstateクラスを作ります。
Freezedで生成します。StateNotifierを使う時は基本的にFreezedとセットで使います。
stateはView Modelが継承する StateNotifier の値として使用されます。
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../entity/message/message.dart';
import '../../entity/chat/chat.dart';
part 'side_bar_state.freezed.dart';
class SideBarState with _$SideBarState {
const factory SideBarState({
// 値の変更をトリガーにUIの再描画を行わせたいプロパティ(状態)を持たせる
([]) List<Chat> chatThread,
}) = _SideBarState;
}
freezed パッケージによって生成される freezed コードでは、イミュータブルなクラスが作成され、copyWith
メソッドなどが提供されます。
自動生成時に1つの元ファイルに対して追加で1つのxxx.freezed.dart
というファイルが生成されるのでまとめるためディレクトリを作ります。
flutter pub run build_runner build
を実行します。
view modelを作成する
View Modelは独自のStateを値としたStateNotifierを継承したクラスです。
View ModelをStateNotifierProviderとしてグローバル定義し、View側からはWidgetRefを通じてアクセスすることができます。
StateNotifierProviderはRiverpodのプロバイダで、StateNotifierのインスタンスをウィジェットツリーに提供することができます。
1つのView Modelは必ず1つのStateを持っているので、stateディレクトリで定義したStateと対応したside_bar_view_model.dart
を作成します。
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import '../entity/chat/chat.dart';
import '../repository/chat_repository.dart';
import '../state/side_bar/side_bar_state.dart';
final sideBarViewModel =
StateNotifierProvider<SideBarViewModel, AsyncValue<SideBarState>>(
(ref) => SideBarViewModel(
ChatRepository(),
ref,
),
);
View Modelとして使っているStateNotifierProviderに持たせるStateを定義します。
SideBarViewModelは StateNotifier
を継承したカスタムクラスで、アプリのサイドメニューに関連する状態を管理するために使用されます。
StateNotifierProviderの2番目のtypeパラメータは、StateNotifier
が管理する状態のタイプを指定します。
AsyncValue<SideBarState>
は SideBarViewModel
によって管理される状態が非同期であり、ロード、データ、エラーなどの異なる状態を持つことができることを示しています。
プロバイダ内部の初期化関数は、ウィジェットが初めてsideBarViewModelプロバイダを要求したときに呼び出されます。この関数は、SideBarViewModelのインスタンスを作成し、ウィジェットツリーに提供します。
SideBarViewModelクラスを作成します。
class SideBarViewModel extends StateNotifier<AsyncValue<SideBarState>> {
SideBarViewModel(
this._chatRepository,
this.ref,
) : super(const AsyncValue.loading()) {
state = const AsyncValue.data(SideBarState());
getChats();
}
}
SideBarViewModel は StateNotifier<AsyncValue<SideBarState>>
を継承しているカスタムクラスです。管理する状態は AsyncValue<SideBarState>
となります。
コンストラクタはいくつかの依存関係 (_chatRepository
, ref
) を受け取り、スーパークラス (StateNotifier
) の初期状態を AsyncValue.loading()
で初期化します。
スーパークラスを初期化した直後に、状態を AsyncValue.data(SideBarState())
に設定します。
SideBarViewModel
は、初期状態の設定、依存関係の初期化、Flutterアプリのサイドバーに関連するデータを取得するための関数の起動を担当します。
関数を定義します。
Future<List<Chat>> getThreads() async {
final currentChatThread = await _chatRepository.getThreads();
final newState = state.value?.copyWith(chatThread: currentChatThread);
if (newState != null) {
state = AsyncValue.data(newState);
}
}
_chatRepository.getThreads()
からチャット一覧を取得し、currentChatThread
に格納します。
既存の状態(state.value)をコピーして新しい状態(newState)を作成し、新しく取得と更新されたcurrentChatThread
でchatThread
プロパティを更新します。
Stateは基本的にViewModelの中で変更をします。copyWith()
メソッドを使ってstateの値を更新します。
考え方としては、stateの値を直接変更するのではなく、copyWithで新しく作成したstateクラスで上書きするイメージです。
newState が null
でない場合、state = AsyncValue.data(newState)
を使用して、新しい状態で SideBarViewModel
の状態を更新します。
viewを作成する
Riverpodは、プロバイダをreadする時にConsumerWidget/ConsumerStatefulWidgetを定義することができます。
ConsumerWidgetとConsumerStatefulWidgetは、StatelessWidget/StatefulWidgetとConsumerを合わせたものでref
を提供するという利点があります。
Riverpodは、Refというものを通じて他の画面とかの状態(グローバルで定義してる各種provider)に簡単にアクセスすることができます。
- ViewModel自体へのアクセス
ref.read({providerの名前}.notifier) ← .notifierをつける
- ViewModelが持つStateへのアクセス
ref.read({providerの名前}) ← read 又は watch
viewにHookConsumerWidgetなどを継承した画面単位のクラスを定義します。
buildメソッド内の引数でWidgetRefを受け取り、下記の処理を行います。
- Stateの値の変更を監視して表示。watchによって自動再描画
- ボタンタップなどのUIイベントをfunction経由でViewModelに伝達して処理を行わせる
- ViewModelからのイベント通知を購読してUI表示など任意の処理を行う(APIエラーの表示など)
class SideBar extends HookConsumerWidget {
SideBar({super.key});
final ScrollController _scrollController = ScrollController();
Widget build(BuildContext context, WidgetRef ref) {
// .notifierをつけるとviewModelとして使ってるprovider自体のメソッドとか変数とかにアクセスできる。ViewModelは基本的にread
final SideBarViewModel viewModel = ref.read(sideBarViewModel.notifier);
// .notifierをつけないと、そのViewModelが持ってるStateにアクセスできて、View側では基本的にwatchで監視して、stateが更新されたタイミングで画面が自動更新される仕組みにして使う
final AsyncValue<SideBarState> viewModelState =
ref.watch(sideBarViewModel);
final List<Chat> chatThreadList =
ref.read(sideBarViewModel.notifier).chatThread;
}
AsyncValueでラップされてる場合はstateの値へのアクセス時に下記の様に.value?
の1クッション必要になるので忘れないでください。また、value以降はオプショナル型になります。
chatThreads: viewModelState.value?.chatThread ?? [],
widgetをビルドされる時に関数を呼び出してチャット一覧を取得します。
チャット一覧のUIを作成します。
ListView.builder(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
padding: const EdgeInsets.only(),
itemCount: chatThreads.length,
itemBuilder: (BuildContext context, int index) {
final Chat thread = chatThreads[index];
final Message lastMessage = thread.messages.last;
return Material(
child: ListTile(
title: Row(
children: <Widget>[
Expanded(
child: Text(
lastMessage.text, // Assuming Message has a 'text' property
style: const TextStyle(
fontSize: 16,
color: Colors.black87,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
},
),
終わりに
簡単ですが、MVVMとRepositoryパータンを使って、Riverpodで状態管理した一覧画面についてまとめてみました。
Discussion