Flutter

FlutterのMVVM実装をRiverpodで書く手順|公式アーキテクチャガイド準拠のViewModel設計

FlutterのMVVM実装をRiverpodで書く手順|公式アーキテクチャガイド準拠のViewModel設計

FlutterのMVVMは、もう個人の流儀ではありません。Flutter公式のアーキテクチャガイドが層構造としてMVVMを採用し、ViewModel・Repository・Serviceの責務を文章で定義しているためです。一方、公式サンプルの実装はChangeNotifierとCommandを使っており、Riverpodを使う現場の書き方とは一致しません。この記事では公式ガイドが定める層構造を、Riverpod 3.4.2のAsyncNotifierへ落とし込む手順を示します。Widget以外の掲載コードは、Dart 3.12.2とriverpod 3.4.2の実環境で解析とテストを通したものです。MVVMという用語の定義やMVC・MVPを含む設計パターンの整理そのものは扱わず、Flutterアプリの設計判断に絞ります。

まとめ

Flutter公式ガイドはUI層(View・ViewModel)とデータ層(Repository・Service)の2層を基本とし、Repositoryを「アプリのデータにおける信頼できる唯一の情報源(source of truth)」、Serviceを「状態を持たないAPIラッパー」と定義しています。Riverpodで書くなら、非同期の読み込みを持つ画面はAsyncNotifierをViewModelとして使うのが最短経路です。読み込み中とエラーの状態をAsyncValueが型で持つため、公式サンプルがCommandクラスを自作して解決している問題の大半をライブラリ側に寄せられます。

逆に、画面が数枚しかなく通信も1系統なら、MVVMの導入はコストのほうが上回ります。層を足す前に、状態がどれだけ複数画面で共有されるかを数えてください。

Flutter公式アーキテクチャガイドが定めるMVVMの層構造

UI層とデータ層の2層構成とドメイン層の位置づけ

公式ガイド(docs.flutter.dev/app-architecture/guide)は、MVVMをFlutterの推奨構造として明示しています。ガイドの記述はこうです。「Views and view models make up the UI layer of an application. Repositories and services represent the data of an application, or the model layer of MVVM.」つまりMVVMの「M」は単一のModelクラスではなく、RepositoryとServiceからなるデータ層全体を指します。

旧来のFlutter記事にありがちな「Modelクラスを1つ作ってViewModelから呼ぶ」という説明は、公式定義とはズレています。データ層は2階層です。ビジネスロジックを扱うユースケース層(interactors)は任意の追加要素で、公式ガイドは複数Repositoryのマージ、ロジックが過度に複雑になった場合、複数ViewModelでの再利用の3条件を切り出しの目安に挙げています。

ViewModel・Repository・Serviceの責務分界

公式ガイドは各要素の責務を具体的に列挙しています。ViewModelが担うのは、Repositoryから取得したデータの表示用形式への変換、Viewが必要とする現在の状態の保持、イベントハンドラへ結びつけるコールバック(commands)の公開の3つ。Repositoryについては「Repositories handle the business logic associated with services, such as:」として、キャッシュ、エラー処理、再試行、データの更新、新しいデータのためのサービスのポーリング、ユーザー操作起点の更新の6項目が例示されます。Serviceの定義は「They wrap API endpoints … They’re only used to isolate data-loading, and they hold no state.」です。

要素 状態保持 主な責務
View UI層 持たない Widget構築・入力の受け渡し
ViewModel UI層 持つ 表示用データへの変換・commands公開
Repository データ層 持つ(キャッシュ) source of truth・再試行・更新
Service データ層 持たない APIエンドポイントのラップ

この表の「状態保持」列が設計の分かれ目になります。Serviceにキャッシュを持たせた時点で、どこが正しいデータを持っているのかが二重化します。キャッシュはRepositoryに寄せてください。

lib配下のディレクトリ分割例

層の定義をそのままフォルダへ写すと、責務違反がファイルの置き場所で見えるようになります。下は公式サンプルCompass(github.com/flutter/samples の compass_app)のディレクトリ構成に合わせた例で、UI層だけを機能単位、データ層をレイヤー単位で切っています。

lib/
├── ui/
│   ├── forecast/
│   │   ├── view_models/forecast_viewmodel.dart
│   │   └── widgets/forecast_page.dart
│   └── core/          # 画面をまたぐ共通Widget・テーマ
├── domain/
│   ├── models/forecast.dart
│   └── use_cases/     # 任意。複数Repositoryをまたぐ処理
├── data/
│   ├── repositories/forecast_repository.dart
│   └── services/forecast_api_client.dart
└── main.dart

