【Flutter】Riverpod × MVVM + Repositoryパターンのサンプル公開!明確な役割分担と拡張性を得る
はじめに
Flutterアプリ開発において、規模が大きくなるにつれて「どこに何を書くか」というアーキテクチャの設計は非常に重要になります。
今回は、Riverpod Generator を活用した MVVM + Repositoryパターン の実装例とそのメリットを解説します。
この構成を採用することで、UI(View)、状態管理(ViewModel)、データアクセス(Repository)の役割が明確になり、テストや保守が容易な拡張性の高いコードを実現できます。
参考
関連記事
同様のテーマで非同期処理を持っていないサンプルはこちらで紹介しています。
アーキテクチャの全体像
今回のアーキテクチャにおける各層の役割は以下の通りです。
- Model: データの構造を定義(Freezedを使用)。
- Repository: データの取得・保存などの「外部との通信」を担当。
- ViewModel (Notifier): UIが必要とする状態を管理し、Repositoryからデータを取得して加工する。
- View: ユーザーインターフェース。ViewModelの状態を監視し、描画する。
処理の流れ
MVVM + Repositoryパターンにおける流れは以下のようになります。
View → ViewModel (Notifier) → Repository (API)
↓
(結果が返る)
↓
ViewModel が Model のメソッドを使って State を更新 → View に反映
フォルダ構成
lib/
├── main.dart
├── models/
│ ├── user.dart
│ ├── user.freezed.dart
│ ├── user.g.dart
│ ├── user_list.dart
│ └── user_list.freezed.dart
├── repository/
│ ├── user_repository.dart
│ └── user_repository.g.dart
├── view_models/
│ ├── user_list_notifier.dart
│ └── user_list_notifier.g.dart
└── views/
└── user_list_page.dart
構成の概要
-
models/: データ構造(
User,UserList)を定義。Freezedにより生成されたファイル(.freezed.dart,.g.dart)が含まれます。 -
repository/: データ取得・保存などの通信周りを担当(
UserRepository)。 -
view_models/: UIの状態管理とロジックを担当(
UserListNotifier)。 -
views/: ユーザーインターフェース(
UserListPage)。 - main.dart: アプリのエントリーポイント。
0. アプリのエントリーポイント
アプリのエントリーポイントです。Riverpodを利用するための設定が含まれています。
lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mvvm_riverpod/views/user_list_page.dart';
void main() {
// ProviderScopeはRiverpodのプロバイダーの状態を管理するウィジェットです。
// これでラップすることで、アプリ全体でrefを使って状態にアクセスできるようになります。
runApp(ProviderScope(child: const App()));
}
class App extends StatelessWidget {
const App({super.key});
Widget build(BuildContext context) {
return MaterialApp(
// ホーム画面としてUserListPageを指定
home: UserListPage(),
);
}
}
1. Model層:イミュータブルなデータ定義
まずはデータの型定義です。Freezed を使用してイミュータブル(不変)なクラスとして定義することで、予期せぬデータの書き換えを防ぎます。
ここでは単なるデータ保持だけでなく、ドメインロジック(リストへの追加処理など)をモデル内に閉じて実装している点がポイントです。
UserListは状態として扱うデータを表す型です。
lib/models/user_list.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:mvvm_riverpod/models/user.dart';
part 'user_list.freezed.dart';
abstract class UserList with _$UserList {
// カスタムメソッド(addUserなど)を追加するためにプライベートコンストラクタが必要です
const UserList._();
// ユーザーのリストを保持するファクトリ
const factory UserList({required List<User> users}) = _UserList;
// ユーザーを追加するドメインロジック
// Notifier(ViewModel)にロジックを書くのではなく、モデル自身に「どう変更されるか」を定義します。
// イミュータブルなので、新しいリストを持った新しいUserListインスタンスを返します(copyWithを使用)。
UserList addUser(User user) {
return copyWith(users: [...users, user]);
}
}
User クラスも同様に Freezed と JsonSerializable を組み合わせて定義します。
lib/models/user.dart
import 'package:freezed_annotation/freezed_annotation.dart';
// 生成されるファイル(part宣言)
part 'user.freezed.dart';
part 'user.g.dart';
// @freezedアノテーション:イミュータブルなクラスやcopyWithメソッドなどを自動生成します
abstract class User with _$User {
// コンストラクタ:IDと名前を持つシンプルなデータ構造
const factory User({
required String id,
required String name,
}) = _User;
// JSONからUserオブジェクトを生成するためのファクトリメソッド
// _$UserFromJsonは user.g.dart で生成されます
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
2. Repository層:データアクセスの抽象化
Repositoryは「データの出どころ」を隠蔽する役割を持ちます。ここでは Dio を使ったAPI通信を行っていますが、ViewModel側はDioの存在を知る必要がありません。
Riverpodの @riverpod アノテーションを使うことで、プロバイダーの定義が自動生成され、依存性の注入(DI)が容易になります。
lib/repository/user_repository.dart
import 'package:dio/dio.dart';
import 'package:mvvm_riverpod/models/user.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'user_repository.g.dart';
// @riverpodアノテーション:UserRepositoryのプロバイダーを自動生成します
// keepAlive: false(デフォルト)なので、使われなくなると破棄されます(APIクライアントなどはtrueにすることもありますが今回は簡易実装)
UserRepository userRepository(Ref ref) {
// HTTPクライアントDioのインスタンス生成
// 本来はDio自体もプロバイダー化して管理するのが一般的です
final dio = Dio(BaseOptions(baseUrl: 'http://localhost:3000'));
return UserRepository(dio);
}
class UserRepository {
final Dio _dio;
UserRepository(this._dio);
// ユーザー一覧を取得するメソッド
Future<List<User>> fetchUser() async {
// GETリクエスト
final response = await _dio.get('/users');
// レスポンスデータをリストとしてキャスト
final usersJson = response.data as List<dynamic>;
// JSONのリストをUserオブジェクトのリストに変換
return usersJson.map((json) => User.fromJson(json as Map<String, dynamic>)).toList();
}
// ユーザーを作成するメソッド
Future<User> createUser(User user) async {
// POSTリクエストでユーザーデータを送信
final response = await _dio.post('/users', data: user.toJson());
// 作成されたユーザーデータをレスポンスからパースして返す
return User.fromJson(response.data as Map<String, dynamic>);
}
}
3. ViewModel (Notifier)層:状態管理とロジック
ここがMVVMの要となるViewModelです。AsyncNotifier を継承することで、非同期データの「ローディング中」「エラー」「データ取得完了」といった状態を安全に管理できます。
特徴的なのは build メソッドでの初期化と、メソッドを通じた状態の更新です。
lib/view_models/user_list_notifier.dart
import 'package:mvvm_riverpod/models/user.dart';
import 'package:mvvm_riverpod/models/user_list.dart';
import 'package:mvvm_riverpod/repository/user_repository.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'user_list_notifier.g.dart';
// @riverpodをつけることで、自動的にプロバイダー(userListProvider)が生成されます
// AsyncNotifierを継承したクラスとして生成され、非同期状態(Loading, Error, Data)を扱えます
class UserListNotifier extends _$UserListNotifier {
// 初期化処理。このメソッドの戻り値が state の初期値(AsyncValue<UserList>)になります。
Future<UserList> build() async {
// リポジトリのプロバイダーを取得(watchすることでリポジトリが変わればここも再実行される構成)
final repository = ref.watch(userRepositoryProvider);
// データを取得
final users = await repository.fetchUser();
// 取得したデータでUserListモデルを作成して返す
return UserList(users: users);
}
// ユーザー追加のアクション
Future<void> addUser(String name) async {
// 現在のstateの値(AsyncValue.dataの中身)を取得
final currentUserList = state.value;
// データがロードされていない、またはエラーの場合は何もしないガード節
if (currentUserList == null) {
return;
}
// IDの簡易生成ロジック(本来はサーバー側で採番されることが多い)
final id = currentUserList.users.length + 1;
final newUser = User(id: id.toString(), name: name + id.toString());
// リポジトリを使ってAPIにデータを送信
// ここではメソッド内で一度だけ使うので read を使用
final repository = ref.read(userRepositoryProvider);
final createdUser = await repository.createUser(newUser);
// 状態を更新
// UserListモデルのaddUserメソッドを使って新しいリストを作成し、
// それをAsyncDataでラップしてstateにセットすることでUIに通知されます
state = AsyncData(currentUserList.addUser(createdUser));
}
}
Repositoryを ref.read や ref.watch で取得することで、テスト時にMockへの差し替えが容易になります。
4. View層:UIの描画
最後にUI部分です。ConsumerWidget を継承し、ref.watch でViewModelの状態を監視します。
lib/views/user_list_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mvvm_riverpod/view_models/user_list_notifier.dart';
// ConsumerWidgetを継承し、refを受け取れるようにします
class UserListPage extends ConsumerWidget {
const UserListPage({super.key});
Widget build(BuildContext context, WidgetRef ref) {
// プロバイダーを監視(watch)し、状態の変化に合わせてリビルドをトリガーします
// userListStateは AsyncValue<UserList> 型です
final userListState = ref.watch(userListProvider);
return Scaffold(
// Dart 3の switch式 を使用した状態分岐
// AsyncValueの状態(Data, Error, Loading)に応じて表示するWidgetを切り替えます
body: switch (userListState) {
// 【データ取得成功時】
// パターンマッチングで AsyncData 型であることを確認しつつ、
// 内部の value を data 変数として取り出しています
AsyncData(value: final data) => Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const SizedBox(height: 100),
// ユーザー追加ボタン
ElevatedButton(
onPressed: () async {
// イベントハンドラ内では ref.read を使用してNotifierのメソッドを呼び出します
// これにより、画面の無駄な再描画を防ぎます
await ref.read(userListProvider.notifier).addUser('new User');
},
child: const Text('add User'),
),
// リスト表示
Expanded(
child: ListView.builder(
// dataは UserList型なので、その中の users リストにアクセス
itemCount: data.users.length,
itemBuilder: (context, index) {
final user = data.users[index];
return ListTile(title: Text(user.name));
},
),
),
],
),
// 【エラー発生時】
// オブジェクトのプロパティパターンを使用して error プロパティを取り出しています
// (:final error) は (error: final error) の省略記法です
AsyncError(:final error) => Center(child: Text('エラー: $error')),
// 【ローディング中、またはその他の状態】
// _(ワイルドカード)は、上記以外の全てのケース(主にAsyncLoading)にマッチします
_ => const Center(child: CircularProgressIndicator()),
},
);
}
}
whenメソッドでの書き方
AsyncValue が提供する .when メソッドを使うことでも、ローディング・エラー・データ表示の分岐を宣言的に記述できます。
lib/views/user_list_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mvvm_riverpod/view_models/user_list_notifier.dart';
// ConsumerWidgetを継承することで、buildメソッド内で ref が使えるようになります
class UserListPage extends ConsumerWidget {
const UserListPage({super.key});
Widget build(BuildContext context, WidgetRef ref) {
// userListProviderの状態(Loading / Error / Data)を監視(watch)します
// 状態が変わるとこのbuildメソッドが再実行され、画面が更新されます
final userListState = ref.watch(userListProvider);
return Scaffold(
// AsyncValueの便利なメソッド when を使って、状態ごとの表示を分岐させます
body: userListState.when(
// ロード中の表示
loading: () => const Center(child: CircularProgressIndicator()),
// エラー発生時の表示
error: (error, stackTrace) => Center(child: Text('エラー: $error')),
// データ取得成功時の表示
data: (data) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
SizedBox(height: 100),
// ユーザー追加ボタン
ElevatedButton(
onPressed: () async {
// イベントハンドラ内では ref.read を使ってNotifierのメソッドを呼び出します
// watchを使うと無駄な再描画の原因になるため、イベント内ではreadが推奨されます
await ref.read(userListProvider.notifier).addUser('new User');
},
child: Text('add User'),
),
// リスト表示部分
Expanded(
child: ListView.builder(
itemCount: data.users.length,
itemBuilder: (context, index) {
final user = data.users[index];
return ListTile(title: Text(user.name));
},
),
),
],
);
},
),
);
}
}
おわりに
今回のサンプルコードのような「Riverpod × MVVM + Repository」構成を採用することには、以下のような大きなメリットがあります。
- 関心の分離: Viewは表示のみ、ViewModelは状態管理のみ、Repositoryは通信のみ、と役割がはっきりしています。
-
型安全性:
FreezedとRiverpod Generatorにより、コンパイル時に多くのミスを防げます。 -
非同期処理の簡略化:
AsyncNotifierと.whenにより、複雑になりがちな非同期処理のUI反映をシンプルに記述できます。 -
テストの容易性: 各層が疎結合(
refを介してつながっている)であるため、RepositoryをモックにしてViewModelをテストする、といったことが簡単に行えます。
Flutter開発において、保守性と拡張性を重視するプロジェクトでは、ぜひ参考にしたいアーキテクチャです。
Discussion