【実践】Flutter MVVMアーキテクチャ入門|Riverpodで実装するTodoアプリ

【実践】Flutter MVVMアーキテクチャ入門|Riverpodで実装するTodoアプリ

「StatefulWidgetにロジックを直書きしてたら、コードがカオスになってきた…」
「BLoCは学習コストが高くて挫折した」
「MVVMって聞くけど、Flutterでどう実装すればいいの?」

そんな悩みをお持ちではありませんか?

結論からお伝えすると、FlutterでMVVMアーキテクチャを採用すれば、UIとビジネスロジックを明確に分離でき、保守性・テスタビリティが大幅に向上します。特にRiverpodと組み合わせることで、シンプルかつ強力な状態管理が実現できます。

この記事では、他のアーキテクチャから移行したい中級者向けに、MVVMの概念説明から実際のTodoアプリ実装まで、コード付きで詳しく解説します。

この記事でわかること

  • MVVMアーキテクチャの基本概念とFlutterでの適用方法
  • MVC/MVPとの違いと、MVVMを選ぶべき理由
  • Riverpodを使ったMVVMの実装パターン
  • Todoアプリを題材にした実践的なコード例
  • MVVM導入時の注意点とベストプラクティス

Flutter MVVMとは?30秒でわかる基礎知識

Flutter MVVMとは?30秒でわかる基礎知識

MVVM(Model-View-ViewModel)は、アプリケーションを3つの層に分離するアーキテクチャパターンです。元々はMicrosoftがWPF向けに提唱したものですが、現在ではモバイルアプリ開発でも広く採用されています。

MVVMの3つの構成要素

Model(モデル)
データ構造とビジネスロジックを担当します。APIからのレスポンス、データベースのエンティティ、ドメインロジックなどがここに含まれます。UIのことは一切知りません。

View(ビュー)
UIの描画のみを担当します。Flutterでは、StatelessWidgetやStatefulWidgetがこれに該当します。Viewは「何を表示するか」だけに集中し、ビジネスロジックは持ちません。

ViewModel(ビューモデル)
ViewとModelの橋渡し役です。Modelから取得したデータをViewが使いやすい形に変換し、Viewからのユーザー操作を受け取ってModelを更新します。Riverpodでは、NotifierやAsyncNotifierがこの役割を担います。

MVCやMVPとの違い

MVVMと似たパターンにMVC(Model-View-Controller)やMVP(Model-View-Presenter)があります。大きな違いは「ViewとModelの依存関係」です。

MVCでは、ViewがModelの状態を直接参照することがあります。ControllerはユーザーからのInputに対応してModelを操作しますが、ViewとModelが密結合になりやすい傾向があります。

MVVMでは、ViewはViewModelだけを知っていれば良く、Modelの存在を意識しません。ViewModelがModelの状態を監視し、Viewが使いやすい形に加工して提供します。これにより、ViewとModelが完全に分離され、それぞれを独立してテスト・変更できます。

FlutterでMVVMを選ぶメリット

Flutterは宣言的UIフレームワークです。「状態が変わったらUIを再構築する」という考え方は、MVVMのデータバインディングと非常に相性が良いです。

具体的なメリットは以下の通りです。

  • 関心の分離:UIロジックとビジネスロジックが明確に分かれる
  • テスタビリティ:ViewModelを単体でテストできる
  • 再利用性:ViewModelを別のViewで使い回せる
  • チーム開発:UI担当とロジック担当で並行作業しやすい

MVVMの各レイヤーの役割と責務

MVVMの各レイヤーの役割と責務

実装に入る前に、各レイヤーの責務をもう少し詳しく整理しておきましょう。

Model層:データとビジネスロジック

Model層には以下が含まれます。

  • エンティティ/データクラス:Todoアイテム、ユーザー情報などのデータ構造
  • ビジネスロジック:データの検証、計算、変換などのルール
  • Repository:データソース(API、DB、ローカルストレージ)へのアクセスを抽象化

ポイントは、Model層がUIフレームワーク(Flutter)に依存しないことです。純粋なDartコードで書かれ、どこからでも再利用できます。

View層:UIの描画に専念

View層の責務はシンプルです。

  • ViewModelから受け取ったデータを表示する
  • ユーザーの操作(タップ、入力など)をViewModelに伝える