判断に迷ったら、importの向きを見てください。data/ のファイルが ui/ をimportしていたら層が逆流しています。Compassでは、APIのレスポンスをそのまま写したモデルを data/ 配下に置き、画面が扱うドメインモデルを domain/models/ に分けています。この2つを1つのクラスで兼ねると、API仕様の変更がUIまで直撃します。ディレクトリ設計の考え方全般はFlutterにおけるアーキテクチャ設計の基本でも扱っています。

RiverpodのAsyncNotifierでViewModelを実装する手順

Riverpod 3系で選ぶProviderとlegacyへ移動した3種

2026年8月時点のflutter_riverpodは3.4.2(2026年7月28日UTC公開、Dart SDK ^3.12.0)です。Riverpod 3で構成が整理され、StateNotifierProvider・StateProvider・ChangeNotifierProviderは非推奨の位置づけになりました。削除はされておらず、package:flutter_riverpod/legacy.dart という別のimportへ移されています。公式ドキュメントは、推奨しなくなったことを明示する目的で移動したと説明しています。純Dart側の package:riverpod/legacy.dart にはChangeNotifierProviderが含まれないため、Flutterアプリからは flutter_riverpod 側のパスを使ってください。

ViewModelとして使うのはNotifierとAsyncNotifierの2つです。同期的な状態だけならNotifier、初期化で通信やDB読み込みが走るならAsyncNotifierを選びます。MVVMのViewModelは大半が後者です。Provider種別ごとの書き分けはRiverpod NotifierProviderの使い方にまとめています。

dependencies:
  flutter_riverpod: ^3.4.2
  http: ^1.5.0

コード生成(riverpod_generator 4.0.8/riverpod_annotation 4.0.6)を併用すると@riverpod注釈で同じ定義を短く書けます。ただしbuild_runnerの実行がCIに増えるため、まずは生成なしで層を固めるほうが責務のズレに気づきやすくなります。ドメインモデル側をfreezedやjson_serializableで生成するかも同じ判断軸です。目安として、フィールドが5つを超えたあたりから手書きのfromJsonは保守コストのほうが高くつきます。

ViewModelクラスとUI状態クラスの実装

下のコードでは気温のフォーマットをViewModel側で確定させ、Viewには整形済みの文字列だけを渡しています。

import 'package:flutter_riverpod/flutter_riverpod.dart';

class ForecastUiState {
  const ForecastUiState({required this.title, required this.tempLabel});

  final String title;
  final String tempLabel;
}

final forecastViewModelProvider =
    AsyncNotifierProvider<ForecastViewModel, ForecastUiState>(
      ForecastViewModel.new,
    );

class ForecastViewModel extends AsyncNotifier<ForecastUiState> {
  static const _cityId = '13101';

  @override
  Future<ForecastUiState> build() async {
    final repository = ref.watch(forecastRepositoryProvider);
    return _load(repository, forceRefresh: false);
  }

  Future<void> reload() async {
    state = const AsyncValue.loading();
    state = await AsyncValue.guard(() {
      final repository = ref.read(forecastRepositoryProvider);
      return _load(repository, forceRefresh: true);
    });
  }

  Future<ForecastUiState> _load(
    ForecastRepository repository, {
    required bool forceRefresh,
  }) async {
    final forecast = await repository.getForecast(
      _cityId,
      forceRefresh: forceRefresh,
    );
    return ForecastUiState(
      title: forecast.cityName,
      tempLabel: '${forecast.maxTemp.toStringAsFixed(1)}度',
    );
  }
}

build()ではref.watchreload()ではref.readと使い分けています。前者は依存が変わったときに再構築させるための購読、後者はボタン操作の瞬間に1回だけ取得する読み出しです。AsyncValue.guardは例外を捕捉してAsyncErrorへ変換するため、try-catchを書かずにエラー状態へ遷移できます。捕捉した例外は加工されず、そのままAsyncErrorのerrorに入ります。

Viewからの購読とローディング・エラー分岐

ViewはConsumerWidgetを継承し、ref.watchで購読します。AsyncValueのwhenが3状態を網羅するため、ローディング用のbool変数やエラー用のString変数を自前で持つ必要はありません。

