Flutterのクリーンアーキテクチャ(MVVM)

投稿者: | 2024年6月17日

Flutterを導入すると決定した時、チームの全員がモバイルアプリの経験がない、というケースは稀であろう。すでにAndroidやiOSで実装経験のあるメンバーがいて、彼らが中心になって設計実装を進めていく。その場合、どのような設計にすれば良いか考慮する必要がある。

いずれのプラットフォームでも近年多く見られるのがMVVM(Model-View-ViewModel)層構造である。アーキテクチャとしてはクリーンアーキテクチャを採用したいというケースが多い。

以下、FlutterアプリケーションにMVVM(Model-View-ViewModel)構造をクリーンアーキテクチャの文脈で実装する方法を説明する。MVVMとクリーンアーキテクチャの組み合わせにより、コードの保守性、テスト性、スケーラビリティが向上する。

構造の概要

  1. ドメイン層
    :

    • エンティティ
    • ユースケース(インタラクター)
    • リポジトリ(インターフェース)
  2. データ層
    :

    • モデル
    • データソース(リモート、ローカル)
    • リポジトリ実装
  3. プレゼンテーション層
    :

    • ビュー(ウィジェット)
    • ViewModel
    • 状態管理(Provider, Riverpod, Bloc など)

ディレクトリ構造

以下のようにディレクトリ構造を整理する:

lib/ ├── core/ │ ├── error/ │ └── usecases/ ├── data/ │ ├── models/ │ ├── repositories/ │ └── sources/ ├── domain/ │ ├── entities/ │ ├── repositories/ │ └── usecases/ ├── presentation/ │ ├── viewmodels/ │ └── views/ └── main.dart

実装例

1. ドメイン層

エンティティ:

// lib/domain/entities/example_entity.dart class ExampleEntity { final int id; final String name; ExampleEntity({required this.id, required this.name}); }

リポジトリインターフェース:

// lib/domain/repositories/example_repository.dart import 'package:dartz/dartz.dart'; import 'package:your_project/core/error/failures.dart'; import 'package:your_project/domain/entities/example_entity.dart'; abstract class ExampleRepository { Future<Either<Failure, ExampleEntity>> getExample(); }

ユースケース:

// lib/domain/usecases/get_example.dart import 'package:dartz/dartz.dart'; import 'package:your_project/core/error/failures.dart'; import 'package:your_project/core/usecases/usecase.dart'; import 'package:your_project/domain/entities/example_entity.dart'; import 'package:your_project/domain/repositories/example_repository.dart'; class GetExample implements UseCase<ExampleEntity, NoParams> { final ExampleRepository repository; GetExample(this.repository); @override Future<Either<Failure, ExampleEntity>> call(NoParams params) async { return await repository.getExample(); } }

2. データ層

モデル:

// lib/data/models/example_model.dart import 'package:your_project/domain/entities/example_entity.dart'; class ExampleModel extends ExampleEntity { ExampleModel({required int id, required String name}) : super(id: id, name: name); factory ExampleModel.fromJson(Map<String, dynamic> json) { return ExampleModel( id: json['id'], name: json['name'], ); } Map<String, dynamic> toJson() { return { 'id': id, 'name': name, }; } }

リモートデータソース:

// lib/data/sources/example_remote_data_source.dart import 'package:http/http.dart' as http; import 'dart:convert'; import 'package:your_project/data/models/example_model.dart'; abstract class ExampleRemoteDataSource { Future<ExampleModel> fetchExample(); } class ExampleRemoteDataSourceImpl implements ExampleRemoteDataSource { final http.Client client; ExampleRemoteDataSourceImpl(this.client); @override Future<ExampleModel> fetchExample() async { final response = await client.get(Uri.parse('https://api.example.com/example')); if (response.statusCode == 200) { return ExampleModel.fromJson(json.decode(response.body)); } else { throw Exception('Failed to load example'); } } }

リポジトリ実装:

