🔥

Flutter x Riverpodのアーキテクチャ考察

に公開

Commune Developers Advent Calendar 2025の5日目の記事です。

わたくしは6年iOS開発をやっておりましたが、今年9月からFlutter開発をしております。

3ヶ月ほどやった感想ですが、Riverpodが圧倒的に便利だなと感じています。
Riverpod自体は以前から個人的に興味があって、ドキュメント読んで試してみたこともありました。
そのときはまだピンと来ていなかったのですが、実際に使ってみるとその強力さが理解できました。
便利と思う反面、学習コストも相応にあって、まだまだ自信を持って使いこなせているとは言えません。

Flutter開発のキャッチアップをする中で、Riverpodを使う前提だと、Flutterアプリのアーキテクチャはどうするのが良いのかを考えました。

Flutterのアーキテクチャ

Flutter公式のドキュメントに、アーキテクチャについて書かれたものが公開されております。
履歴を遡ったところ、2024年11月頃に公開されたようです。

https://docs.flutter.dev/app-architecture/guide

公式が紹介しているアーキテクチャはMVVMでした。
(紹介であり、推奨ではないという話は後述します)


公式記事より引用

アプリが大きくなって、機能が増えたタイミングで、UseCaseを導入してもいいかもね、とも書かれています。

ChangeNotifierを使ったViewModel

公式ドキュメントだけ見ていると、じゃあとりあえず初手はシンプルなMVVMで良いのかなと思ったのですが、
サンプルコードを見ると、Riverpodは利用せず、Flutter標準のChangeNotifierを使った実装になっていました。

https://github.com/flutter/samples/blob/main/compass_app/app/lib/ui/home/view_models/home_viewmodel.dart#L17

https://github.com/flutter/samples/blob/main/compass_app/app/lib/ui/home/view_models/home_viewmodel.dart#L40-L65

_load() メソッド内で予約情報とユーザー情報の取得が完了(あるいはエラー)すると、notifyListeners() を呼んで、ウィジェット側に更新通知を発行します。
ウィジェット側はListenableBuilderというウィジェットを使って、ViewModelの変更を監視します。

https://github.com/flutter/samples/blob/main/compass_app/app/lib/ui/home/widgets/home_screen.dart#L63-L64

将来のViewModelの肥大化の可能性などの課題はあるかもしれませんが、
この書き方であれば、ViewModelは苦労なく書けると思います。

RiverpodとChangeNotifier

Riverpod環境ではどうでしょうか?
いくつか選択肢があります。

ChangeNotifierを使ったViewModelとRiverpodを共存させることは可能です。
ただこのやり方では、何のためにRiverpodを入れているのかよくわからないので、オススメできません。

ChangeNotifierをRiverpodで使いたい場合、便利なProviderとしてChangeNotifierProviderが用意されていました。
(ややこしい話ですが、Flutter標準のProviderパッケージにも同名でChangeNotifierProviderがあり、名前は同じですが異なる仕様となっております。ここでは触れません)

https://riverpod.dev/docs/from_provider/quickstart#start-with-changenotifierprovider

ChangeNotifierProviderはラッパー的な役割をしてくれます。

// If you have this...
class MyNotifier extends ChangeNotifier {
  int state = 0;

  void increment() {
    state++;
    notifyListeners();
  }
}

// ... just add this!
final myNotifierProvider = ChangeNotifierProvider<MyNotifier>((ref) {
  return MyNotifier();
});

これだけでProvider化できるので、とりあえず移行したい場合には便利です。
ただこの書き方はRiverpodの設計思想と合っていないのと、コードの自動生成も効きません。
Riverpodのマイングレーションガイド上、ChangeNotifierProviderは段階的な移行の際に使い、最終的にはNotifierProviderを使った実装に置き換えることが推奨されています。


class MyNotifier extends _$MyNotifier {
  
  int build() => 0;

  void increment() => state++;
}

更にRiverpod 3.0から、ChangeNotifierProviderはlegacy扱いとなりました。

https://riverpod.dev/docs/3.0_migration#stateprovider-statenotifierprovider-and-changenotifierprovider-are-moved-to-a-new-import