class ForecastPage extends ConsumerWidget {
  const ForecastPage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(forecastViewModelProvider);
    return Scaffold(
      appBar: AppBar(title: const Text('天気')),
      body: state.when(
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (error, stackTrace) => Center(
          child: TextButton(
            onPressed: () =>
                ref.read(forecastViewModelProvider.notifier).reload(),
            child: const Text('再読み込み'),
          ),
        ),
        data: (uiState) => Center(
          child: Text('${uiState.title} ${uiState.tempLabel}'),
        ),
      ),
    );
  }
}

1点だけ落とし穴があります。whenの引数skipLoadingOnRefreshは既定でtrueのため、前回の値を保持したまま再取得するとloadingが呼ばれません。上のコードはreload()で素のAsyncLoadingを代入しているので進捗表示が出ますが、ref.invalidateへ置き換えると出なくなります。再取得中もインジケーターを出すならskipLoadingOnRefresh: falseを明示してください。

画面遷移やSnackBarのような一度きりの副作用は、AsyncValueの状態としては表現できません。これらはref.listenで状態変化を監視し、コールバックの中で実行します。ViewModelのstateに「遷移すべきか」を示すフラグを持たせると、画面復帰のたびに再実行される不具合を抱え込みます。

ProviderContainerで書くViewModelのユニットテスト

MVVMを採用する最大の見返りは、UIを起動せずにViewModelを検証できることです。RiverpodはProviderContainerでDIを差し替えられるため、Widgetテストを立てずに変換ロジックだけを確認できます。

import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

void main() {
  test('ViewModelは取得結果を表示用の文字列へ変換する', () async {
    final mockClient = MockClient((request) async {
      return http.Response(
        '{"city":"千代田区","max_temp":31.4,"updated_at":"2026-08-05T09:00:00Z"}',
        200,
        headers: {'content-type': 'application/json; charset=utf-8'},
      );
    });
    final container = ProviderContainer(
      overrides: [
        forecastRepositoryProvider.overrideWithValue(
          ForecastRepository(apiClient: ForecastApiClient(client: mockClient)),
        ),
      ],
    );
    addTearDown(container.dispose);

    final state = await container.read(forecastViewModelProvider.future);

    expect(state.title, '千代田区');
    expect(state.tempLabel, '31.4度');
  });
}

container.read(provider.future)で初期化の完了を待てるため、AsyncNotifierのbuild結果をそのまま検証できます。なおhttp.Responseの第1引数は既定でLatin-1として符号化されるので、日本語を含むレスポンスを組み立てるときはcontent-typeにcharset=utf-8を付けてください。付け忘れるとInvalid argumentで落ちます。

Serviceのラップ範囲とRepositoryのキャッシュ責務

状態を持たないServiceの実装

Serviceが担うのはHTTPリクエストの組み立てとレスポンスの受け取りまでです。ドメインモデルへの変換もキャッシュも入れません。下のコードは公式定義の「hold no state」をそのまま実装したものです。

import 'dart:convert';

import 'package:http/http.dart' as http;

class ForecastException implements Exception {
  const ForecastException(this.message);

  final String message;

  @override
  String toString() => 'ForecastException: $message';
}

class ForecastApiClient {
  ForecastApiClient({http.Client? client}) : _client = client ?? http.Client();

  final http.Client _client;

  Future<Map<String, dynamic>> fetchDaily(String cityId) async {
    final response = await _client.get(
      Uri.https('api.example.com', '/v1/forecast/$cityId'),
    );
    if (response.statusCode != 200) {
      throw ForecastException('HTTP ${response.statusCode}');
    }
    return jsonDecode(response.body) as Map<String, dynamic>;
  }
}

コンストラクタでhttp.Clientを差し込めるようにしてあるのは、テスト時にMockClientへ置き換えるためです。

source of truthとしてのRepositoryとキャッシュ制御

Repositoryは、Serviceの生データをドメインモデルへ変換し、キャッシュを保持します。公式サンプルCompassのActivityRepositoryRemoteも、Map型のフィールドに結果を保持し、キャッシュが無いときだけAPIを呼ぶ実装です。

class Forecast {
  const Forecast({
    required this.cityName,
    required this.maxTemp,
    required this.updatedAt,
  });

  factory Forecast.fromJson(Map<String, dynamic> json) {
    return Forecast(
      cityName: json['city'] as String,
      maxTemp: (json['max_temp'] as num).toDouble(),
      updatedAt: DateTime.parse(json['updated_at'] as String),
    );
  }

  final String cityName;
  final double maxTemp;
  final DateTime updatedAt;
}

