Flutter Clean Architecture実装ガイド:構造からテストまで完全解説

IT開発

FlutterアプリケーションでClean Architectureを実装する方法を、実際のクイズアプリを例に解説します。この記事では、理論だけでなく実践的な実装方法とハマりポイントも含めて紹介します。

📱 記事で使用したプロジェクト
GitHub: share_quiz
Google Play: クイズ共有アプリ

実際にリリースされているFlutterアプリのソースコードと動作を確認しながら読み進めることをお勧めします。GitHubでコードを参照し、Google Playで実際のアプリを体験できます。

Clean Architectureとは

Clean Architectureは、ソフトウェアの依存関係を整理し、保守性・テスタビリティを向上させる設計手法です。Flutterアプリケーションでは、UI・ビジネスロジック・データアクセスを明確に分離することで、変更に強いアーキテクチャを構築できます。

3つの主要レイヤー

┌───────────────────────────────┐
│      Presentation Layer       │
│   (UI・状態管理・Provider)        │
└─────────────┬─────────────────┘
              │ depends on
              ▼
┌───────────────────────────────┐
│        Domain Layer           │
│  (Entity・UseCase・Repository)  │
└─────────────┬─────────────────┘
              ▲ implements
              │
┌───────────────────────────────┐
│         Data Layer            │
│   (Repository実装・DTO・API)     │
└───────────────────────────────┘

依存性逆転の原則:

  • Presentation層はDomain層に依存
  • Data層はDomain層に依存
  • Domain層は他の層に依存しない

プロジェクト構成

Clean ArchitectureをFlutterで実装する際のディレクトリ構造を紹介します。各レイヤーを明確に分離し、依存関係を一方向に保つことが重要です。

lib/
├── data/                    # データ層
│   ├── firestore/          # Data Store(Firestore)
│   ├── repository_impl/    # Repository実装
│   ├── mapper/             # DTO→Entity変換
│   └── storage/            # ストレージ関連
├── domain/                 # ドメイン層
│   ├── models/             # エンティティ
│   ├── repository/         # Repository抽象化
│   └── use_cases/          # ユースケース
├── presentation/           # プレゼンテーション層
│   ├── common/             # 共通UI
│   ├── screen/             # 画面
│   └── widget/             # ウィジェット
└── provider/               # 状態管理

Domain層(ドメイン層)

Domain層は、アプリケーションのコアとなるビジネスロジックを含む層です。他の層に依存せず、純粋なDartコードで構成されます。エンティティ、Repositoryのインターフェース、ユースケースを定義します。

エンティティの定義

エンティティはアプリケーションの核となるデータ構造です。ビジネスルールやロジックを含まず、純粋なデータの入れ物として機能します。Freezedを使うことで、不変オブジェクトとして安全に扱えます。

// lib/domain/models/quiz/quiz.dart
@freezed
class Quiz with _$Quiz {
  const factory Quiz({
    required String documentId,
    required String title,
    required String description,
    required List<String> choices,
    required int correctAnswerIndex,
    required String imageUrl,
    required DateTime createdAt,
    required String creatorId,
  }) = _Quiz;
}

Repository抽象化

Repositoryパターンは、データアクセスの抽象化を行います。Domain層ではインターフェースとして定義し、Data層で実装します。これにより、ビジネスロジックが具体的なデータソースに依存しない構造を作れます。

// lib/domain/repository/quiz_list_repository.dart
abstract class QuizListRepository {
  Stream<QuizList> fetchQuizList(QuizListOrderBy orderBy);
}

ユースケース

ユースケースは、アプリケーションの具体的な機能や操作を表現します。Repositoryを使ってデータを取得し、必要に応じてビジネスロジックを適用します。RiverpodのStreamNotifierを継承することで、状態管理と統合できます。

// lib/domain/use_cases/quiz_list_use_case.dart
class QuizListUseCase extends StreamNotifier<QuizList> {
  final QuizListRepository _repository;
  final QuizListOrderBy _orderBy;

  QuizListUseCase(this._repository, this._orderBy);

  @override
  Stream<QuizList> build() {
    return _repository.fetchQuizList(_orderBy);
  }
}

