はじめに
Riverpod + MVVMという設計で約9ヶ月間の開発が終了しました。
Flutterの公式が出しているアーキテクチャを参照して、実装してみて、良かった点や改善点など気づきを得たので記事にします。
参照したのは以下の記事とリポジトリです!
今回のプロジェクトでは以下の点を重視しました。
- Flutter公式の推奨構成に近いこと
- Riverpodを利用した状態管理の統一
- テストしやすい構成
- 中規模以上のアプリでも破綻しない構造
そのためFlutter公式のApp Architecture Guideを参考に、
MVVM + Riverpodの構成を採用しました。
対象読者
- Flutterで中規模以上のアプリを開発する人
- Riverpodを使った設計を検討している人
- Flutter公式のアーキテクチャを実践した例を知りたい人
構成図
lib/
├── app.dart # アプリの初期化、エラーハンドリング、ProviderScopeの設定
├── main_*.dart # 各Flavor(dev/stg/prd/local)ごとのエントリーポイント
├── core/ # アプリ全般の共通基盤
│ ├── app_logger.dart # 独自のロガー実装
│ ├── config/ # Flavorごとの設定値
│ ├── extensions/ # プロジェクト全体で使用できる汎用的な拡張
│ └── providers/ # ロガーや設定等のグローバルProvider
├── data/ # データソースへのアクセス(外部依存の具体的な実装)
│ ├── repositories/ # 各機能のリポジトリ実装
│ ├── services/ # 外部SDKやAPIのラップ(API, Amplify, SharedPrefs等)
│ └── extensions/ # データ層で使用する拡張
├── domain/ # ビジネスロジックとモデル
│ ├── models/ # Freezedを用いたイミュータブルなデータモデル
│ └── use_cases/ # 複雑なユースケースで使用
├── ui/ # UI層(Flutter/UIロジック)
│ ├── samples/ # 機能ごとのディレクトリ
│ │ ├── view_models/ # Riverpodを用いた状態管理とUIイベント
│ │ └── widgets/ # UI
│ ├── core/ # UI共通パーツ(テーマ、共通ウィジェット)
│ ├── extensions/ # UI層で使用する拡張
│ └── validators/ # バリデーター
├── routing/ # 画面遷移(GoRouter)
│ ├── app_route.dart # ルート定義(pathとnameの管理)
│ ├── router.dart # GoRouter本体のインスタンス定義
│ └── routes/ # 機能ごとのルート定義
├── i18n/ # 多言語化(slangによる管理)
└── gen/ # 自動生成ファイル(Assets, Fonts等)
アーキテクチャ概要
技術スタック
- 状態管理: Riverpod (hooks_riverpod) + riverpod_generator
- API: Dio + OpenAPI Generator による自動生成
- 多言語化: slang
- モニタリング: Firebase Analytics / Crashlytics
- 認証: AWS Amplify (Cognito)
全体構造
-
core: ログ、設定、共通ユーティリティ -
data: リポジトリ、APIサービス、SharedPrefereces -
domain: エンティティ(モデル)、ユースケース(ビジネスロジック) -
ui: 画面、ウィジェット、状態管理(Provider/ViewModel) -
routing: GoRouter による画面遷移管理
core
lib/coreには、アプリ全体で使用するコードを格納しています。
具体的にはログに関するコードや、アプリの環境ごとの設定(Flavorや、APIのベースURLなど)を格納しています。
アプリの設定やロガーは下記のようにRiverpodで管理しています。
ロガーに関しては、main.dartで上書きするようにしているので、定義していません。
@Riverpod(keepAlive: true)
AppLogger appLogger(Ref ref) {
throw UnimplementedError();
}
data
lib/dataには、リポジトリと外部機能とやり取りするコードを格納しています。
外部機能とのやり取りとは、例えばAPIやSharedPreferencesなどのアプリ内のデータの読み書きをする機能、認証に使うAWS Amplifyを使用するコードなどです。
domain
ドメイン層はモデルクラスを格納しています。
いわゆるドメインモデルという位置づけですが、
今回のプロジェクトでは UIでそのまま利用するデータクラスとしても使用しています。
本来は
- Domain Model
- UI Model
を分ける設計もありますが、
今回の規模ではシンプルさを優先して共通のモデルとして扱いました。
あとはユースケースもこの層に格納しています。ユースケースは必ず実装しないというわけではなくオプションになっています。単純なCRUDの場合、リポジトリ層のコードをViewModelから呼び出しますが、複雑な操作の場合はユースケースを定義します。
/// サンプルユースケース
class SampleUseCase {
/// コンストラクタ
SampleUseCase({
required this.sampleRepository,
});
/// サンプルリポジトリ
SampleRepository sampleRepository;
/// サンプルユースケース処理
///
/// 戻り値は成功(true)、失敗(false)
Future<bool> execute() async {
final status = sampleRepository.getStatus();
if(status == SampleStatus.disabled) {
return false;
}
final result = await sampleRepository.executeSampleAction();
return result;
}
}
ui
UI層は画面やUIパーツ、ViewModel、画面の状態クラスを格納しています。
画面
class SampleScreen extends HookConsumerWidget {
/// コンストラクタ
const SampleScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(sampleListViewModelProvider);
final viewModel = ref.read(sampleListViewModelProvider.notifier);
return Scaffold(
appBar: AppPageHeader(title: 'Sample'),
body: SafeArea(
child: switch (state) {
AsyncLoading<SampleListState>() => const Center(
child: CircularProgressIndicator(),
),
AsyncData<SampleListState>(:final value) => Center(
child: Text('Sample Page'),
),
AsyncError<SampleListState>(:final error) =>
state.isLoading
? const Center(
child: CircularProgressIndicator(),
)
: Center(
child: ErrorView(
error: error,
retry: () {
ref.invalidate(sampleListViewModelProvider);
},
),
),
},
),
);
}
}
画面の状態モデルクラス
import 'package:freezed_annotation/freezed_annotation.dart';
part 'sample_list_state.freezed.dart';
/// サンプルの一覧画面の状態モデルクラス
@freezed
abstract class SampleListState with _$SampleListState {
/// コンストラクタ
const factory SampleListState({
required List<String> sampleDataList,
/// チェック時データを絞り込む
@Default(false) bool isFilterChecked,
}) = _SampleListState;
}
画面のViewModel
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'sample_list_view_model.g.dart';
/// サンプルの一覧画面のビューモデル
@riverpod
class SampleListViewModel extends _$SampleListViewModel {
/// 初期化
@override
Future<SampleListState> build() async {
final repository = ref.read(sampleRepositoryProvider);
return repository.listSampleDataList();
}
}
routing
GoRouterでルート定義をしているので、関連コードをまとめています。
振り返り
良かった点
- 1画面1ViewModelなのでルールが明確
- リポジトリはインターフェースを作成したので差し替えが楽
- APIエラーのハンドリングを共通化することでAPIのエラーハンドリングが統一できた
- UIのテーマや共通コンポーネントの作成により、実装工数、修正工数が少なくなった
改善すべき点
- ViewModelをもう少し分割するべきだった
- フォルダ分けに工夫の余地があった
設計のよかった点
ルールが明確
基本的に画面ごとに一つのViewModelを定義して、画面ではViewModelの状態を参照して表示するようにしています。
一部、複雑な画面では画面のパーツ単位で分割していますが、画面とViewModelが一対一になることによって、判断が明確でした。
また、同じ構造の画面では、ほとんどコピペで済んだ点も良かったです。
リポジトリの差し替えが楽
ViewModelからデータの取得や更新をするとき、リポジトリのインターフェースを参照するようにしています。
抽象に依存することで、リポジトリをテスト用に差し替えることが可能となりました。
開発初期はバックエンド側の開発が遅れていたこともあり、テスト用のリポジトリで開発を進めました。
画面全体の状態ハンドリングが楽
画面全体としての状態ハンドリングは、RiverpodのAsyncValueを使って、正常、ローディング、エラーを出し分けるようにしました。
全ての画面で下記のように決まったハンドリングを実装できたので、全体のハンドリングを統一できました。
final state = ref.watch(sampleListViewModelProvider);
// Dart3のpattern matchingを使い、AsyncValueを以下のように分岐しています。
return switch (state) {
AsyncLoading<SampleListState>() => const Center(
child: CircularProgressIndicator(),
),
AsyncData<SampleListState>(:final value) => Center(
child: Text('Sample Page'),
),
AsyncError<SampleListState>(:final error) => ErrorView(),
また、ErrorViewというエラーを表示する共通Widgetを作成したことにより、全ページでエラーの表示方法やハンドリングも統一できたのは良かったと思います。
APIエラーのハンドリングを共通化
APIエラーの状態を管理するプロバイダーをアプリ全体の共通として定義しました。
/// エラー状態を管理するプロバイダー
@Riverpod(keepAlive: true)
class AppError extends _$AppError {
@override
Exception? build() {
return null;
}
/// 現在のエラー状態を取得する
Exception? get error => state;
/// エラー状態を設定する
set error(Exception? exception) {
state = exception;
}
/// エラー状態をクリアする
void clear() {
state = null;
}
}
上記のプロバイダーは下記のWidgetで監視しており、下記のWidgetはルートで使用して、全ページでエラーハンドリングが共通となるようにしています。
/// エラーハンドリングするラッパー
class ErrorHandlerWrapper extends ConsumerStatefulWidget {
/// コンストラクタ
const ErrorHandlerWrapper({required this.child, super.key});
/// ラップするWidget
final Widget child;
@override
ConsumerState<ErrorHandlerWrapper> createState() =>
_ErrorHandlerWrapperState();
}
class _ErrorHandlerWrapperState extends ConsumerState<ErrorHandlerWrapper> {
/// モーダルが表示中かどうかを追跡するフラグ
bool _isDialogShowing = false;
@override
Widget build(BuildContext context) {
ref.listen<Exception?>(appErrorProvider, (previous, next) {
if (next != null && !_isDialogShowing) {
_showErrorDialog(context, ref, next.toMessage());
}
});
return widget.child;
}
void _showErrorDialog(
BuildContext context,
WidgetRef ref,
String message,)
{
if (_isDialogShowing) {
return;
}
_isDialogShowing = true;
AppErrorDialog.show(
context,
title: 'Error',
message: message,
).then((_) {
_isDialogShowing = false;
ref.read(appErrorProvider.notifier).clear();
});
}
}
デザインテーマの共通化
色、テキストスタイル、パディングなど、デザインに関わるものは定数とし、共通化しました。
本来なら、デザイナーの方にFigma上でデザインテーマをまとめてもらうのが良いのですが、時間の制約上難しかったため、私の方で共通化して、Flutter内で定義するという形にしました。
8割くらいはカバーできており、実装者ごとにデザインが違うといった問題を防げました。
/// アプリケーション全体で使用する色定義
class AppColors {
AppColors._();
/// プライマリカラー
static const Color primary = Color(0xFFFFFFFF);
/// プライマリーカラーの背景時の文字色
static const Color onPrimary = Color(0xFFFFFFFF);
/// 選択時の色
static const Color primaryContainer = Color(0xFFFFFFFF);
}
/// アプリケーション共通スペーシング
class AppSpacings {
AppSpacings._();
/// 8.h
static double get h8 => 8.h;
/// 16.h
static double get h16 => 16.h;
/// 8.w
static double get w8 => 8.w;
/// 16.w
static double get w16 => 16.w;
/// 高さ8.hのSizedBox
static SizedBox get spacingH8 => SizedBox(height: 8.h);
/// 高さ16.hのSizedBox
static SizedBox get spacingH16 => SizedBox(height: 16.h);
/// 横幅8.wのSizedBox
static SizedBox get spacingW8 => SizedBox(width: 8.w);
/// 横幅16.wのSizedBox
static SizedBox get spacingW16 => SizedBox(width: 16.w);
/// 上下左右に8の余白
static EdgeInsets get all8 => EdgeInsets.only(
left: h8,
top: h8,
right: h8,
bottom: h8,
);
/// 左右に8の余白
static EdgeInsets get horizontal8 => EdgeInsets.symmetric(horizontal: w8);
/// 左右に16の余白
static EdgeInsets get horizontal16 => EdgeInsets.symmetric(horizontal: w16);
/// 上下に8の余白
static EdgeInsets get vertical8 => EdgeInsets.symmetric(vertical: h8);
/// 上下に16の余白
static EdgeInsets get vertical16 => EdgeInsets.symmetric(vertical: h16);
}
/// アプリの共通テキストスタイル
class AppTextStyles {
AppTextStyles._();
static TextStyle _toStyle({
required double fontSize,
required FontWeight fontWeight,
required double letterSpacing,
Color color = AppColors.primary,
}) {
return TextStyle(
fontFamily: AppFonts.notoSansJp,
color: color,
fontSize: fontSize,
fontWeight: fontWeight,
letterSpacing: letterSpacing,
);
}
// display, headline, title, bodyなども、下記と
// 同じような感じで定義
/// 補助的・短いテキスト、ボタン・タグ・補足ラベル
///
/// - large: 16 medium
/// - medium: 14 medium
/// - small: 12 medium
static final label = _AppTextStyleSet(
/// test
large: _toStyle(
fontSize: 16.sp,
fontWeight: AppFontWeights.medium,
),
medium: _toStyle(
fontSize: 14.sp,
fontWeight: AppFontWeights.medium,
),
small: _toStyle(
fontSize: 12.sp,
fontWeight: AppFontWeights.medium,
),
);
// エラーテキストなど、複数箇所で使用する具体的なテキストスタイルは下記のように定義
/// エラーメッセージ用テキストスタイル
static final TextStyle errorMessage = _toStyle(
fontSize: 14.sp,
fontWeight: AppFontWeights.regular,
color: AppColors.error,
);
}
/// Wrapper for TextStyle
class _AppTextStyle extends TextStyle {
_AppTextStyle(TextStyle style)
: super(
color: style.color,
backgroundColor: style.backgroundColor,
fontSize: style.fontSize,
fontFamily: style.fontFamily,
fontWeight: style.fontWeight,
fontStyle: style.fontStyle,
letterSpacing: style.letterSpacing,
wordSpacing: style.wordSpacing,
textBaseline: style.textBaseline,
height: style.height,
leadingDistribution: style.leadingDistribution,
locale: style.locale,
foreground: style.foreground,
background: style.background,
shadows: style.shadows,
fontFeatures: style.fontFeatures,
decoration: style.decoration,
decorationColor: style.decorationColor,
decorationStyle: style.decorationStyle,
decorationThickness: style.decorationThickness,
debugLabel: style.debugLabel,
overflow: style.overflow,
);
}
class _AppTextStyleSet extends _AppTextStyle {
_AppTextStyleSet({
required TextStyle large,
required TextStyle medium,
required TextStyle small,
}) : large = _AppTextStyle(large),
medium = _AppTextStyle(medium),
small = _AppTextStyle(small),
super(medium);
final _AppTextStyle large;
final _AppTextStyle medium;
final _AppTextStyle small;
}
共通UIコンポーネントの作成
デザインテーマの共通化にも被る部分がありますが、繰り返しデザインに出てくるUIパーツを共通化しました。
共通化しておくことで、後々、実装や修正が楽でした。
また、デザインが違うUIでも構造が同じ場合、構造と動きの部分だけ共通Widgetとして定義し、デザインは使う側で決めるというパターンの実装も共通化するために役に立つなと学びました。
Flutterの公式のWidgetで言うと、ListViewのような設計です。ListViewではリスト表示すると言うのは共通ですが、リストのアイテムの描画は使う側で自由に変更できます。このようなパターンを取り入れることで、デザインだけ使用側で実装して、構造や動きの部分は共通化が可能です。
自動コード生成
今回初めてOpenAPI Generatorを使ったのですが、API実装の手間が省けてかなり良かったです。
しかし、anyOfが使われているとうまくコード生成ができないなど、全てのOpenAPIの仕様が使えるわけではなかったので、そこは注意が必要でした。
改善点
ViewModelを分割する
基本的にページ単位でViewModelを作成していましたが、もう少し分けても良かったかなと思っています。
例えば、リストのソート順やフィルター条件といったものも、ページのViewModelに持たせていたのですが、ソート順やフィルター条件を選択するUIと状態を分割したほうがパフォーマンスと保守性が高くなったかなと考えました。
理由としては、
- フィルターUIの変更だけでページ全体のViewModelが再構築される
- UIの関心とデータ取得の関心が混ざる
- テストが難しくなる
といった問題があったためです。
したがって、状態を以下の3種類に分けて管理すると、責務が整理されて保守性が高くなると感じました。
| 状態のスコープ | 具体例 | 管理場所 |
|---|---|---|
| ページ状態 | APIで取得した一覧データや詳細データ | ページのViewModel |
| UIローカル状態 | チェック状態などWidget単位の状態 | StatefulWidget / hooks |
| UIコンポーネント状態 | フィルター条件、ソート条件、ボタンのローディング状態 | UIパーツ専用ViewModel |
フォルダ分け
UIのコードは以下のように分類していました
├── ui/ # UI層(Flutter/UIロジック)
│ ├── samples/ # 機能ごとのディレクトリ
│ │ ├── view_models/ # Riverpodを用いた状態管理とUIイベント
│ │ └── widgets/ # UI
機能ごとにview_modelsとwidgetsでフォルダ分けしていたのですが、複数ページがある機能(大体そうですが)だと、どのファイルがどの画面で使われているのかわかりづらかったです。
また、ViewModelとWidgetのファイルをフォルダ分けしていますが、基本的にその二つはセットで修正することが多いので、同じフォルダ内に合ったほうが良いと感じました。
なので、以下のようにしたほうが良いと考えました。
├── ui/ # UI層(Flutter/UIロジック)
│ ├── samples/ # 機能ごとのディレクトリ
│ │ ├── core # その機能の共通のコード
│ │ ├── list_page/ # 画面ごとのフォルダで分割
│ │ │ ├── sample_page.dart
│ │ │ ├── sample_view_model.dart
│ │ │ └── sample_state.dart
│ │ └── detail_page/ # UI