class ForecastRepository {
  ForecastRepository({required ForecastApiClient apiClient})
    : _apiClient = apiClient;

  final ForecastApiClient _apiClient;
  final Map<String, Forecast> _cache = {};

  Future<Forecast> getForecast(
    String cityId, {
    bool forceRefresh = false,
  }) async {
    final cached = _cache[cityId];
    if (cached != null && !forceRefresh) {
      return cached;
    }
    final forecast = Forecast.fromJson(await _apiClient.fetchDaily(cityId));
    _cache[cityId] = forecast;
    return forecast;
  }
}

final forecastRepositoryProvider = Provider<ForecastRepository>((ref) {
  return ForecastRepository(apiClient: ForecastApiClient());
});

forceRefreshの引数をRepositoryに置くか、ViewModel側でキャッシュを消すかは設計判断になります。公式ガイドがデータの更新をRepositoryの責務に挙げている以上、引数で受けるほうが定義に沿います。

永続化まで踏み込むなら、Riverpod 3のpersist()という選択肢もあります。ただし実験的機能であり、Notifier系のプロバイダー限定、package:flutter_riverpod/experimental/persist.dart の追加import、非コード生成ではkeyencodedecodeが必須、保存先のStorage実装は自前という制約が付きます。Riverpod自体はデータベースを同梱しません。オフライン前提のアプリで採用する場合は、APIの変更を織り込んでください。

例外を投げるかResult型で返すかの判断

公式サンプルCompassは、例外を投げずにsealed class Result<T>を返し、呼び出し側がswitchOkErrorを分岐する方式を採っています。ViewModelがChangeNotifierベースで、失敗を状態として保持する必要があるためです。

Riverpodを使う場合、この自作Result型は基本的に不要です。AsyncValueが同じ役割を型で果たすため、Result型を挟むとラッパーが二重になります。なおRiverpod 3では、エラー状態の別プロバイダーをref.readawait provider.futurerequireValueで読み取ったときに例外がProviderExceptionでラップされます。ラップされるのはこの経路だけで、AsyncValue.guardAsyncValue.errorが保持する例外は元のままです。この型を実際にcatchするにはpackage:flutter_riverpod/misc.dart のimportが要ります。メインのimportからは公開されていません。

公式サンプルのChangeNotifier+Command方式との選び分け

Command方式が標準で抑止する二重実行

公式サンプルCompassでは、状態を持つViewModelがChangeNotifierを継承し、非同期処理をCommand0・Command1というクラスでくるみます(状態を持たないログイン画面などは素のクラスで、Command自身がChangeNotifierを担います)。Commandの実装にはif (_running) return; という早期returnがあり、処理が終わるまで再実行できません。送信ボタンの連打をライブラリ側で止められるということです。ViewはListenableBuilderで購読し、DIにはpackage:providerを使います。

ここはRiverpodが弱い部分です。実験的機能のMutationにも並行呼び出しの制限は入っておらず、ソース上も「Currently, mutations do not restrict concurrent calls in any capacity.」と明記されています。フォーム送信の二重実行を防ぐなら、ViewModel側で実行中フラグを持つ実装が別途必要です。

観点 Riverpod(AsyncNotifier) 公式サンプル(ChangeNotifier+Command)
依存の解決 ProviderScope+ref.watch package:provider
非同期の状態 AsyncValueが型で保持 Command側で自前管理
二重実行の抑止 抑止しない(自前実装) Commandが標準で抑止
初期化失敗の再試行 自動(200ms〜6.4秒・最大10回) 自前実装
ユニットテスト ProviderContainerで差し替え コンストラクタ注入

移行コストから引く選択基準

新規プロジェクトならRiverpodを選んでください。ローディングとエラーの状態管理が型で済みます。逆に、既存アプリがproviderパッケージでDIを組んでいるなら、公式サンプルの方式に寄せたほうが移行コストは小さくなります。両者を1アプリに混在させる構成だけは避けてください。ViewModelの生存期間を管理する仕組みが二重になり、破棄漏れの原因を追いにくくなります。

再試行の挙動には条件があります。自動再試行が働くのはプロバイダーの初期化、つまりbuild()が失敗したときだけです。上で示したreload()のようにAsyncValue.guard経由で発生したエラーは対象外で、待機時間は200ミリ秒から倍増して最大6.4秒、回数は既定で10回打ち切りです。StateErrorのようなErrorのサブクラスとProviderExceptionは、そもそも再試行されません。