View層では、条件分岐やループは許容されますが、ビジネスロジック(「このデータをどう加工するか」「このアクションで何を更新するか」)は書きません。

ViewModel層:状態管理と変換ロジック

ViewModel層が担う責務は以下の通りです。

  • Viewが必要とする状態を保持・公開する
  • Modelから取得したデータをView向けに変換する
  • Viewからのアクションを受け取り、Modelを更新する
  • 非同期処理(API呼び出しなど)の状態管理

Riverpodでは、Notifier(同期)やAsyncNotifier(非同期)がViewModelの役割を果たします。

Repository層:データアクセスの抽象化

厳密にはMVVMの構成要素ではありませんが、実務ではRepository層を追加するのが一般的です。

Repositoryは「データをどこから取得するか」を隠蔽します。ViewModelから見ると、データがAPIから来るのか、ローカルDBから来るのか、キャッシュから来るのかを意識する必要がありません。これにより、データソースの切り替えやテスト時のモック差し替えが容易になります。

RiverpodでMVVMを実装する

RiverpodでMVVMを実装する

FlutterでMVVMを実装する際、状態管理ライブラリの選択が重要です。ここではRiverpodを使用します。

なぜRiverpodか

Riverpodは、Providerパッケージの作者であるRemi Rousselet氏が開発した次世代の状態管理ライブラリです。

Providerと比較した主なメリットは以下の通りです。

  • コンパイル時安全性:ProviderNotFoundExceptionのような実行時エラーが発生しない
  • BuildContext不要:Widgetツリーの外からでもProviderにアクセスできる
  • 自動破棄:使われなくなったProviderは自動的にDisposeされる
  • テストしやすい:Providerのオーバーライドが簡単

BLoCと比較すると、Riverpodはボイラープレートが少なく、学習コストも低めです。MVVMパターンとの相性も良く、NotifierがそのままViewModelとして機能します。

プロジェクト構成(フォルダ構造)

MVVMを採用する際の典型的なフォルダ構成は以下の通りです。


lib/
├── main.dart
├── models/              # Model層
│   └── todo.dart
├── repositories/        # Repository層
│   └── todo_repository.dart
├── view_models/         # ViewModel層
│   └── todo_view_model.dart
├── views/               # View層
│   ├── todo_list_view.dart
│   └── widgets/
│       └── todo_item.dart
└── providers/           # Providerの定義
    └── providers.dart

機能ごとにフォルダを分ける(feature-first)アプローチもありますが、小〜中規模のプロジェクトでは、上記のレイヤーごとの構成がシンプルでわかりやすいです。

必要なパッケージ

pubspec.yamlに以下を追加します。


dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^2.5.1
  freezed_annotation: ^2.4.1

dev_dependencies:
  flutter_test:
    sdk: flutter
  build_runner: ^2.4.8
  freezed: ^2.4.7

freezedはイミュータブルなデータクラスを簡単に作成するためのパッケージです。Model層のデータクラス定義に使用します。

なお、2025年9月にRiverpod 3.0がリリースされ、自動リトライ機能やオフラインサポートなどの新機能が追加されています。本記事のコードはRiverpod 2.x系で動作しますが、3.0への移行も比較的スムーズに行えます。

【実装】TodoアプリでMVVMを体験

【実装】TodoアプリでMVVMを体験

それでは、実際にTodoアプリを作りながらMVVMの実装方法を見ていきましょう。

Model層の実装

まず、Todoアイテムのデータクラスを定義します。


// lib/models/todo.dart
import ‘package:freezed_annotation/freezed_annotation.dart’;

part ‘todo.freezed.dart’;
part ‘todo.g.dart’;

@freezed
class Todo with _$Todo {
  const factory Todo({
    required String id,
    required String title,
    @Default(false) bool isCompleted,
    DateTime? createdAt,
  }) = _Todo;

  factory Todo.fromJson(Map json) => _$TodoFromJson(json);
}

freezedを使うことで、copyWith、==演算子、hashCode、toStringなどが自動生成されます。以下のコマンドでコード生成を実行します。


flutter pub run build_runner build –delete-conflicting-outputs

Repository層の実装

次に、Todoデータへのアクセスを抽象化するRepositoryを作成します。


// lib/repositories/todo_repository.dart
import ‘../models/todo.dart’;