Data層(データ層)

Data層は、外部データソース(Firestore、REST API等)との通信を担当します。Domain層で定義したRepositoryインターフェースを実装し、DTOとEntityの変換も行います。

Data層は以下の3つのコンポーネントで構成されます:

  • Repository実装:Domain層のインターフェースを実装し、ビジネスロジックに適したデータ形式に変換
  • Data Store:具体的なデータアクセスロジック(Firestore、REST API等)を担当
  • DTO:外部データソースとのデータ変換を担当

DTO(Data Transfer Object)

DTOは、外部データソース(Firestore等)とのデータのやり取りを担当します。JSONのシリアライゼーションやデシリアライゼーションを担い、Domain層のエンティティとは別に管理します。@JsonKeyでフィールド名をマッピングできます。

// lib/data/firestore/quiz/quiz_dto.dart
@freezed
class QuizDto with _$QuizDto {
  const factory QuizDto({
    @JsonKey(name: 'title') required String title,
    @JsonKey(name: 'description') required String description,
    @JsonKey(name: 'choices') required List<String> choices,
    @JsonKey(name: 'correct_answer_index') required int correctAnswerIndex,
    @JsonKey(name: 'image_url') required String imageUrl,
    @JsonKey(name: 'created_at') @TimestampConverter() required DateTime createdAt,
    @JsonKey(name: 'creator_id') required String creatorId,
  }) = _QuizDto;

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

Data Store

Data Storeは、具体的なデータアクセスロジックを担当します。Firestore、REST API、ローカルストレージなど、特定のデータソースとの通信を抽象化し、Repository実装から利用されます。

// lib/data/firestore/quiz/quiz_firebase_store.dart
class QuizFirebaseStore {
  static CollectionReference<Map<String, dynamic>> _getCollection() {
    return FirebaseFirestore.instance.collection('quiz');
  }

  Stream<List<QuizDto>> fetchList(Object field, bool descending) {
    return _getCollection()
        .orderBy(field, descending: descending)
        .limit(100)
        .snapshots()
        .map((event) => event.docs.map((e) {
          return QuizDto.fromJson(e.data()).copyWith(docId: e.id);
        }).toList());
  }

  Future<void> post(Map<String, dynamic> json) {
    return _getCollection().doc().set(json);
  }
}

Repository実装

Repositoryの実装クラスでは、Domain層で定義したインターフェースを実装します。Data Storeを使ってデータを取得し、DTOからEntityへの変換を行います。複数のData Storeを組み合わせることも可能です。

// lib/data/repository_impl/quiz_list_repository_impl.dart
class QuizListRepositoryImpl implements QuizListRepository {
  final QuizFirebaseStore _dataStore;

  QuizListRepositoryImpl(this._dataStore);

  @override
  Stream<QuizList> fetchQuizList(QuizListOrderBy orderBy) {
    return _dataStore
        .fetchList(orderBy.name, orderBy.desc)
        .map((dtoList) => dtoList.map((dto) => QuizMapper.transform(dto)).toList())
        .map((quizzes) => QuizList(quizzes: quizzes));
  }
}

Data Store パターンの利点:

  • データソース固有のロジックを分離
  • Repository実装がシンプルになる
  • 複数のデータソースを組み合わせやすい
  • テスト時のモック化が容易

Presentation層(プレゼンテーション層)

Presentation層は、UIと状態管理を担当します。FlutterのWidgetやRiverpodのProviderを使って、ユーザーインターフェースを構築し、Domain層のユースケースを呼び出します。

Provider設定

RiverpodのProviderを使って、依存性注入と状態管理を行います。Repositoryのインスタンスを提供し、UseCaseと組み合わせてUIから使える状態を作成します。StreamNotifierProviderでリアルタイムデータを扱えます。

// lib/provider/app_providers.dart
final quizListRepositoryProvider = Provider<QuizListRepository>((ref) {
  return QuizListRepositoryImpl();
});

final quizListNewProvider = StreamNotifierProvider<QuizListUseCase, QuizList>((ref) {
  final repository = ref.read(quizListRepositoryProvider);
  return QuizListUseCase(repository, QuizListOrderBy.createdAtDesc);
});

画面実装

Presentation層では、HookConsumerWidgetを使ってUIを構築します。ref.watch()でProviderの状態を監視し、when()メソッドでローディング・データ・エラーの状態を適切にハンドリングします。

// lib/presentation/screen/home_screen.dart
class HomeScreen extends HookConsumerWidget {
  const HomeScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final quizListAsync = ref.watch(quizListNewProvider);

