🦔

【Flutter】Riverpod + Freezed + MVVM で作る、シンプルかつ堅牢なリスト管理アプリ

に公開

はじめに


Flutterでの状態管理、どうしていますか? 「Riverpodが良いとは聞くけど、具体的にどうクラスを分ければいいの?」「MVVMってFlutterだとどうなるの?」と悩む方も多いと思います。

今回は、Riverpod Generator と Freezed を組み合わせた、モダンで実務的な MVVM (Model-View-ViewModel) 構成のサンプルアプリを作ってみました。 「ただ動くだけ」ではなく、将来的な拡張やテストのしやすさも考慮した「ちょっと良い設計」を紹介します。

作成するアプリ


ボタンを押すとユーザーリストに新しいユーザーが追加される、非常にシンプルなアプリです。 しかし、その裏側はしっかりとした責務分担(MVVM)がなされています。

アプリのデータの流れ


View (UI) → (操作) → ViewModel (Notifier) → (ロジック実行) → Model (Data) → (新しい状態) → ViewModel → (通知) → View

プロジェクト構成


まずはディレクトリ構成を見てみましょう。役割ごとに綺麗に分かれています。

lib/
  ├── main.dart
  ├── models/               # Model: データとビジネスロジック
  │   ├── user.dart
  │   └── user_list.dart
  ├── view_models/          # ViewModel: 状態管理とViewへの橋渡し
  │   └── user_list_notifier.dart
  └── views/                # View: UI描画
      └── user_list_page.dart

Step 1. Model (データとロジック)


まずはアプリの主役となるデータ構造です。ここでは freezed パッケージを使って、イミュータブル(不変) なクラスを作成します。

ポイントは、「データの加工ロジックをModelに持たせる」 ことです。

ユーザー単体 (user.dart)

名前を持つだけの最小単位のデータ。まずは最小単位のユーザーモデルを作ります。freezed を使うことで、値の等価性比較(==)などが自動で実装されます。

// lib/models/user.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'user.freezed.dart';


class User with _$User {
  const factory User({required String name}) = _User;
}

ユーザーリスト (user_list.dart)

ユーザーの一覧を束ねるクラス。ここが設計のキモです!単にリストを持つだけでなく、「自分自身に新しいユーザーを追加した新しいリストを返す」 というメソッド (addUser) をここに定義します。

これにより、ViewModel (UserListNotifier) は「Modelのメソッドを呼ぶだけ」というシンプルな役割に徹することができています。

// lib/models/user_list.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:mvvm_riverpod/models/user.dart';

part 'user_list.freezed.dart';


abstract class UserList with _$UserList {
  const UserList._();
  const factory UserList({required List<User> users}) = _UserList;

  // ポイント: ロジックをここに閉じ込める
  UserList addUser(User user) {
    return copyWith(users: [...users, user]);
  }
}

Step 2. ViewModel (状態管理)


次に、ViewとModelをつなぐViewModelです。Riverpod Generator を使ってシンプルに記述します。state として UserList (Model) を持っています。

ここでは UserList (Model) を状態として持ちます。addUser メソッドでは、先ほどModelに定義したロジックを呼び出すだけです。

画面から「追加して!」と言われたら (addUserメソッド)、具体的な計算は Model (state.addUser) に任せ、返ってきた新しい結果を state にセットします。

// lib/view_models/user_list_notifier.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:mvvm_riverpod/models/user.dart';
import 'package:mvvm_riverpod/models/user_list.dart';

part 'user_list_notifier.g.dart';


class UserListNotifier extends _$UserListNotifier {
  
  UserList build() {
    // 初期状態を返す
    return const UserList(
      users: [
        User(name: 'AAA'),
        User(name: 'BBB'),
        User(name: 'CCC'),
      ],
    );
  }

  // 画面からのアクションを受け取る
  void addUser(String name) {
    final id = state.users.length + 1;
    // Modelのメソッドを使って状態を更新(イミュータブルな更新)
    state = state.addUser(User(name: name + id.toString()));
  }
}

Step 3. View (UI)


最後に画面の実装です。 ViewModel (userListProvider) を ref.watch で監視し、変更があったら自動で再描画します。リスト表示には ListView.builder を使い、パフォーマンスも考慮します。

ロジック(どうやって追加するか)はここには書かず、notifier.addUser(...) を呼ぶだけに留めています。

// lib/views/user_list_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mvvm_riverpod/view_models/user_list_notifier.dart';

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

  
  Widget build(BuildContext context, WidgetRef ref) {
    // 状態(Model)を監視
    final userListState = ref.watch(userListProvider);

    return Scaffold(
      body: Column(
        children: [
          const SizedBox(height: 100),
          ElevatedButton(
            onPressed: () {
              // ViewModelのアクションを呼び出し
              ref.read(userListProvider.notifier).addUser('new User');
            },
            child: const Text('Add User'),
          ),
          Expanded(
            // 要素が多くても大丈夫なようにListView.builderを使用
            child: ListView.builder(
              itemCount: userListState.users.length,
              itemBuilder: (context, index) {
                final user = userListState.users[index];
                return ListTile(title: Text(user.name));
              },
            ),
          ),
        ],
      ),
    );
  }
}

仕上げ main.dart


最後に ProviderScope でアプリ全体を囲むのを忘れずに。ProviderScopeでアプリ全体を囲むことで、どこからでも ref が使えるようになります。Riverpodを使うための必須設定です。

// lib/main.dart
void main() {
  runApp(const ProviderScope(child: App()));
}

この構成のメリット


今回実装したこの構成には、以下のようなメリットがあります。

責務が明確

  • データ加工は Model
  • 状態管理は ViewModel
  • 描画は View
    と分かれているため、どこに何を書けばいいか迷いません。

安全な状態更新

Freezed によるイミュータブルなオブジェクト操作のおかげで、意図しないデータの書き換えを防げます。

拡張性

例えば「ユーザーを削除する」機能を追加したい場合、Modelに removeUser メソッドを追加し、ViewModelからそれを呼ぶだけで済みます。

Discussion