// lib/data/repositories/example_repository_impl.dart import 'package:dartz/dartz.dart'; import 'package:your_project/core/error/failures.dart'; import 'package:your_project/domain/entities/example_entity.dart'; import 'package:your_project/domain/repositories/example_repository.dart'; import 'package:your_project/data/sources/example_remote_data_source.dart'; class ExampleRepositoryImpl implements ExampleRepository { final ExampleRemoteDataSource remoteDataSource; ExampleRepositoryImpl(this.remoteDataSource); @override Future<Either<Failure, ExampleEntity>> getExample() async { try { final remoteExample = await remoteDataSource.fetchExample(); return Right(remoteExample); } catch (e) { return Left(ServerFailure()); } } }

3. プレゼンテーション層

ViewModel:

// lib/presentation/viewmodels/example_viewmodel.dart import 'package:flutter/material.dart'; import 'package:your_project/domain/entities/example_entity.dart'; import 'package:your_project/domain/usecases/get_example.dart'; class ExampleViewModel with ChangeNotifier { final GetExample getExample; ExampleEntity? example; bool isLoading = false; String? errorMessage; ExampleViewModel({required this.getExample}); Future<void> fetchExample() async { isLoading = true; notifyListeners(); final result = await getExample(NoParams()); result.fold( (failure) => errorMessage = 'Failed to load example', (data) => example = data, ); isLoading = false; notifyListeners(); } }

ビュー:

// lib/presentation/views/example_view.dart import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:your_project/presentation/viewmodels/example_viewmodel.dart'; class ExampleView extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('Example')), body: Center( child: Consumer<ExampleViewModel>( builder: (context, viewModel, child) { if (viewModel.isLoading) { return CircularProgressIndicator(); } else if (viewModel.example != null) { return Text(viewModel.example!.name); } else if (viewModel.errorMessage != null) { return Text(viewModel.errorMessage!); } else { return ElevatedButton( onPressed: () { viewModel.fetchExample(); }, child: Text('Fetch Data'), ); } }, ), ), ); } }

main.dart:

// lib/main.dart import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:your_project/domain/usecases/get_example.dart'; import 'package:your_project/presentation/viewmodels/example_viewmodel.dart'; import 'package:your_project/presentation/views/example_view.dart'; import 'package:your_project/injection_container.dart' as di; void main() async { WidgetsFlutterBinding.ensureInitialized(); await di.init(); runApp(MyApp()); } class MyApp extends StatelessWidget { @override Widget build(BuildContext context) { return MultiProvider( providers: [ ChangeNotifierProvider( create: (_) => ExampleViewModel(getExample: di.sl<GetExample>()), ), ], child: MaterialApp( home: ExampleView(), ), ); } }

依存性注入(DI)

get_it
のような依存性注入フレームワークを使用すると、依存関係を効率的に管理できる。

injection_container.dart:

import 'package:get_it/get_it.dart'; import 'package:http/http.dart' as http; import 'package:your_project/data/repositories/example_repository_impl.dart'; import 'package:your_project/data/sources/example_remote_data_source.dart'; import 'package:your_project/domain/repositories/example_repository.dart'; import 'package:your_project/domain/usecases/get_example.dart'; final sl = GetIt.instance; Future<void> init() async { // 外部ライブラリ sl.registerLazySingleton(() => http.Client()); // データソース sl.registerLazySingleton<ExampleRemoteDataSource>( () => ExampleRemoteDataSourceImpl(sl())); // リポジトリ sl.registerLazySingleton<ExampleRepository>( () => ExampleRepositoryImpl(sl())); // ユースケース sl.registerLazySingleton(() => GetExample(sl())); }

結論

MVVMの原則とクリーンアーキテクチャを組み合わせることで、保守性、テスト性、スケーラビリティに優れたFlutterアプリケーションを構築することが可能だ。プレゼンテーション層にはビューとViewModelを配置し、UIロジックと状態管理を担当させる。ドメイン層とデータ層は、それぞれビジネスロジックとデータ操作を担当する。

コメントを残す