LoginSignup
6
2

【Flutter】Riverpodを使ったレイヤードアーキテクチャとDI(依存性の注入)について学んでみた

Posted at

はじめに

初学者にとってある程度学習が進んでくるとぶつかる壁の一つがアーキテクチャと依存性の注入(DI)だと思います。

今までMVCベースで学習してきた私が、レイヤードアーキテクチャを導入している現場に入ったことをきっかけに習得した知識をお話ししていきたいと思います。

記事の対象者

  • レイヤードアーキテクチャを学びたい方
  • Riverpodを使ったDIを学びたい方
  • データ層の作り方を学びたい方

記事を執筆時点での筆者の環境

  • macOS 14.3.1
  • Xcode 15.2
  • Swift 5.9
  • iPhone11 pro ⇒ iOS 17.2.1
  • Flutter 3.19.0
  • Dart 3.3.0
  • Pixel 7a ⇒ Android

1. アーキテクチャとDIについて

アーキテクチャーとDIについて初学者(私)向けに簡単な概念を述べていきます。

1-1. アーキテクチャ

アーキテクチャとは、簡単にいうとコードの設計方法です。
もっと砕いていうと、「コードを書いたファイルを役割に分けて、どこのフォルダに置くか決めること」です。

有名なアーキテクチャとしては以下がよくあげられます。

  • MVC (Model-Vew-Controller)
  • MVP(Model-View-Presenter)
  • MVVM(Model-View-ViewModel)
  • Clean Architecture

1-2. レイヤードアーキテクチャ

今回ご紹介するのがこのレイヤードアーキテクチャです。
レイヤー、と言っているようにコードの分類を階層に分けて分類し、処理の流れも上から順に流れて、結果を出力するときはその逆に返していくように設計するアーキテクチャです。
各階層の名称はオニオンアーキテクチャと似ています。

各階層の名称と役割を私なりに解釈すると以下のようになります。

  1. プレゼンテーション層(Presentation Layer)
    • ユーザーインターフェース(UI)を担当します。
    • ユーザーからの入力を受け取り、結果を表示します。
    • 例:ビュー、ビューモデルなど。

  2. アプリケーション層(Application Layer)
    • ビジネスロジックを実行します。
    • データ層の処理を組み合わせて一つの機能を実行します。
    • アプリケーションの機能を提供し、プレゼンテーション層とデータ層の間を仲介します。
    • 例:サービス、ユースケースなど。

  3. ドメイン層(Domain Layer)
    • アプリケーションの中核となるオブジェクトを定義します。
    • 例えば、ユーザー、注文、商品など。
    • 例:エンティティ

  4. データ層(Data Layer)
    • データの保存や取得を担当します。
    • データベースや外部サービスとの通信を行います。
    • 例:リポジトリなど。

ここら辺は有名なこちらの記事を参考にされている方が多いです。

1-3. DIとは

DIとはDependency Injection(依存性の注入)の略で、簡単にかみ砕いていうとクラスとクラスを繋げる手法です。

こちらの方の記事が、初学者にもわかりやすく書かれていました。

あるクラスが他のクラスを直接生成するのではなく、必要な依存関係(他のクラスのインスタンス)を外部から注入することを指します。
DIを行う一般的なメリットは以下が挙げられます。

  1. 疎結合:クラス間の依存関係を減らし、変更や再利用がしやすくなります
  2. テスト容易性:モックやスタブを使用して単体テストを簡単に実行できるようになります
  3. 可読性と保守性の向上:依存関係が明示的になるため、コードの理解と保守が容易になります
  4. コンポーネントの再利用:依存関係が注入可能であるため、同じコンポーネントを異なるコンテキストで再利用しやすくなります
  5. 変更の影響範囲の限定:依存するクラスが変わっても、依存先のクラスに影響を与えずに変更が可能になります
  6. 単一責任の原則の強化:クラスが自身の依存関係を管理しないため、本来の責任に集中できます

つまり、DIを行うとクラス間の結びつきが弱まることで新しい機能と差し替えが容易になり、テストもしやすくなります。

