【Flutter】小規模〜中規模開発におけるベストプラクティス
スポンサーリンク

Flutter開発における小・中規模プロジェクトのベストプラクティス

小〜中規模のFlutter開発プロジェクトにおける現時点で推奨されるベストプラクティスをまとめてみた

ヒーラー
iOS/Androidアプリを同時に開発するためにFlutterを使用した新規プロジェクトが立ち上がるが、アーキテクチャ設計のベストプラクティスが分からない

はじめに

Flutterによるモバイルアプリ開発において、プロジェクト立ち上げ時のアーキテクチャ設計は、その後の開発効率や保守性に大きな影響を与えます。
適切な設計なしに開発を進めると、機能追加の度にコードが複雑化し、バグの原因特定が困難になり、テストが書きにくいといった問題に直面することになります。

この記事では、小規模から中規模のFlutterプロジェクト新規立ち上げ時に採用すべきアーキテクチャパターン、技術スタック、設計指針について、実践的な例を交えて解説します。

特に以下の3つのポイントを中心に、実装例とともに詳しく紹介していきます。

  1. MVVM + Repositoryパターン
    • UIロジックとビジネスロジックを明確に分離し、保守性の高いコード構造を実現
  2. Riverpod + Hooks + Freezedの技術スタック
    • 型安全性と開発効率を両立させる現代的なFlutter開発の定番構成
  3. プロジェクト新規立ち上げ時の設計指針
    • 最初から適切な構造で始めるためのチェックリストと心得

これらの知識を身につけることで、スケーラブルで保守性の高いFlutterアプリケーションを構築できるようになるでしょう。

MVVM + Repositoryパターンで実現する責務の明確な分離

アーキテクチャパターンの概要と特徴

小規模から中規模のFlutterプロジェクトでは、MVVM (Model-View-ViewModel) + Repository パターンが最も推奨されるアーキテクチャです。
このパターンは、関心の分離という基本原則に基づいており、各レイヤーが明確な責務を持つことで、開発効率とコードの品質を大幅に向上させます。

アーキテクチャの全体像は以下のとおりです。

各レイヤーの役割
  • View (UI層): ユーザーインターフェースの表示を担当
    • HookConsumerWidgetを使用してViewModelの状態を監視し、
      ユーザーの操作をViewModelに委譲します。
  • ViewModel (UI層): ビジネスロジックと状態管理を担当
    • StateNotifierを継承して実装し
      Repositoryからデータを取得してStateを更新します。
  • Repository (データ層): データソースへのアクセスを抽象化
    • APIクライアントやデータベースとの通信を担当し、
      エラーハンドリングも行います。
  • Service(API) (データ層): 外部APIアクセスを担当
    • 外部Web APIへの具体的なHTTPリクエストの実行を担当し、
      エンドポイントに対してデータの取得や送信などを行います。
  • Model: ドメインオブジェクト
    • freezedによるイミュータブルなデータクラスとして定義します。
具体的なメリット

このアーキテクチャを採用することで得られる具体的なメリットは以下のとおりです。

1. テストが容易になる
各レイヤーが独立しているため、モックを使った単体テストが簡単に書けます。
例えば、ViewModelのテストではRepositoryをモック化することで、APIの状態に依存せずロジックを検証できます。

2. コードの再利用性が向上
Repositoryを共通化することで、同じデータソースを複数の画面で利用できます。
また、ViewModelも再利用可能な単位で設計できます。

3. 変更の影響範囲が最小化
UIの変更はViewのみ、API仕様の変更はRepositoryのみに影響を限定できます。
レイヤー間の依存が一方向なので、変更時のリスクが低減されます。

4. チーム開発がスムーズ
役割分担が明確なので、UIデザイナー、フロントエンドエンジニア、バックエンドエンジニアがそれぞれ独立して作業を進められます。

実装における具体的なアクション

プロジェクト立ち上げ時に以下のアクションを実施することで、このアーキテクチャを効果的に導入できます。

ステップ1: ディレクトリ構造の設計

例えばプロジェクト開始時に以下のようなディレクトリ構造を作成します。