abstract class TodoRepository {
  Future<List> fetchTodos();
  Future addTodo(Todo todo);
  Future updateTodo(Todo todo);
  Future deleteTodo(String id);
}

// インメモリ実装(実際のアプリではAPI呼び出しやDB操作に置き換え)
class InMemoryTodoRepository implements TodoRepository {
  final List _todos = [];

  @override
  Future<List> fetchTodos() async {
    // APIコールをシミュレート
    await Future.delayed(const Duration(milliseconds: 500));
    return List.unmodifiable(_todos);
  }

  @override
  Future addTodo(Todo todo) async {
    await Future.delayed(const Duration(milliseconds: 200));
    _todos.add(todo);
  }

  @override
  Future updateTodo(Todo todo) async {
    await Future.delayed(const Duration(milliseconds: 200));
    final index = _todos.indexWhere((t) => t.id == todo.id);
    if (index != -1) {
      _todos[index] = todo;
    }
  }

  @override
  Future deleteTodo(String id) async {
    await Future.delayed(const Duration(milliseconds: 200));
    _todos.removeWhere((t) => t.id == id);
  }
}

抽象クラスを定義しておくことで、テスト時にモックに差し替えたり、将来的にFirestoreやSQLiteに移行したりすることが容易になります。

ViewModel層の実装(Notifier)

ViewModelとして機能するNotifierを実装します。


// lib/view_models/todo_view_model.dart
import ‘package:flutter_riverpod/flutter_riverpod.dart’;
import ‘../models/todo.dart’;
import ‘../repositories/todo_repository.dart’;

// Todoリストの状態を管理するViewModel
class TodoListNotifier extends AsyncNotifier<List> {
  late final TodoRepository _repository;

  @override
  Future<List> build() async {
    _repository = ref.read(todoRepositoryProvider);
    return _repository.fetchTodos();
  }

  // Todoを追加
  Future addTodo(String title) async {
    final newTodo = Todo(
      id: DateTime.now().millisecondsSinceEpoch.toString(),
      title: title,
      createdAt: DateTime.now(),
    );

    // 楽観的更新:UIを先に更新
    state = AsyncData([…state.value ?? [], newTodo]);

    try {
      await _repository.addTodo(newTodo);
    } catch (e) {
      // エラー時はリフェッチ
      state = await AsyncValue.guard(() => _repository.fetchTodos());
      rethrow;
    }
  }

  // Todoの完了状態を切り替え
  Future toggleTodo(String id) async {
    final todos = state.value ?? [];
    final index = todos.indexWhere((t) => t.id == id);
    if (index == -1) return;

    final todo = todos[index];
    final updatedTodo = todo.copyWith(isCompleted: !todo.isCompleted);

    // 楽観的更新
    final updatedList = […todos];
    updatedList[index] = updatedTodo;
    state = AsyncData(updatedList);

    try {
      await _repository.updateTodo(updatedTodo);
    } catch (e) {
      state = await AsyncValue.guard(() => _repository.fetchTodos());
      rethrow;
    }
  }

  // Todoを削除
  Future deleteTodo(String id) async {
    final todos = state.value ?? [];

    // 楽観的更新
    state = AsyncData(todos.where((t) => t.id != id).toList());

    try {
      await _repository.deleteTodo(id);
    } catch (e) {
      state = await AsyncValue.guard(() => _repository.fetchTodos());
      rethrow;
    }
  }

  // 完了済みTodoの数を取得(派生状態)
  int get completedCount => 
      state.value?.where((t) => t.isCompleted).length ?? 0;

  // 未完了Todoの数を取得
  int get pendingCount => 
      state.value?.where((t) => !t.isCompleted).length ?? 0;
}

ポイントは「楽観的更新」です。APIの応答を待たずにUIを先に更新することで、ユーザー体験が向上します。エラー発生時は、サーバーから最新データを再取得して状態を修正します。

Providerの定義

ProviderをまとめたファイルI作成します。


// lib/providers/providers.dart
import ‘package:flutter_riverpod/flutter_riverpod.dart’;
import ‘../repositories/todo_repository.dart’;
import ‘../view_models/todo_view_model.dart’;

// Repositoryのプロバイダー
final todoRepositoryProvider = Provider((ref) {
  return InMemoryTodoRepository();
});