DIを行う手法にはいくつかあります。

  • コンストラクタ注入
  • メソッド注入
  • プロパティ注入

その中で今回はRiverpodを用いたプロパティ注入によるDIを解説していきたいと思います。

1-4. 補足

レイヤードアーキテクチャを基本にしていますが、以下の点に注意してください。

  1. 必ずしも全てのプロジェクトで共通ではない
    • 現場によっては独自のルールでフォルダを追加してたりする
  2. 今回はアプリケーション層をほぼ使っていない
    • 私の現場では複雑なロジックでないのであればアプリケーション層を飛ばしていました
    • アプリケーション層では複数のデータ層を組み合わせて一つの機能を提供する場合に使っていた感じです
  3. データ層に焦点を当てて解説している
    • 私的に一番理解に時間を使ったデータ層の構造や作り方を中心にしている

アーキテクチャを学んでいた時に一番混乱したのが、同じアーキテクチャの名前なのに記事によってフォルダ名が違ったり、DIの仕方が違ったりとさまざまで混乱しました。
結論これはどれも正解です。

アーキテクチャは複数人で開発する際に皆が同じようにコードを書いて、ファイルを置いて管理するためのルール決めです。
一般的な大枠は決まっていて、それを大体は現場ごとにアレンジしていることがほとんどです。
なのでこの記事でもトップのフォルダ名(プレゼンテーションとか)や中身の分け方は一例として参考程度に捉えていただければ幸いです。

また、今回はriverpod_generatorを使って自動生成しています。
他の方が執筆された過去の記事では自動生成を使わない書き方をしていますので、
その点が違うところを理解して読み進めてみてください。

2. 【実践】階層のトップフォルダとプレゼンテーション層を作成

ここからはサンプルプロジェクトを使って実際にレイヤードアーキテクチャを用いてプロジェクトを作成する過程をかいつまんで解説していきます。
この章では主に画面作成についてですが以下のGithubにソースを公開していますので、詳細はこちらをご覧ください。

今回のサンプルはローカルにデータを保存するためのパッケージ、SharedPreferencesを使って値を保存、表示、削除するアプリです。

【Flutter】riverpodを使ったDIとレイヤードアーキテクチャ、特にデータ層について(仮).gif

2-1. フォルダの作成

階層名でトップフォルダを作成します。

スクリーンショット 2024-05-26 14.58.24.png

2-2. プレゼンテーション層を作成

ユーザーインターフェース(UI)を担当し、ユーザーからの入力を受け取り、結果を表示する部分です。
以下のようなフォルダ構成にして画面を作成しています。
なお、各view_modelは枠だけ作って、内部の処理は後から記述します。

presentations
├── edit_custom_setting_page // カスタム設定を編集する画面に関するフォルダ
│   ├── edit_custom_setting_page_view_model.dart
│   ├── edit_custom_setting_page_view_model.g.dart
│   └── edit_custom_setting_page.dart
├── my_home_page // ホーム画面に関するフォルダ
│   ├── my_home_page_view_model.dart
│   ├── my_home_page_view_model.g.dart
│   └── my_home_page.dart
├── router // 画面遷移に関するフォルダ
│   ├── app.dart
│   └── main.dart
└── shared // プロジェクトで共有して使うWidgetをおくフォルダ
    ├── action_bottom_sheet.dart
    ├── custom_bottom_sheet.dart
    └── info_list_tile.dart

3. 【実践】アプリケーション層を作る

データ層の処理を組み合わせて一つの機能を実行するのが主な役割です。
今回はそこまで複雑なロジックはないので、サービスフォルダは空にしています。(つまり未実装)
また、賛否は分かれるところだとは思いますが、ログを出す機能をここに配置しています。
プロジェクトによってはこの機能はレイヤーとは別のcoreというフォルダを作ってそこに配置する場合もあります。

applications
├── log // ログを出力する機能のフォルダ
│   └── logger.dart
└── service // ここに何らかの機能を提供するクラスを作成する

4. 【実践】ドメイン層を作る

アプリケーションの中核となるオブジェクトを定義します。
今回はカスタム設定の内容を定義するCutomSettingオブジェクトを定義しています。