lib/
├── model/                      # ドメインモデル
│   ├── user.dart
│   ├── user_list_response.dart
│   └── ...
│
├── api/                        # APIクライアント
│   └── user_api_client.dart    # Retrofit API Client
│
├── repository/                 # Repository層
│   └── user_repository.dart
│
├── page/                       # 画面UI
│   ├── user_list_page/
│   │   ├── user_list_page.dart           # View
│   │   └── component/
│   │       ├── user_list_viewmodel.dart  # ViewModel
│   │       └── user_list_state.dart      # State
│   └── user_detail_page/
│       └── ...
│
├── provider/                   # Riverpod Provider定義
│   ├── dio_provider.dart
│   ├── user_api_client_provider.dart
│   └── user_repository_provider.dart
│
└── util/                       # ユーティリティ
    ├── logger.dart
    └── router.dart             # go_router
ステップ2: 依存関係の定義

Riverpodを使用して、各レイヤーの依存関係を明確に定義します。

// API ClientのProvider
final userApiClientProvider = Provider<UserApiClient>((ref) {
  final dio = ref.watch(dioProvider);
  return UserApiClient(dio);
});

// RepositoryのProvider
final userRepositoryProvider = Provider<UserRepository>((ref) {
  final apiClient = ref.watch(userApiClientProvider);
  return UserRepository(apiClient);
});

// ViewModelのProvider
final userListViewModelProvider =
    StateNotifierProvider<UserListViewModel, UserListState>((ref) {
  final repository = ref.watch(userRepositoryProvider);
  return UserListViewModel(repository);
});
ステップ3: 実装の流れ

以下の順序で実装を進めることで、効率的に開発できます。

  1. Modelの定義: freezedを使ってデータクラスを作成
  2. APIクライアントの実装: retrofitでAPI通信層を実装
  3. Repositoryの実装: APIクライアントを使ったデータ取得処理
  4. Stateの定義: ViewModelが管理する状態を定義
  5. ViewModelの実装: ビジネスロジックと状態管理
  6. Viewの実装: UIの表示とユーザー操作の受付

この順序で実装することで、下位レイヤーから順に構築でき、各レイヤーの動作確認をしながら進められます。

Riverpod + Hooks + Freezedで実現する型安全で効率的な開発

技術スタックの概要と特徴

現代のFlutter開発において、Riverpod + Hooks + Freezedの組み合わせは事実上のスタンダードとなっています。
この技術スタックは、【型安全性】【開発効率】【コードの簡潔さ】の3つを高いレベルで実現します。

各パッケージの役割

Riverpod (hooks_riverpod)

  • 状態管理とDI(依存性注入)を一元的に管理
  • グローバルに状態を共有しつつ、テスタビリティを確保
  • ProviderStateNotifierProviderFutureProviderなど、用途に応じた複数の種類を提供

Flutter Hooks (flutter_hooks)

  • 【ライフサイクル管理】【ローカル状態】【副作用】を簡潔に記述
  • useStateuseEffectuseMemoizedなど、Reactライクな記法
  • HookConsumerWidgetでRiverpodと統合

Freezed

  • イミュータブルなデータクラを自動生成
  • copyWith==hashCode、JSONシリアライゼーションを自動実装
  • Union typesによる型安全な状態表現

実装例を見ていきましょう。

Modelの定義 (Freezed)

import 'package:freezed_annotation/freezed_annotation.dart';

part 'user.freezed.dart';
part 'user.g.dart';

@freezed
class User with _$User {
  const factory User({
    required int id,
    required String name,
    required String email,
    String? avatarUrl,
  }) = _User;

  factory User.fromJson(Map<String, dynamic> json) =>
      _$UserFromJson(json);
}

Stateの定義 (Freezed)

@freezed
class UserListState with _$UserListState {
  const factory UserListState({
    @Default([]) List<User> users,
    @Default(false) bool isLoading,
    @Default(true) bool hasMore,
    String? error,
  }) = _UserListState;
}

ViewModelの実装 (Riverpod + StateNotifier)

class UserListViewModel extends StateNotifier<UserListState> {
  UserListViewModel(this._repository) : super(const UserListState()) {
    fetchUserList();
  }

  final UserRepository _repository;

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

    try {
      final response = await _repository.fetchUserList();
      state = state.copyWith(
        users: response.results,
        isLoading: false,
      );
    } catch (e) {
      state = state.copyWith(
        isLoading: false,
        error: e.toString(),
      );
    }
  }

  Future<void> refresh() async {
    state = const UserListState();
    await fetchUserList();
  }
}