したがって、今後のRiverpod導入済プロジェクトで、ChangeNotifierProviderを積極的に使う選択肢はないと思います。
Riverpod公式の推奨通り、NotifierProviderを使った書き方が良いでしょう。

Riverpodを使っているとViewModelが書きづらい

さて、NotifierProviderを使った書き方が良い、という前提になったとき、ViewModelが非常に書きづらいということに気づきました。
サンプルコード全体は長いので折りたたみます。

生成AIによる書き換え例
import 'dart:async';

import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:logging/logging.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

import '../../../data/repositories/booking/booking_repository.dart';
import '../../../data/repositories/user/user_repository.dart';
import '../../../domain/models/booking/booking_summary.dart';
import '../../../domain/models/user/user.dart';
import '../../../utils/result.dart';

part 'home_viewmodel.freezed.dart';
part 'home_viewmodel.g.dart';


class HomeState with _$HomeState {
  const factory HomeState({
    ([]) List<BookingSummary> bookings,
    User? user,
    (false) bool isLoading,
    Object? error,
  }) = _HomeState;
}


class HomeViewModel extends _$HomeViewModel {
  final _log = Logger('HomeViewModel');

  
  HomeState build() {
    Future.microtask(() => load());
    return const HomeState();
  }

  Future<Result> load() async {
    state = state.copyWith(isLoading: true, error: null);

    try {
      final bookingRepository = ref.read(bookingRepositoryProvider);
      final result = await bookingRepository.getBookingsList();
      switch (result) {
        case Ok<List<BookingSummary>>():
          state = state.copyWith(bookings: result.value);
          _log.fine('Loaded bookings');
        case Error<List<BookingSummary>>():
          _log.warning('Failed to load bookings', result.error);
          state = state.copyWith(error: result.error);
          return result;
      }

      final userRepository = ref.read(userRepositoryProvider);
      final userResult = await userRepository.getUser();
      switch (userResult) {
        case Ok<User>():
          state = state.copyWith(user: userResult.value);
          _log.fine('Loaded user');
        case Error<User>():
          _log.warning('Failed to load user', userResult.error);
          state = state.copyWith(error: userResult.error);
      }

      return userResult;
    } finally {
      state = state.copyWith(isLoading: false);
    }
  }

  Future<Result<void>> deleteBooking(int id) async {
    state = state.copyWith(isLoading: true, error: null);

    try {
      final bookingRepository = ref.read(bookingRepositoryProvider);
      final resultDelete = await bookingRepository.delete(id);
      switch (resultDelete) {
        case Ok<void>():
          _log.fine('Deleted booking $id');
        case Error<void>():
          _log.warning('Failed to delete booking $id', resultDelete.error);
          state = state.copyWith(error: resultDelete.error);
          return resultDelete;
      }

      final bookingRepository = ref.read(bookingRepositoryProvider);
      final resultLoadBookings = await bookingRepository.getBookingsList();
      switch (resultLoadBookings) {
        case Ok<List<BookingSummary>>():
          state = state.copyWith(bookings: resultLoadBookings.value);
          _log.fine('Loaded bookings');
        case Error<List<BookingSummary>>():
          _log.warning('Failed to load bookings', resultLoadBookings.error);
          state = state.copyWith(error: resultLoadBookings.error);
          return resultLoadBookings;
      }

      return resultLoadBookings;
    } finally {
      state = state.copyWith(isLoading: false);
    }
  }
}

ウィジェットの状態変数をすべてまとめた HomeState をつくって、ViewModelはそのStateを返す実装となるかと思います。
ウィジェットからはhomeViewModelProviderをwatchして、状態変数を適宜監視する……になるのですが、このシンプルな例ですが、HomeState が肥大化しているのを感じます。

この例で言えば、bookingsProviderとuserProviderを2つつくって、それを監視する方が扱いやすいでしょう。
ローディングやエラーもFutureProviderが返すAsyncValueで分岐する方がスマートに書けます。分岐させている例として、FutureProviderの公式ドキュメントにあったサンプルコードを引用します。