lib/domains/custom_setting.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'custom_setting.freezed.dart';
part 'custom_setting.g.dart';

/// カスタム設定
@freezed
class CustomSetting with _$CustomSetting {
  /// カスタム設定
  // ignore: invalid_annotation_target
  @JsonSerializable(fieldRename: FieldRename.snake)
  const factory CustomSetting({
    // アイコン設定
    bool? iconSetting,
    // 背景色番号
    int? backgroundColorNumber,
    // タイトル
    String? titleText,
  }) = _CustomSetting;

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

5. 【実践】データ層を作る

データの保存や取得を担当し、データベースや外部サービスとの通信を行います。
今回でいくとSharedPreferencesを使ってデータベースにアクセスします。
構造としては以下のようなフォルダ構成になります。

data
├── local_sources // ローカルデータベースに関するパッケージのインスタンスを管理
│   ├── shared_preference.dart
│   └── shared_preference.g.dart
└── repositories // データ毎に取得、保存、削除を定義する
    └── key_value_repository
        ├── provider.dart // プロパティ注入によるDIを定義、各データのストリームを変数で定義
        ├── provider.g.dart
        └── repository.dart // インターフェースと実装

今回はrepositorieskey_value_repositoryとして一つにまとめています。
厳密に行うのであれば以下のようにデータ毎に分けるのが良いとされています。

icon_setting_repository
background_color_number_repository

あまり厳密に行うとフォルダやファイルが多くなり作成、管理が大変になります。
どこまで厳密に行うかはチームや個人の方針によります。

5-1. shared_preferenceのインスタンスは別に定義する

shared_preferenceのインスタンスを外部から参照するために以下のようにriverpodproviderとして定義しています。
この後で説明するkey_value_repositoryにてref.readでインスタンスを注入(DI)するためです。

lib/data/local_sources/shared_preference.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preference_sample/applications/log/logger.dart';
import 'package:shared_preferences/shared_preferences.dart';

part 'shared_preference.g.dart';

/// SharedPreferencesのインスタンスを非同期に生成
@Riverpod(keepAlive: true)
Future<SharedPreferences> sharedPreferences(SharedPreferencesRef ref) async {
  try {
    // 全ての SharedPreferences のキーに接頭辞を設定
    SharedPreferences.setPrefix('shared_preference_sample');
  } catch (error, stackTrace) {
    logger.d(
      'SharedPreferences.setPrefixエラー',
      error: error,
      stackTrace: stackTrace,
    );
  }

  return SharedPreferences.getInstance();
}

@Riverpod(keepAlive: true) を使用して、プロバイダーを定義しています。
keepAlive: true によって、プロバイダーのインスタンスは一度生成されるとキャッシュされ続けます。
逆に@riverpodとしてしまうと、参照されなくなってしまった場合にインスタンスが破棄されてしまうので、依存性注入のためのプロバイダーでは@Riverpod(keepAlive: true)が基本必要です。

しかし、以下のようにインスタンスをリポジトリで直接生成してもいいのでは?と一見思いがちです。

lib/data/repositories/key_value_repository/repository.dart
import 'package:shared_preferences/shared_preferences.dart';

class KeyValueRepository {

  Future<void> setHogeData({required bool value}) async {
    // ここで直接インスタンスを生成する
    final prefs = await SharedPreferences.getInstance();
    await prefs.setBool('hoge', value);
  }
}

直接インスタンスを生成しない方が良い理由は以下のとおりです。

  • 特定のインスタンスへの依存を弱めるため
  • 他のパッケージへ差し替えやすくするため
  • 依存を弱めることでテストを行いやすくするため

仮にプロジェクトの作成途中でshared_preference以外のパッケージに差し替えようと思った場合に
変更を最小限に抑えることが目的です。

5-2. 抽象インターフェースを定義する

key_value_repositoryが提供する機能のインターフェースを定義します。
インターフェースとは中身のない、機能の入口だけ作ると解釈しましょう。
インターフェースには定数と関数が定義できます。

lib/data/repositories/key_value_repository/repository.dart
/// キーバリューペアを管理するための抽象インターフェース
abstract interface class KeyValueRepositoryBase {
  /// 値の変更を監視するためのストリーム
  ///
  /// このストリームは、保存された値が変更された時に値の変更通知を送信する
  Stream<String?> get onValueChange;

