Flutterを導入すると決定した時、チームの全員がモバイルアプリの経験がない、というケースは稀であろう。すでにAndroidやiOSで実装経験のあるメンバーがいて、彼らが中心になって設計実装を進めていく。その場合、どのような設計にすれば良いか考慮する必要がある。
いずれのプラットフォームでも近年多く見られるのがMVVM(Model-View-ViewModel)層構造である。アーキテクチャとしてはクリーンアーキテクチャを採用したいというケースが多い。
以下、FlutterアプリケーションにMVVM(Model-View-ViewModel)構造をクリーンアーキテクチャの文脈で実装する方法を説明する。MVVMとクリーンアーキテクチャの組み合わせにより、コードの保守性、テスト性、スケーラビリティが向上する。
構造の概要
-
ドメイン層
:- エンティティ
- ユースケース(インタラクター)
- リポジトリ(インターフェース)
-
データ層
:- モデル
- データソース(リモート、ローカル)
- リポジトリ実装
-
プレゼンテーション層
:- ビュー(ウィジェット)
- 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ロジックと状態管理を担当させる。ドメイン層とデータ層は、それぞれビジネスロジックとデータ操作を担当する。