公式docsの例
Widget build(BuildContext context, WidgetRef ref) {
  AsyncValue<Configuration> config = ref.watch(configProvider);

  return config.when(
    loading: () => const CircularProgressIndicator(),
    error: (err, stack) => Text('Error: $err'),
    data: (config) {
      return Text(config.host);
    },
  );
}

Flutter公式もMVVMを「推奨」はしていない

Flutter公式ドキュメントも、よく読むとMVVMは1つの実装例として紹介しているだけで、別の選択肢があることも書いています。

他のアーキテクチャオプション

このケーススタディの例は、ある1つのアプリケーションが我々の推奨するアーキテクチャルールにどのように従っているかを示していますが、他にもたくさんの書き方があるでしょう。このアプリのUIはViewModelとChangeNotifierに大きく依存していますが、ストリームを使ったり、riverpodflutter_blocsignalsパッケージが提供する他のライブラリを使って簡単に書くこともできました。このアプリのレイヤー間の通信は、新しいデータのポーリングを含め、すべてをメソッド呼び出しで処理していました。代わりに、ストリームを使用してリポジトリからViewModelにデータを公開することもでき、それでもこのガイドでカバーされているルールに従うことができます。

(https://docs.flutter.dev/app-architecture/case-study#other-architecture-options から。生成AIによる日本語翻訳)

この文章で言っている「我々の推奨するアーキテクチャルール」については、下記のページに書かれています。
具体的には関心の分離/レイヤードアーキテクチャ/Single source of truth/単方向データフローなどです。
こちらはFlutter開発のみならず、宣言的UIにおけるアーキテクチャの一般論として良いドキュメントだと感じました。

https://docs.flutter.dev/app-architecture/concepts

もっと具体的に、推奨内容についてStrongly recommend/Recommend/Conditionalの3段階で評価したページもありました。

https://docs.flutter.dev/app-architecture/recommendations

「アーキテクチャに正解はないけれど、より良い設計のために守るべき原則はあるので、それを意識することが本質」というのがFlutter公式ドキュメントの本旨でしょう。

では何が良いか

Riverpod環境でMVVMが合っていないということがわかりました。
では、どういうアーキテクチャを選ぶべきか。
色々と調べたのですが、正直僕も結論は出ていません。
(「アーキテクチャに正解はない」というのは理解しつつ、自分が新規開発するとしたらこれを選ぶは決めておきたい)

こちらのスクラップで各社のFlutterアプリのアーキテクチャの情報がまとめられており、参考になりました。

https://zenn.dev/yamazaking/scraps/5574fb0fb4aafc

各社それぞれのアプローチしており、明確なコンセンサスはないようです。
やはり安直に「Flutterアプリのアーキテクチャはこれがベストプラクティス!」と言えない状況を感じました。
またBLoCというアーキテクチャが海外のFlutter勢では人気なようです。

個人的に良いと思ったアプローチ

選択肢はたくさんあるかと思うのですが、個人的にはエムスリー社のこちらの記事のアプローチが良いなと思いました。


記事より引用

https://www.m3tech.blog/entry/2025/07/31/150000

アーキテクチャ名は特にないので、記事中では便宜上MVHR (Model-View-Hooks-Riverpod) と呼ばれています。
下記の方針で状態管理を行います。

  • ローカルState: Flutter Hooks
  • グローバルState: Riverpod

Riverpod環境だったらこれが一番良いのではないかと思いました。
UseCase層の要否・ユニットテストの戦略はもうちょっと具体的に掘ってみたいところです。

まとめ

以上つらつら書いてきましたが、まとめです。

  • Riverpod環境ではViewModelが書きづらいのでMVVMではない方が良さそう
  • ではどのアーキテクチャが良いかというと明確な答えはなし
  • 現状 Flutter Hooks / Riverpod の組み合わせで状態管理するのが良いと考えている

アーキテクチャに絶対の正解はなく、チームやプロダクトの状況次第で最適な選択は変わります。
この記事に書いた内容も、「これが正解だ!」という押し付けではなく、あくまでより良いアーキテクチャのための調査・考察ですので、その点はご留意ください。

(了)

Discussion