🏹

Dependency Injectionとアーキテクチャパターン(MVC、MVP、MVVM、Clean Architecture)の相性

2024/05/18に公開

FlutterでのDependency Injection(DI)は、様々なアーキテクチャパターンと組み合わせることで、アプリケーションの設計をより効率的に、またメンテナンスしやすくすることができます。

FlutterにおけるDI(依存性注入)について詳しく知りたい方は、以下を参考にしてください!
https://zenn.dev/takuowake/articles/9cf546c02d71aa

ここでは、Riverpodを使ったDIの具体例を交えながら、各アーキテクチャパターンとの相性について説明します。

MVC(Model-View-Controller)

MVCは、アプリケーションをModel(データやビジネスロジック)、View(ユーザーインターフェース)、Controller(ModelとViewの間の仲介役)の3つのコンポーネントに分けるアーキテクチャパターンです。

DIの役割とメリット

MVCパターンでは、Model、View、Controllerがそれぞれ独立して役割を持ちます。DIを使用することで、ControllerがModelに依存する部分を外部から注入し、テストやメンテナンスを容易にします。

以下は、Riverpodを使用してMVCパターンを実装する例です。

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// Model
class ApiService {
  Future<String> fetchData() async {
    await Future.delayed(Duration(seconds: 1));
    return 'Hello from API';
  }
}

// Controller
class HomeController {
  final ApiService apiService;

  HomeController(this.apiService);

  Future<String> loadData() {
    return apiService.fetchData();
  }
}

// Provider
final apiServiceProvider = Provider((ref) => ApiService());
final homeControllerProvider = Provider((ref) => HomeController(ref.read(apiServiceProvider)));

void main() {
  runApp(ProviderScope(child: MyApp()));
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: Text('MVC with Riverpod')),
        body: HomeView(),
      ),
    );
  }
}

// View
class HomeView extends ConsumerWidget {
  @override
  Widget build(BuildContext context, ScopedReader watch) {
    final controller = watch(homeControllerProvider);

    return Center(
      child: FutureBuilder<String>(
        future: controller.loadData(),
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return CircularProgressIndicator();
          } else if (snapshot.hasError) {
            return Text('Error: ${snapshot.error}');
          } else {
            return Text('Data: ${snapshot.data}');
          }
        },
      ),
    );
  }
}

解説

  • Model: ApiServiceがデータの取得を担当しています。
  • Controller: HomeControllerがApiServiceのインスタンスを受け取り、loadDataメソッドを通じてデータを取得します。依存性はコンストラクタを通じて注入されています。
  • Provider: RiverpodのProviderを使ってApiServiceとHomeControllerのインスタンスを管理し、依存性注入を行っています。
  • View: HomeViewがユーザーインターフェースを提供し、HomeControllerのデータを表示します。ConsumerWidgetを使ってプロバイダーからHomeControllerを取得しています。

MVP(Model-View-Presenter)

MVPは、Model、View、Presenterの3つのコンポーネントで構成されるアーキテクチャパターンです。PresenterがModelとViewの間のロジックを担当し、ViewはUIの表示のみを担当します。

DIの役割とメリット

MVPパターンでは、PresenterがViewとModelの仲介役を担います。DIを使用することで、Presenterに必要なModelや他の依存関係を注入し、テストしやすい構造にします。

以下は、Riverpodを使用してMVPパターンを実装する例です。

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// Model
class ApiService {
  Future<String> fetchData() async {
    await Future.delayed(Duration(seconds: 1));
    return 'Hello from API';
  }
}

// View
abstract class HomeView {
  void showData(String data);
  void showError(String message);
}

// Presenter
class HomePresenter {
  final HomeView view;
  final ApiService apiService;

  HomePresenter(this.view, this.apiService);

  void loadData() async {
    try {
      final data = await apiService.fetchData();
      view.showData(data);
    } catch (e) {
      view.showError(e.toString());
    }
  }
}

// Providers
final apiServiceProvider = Provider((ref) => ApiService());
final homePresenterProvider = Provider.family<HomePresenter, HomeView>((ref, view) {
  return HomePresenter(view, ref.read(apiServiceProvider));
});

void main() {
  runApp(ProviderScope(child: MyApp()));
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: HomeViewImpl(),
    );
  }
}

class HomeViewImpl extends StatefulWidget implements HomeView {
  @override
  _HomeViewImplState createState() => _HomeViewImplState();
}

class _HomeViewImplState extends State<HomeViewImpl> {
  String _data = '';
  String _error = '';

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('MVP with Riverpod')),
      body: Center(
        child: _data.isNotEmpty
            ? Text('Data: $_data')
            : _error.isNotEmpty
                ? Text('Error: $_error')
                : ElevatedButton(
                    onPressed: () => context.read(homePresenterProvider(this)).loadData(),
                    child: Text('Load Data'),
                  ),
      ),
    );
  }

  @override
  void showData(String data) {
    setState(() {
      _data = data;
      _error = '';
    });
  }

  @override
  void showError(String message) {
    setState(() {
      _error = message;
      _data = '';
    });
  }
}

解説

  • Model: ApiServiceがデータの取得を担当しています。
  • View: HomeViewがインターフェースとして定義され、HomeViewImplが具体的な実装を行っています。データ表示とエラーメッセージの表示を担当します。
  • Presenter: HomePresenterがHomeViewとApiServiceを受け取り、データ取得のロジックを実行します。依存性はコンストラクタを通じて注入されています。
  • Provider: RiverpodのProviderとProvider.familyを使って依存性注入を行い、HomePresenterのインスタンスを管理しています。