選択肢はこの2つに限りません。BLoCはイベントと状態を明示的に分離するぶん記述量が増えますが、状態遷移の履歴を追える利点があります。画面遷移の条件が複雑な業務アプリでは今も有力です。ただしFlutter公式ガイドが定義しているのは層構造であり、BLoCを採用してもUI層とデータ層の分け方はそのまま使えます。

MVVMを採用すべきでない場面と設計が破綻する兆候

画面数と共有状態から引く見送りライン

Riverpodを使うならMVVMという名前を意識する必要はない、という主張には一理あります。Providerが状態を持つ以上、ViewModelという層名を付けなくても構造は似るためです。それでもMVVMを入れない判断が正しい場面は実在します。目安は2つです。画面が3枚以下、かつ複数画面で共有する状態が無いアプリでは、ViewModelとRepositoryを分けても得られる利点がほとんどありません。設定画面だけのユーティリティ、社内向けの表示専用ツールがこれに当たります。この規模でServiceとRepositoryを分けると、1回の通信仕様変更で3ファイルを触ることになります。

もう1つは、UIがサーバーの返すJSONをほぼそのまま表示するだけの画面です。ViewModelでの変換処理が空になるため、FutureProviderでRepositoryを直接購読するほうが読みやすくなります。MVVMは変換すべき差があるときに効く構造です。差が無いなら層は要りません。

ViewModel肥大化とRepository素通しという失敗パターン

導入後に破綻する兆候は2つあり、どちらも責務の置き場所を間違えたときに現れます。1つ目はViewModelの肥大化です。日付フォーマットや金額の丸めといった変換処理が画面ごとのViewModelに重複し始めたら、それはドメイン層へ切り出す合図になります。ViewModelは変換の呼び出し元であって、変換ロジックの置き場ではありません。

2つ目はRepositoryの素通しです。Serviceを呼んで結果をそのまま返すだけのメソッドが並んでいるRepositoryは、層としての価値を持ちません。キャッシュもエラー処理も再試行も入っていないなら、そのRepositoryは削除してService直結にするか、公式ガイドが挙げる責務のどれかを実際に担わせるかの二択です。中間の「とりあえず作った層」がいちばん保守コストを押し上げます。

よくある質問

FlutterでMVVMは公式に推奨されているのですか?

推奨されています。公式ドキュメントのApp architectureガイドがMVVMを名指しで採用し、UI層をViewとViewModel、データ層をRepositoryとServiceで構成する形を示しました。ガイド本文の記述は「Repositories and services represent the data of an application, or the model layer of MVVM.」です。ただし公式が定義しているのは層と責務だけで、状態管理ライブラリの指定はありません。Riverpodを使うかproviderを使うかは実装者の選択に委ねられています。

MVCで組まれた既存のFlutterアプリはどこから移行すべきですか?

画面1枚単位で進めてください。全体を一度に組み替えると、動作確認の範囲が広がりすぎます。手順としては、対象画面のWidgetから状態と変換処理を抜いてViewModelへ移し、次に通信部分をServiceとRepositoryへ分けます。この順序なら、各段階でアプリは動いたままです。設計パターンごとの構成要素の違いはMVVMの仕組みと構成要素を整理した記事で確認できます。

RiverpodのStateNotifierProviderはもう使えないのですか?

使えます。Riverpod 3.4.2でも削除はされておらず、package:flutter_riverpod/legacy.dart からimportすれば従来どおり動作します。とはいえ新規実装で選ぶ理由はありません。公式が推奨しない意図を示すために移動させた以上、NotifierまたはAsyncNotifierを使うべきです。移行手順はStateNotifierProviderとの違いを解説した記事を参照してください。

ViewModelからRepositoryを直接呼んでよいのですか?

問題ありません。公式ガイドもViewModelの責務として「Retrieving application data from repositories」を挙げており、間にユースケース層を必ず挟む構造にはなっていません。ユースケース層(interactors)は任意の追加要素です。最初から3層で組むと、変換処理が空のクラスが増えます。

Serviceクラスは必ず作る必要がありますか?

通信やローカルDBなど外部I/Oがある場合は分けてください。公式定義でServiceは状態を持たずAPIエンドポイントをラップする役割なので、テスト時にここだけをモックへ差し替えられます。逆に、データ源が端末内のSharedPreferences1つだけといった単純な構成なら、Repositoryに直接まとめても支障はありません。判断基準はデータ源の数です。2つ以上あるならService分離の効果が出ます。

関連記事

資料請求

RELATED POSTS 関連記事