Viewの実装 (HookConsumerWidget)

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final viewModel = ref.watch(userListViewModelProvider.notifier);
    final state = ref.watch(userListViewModelProvider);

    // ローカル状態の管理にHooksを使用
    final scrollController = useScrollController();

    useEffect(() {
      // スクロール監視などの副作用
      void listener() {
        if (scrollController.position.pixels ==
            scrollController.position.maxScrollExtent) {
          viewModel.fetchUserList();
        }
      }
      scrollController.addListener(listener);
      return () => scrollController.removeListener(listener);
    }, [scrollController]);

    if (state.isLoading && state.users.isEmpty) {
      return const Center(child: CircularProgressIndicator());
    }

    return Scaffold(
      appBar: AppBar(title: const Text('ユーザー一覧')),
      body: RefreshIndicator(
        onRefresh: viewModel.refresh,
        child: ListView.builder(
          controller: scrollController,
          itemCount: state.users.length,
          itemBuilder: (context, index) {
            final user = state.users[index];
            return ListTile(
              leading: user.avatarUrl != null
                  ? CircleAvatar(backgroundImage: NetworkImage(user.avatarUrl!))
                  : const CircleAvatar(child: Icon(Icons.person)),
              title: Text(user.name),
              subtitle: Text(user.email),
            );
          },
        ),
      ),
    );
  }
}
具体的なメリット

1. 圧倒的な型安全性
Freezedによって、【nullの扱い】【不正な状態遷移】【データの不変性】が保証されます。
コンパイル時にエラーを検出できるため、ランタイムエラーが劇的に減少します。

2. 開発速度の向上

  • copyWithによる状態更新が一行で完結
  • ボイラープレートコードが自動生成されるため、本質的なロジックに集中できる
  • Hooksによりライフサイクル管理が簡潔になる

3. テストの容易性
RiverpodのoverrideWithValueにより、テスト時の状態管理が簡単
ViewModelの単体テストも書きやすくなります。

test('ユーザー一覧の取得に成功', () async {
  final mockRepository = MockUserRepository();
  when(mockRepository.fetchUserList())
      .thenAnswer((_) async => UserListResponse(results: [testUser]));

  final container = ProviderContainer(
    overrides: [
      userRepositoryProvider.overrideWithValue(mockRepository),
    ],
  );

  final viewModel = container.read(userListViewModelProvider.notifier);
  await viewModel.fetchUserList();

  final state = container.read(userListViewModelProvider);
  expect(state.users.length, 1);
  expect(state.isLoading, false);
});
導入のための具体的なアクション
ステップ1: pubspec.yamlの設定
dependencies:
  flutter:
    sdk: flutter
  hooks_riverpod: ^2.4.0
  flutter_hooks: ^0.20.0
  freezed_annotation: ^2.4.0
  json_annotation: ^4.8.0

dev_dependencies:
  build_runner: ^2.4.0
  freezed: ^2.4.0
  json_serializable: ^6.7.0
ステップ2: コード生成の実行

プロジェクト開始時に以下のスクリプトを用意しておきます。

#!/bin/bash
# generate.sh
flutter pub get
flutter pub run build_runner build --delete-conflicting-outputs

開発中はwatchモードで自動生成

flutter pub run build_runner watch --delete-conflicting-outputs
ステップ3: 命名規則の統一

チーム全体で以下の命名規則を統一することが重要です。

  • ページ: {feature}_page.dart (例: user_list_page.dart)
  • ViewModel: {feature}_viewmodel.dart (例: user_list_viewmodel.dart)
  • State: {feature}_state.dart (例: user_list_state.dart)
  • Repository: {domain}_repository.dart (例: user_repository.dart)
  • Model: {entity}.dart (例: user.dart)

これにより、ファイルの検索が容易になり、チームメンバー全員が同じ構造でコードを書けます。

プロジェクト新規立ち上げ時の設計指針とチェックリスト

初期設定で決めておくべき重要事項

Flutterプロジェクトを新規に立ち上げる際、最初に以下の項目を明確にしておくことで、後々の手戻りを防げます。

1. プロジェクト作成オプションの選定

プロジェクト作成時に適切なオプションを指定することが重要です:

# fvmを使用する場合の推奨コマンド
fvm use 3.29.0 --force
fvm flutter create . \
  --project-name my_app \
  --platforms android,ios \
  --org com.example \
  -e

各オプションの意味:

  • --project-name: アプリ名(アンダースコア区切り、小文字のみ)
  • --platforms: 対象プラットフォーム(不要なものは除外してファイル削減)
  • --org: 組織ドメイン(逆順DNS形式)
  • -e: デフォルトのサンプルコードを除外(クリーンな状態で開始)
2. ネイティブコードの言語選択

将来のネイティブ拡張を見越して、モダンな言語を選択します:

  • iOS: Swift(Objective-Cではなく)
  • Android: Kotlin(Javaではなく)