MVVM(Model-View-ViewModel)

MVVMは、Model、View、ViewModelの3つのコンポーネントで構成されるアーキテクチャパターンです。ViewModelがViewとModelのデータバインディングを担当し、ViewはUIの表示のみを担当します。

DIの役割とメリット

MVVMパターンでは、ViewModelがViewとModelのデータバインディングを担当します。DIを使用することで、ViewModelに必要なModelや他の依存関係を注入し、シンプルでテストしやすいコードを実現します。

以下は、Riverpodを使用してMVVMパターンを実装する例です。

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// Model
class ApiService {
  Future<String> fetchData() async {
    await Future.delayed(Duration(seconds: 1));
    return 'Hello from API';
  }
}

// ViewModel
class HomeViewModel extends StateNotifier<AsyncValue<String>> {
  final ApiService apiService;

  HomeViewModel(this.apiService) : super(const AsyncValue.loading()) {
    loadData();
  }

  Future<void> loadData() async {
    try {
      final data = await apiService.fetchData();
      state = AsyncValue.data(data);
    } catch (e) {
      state = AsyncValue.error(e);
    }
  }
}

// Providers
final apiServiceProvider = Provider((ref) => ApiService());
final homeViewModelProvider = StateNotifierProvider<HomeViewModel, AsyncValue<String>>((ref) {
  return HomeViewModel(ref.read(apiServiceProvider));
});

void main() {
  runApp(ProviderScope(child: MyApp()));
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: HomeView(),
    );
  }
}

// View
class HomeView extends ConsumerWidget {
  @override
  Widget build(BuildContext context, ScopedReader watch) {
    final state = watch(homeViewModelProvider);

    return Scaffold(
      appBar: AppBar(title: Text('MVVM with Riverpod')),
      body: Center(
        child: state.when(
          data: (data) => Text('Data: $data'),
          loading: () => CircularProgressIndicator(),
          error: (e, _) => Text('Error: $e'),
        ),
      ),
    );
  }
}

解説

  • Model: ApiServiceがデータの取得を担当しています。
  • ViewModel: HomeViewModelが状態管理を行い、ApiServiceを使用してデータを取得します。依存性はコンストラクタを通じて注入されています。
  • Provider: RiverpodのProviderとStateNotifierProviderを使って依存性注入を行い、HomeViewModelのインスタンスを管理しています。
  • View: HomeViewがユーザーインターフェースを提供し、HomeViewModelの状態を監視してデータを表示します。ConsumerWidgetを使ってプロバイダーからHomeViewModelを取得しています。

Clean Architecture

Clean Architectureは、層ごとに責任を分けて設計することを重視します。典型的には、データ、ドメイン、プレゼンテーションの3層または4層で構成され、各層が独立して開発・テストできるようにします。

DIの役割とメリット

Clean Architectureは、層ごとに責任を分けて設計することを重視します。DIを使用することで、各層間の依存関係を明確にし、テスト可能で拡張性の高いコードを実現します。

以下は、Riverpodを使用してClean Architectureパターンを実装する例です。

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

// Data Layer
class ApiService {
  Future<String> fetchData() async {
    await Future.delayed(Duration(seconds: 1));
    return 'Hello from API';
  }
}

// Domain Layer
class FetchDataUseCase {
  final ApiService apiService;

  FetchDataUseCase(this.apiService);

  Future<String> execute() {
    return apiService.fetchData();
  }
}

// Presentation Layer
class HomeViewModel extends StateNotifier<AsyncValue<String>> {
  final FetchDataUseCase fetchDataUseCase;

  HomeViewModel(this.fetchDataUseCase) : super(const AsyncValue.loading()) {
    loadData();
  }

  Future<void> loadData() async {
    try {
      final data = await fetchDataUseCase.execute();
      state = AsyncValue.data(data);
    } catch (e) {
      state = AsyncValue.error(e);
    }
  }
}

// Providers
final apiServiceProvider = Provider((ref) => ApiService());
final fetchDataUseCaseProvider = Provider((ref) => FetchDataUseCase(ref.read(apiServiceProvider)));
final homeViewModelProvider = StateNotifierProvider<HomeViewModel, AsyncValue<String>>((ref) {
  return HomeViewModel(ref.read(fetchDataUseCaseProvider));
});

void main() {
  runApp(ProviderScope(child: MyApp()));
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context)

解説

  • Data Layer: ApiServiceがデータの取得を担当しています。
  • Domain Layer: FetchDataUseCaseがビジネスロジックを担当し、ApiServiceからデータを取得します。依存性はコンストラクタを通じて注入されています。
  • Presentation Layer: HomeViewModelが状態管理を行い、FetchDataUseCaseを使用してデータを取得します。依存性はコンストラクタを通じて注入されています。
  • Provider: RiverpodのProviderとStateNotifierProviderを使って依存性注入を行い、各層のインスタンスを管理しています。
  • View: HomeViewがユーザーインターフェースを提供し、HomeViewModelの状態を監視してデータを表示します。ConsumerWidgetを使ってプロバイダーからHomeViewModelを取得しています。

まとめ

各アーキテクチャパターンにおいて、Dependency Injectionは重要な役割を果たし、コードの再利用性やテストのしやすさ、メンテナンス性を向上させます。特に、Riverpodを使用することで、依存関係の管理が容易になり、クリーンでスケーラブルなアプリケーションを構築することができます。

各パターンの選定に少しでも参考になりましたら幸いです。

Discussion