  /// アイコン設定の値を取得する
  Future<bool?> getIconSetting();

  /// アイコン設定の値を設定する
  Future<void> setIconSetting({bool? value});

  // 一部省略

  /// 全てのデータを初期化する
  Future<void> initData();
}

今回はインターフェースと実装を同じファイルに書いています。
別ファイルに分けるプロジェクトもあると思うので、必ず確認しましょう。

5-3. 処理を定義する

インターフェースに沿って実際の処理を定義していきます。
以下に一部抜粋して記載します。

lib/data/repositories/key_value_repository/repository.dart
/// アプリケーションのキー・バリュー設定を管理するクラス
class KeyValueRepository implements KeyValueRepositoryBase {
  /// アプリケーションのキー・バリュー設定を管理するクラス
  KeyValueRepository(this.ref);

  /// レフ
  ///
  /// 今後の変更で変えられるように固定のレフではなくする
  final ProviderRef<dynamic> ref;

  // SharedPreferencesはkeyとvalueで紐づけて保存する
  // ここでキーを設定するが、各設定の値を関係するプロバイダーで指定できるように
  // staticで定義する

  /// アイコン設定のキー
  static const iconSettingKey = 'iconSetting';

  /// 設定値の変更をアプリケーション全体にブロードキャストするための`StreamController`
  final _onValueChanged = StreamController<String>.broadcast();

  @override
  Stream<String> get onValueChange => _onValueChanged.stream;

  @override
  Future<bool?> getIconSetting() => _get(iconSettingKey);

  // 一部省略
 
  @override
  Future<void> initData() async {
    // ここでshared_preferenceのインスタンスを取得
    final pref = await ref.read(sharedPreferencesProvider.future);
    final result = await pref.clear();
    logger.d(result);
    // カスケード記法で重複する`_onValueChanged`を一つに省略している
    _onValueChanged
      ..add(iconSettingKey)
      ..add(backgroundColorNumberKey)
      ..add(titleTextKey)
      ..add(customSettingKey);
  }
}
_getと_setの中でもshared_preferenceのインスタンスを取得しています

以下のメソッドをスニペットで持っておくと、各処理でいちいちpref.get~~を書かなくていいので便利です。
(先輩に教えてもらいました😉)

 Future<T?> _get<T>(String key) async {
    // shared_preferenceのインスタンスを取得
    final pref = await ref.read(sharedPreferencesProvider.future);

    switch (T) {
      case int:
        return pref.getInt(key) as T?;
      case double:
        return pref.getDouble(key) as T?;
      case String:
        return pref.getString(key) as T?;
      case bool:
        return pref.getBool(key) as T?;
      case DateTime:
        return switch (pref.getString(key)) {
          final dateTimeString? => DateTime.parse(dateTimeString) as T,
          _ => null,
        };
      case const (List<dynamic>):
        final value = pref.get(key);
        if (value is List<String>) {
          return value as T?;
        }

        return switch (value) {
          final String stringValue => json.decode(stringValue) as T,
          _ => null,
        };
      case const (Map<dynamic, dynamic>):
        return switch (pref.getString(key)) {
          final value? => json.decode(value) as T,
          _ => null,
        };
      case _:
        throw UnsupportedError('対応していない型です');
    }
  }

  Future<void> _set(String key, Object? value) async {
    // shared_preferenceのインスタンスを取得
    final pref = await ref.read(sharedPreferencesProvider.future);

    switch (value) {
      case final int intValue:
        await pref.setInt(key, intValue);
      case final double doubleValue:
        await pref.setDouble(key, doubleValue);
      case final bool boolValue:
        await pref.setBool(key, boolValue);
      case final String stringValue:
        await pref.setString(key, stringValue);
      case final DateTime dateTimeValue:
        await pref.setString(key, dateTimeValue.toIso8601String());
      case final List<String> listStringValue:
        await pref.setStringList(key, listStringValue);
      case null:
        await pref.remove(key);
      case _:
        await pref.setString(key, jsonEncode(value));
    }

    _onValueChanged.add(key);
  }
}