flutter create my_app -i swift -a kotlin
3. Flutterバージョン管理

チーム全員が同じFlutterバージョンを使用するため、【fvm(Flutter Version Management)】の導入を強く推奨します:

# プロジェクトルートでバージョン固定
fvm use 3.29.0

# .gitignoreに追加
echo ".fvm/" >> .gitignore

# チームメンバーはこれでセットアップ
fvm install
fvm flutter pub get

設計の基本原則とベストプラクティス
Single Source of Truth (SSOT)

状態は一箇所でのみ管理し、複数箇所での重複を避けます。
Riverpodのグローバルプロバイダーがこれを実現します。

単方向データフロー

データの流れを【View → ViewModel → Repository → API】の一方向に保ち、予測可能性を高めます。

Immutableプログラミング

Freezedを使って【不変なデータクラス】を定義し、状態の予期せぬ変更を防ぎます。

関心の分離
  • UIロジック: ボタンの有効/無効、表示/非表示など
  • ビジネスロジック: データの取得、加工、バリデーションなど
  • データアクセス: API通信、ローカルDB操作など

これらを明確に分離します。

開発開始前のチェックリスト

プロジェクト立ち上げ時に以下をチェックすることで、スムーズな開発が可能になります。

技術選定チェックリスト
  • [ ] アーキテクチャパターン(MVVM + Repository推奨)
  • [ ] 状態管理手法(Riverpod推奨)
  • [ ] ルーティング方式(go_router推奨)
  • [ ] API通信ライブラリ(Dio + Retrofit推奨)
  • [ ] ローカルストレージ(shared_preferences / Hive / Isar)
  • [ ] 画像キャッシュ(cached_network_image)
  • [ ] ロギング(logger)
ディレクトリ構造チェックリスト
  • [ ] /model - ドメインモデル
  • [ ] /api - APIクライアント
  • [ ] /repository - Repository層
  • [ ] /page - 画面UI(配下に画面ごとのフォルダ)
  • [ ] /provider - Riverpod Provider定義
  • [ ] /util - ユーティリティ(logger, router等)
  • [ ] /constant - 定数・設定
  • [ ] /extension - 拡張機能
設定ファイルチェックリスト
  • [ ] pubspec.yaml - 依存パッケージの定義
  • [ ] analysis_options.yaml - Lintルールの設定
  • [ ] .gitignore - バージョン管理除外設定
  • [ ] README.md - プロジェクト概要とセットアップ手順
開発環境チェックリスト
  • [ ] fvmによるFlutterバージョン固定
  • [ ] build_runnerのセットアップ
  • [ ] IDEプラグインのインストール(Dart, Flutter)
  • [ ] コードフォーマッター設定(dart format .
  • [ ] エミュレータ/シミュレータの動作確認

コード品質を保つための初期設定

analysis_options.yamlの推奨設定:

include: package:flutter_lints/flutter.yaml

analyzer:
  exclude:
    - "**/*.g.dart"
    - "**/*.freezed.dart"

  errors:
    invalid_annotation_target: ignore

linter:
  rules:
    # 推奨ルール
    - prefer_const_constructors
    - prefer_const_literals_to_create_immutables
    - prefer_final_fields
    - prefer_final_locals
    - avoid_print
    - use_key_in_widget_constructors
    - sort_constructors_first
    - always_declare_return_types

README.mdのテンプレート:

新規プロジェクトでは、以下の内容を含むREADME.mdを作成します。

# Project Name

## 使用技術

### 状態管理
- hooks_riverpod
- flutter_hooks
- state_notifier

### API通信
- dio
- retrofit

### モデル
- freezed
- json_serializable

## セットアップ

### 1. 依存関係のインストール
\`\`\`bash
fvm flutter pub get
\`\`\`

### 2. コード生成
\`\`\`bash
fvm flutter pub run build_runner build --delete-conflicting-outputs
\`\`\`

### 3. アプリの起動
\`\`\`bash
fvm flutter run
\`\`\`

## アーキテクチャ

[ARCHITECTURE.md](ARCHITECTURE.md)を参照

このようなドキュメントを初期段階で整備することで、新メンバーのオンボーディング将来の自分への備忘録として機能します。

具体的なREADME.mdやARCHITECTURE.mdは先のページで紹介します。


楽天ブックス
¥3,080 (2026/02/12 13:46時点 | 楽天市場調べ)
スポンサーリンク

Twitterでフォローしよう

おすすめの記事