    return Scaffold(
      body: quizListAsync.when(
        data: (quizList) => QuizListPage(quizList: quizList),
        loading: () => const LoadingScreen(),
        error: (error, stack) => ErrorHandler.handle(context, error),
      ),
    );
  }
}

状態管理(Riverpod)

FlutterでのClean Architecture実装において、Riverpodは状態管理と依存性注入の両方を担う重要なライブラリです。StreamNotifierやAsyncNotifierを使って、宣言的でテスタブルな状態管理を実現しましょう。

StreamNotifierの活用

StreamNotifierは、リアルタイムで変化するデータ(FirestoreのStream等)を扱うためのRiverpodの機能です。build()メソッドでStreamを返すことで、UIが自動的に更新されます。

class QuizDetailUseCase extends StreamNotifier<QuizDetail?> {
  final QuizDetailRepository _repository;
  final String _quizId;

  QuizDetailUseCase(this._repository, this._quizId);

  @override
  Stream<QuizDetail?> build() {
    return _repository.fetchQuizDetail(_quizId);
  }
}

AsyncNotifierでの非同期処理

AsyncNotifierは、ログインやデータ更新などの非同期処理を扱うための機能です。ローディング状態やエラーハンドリングを自動的に管理し、UI側で簡単に状態を取得できます。

class LoginUseCase extends AsyncNotifier<void> {
  final LoginRepository _repository;

  LoginUseCase(this._repository);

  @override
  FutureOr<void> build() => const AsyncValue.data(null);

  Future<void> signIn() async {
    state = const AsyncValue.loading();
    try {
      await _repository.signIn();
      state = const AsyncValue.data(null);
    } catch (error, stackTrace) {
      state = AsyncValue.error(error, stackTrace);
    }
  }
}

依存関係の注入

Clean Architectureでは、各レイヤー間の依存関係を適切に管理することが重要です。RiverpodのProvider Overrides機能を使うことで、テスト時のモック化や環境別の設定切り替えが簡単に行えます。

Provider Overridesは、アプリケーションの初期化時に依存性を設定する機能です。本番環境では実際のRepository、テスト環境ではモックを注入することで、柔軟なアーキテクチャを実現できます。以下は実装例です:

// lib/provider/app_providers.dart
final globalOverrides = [
  quizListRepositoryProvider.overrideWith((ref) {
    return QuizListRepositoryImpl();
  }),
  quizListNewProvider.overrideWith((ref) {
    final repository = ref.read(quizListRepositoryProvider);
    return QuizListUseCase(repository, QuizListOrderBy.createdAtDesc);
  }),
];

// lib/main.dart
void main() {
  runApp(
    ProviderScope(
      overrides: globalOverrides,
      child: const Application(),
    ),
  );
}

テスト戦略

Clean Architectureの大きなメリットの一つがテスタビリティの向上です。各レイヤーが明確に分離されているため、モックを使った単体テストが容易に行えます。

モックテストでは、mockitoパッケージを使ってRepositoryのフェイク実装を作成します。when()でモックの振る舞いを定義し、expect()で期待する結果を検証します。これにより、実際のデータベースに依存しない高速なテストが可能です。以下は実装例です:

class MockQuizListRepository extends Mock implements QuizListRepository {}

void main() {
  group('QuizListUseCase', () {
    late MockQuizListRepository mockRepository;
    late QuizListUseCase useCase;

    setUp(() {
      mockRepository = MockQuizListRepository();
      useCase = QuizListUseCase(mockRepository, QuizListOrderBy.createdAtDesc);
    });

    test('should return quiz list when repository returns data', () async {
      // Arrange
      final expectedQuizList = QuizList(quizzes: []);
      when(() => mockRepository.fetchQuizList(any()))
          .thenAnswer((_) => Stream.value(expectedQuizList));

      // Act & Assert
      expect(useCase.build(), emits(expectedQuizList));
    });
  });
}