5-4. keyValueRepositoryの依存性を注入するためのプロバイダーを定義する

ちょっとややこしい話ですが、
KeyValueRepositoryに直接アクセスしないで、
KeyValueRepositoryBaseにアクセスするとKeyValueRepositoryの機能にアクセスできるように定義します。
プロバイダー名がkeyValueRepositoryでは混乱する方はkeyValueRepositoryBaseとしても良いでしょう。

lib/data/repositories/key_value_repository/provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preference_sample/data/repositories/key_value_repository/repository.dart';
import 'package:shared_preference_sample/domains/custom_setting.dart';

part 'provider.g.dart';

/// `KeyValueRepositoryBase` のインスタンスを生成
@Riverpod(keepAlive: true)
KeyValueRepositoryBase keyValueRepository(KeyValueRepositoryRef ref) {
  return KeyValueRepository(ref);
}

// =============================================================================
// 以下は依存性の注入には関係なし
// keyValueRepositoryProviderを使って状態を作成しているので同一ファイルに定義しているだけ

/// アイコン設定の値を提供するStreamを生成
/// `IconSettingRef`を通じてリポジトリにアクセスし、現在のアイコン設定を取得し、
/// その後、アイコン設定が変更されるたびに新しい値を提供する
@riverpod
Stream<bool?> iconSetting(IconSettingRef ref) async* {
  // キー値リポジトリのプロバイダーからリポジトリオブジェクトを取得
  final repository = ref.read(keyValueRepositoryProvider);

  // 最初のアイコン設定の値を取得し、yieldを使用してStreamに出力
  yield await repository.getIconSetting();

  // リポジトリの値変更通知を購読し、アイコン設定キーの変更のみにフィルターをかける
  await for (final _ in repository.onValueChange
      .where((key) => key == KeyValueRepository.iconSettingKey)) {
    // アイコン設定キーが変更されたとき、新しいアイコン設定の値を取得し、yieldでStreamに出力
    yield await repository.getIconSetting();
  }
}

// 以下省略

Stream<bool?> iconSetting(IconSettingRef ref)を別ファイルに定義するのもありです。
ここはプロジェクトや個人の判断でお願いします。

6. 【実践】データ層の処理をプレゼンテーション層で呼び出す

6-1. view_modelで呼び出す

データ層の処理を呼び出す場合はref.readでKeyValueRepositoryBaseを参照します。

ここで重要なのはインポートしてるのがdata/repositories/key_value_repository/provider.dart'であるところです。

実装内容が書かれているdata/repositories/key_value_repository/repository.dartをインポートしていないのが、密結合になっていない=疎結合の状態になっています。
この部分がテストを行う上でも、保守性の部分でも重要になってきます。

lib/presentations/my_home_page/my_home_page_view_model.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:shared_preference_sample/data/repositories/key_value_repository/provider.dart';
import 'package:shared_preference_sample/domains/custom_setting.dart';

part 'my_home_page_view_model.g.dart';

/// MyHomePageで行う操作の処理を司るクラス
@riverpod
class MyHomePageViewModel extends _$MyHomePageViewModel {
  @override
  Future<void> build() async {}

  /// アイコン設定を保存する
  Future<void> saveIconSetting({required bool value}) =>
      ref.read(keyValueRepositoryProvider).setIconSetting(value: value);

  /// 背景色番号を保存する
  Future<void> saveBackgroundColorNumber(int value) =>
      ref.read(keyValueRepositoryProvider).setBackgroundColorNumber(value);

  /// タイトルを保存する
  Future<void> saveTitleText(String value) =>
      ref.read(keyValueRepositoryProvider).setTitleText(value);

  /// CustomSettingを保存する
  Future<void> saveCustomSetting(CustomSetting value) =>
      ref.read(keyValueRepositoryProvider).setCustomSetting(value);