// ViewModelのプロバイダー
final todoListProvider = AsyncNotifierProvider<TodoListNotifier, List>(
  TodoListNotifier.new,
);

// 派生状態:完了済みTodoの数
final completedCountProvider = Provider((ref) {
  final todosAsync = ref.watch(todoListProvider);
  return todosAsync.value?.where((t) => t.isCompleted).length ?? 0;
});

// 派生状態:未完了Todoの数
final pendingCountProvider = Provider((ref) {
  final todosAsync = ref.watch(todoListProvider);
  return todosAsync.value?.where((t) => !t.isCompleted).length ?? 0;
});

View層の実装

最後に、UIを実装します。


// lib/views/todo_list_view.dart
import ‘package:flutter/material.dart’;
import ‘package:flutter_riverpod/flutter_riverpod.dart’;
import ‘../providers/providers.dart’;
import ‘../models/todo.dart’;

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final todosAsync = ref.watch(todoListProvider);
    final completedCount = ref.watch(completedCountProvider);
    final pendingCount = ref.watch(pendingCountProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text(‘Todo App (MVVM)’),
        actions: [
          Center(
            child: Padding(
              padding: const EdgeInsets.only(right: 16),
              child: Text(‘完了: $completedCount / 残り: $pendingCount’),
            ),
          ),
        ],
      ),
      body: todosAsync.when(
        data: (todos) => todos.isEmpty
            ? const Center(child: Text(‘Todoがありません’))
            : ListView.builder(
                itemCount: todos.length,
                itemBuilder: (context, index) {
                  final todo = todos[index];
                  return TodoItem(todo: todo);
                },
              ),
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (error, stack) => Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text(‘エラー: $error’),
              ElevatedButton(
                onPressed: () => ref.invalidate(todoListProvider),
                child: const Text(‘再読み込み’),
              ),
            ],
          ),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => _showAddTodoDialog(context, ref),
        child: const Icon(Icons.add),
      ),
    );
  }

  void _showAddTodoDialog(BuildContext context, WidgetRef ref) {
    final controller = TextEditingController();

    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text(‘新しいTodo’),
        content: TextField(
          controller: controller,
          autofocus: true,
          decoration: const InputDecoration(
            hintText: ‘Todoのタイトル’,
          ),
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text(‘キャンセル’),
          ),
          TextButton(
            onPressed: () {
              final title = controller.text.trim();
              if (title.isNotEmpty) {
                ref.read(todoListProvider.notifier).addTodo(title);
                Navigator.pop(context);
              }
            },
            child: const Text(‘追加’),
          ),
        ],
      ),
    );
  }
}

// Todoアイテムのウィジェット
class TodoItem extends ConsumerWidget {
  final Todo todo;

  const TodoItem({super.key, required this.todo});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Dismissible(
      key: Key(todo.id),
      direction: DismissDirection.endToStart,
      onDismissed: (_) {
        ref.read(todoListProvider.notifier).deleteTodo(todo.id);
      },
      background: Container(
        color: Colors.red,
        alignment: Alignment.centerRight,
        padding: const EdgeInsets.only(right: 16),
        child: const Icon(Icons.delete, color: Colors.white),
      ),
      child: ListTile(
        leading: Checkbox(
          value: todo.isCompleted,
          onChanged: (_) {
            ref.read(todoListProvider.notifier).toggleTodo(todo.id);
          },
        ),
        title: Text(
          todo.title,
          style: TextStyle(
            decoration: todo.isCompleted
                ? TextDecoration.lineThrough
                : TextDecoration.none,
            color: todo.isCompleted ? Colors.grey : null,
          ),
        ),
      ),
    );
  }
}

main.dartの設定

最後に、アプリのエントリーポイントを設定します。


// lib/main.dart
import ‘package:flutter/material.dart’;
import ‘package:flutter_riverpod/flutter_riverpod.dart’;
import ‘views/todo_list_view.dart’;

void main() {
  runApp(
    const ProviderScope(
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: ‘Flutter MVVM Demo’,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
        useMaterial3: true,
      ),
      home: const TodoListView(),
    );
  }
}

これで、MVVMアーキテクチャに基づいたTodoアプリが完成です。

MVVMを導入するときの注意点

MVVMを導入するときの注意点

