【Flutter】公式推奨のMVVM構成がRiverpodと相性が良い訳
はじめに
Flutterアプリ開発において、アーキテクチャの選定は重要なテーマです。
本記事では、Flutter公式ドキュメントでも言及されているMVVMと、デファクトスタンダードとなりつつあるRiverpodの相性がなぜ良いのかをまとめます。
参考
MVVMとは
MVVM(Model-View-ViewModel)は、アプリケーションの設計パターン(アーキテクチャ)の一つです。
一言でいうと「見た目(UI)」と「処理(ロジック)」をきれいに分けて、管理しやすくするための仕組みのことです。
3つの役割
MVVMパターンは、アプリケーションの機能を以下の3つに分離します。
| 役割 | 担当 | Flutterでの実装イメージ |
|---|---|---|
| Model | データとロジック。データの定義、API通信、DB操作、ビジネスロジックなど。 | Repository, Entity, Data Source |
| ViewModel | 仲介役。Modelからデータを受け取り、Viewが表示しやすい形(状態)に加工して保持する。 | RiverpodのNotifierクラス |
| View | 見た目。画面の描画、ユーザー操作の受付を担当。ロジックは持たない。 | Widget (ConsumerWidget) |
RiverpodをMVVMの構成で使う訳
MVVMを実現する上で重要な要素(関心の分離、データバインディング、依存性注入)が、Riverpodを使うことで自然と解決されます。これが「相性が良い」と言われる理由です。
1. VとVMのデータバインディング(状態監視)
MVVMで最も重要なのは、「ViewModelの状態が変わったら、Viewが勝手に再描画される」という仕組み(データバインディング)です。
Riverpodの場合、ref.watch(provider) と書くだけで、「値が変わった時だけ、そのWidgetだけを再描画する」という最適化されたバインディングが完了します。
// View (ConsumerWidget)
class UserScreen extends ConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
// ViewModelの状態を監視。値が変わるとここだけ再描画される
final state = ref.watch(userViewModelProvider);
return Scaffold(
body: Center(
// 状態に応じた表示(データがあれば名前を表示)
child: Text(state.value?.name ?? 'No Data'),
),
);
}
}
2. DI(依存性注入)でVMからMを利用
ViewModelは、Model(Repositoryなど)を利用してデータを取得する必要がありますが、どうやってインスタンスを持ってくるかが課題になります。
Riverpodはそれ自体が強力なDIシステムです。ViewModelの中で ref.read や ref.watch をするだけで、どこにあるModelでも安全に取得できます。
class UserViewModel extends _$UserViewModel {
Future<User> build() async {
// RiverpodのDI機能を利用してRepository(Model)を取得
// 自分でインスタンス化(new UserRepository)する必要がない
final repository = ref.watch(userRepositoryProvider);
return repository.fetchUser();
}
}
3. 純粋なDart化(UIへの非依存)
MVVMの理想は「ViewModelやModelに、UIの都合(BuildContextなど)を入れないこと」です。
Riverpodの場合、Ref というオブジェクトを使います。これは BuildContext とは無関係なRiverpod独自のものです。
これにより、ViewModelやModelを import 'package:flutter/material.dart' を必要としない「純粋なDartクラス」として記述でき、テストが容易になります。
// FlutterのUIライブラリに依存しないため、テストが書きやすい
import 'package:riverpod_annotation/riverpod_annotation.dart';
class Counter extends _$Counter {
int build() => 0;
void increment() {
// ここで BuildContext は不要
state++;
}
}
4. 非同期処理(AsyncValue)がMVVM専用設計
MVVMでは「ローディング中」「エラー」「データ取得完了」という3つの状態をViewModelで管理し、Viewで出し分ける必要があります。これを自前で実装するのはフラグ管理などが大変です。
Riverpodの場合、AsyncValue という型が標準装備されています。これが、MVVMの状態管理そのものです。
// View側での記述
final asyncValue = ref.watch(userViewModelProvider);
return asyncValue.when(
// 1. データ取得完了(正常系)
data: (user) => Text(user.name),
// 2. エラー発生
error: (err, stack) => Text('エラー: $err'),
// 3. ローディング中
loading: () => const CircularProgressIndicator(),
);
MVVMのレイヤーとRiverpodの要素の対応
整理すると、Riverpodの各要素はMVVMの以下に対応します。
| MVVM | Riverpod / Flutter 要素 | 具体例 |
|---|---|---|
| Model | Repository, Entity |
Userクラス, UserRepositoryクラス, APIクライアント |
| ViewModel | Notifier (Provider) |
@riverpod アノテーションが付いたクラス |
| View | ConsumerWidget |
ConsumerWidget, HookConsumerWidget
|
具体的なコード例
「ユーザープロフィールの表示と更新」が完成するイメージ
lib/
├── main.dart <-- アプリのエントリーポイント
│
└── features/
└── user/ <-- 【機能名】ユーザー機能
│
├── data/ <-- 【Model (Repository)】
│ ├── user_repository.dart <-- コード例 1. Repository
│ └── user_repository.g.dart <-- 自動生成ファイル (Riverpod Generator)
│
├── domain/ <-- 【Model (Entity)】
│ ├── user.dart <-- コード例 1. Entity
│ ├── user.freezed.dart <-- 自動生成ファイル (Freezed)
│ └── user.g.dart <-- 自動生成ファイル (JsonSerializable)
│
└── presentation/ <-- 【View & ViewModel】
├── user_view_model.dart <-- コード例 2. ViewModel
├── user_view_model.g.dart <-- 自動生成ファイル (Riverpod Generator)
└── user_screen.dart <-- コード例 3. View
1. Model(データと通信)
Modelは「データそのもの(Entity)」と「データの窓口(Repository)」に分かれます。
Entity (Userクラス)
データの形を定義します。Riverpodと相性の良い freezed を使います。
// lib/features/user/domain/user.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user.freezed.dart';
part 'user.g.dart';
class User with _$User {
// モデルにメソッドを追加するためのプライベートコンストラクタ
const User._();
const factory User({
required String id,
required String name,
required String email,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
Repository (UserRepositoryクラス)
APIやDBとの通信を担当します。ViewModelはこのRepositoryを通してデータを取得します。
// lib/features/user/data/user_repository.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../domain/user.dart';
part 'user_repository.g.dart';
// Repository自体もProvider化して、DI(依存性注入)できるようにする
UserRepository userRepository(UserRepositoryRef ref) {
return UserRepository();
}
class UserRepository {
// APIからユーザーを取得する(擬似コード)
Future<User> fetchUser() async {
await Future.delayed(const Duration(seconds: 1)); // 通信の遅延
return const User(id: '1', name: 'Flutter Taro', email: 'test@example.com');
}
// 名前を更新するAPI
Future<void> updateName(String newName) async {
await Future.delayed(const Duration(milliseconds: 500));
// サーバーへの送信処理...
}
}
2. ViewModel(状態管理)
ここがRiverpodの主戦場です。
@riverpod をつけたクラス(Notifier)を作成します。Repositoryからデータを取得し、UIのための状態(AsyncValue<User>)を管理します。
// lib/features/user/presentation/user_view_model.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../data/user_repository.dart';
import '../domain/user.dart';
part 'user_view_model.g.dart';
class UserViewModel extends _$UserViewModel {
// buildメソッド:初期化処理。ここで返した型がStateの型になる(今回は Future<User>)
// 自動的に AsyncValue<User> としてラップされて扱われる
Future<User> build() async {
// 1. RepositoryをDIで取得
final repository = ref.read(userRepositoryProvider);
// 2. データを取得して返す(これが初期状態になる)
return repository.fetchUser();
}
// ユーザー名を更新するロジック
Future<void> updateName(String newName) async {
// ローディング状態にする(楽観的UI更新の準備)
state = const AsyncValue.loading();
// エラーハンドリングもしつつ処理
state = await AsyncValue.guard(() async {
final repository = ref.read(userRepositoryProvider);
await repository.updateName(newName);
// 成功したら新しいデータを再取得して状態を更新(あるいはcopyWithで書き換え)
return repository.fetchUser();
});
}
}
3. View(画面表示)
ConsumerWidget を継承し、ViewModelの状態を監視(Watch)して描画します。
// lib/features/user/presentation/user_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'user_view_model.dart';
// 「ConsumerWidget」を継承するのがポイント
class UserScreen extends ConsumerWidget {
const UserScreen({super.key});
Widget build(BuildContext context, WidgetRef ref) {
// 1. ViewModelの状態を監視 (AsyncValue<User>が返ってくる)
// データが変わると、このbuildメソッドが再実行される
final asyncUser = ref.watch(userViewModelProvider);
return Scaffold(
appBar: AppBar(title: const Text('User Profile')),
body: Center(
// 2. AsyncValueの機能で、状態(ロード中・エラー・データあり)を出し分け
child: asyncUser.when(
// ロード中
loading: () => const CircularProgressIndicator(),
// エラー発生時
error: (error, stack) => Text('Error: $error'),
// データ取得完了時
data: (user) => Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Name: ${user.name}', style: Theme.of(context).textTheme.headlineMedium),
Text('Email: ${user.email}'),
const SizedBox(height: 20),
ElevatedButton(
onPressed: () {
// 3. ボタンを押したらViewModelのメソッドを呼ぶ
// (.notifier を付けてクラス自体にアクセスする)
ref.read(userViewModelProvider.notifier).updateName('New Name');
},
child: const Text('Update Name'),
),
],
),
),
),
);
}
}
コードの関係図
このコード群は以下のように連携して動きます。
-
View (
UserScreen) が起動。ref.watchする。 -
ViewModel (
UserViewModel) のbuild()が走り、Repository (UserRepository) を呼ぶ。 -
Repository がデータを返し、ViewModel が
AsyncData(User)になる。 -
View の
when(data: ...)が反応して画面が表示される。 - ボタンを押すと ViewModel の
updateNameが呼ばれ、状態が変化し、View が再描画される。
MVVMらしいフォルダ構成例
機能単位で分割(中規模)
現在、Riverpodの作者(Remi氏)や主要な教育者(Andrea氏)が最も推奨している構成です。
機能ごと(Auth, Cart, Productsなど)にフォルダを切り、その中でMVVMのレイヤーを分けます。
機能が増えてもコードが迷子になりにくいのが特徴です。
lib/
├── src/
│ ├── features/ <-- 機能ごとに分ける
│ │ ├── authentication/ <-- [機能A] 認証
│ │ │ ├── data/ <-- 【Model】Repository (auth_repository.dart)
│ │ │ ├── domain/ <-- 【Model】Entity (app_user.dart)
│ │ │ └── presentation/ <-- 【View & ViewModel】
│ │ │ ├── sign_in_controller.dart <-- ViewModel
│ │ │ └── sign_in_screen.dart <-- View
│ │ │
│ │ └── products/ <-- [機能B] 商品
│ │ ├── data/
│ │ ├── domain/
│ │ └── presentation/
│ │
│ ├── common_widgets/ <-- 共通のボタンやUI部品
│ ├── constants/ <-- 定数
│ └── exceptions/ <-- エラーハンドリング
│
└── main.dart
参考
Riverpod界隈で最も信頼されているAndrea Bizzotto氏の記事です。公式ドキュメントからもリンクされています。
レイヤー単位で分割(小規模)
昔からある構成です。機能ではなく「役割」でフォルダを分けます。
画面数が少ない(10画面以下)アプリや、プロトタイプ作成時はこちらの方が直感的で速いです。
シンプルですが、ファイル数が増えると関連ファイルを探すのが大変になりやすいです。
lib/
├── models/ <-- 【Model】Entity
├── repositories/ <-- 【Model】Repository
├── view_models/ <-- 【ViewModel】Notifier
├── views/ <-- 【View】Screen
│ ├── widgets/ <-- 小さな部品
│ └── pages/ <-- 画面全体
├── main.dart
└── app.dart
Flutter登場初期からの慣習的なMVVM構成です。多くの初心者向けチュートリアルはこの形をとります。
Clean Architecture風(大規模・厳格)
機能ごとの分割の構成をさらに厳格にし、依存関係のルール(Domain層は他の層を知らない、など)を徹底する構成です。
presentation の中に controllers (ViewModel) と ui (View) をさらに分けることもあります。
lib/
├── core/ <-- 共有コード
├── features/
│ └── todo/
│ ├── data/
│ │ ├── datasources/ <-- API/DB
│ │ ├── models/ <-- DTO
│ │ └── repositories/ <-- Repository実装
│ │
│ ├── domain/
│ │ ├── entities/ <-- Entity
│ │ ├── repositories/ <-- Repositoryインターフェース
│ │ └── usecases/ <-- ビジネスロジック
│ │
│ └── presentation/
│ ├── notifiers/ <-- 【ViewModel】
│ └── pages/ <-- 【View】
└── main.dart
参考
おわりに
Riverpodは単なる状態管理パッケージではなく、MVVMアーキテクチャを効率的かつ安全に実装するためのフレームワークとしての側面を持っています。
「FlutterでMVVMをやりたい」と思った時は、まずはRiverpodの導入を検討し、推奨されているFeature-firstな構成で始めてみることをおすすめします。
Discussion