  /// shared_preferenceを初期化する
  Future<void> initAllData() => ref.read(keyValueRepositoryProvider).initData();
}

6-2. view_modelの処理を画面で呼び出す

lib/presentations/my_home_page/my_home_page.dart

              ElevatedButton(
                child: const Text('アイコンの設定を変更'),
                onPressed: () async {
                  final result =
                      await showSelectIconSettingBottomSheet(context);
                  if (result != null) {
                    // ここでview_modelを介して処理を呼び出す
                    await ref
                        .read(myHomePageViewModelProvider.notifier)
                        .saveIconSetting(value: result);
                  }
                },
              ),

7. 処理の流れ総括

7-1.ボタンタップでアイコンの設定を保存する処理のおさらい

  1. MyHomePageでユーザーからボタンのタップがあったことをsaveIconSetting()メソッドをとおしてMyHomePageViewModelに知らせます
  2. MyHomePageViewModelKeyValueRepositoryBaseに対してsetIconSetting(bool value)メソッドを実行するように伝えます
  3. KeyValueRepositoryBasesetIconSetting(bool value)メソッドを実行するように指示がありましたが、実装の実態は持っていないのでそのままKeyValueRepositoryBaseを準拠しているKeyValueRepositoryに具体的な処理をするよう指示を伝えます
  4. KeyValueRepositorysetIconSetting(bool value)メソッドを実行するように指示が伝わり、ここで初めてshared_preferenceを使ってローカルにアイコンの設定を保存する処理を実行します

今回の例ではドメイン層が登場していませんが、カスタム設定を保存する場合は全体をとおしてCustomSettingオブジェクトの形を使ってやりとりします。
詳しくはソースコードを追ってみてください。

7-2. アプリケーション層を追加すにはインターフェースと実装を追加するだけ

もしも上記の流れにアプリケーション層を追加する場合は以下の流れになります。

  1. MyHomePageでユーザーからボタンのタップがあったことをsaveIconSetting()メソッドをとおしてMyHomePageViewModelに知らせます
  2. (New)MyHomePageViewModelHogeServiseBaseに対してhoge()メソッドを実行するように伝えます
  3. (New)HogeServiseBasehoge()メソッドを実行するように指示がありましたが、実装の実態は持っていないのでそのままhogeServiseBaseを準拠しているHogeServiseに具体的な処理をするよう指示を伝えます
  4. HogeServisehoge()メソッドを実行するように指示が伝わり、その中でKeyValueRepositoryBaseに対してsetIconSetting(bool value)メソッドを実行するように伝えます
  5. KeyValueRepositoryBasesetIconSetting(bool value)メソッドを実行するように指示がありましたが、実装の実態は持っていないのでそのままKeyValueRepositoryBaseを準拠しているKeyValueRepositoryに具体的な処理をするよう指示を伝えます
  6. KeyValueRepositorysetIconSetting(bool value)メソッドを実行するように指示が伝わり、ここで初めてshared_preferenceを使ってローカルにアイコンの設定を保存する処理を実行します

終わりに

この記事では、レイヤードアーキテクチャと依存性の注入(DI)について、実際のコード例を交えて説明しました。特に、Riverpodを用いたプロパティ注入によるDIの具体的な実装方法について詳しく解説しました。

アーキテクチャの設計は、プロジェクトの規模やチームの方針によって異なることが多いですが、基本的な概念を理解しておくことは非常に重要です。レイヤードアーキテクチャはコードの管理を効率的に行うための強力な手法であり、DIはクラス間の依存関係を柔軟に管理するための技術です。これらをしっかりと理解し、実践できるようになることで、より保守性が高く、拡張性のあるコードを書くことができるようになります。

また、今回はシンプルな例を用いて説明しましたが、実際のプロジェクトではさらに複雑な構造や機能を持つことが多いです。その際には、今回学んだ基本的な概念を応用し、適切にアーキテクチャを設計していくことが求められます。

この記事が、レイヤードアーキテクチャとDIについての理解を深める一助となれば幸いです。質問や不明点、間違いがあれば、ぜひコメントやフィードバックをお寄せください。

お読みいただき、ありがとうございました。

6
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
2