MVVMは強力なパターンですが、導入時にいくつか注意すべき点があります。

ViewModelが肥大化しやすい問題

すべてのロジックをViewModelに詰め込むと、巨大なクラスになりがちです。対策として以下を意識しましょう。

  • ビジネスロジックはModel層(またはUseCase層)に分離する
  • 1つのViewModelは1つの画面または機能に対応させる
  • 複数の画面で共有する状態は、別のProviderに切り出す

テスタビリティを意識した設計

MVVMのメリットを最大限活かすには、テストしやすい設計が重要です。

  • Repositoryは抽象クラス(インターフェース)として定義する
  • ViewModelはRepositoryをコンストラクタで受け取る(依存性注入)
  • Providerのオーバーライド機能を活用してモックを差し替える

// テスト例
void main() {
  test(‘addTodoでリストに追加される’, () async {
    final container = ProviderContainer(
      overrides: [
        todoRepositoryProvider.overrideWithValue(MockTodoRepository()),
      ],
    );

    final notifier = container.read(todoListProvider.notifier);
    await notifier.addTodo(‘テストTodo’);

    final todos = container.read(todoListProvider).value;
    expect(todos?.length, 1);
    expect(todos?.first.title, ‘テストTodo’);
  });
}

小規模アプリには過剰な場合も

MVVMは中〜大規模アプリで真価を発揮します。数画面程度の小規模アプリでは、オーバーエンジニアリングになる可能性があります。

プロジェクトの規模や将来の拡張性を考慮して、適切なアーキテクチャを選択しましょう。小さく始めて、複雑になってきたらMVVMに移行するアプローチも有効です。

よくある質問(FAQ)

よくある質問(FAQ)

Q. BLoCとの使い分けは?

BLoCとMVVM(Riverpod)は、どちらも状態管理とUIの分離を目的としています。

BLoCはStreamベースで、イベント駆動のアプローチを取ります。複雑な非同期フローや、厳格なアーキテクチャルールを求めるチームに向いています。一方、Riverpodはよりシンプルで柔軟性が高く、学習コストも低めです。

チームの経験や好み、プロジェクトの要件に応じて選択してください。どちらを選んでも、MVVMの考え方(UIとロジックの分離)は適用できます。

Q. Clean Architectureとの関係は?

Clean Architectureは、MVVMよりも広範なアーキテクチャパターンです。Domain層、Data層、Presentation層という3層構造を持ち、依存関係のルール(内側から外側への依存のみ許可)を定めています。

MVVMはPresentationルの設計パターンとして、Clean Architectureの中に組み込むことができます。大規模プロジェクトでは、Clean Architecture + MVVMの組み合わせがよく採用されます。

Q. 既存プロジェクトへの導入方法は?

一度にすべてを書き換える必要はありません。以下のステップで段階的に移行できます。

  1. まずRiverpodを導入し、新規画面からMVVMで実装する
  2. 既存画面のリファクタリング時に、少しずつMVVMに移行する
  3. 共通のRepositoryやProviderを整備していく

StatefulWidgetのsetStateで管理していた状態を、まずStateNotifierProviderに移すところから始めるのがおすすめです。

まとめ:MVVMで保守性の高いFlutterアプリを

まとめ:MVVMで保守性の高いFlutterアプリを

この記事では、FlutterにおけるMVVMアーキテクチャの実装方法を解説しました。

重要ポイント

  • MVVMは、Model(データ)・View(UI)・ViewModel(状態管理)の3層に分離するパターン
  • Flutterの宣言的UIとMVVMのデータバインディングは相性が良い
  • Riverpodを使えば、シンプルかつ強力なMVVM実装が可能
  • Repository層を追加することで、データアクセスを抽象化できる
  • 楽観的更新でUXを向上させ、エラー時はリフェッチで整合性を保つ

次のステップ

  1. この記事のTodoアプリを実際に動かしてみる
  2. 自分のプロジェクトで1画面だけMVVMで実装してみる
  3. テストコードを書いて、テスタビリティの向上を体感する
  4. 必要に応じてClean ArchitectureやDDD(ドメイン駆動設計)を学ぶ

MVVMは「銀の弾丸」ではありませんが、UIとビジネスロジックを分離する明確な指針を与えてくれます。まずは小さく始めて、徐々にパターンに慣れていってください。

コメント

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です