主要ライブラリの紹介

FlutterでClean Architectureを実装する際に使用した主要なライブラリを紹介します。これらのライブラリを理解することで、より効率的な開発が可能になります。

Riverpod

状態管理と依存性注入を担う中核ライブラリです。StreamNotifierやAsyncNotifierを使って宣言的な状態管理を実現し、Provider Overridesでテスト時のモック化も簡単に行えます。

Freezed

不変オブジェクトの生成を自動化するコード生成ライブラリです。EntityやDTOの定義で使用し、copyWithメソッドやtoStringメソッドを自動生成してくれます。json_serializableと組み合わせてJSONシリアライゼーションも対応できます。

Firebase関連

バックエンドサービスとして以下のFirebaseサービスを使用しています:

  • Firebase Auth – ユーザー認証(Googleサインイン)
  • Cloud Firestore – NoSQLデータベース(リアルタイム同期)
  • Firebase Storage – 画像ファイルの保存
  • Firebase Crashlytics – クラッシュレポート収集

その他の重要ライブラリ

  • flutter_hooks – React Hooksライクな状態管理
  • image_picker – 画像選択機能
  • share_plus – コンテンツ共有機能
  • package_info_plus – アプリ情報取得

パッケージ分割は必要?

Clean Architectureの実装でよく議論になるのが、Dartパッケージとして各レイヤーを分割するかどうかです。実際にモノレポ構成を試した結果をもとに、現実的な推奨事項を紹介します。

結論:フォルダ分けで十分

実際にDartパッケージ分割を試した結果:

デメリット

  • flutter pub get を各パッケージで実行が必要
  • ビルド時間の短縮効果なし
  • 管理コマンドが増加
  • IDEでの依存関係解決が複雑

メリット

  • 他プロジェクトでの再利用(稀)
  • 完全な依存関係の分離

推奨: 通常のプロジェクトではフォルダ分けで十分です。

実装時のハマりポイント

FlutterでClean Architectureを実装する際に、実際に遇遇したトラブルとその解決方法を紹介します。これらの情報を事前に知っておくことで、スムーズな開発が可能になります。

Freezedのバージョン競合

Freezed、json_serializable、build_runnerのバージョンが競合し、ビルドエラーが発生することがあります。特に新しいプロジェクトで最新バージョンを使うと、依存関係の解決に失敗します。

# 解決方法:バージョンを統一
dev_dependencies:
  freezed_annotation: 2.4.1  # ^を削除して固定
  freezed: 2.5.7
  build_runner: 2.5.4

Firebase初期化エラー

GoogleサインインやFirebaseの設定で、google-services.jsonの設定不備やWebクライアントIDの設定ミスにより初期化エラーが発生します。特にAndroidとWebで異なる設定が必要です。

// google-services.jsonの設定確認
await GoogleSignIn.instance.initialize(
  serverClientId: 'your-web-client-id.apps.googleusercontent.com',
);

環境分けの複雑さ

AndroidのFlavorやiOSのSchemeを使った環境分けは設定が複雑で、管理コストが高くなります。小規模プロジェクトでは、dart-defineを使ったシンプルな方法が効果的です。

# シンプルな方法:dart-define使用
flutter build apk --dart-define=ENVIRONMENT=prod

まとめ

この記事では、FlutterでのClean Architectureの実装方法を実際のコード例とともに解説しました。重要なポイントを整理しておきましょう。

FlutterでのClean Architecture実装のポイント:

  1. レイヤー分離: data/domain/presentationの3層構造
  2. 依存関係: 上位層から下位層への一方向依存
  3. 状態管理: Riverpodでの宣言的な状態管理
  4. テスト: Mockを使った単体テスト
  5. パッケージ分割: 通常は不要、フォルダ分けで十分

Clean Architectureは銀の弾丸ではありませんが、適切に実装することで保守性の高いFlutterアプリケーションを構築できます。

参考リンク

コメント

タイトルとURLをコピーしました