🌊

Riverpod (riverpod_annotation、riverpod_generator) を使って依存を注入する方法

2024/12/13に公開

はじめに

2024年11月中旬頃、Flutter公式からFlutterアプリにおいて推奨されるアーキテクチャについて述べられたドキュメントが公開されました。


画像引用元: https://docs.flutter.dev/app-architecture/guide

ドキュメント内では推奨されるアーキテクチャの内容のほとんどが適用されたFlutter公式サンプルGitHubリポジトリも紹介されています。

このFlutter公式サンプルGitHubリポジトリを参考に推奨されるアーキテクチャを真似してみようと思ったのですが、Flutter公式サンプルGitHubリポジトリでは状態管理にProviderが使われており、一方で自分はRiverpod (riverpod_annotationriverpod_generator)が使いたかったため、よしなに変換する必要がありました。

その際にRiverpodを使ってどのようにして依存を注入すればよいか試行錯誤したため、「同じように真似をしてみようとされている方の参考になるかも」と思い、Riverpodを使って依存を注入する方法を記事にまとめました。

※「コードを見た方が速い」という方は、この記事で説明したコードを使ったGitHubリポジトリを用意していますので、そちらをご覧ください。

https://docs.flutter.dev/app-architecture
https://github.com/flutter/samples/tree/main/compass_app

前提

  • ディレクトリ構造やファイル名はできる限りFlutter公式サンプルGitHubリポジトリを踏襲しています。
  • 簡単のため依存の注入に関わる部分以外は最低限のコードにしています。
    • 例えばFlutter公式サンプルGitHubリポジトリでは以下のようになっていますが、今回説明に使うコードでは単純に文字列を返すようにしています。
      • RepositoryクラスはDomain modelのデータを返す。
      • ServiceクラスはAPI modelのデータを返す。

言語、フレームワーク、パッケージのバージョン

各バージョンは以下の通りです。

この記事に記載したバージョンとズレないようにするため、pubspec.yamlではバージョンを固定しました。

pubspec.yaml
name: flutter_sample_dependency_injection_with_riverpod
description: "A new Flutter project."
publish_to: 'none'
version: 0.1.0

environment:
  sdk: 3.6.0
  flutter: 3.27.0

dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: 2.6.1
  go_router: 14.6.2
  riverpod_annotation: 2.6.1

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: 5.0.0
  riverpod_generator: 2.6.3
  build_runner: 2.4.13
  custom_lint: 0.7.0
  riverpod_lint: 2.6.3

flutter:
  uses-material-design: true

ディレクトリツリー

今回の説明に関連する主要なディレクトリとファイルです。
Riverpodを使っているため若干異なる部分はありますが、Flutter公式サンプルGitHubリポジトリとほぼ同じです。

lib/
├─ config/
│  └─ dependencies.dart
├─ data/
│  ├─ repositories/
│  │  └─ foo/
│  │     ├─ foo_repository.dart
│  │     ├─ foo_repository.g.dart
│  │     ├─ foo_repository_local.dart
│  │     └─ foo_repository_remote.dart
│  └─ services/
│     ├─ api/
│     │  └─ api_client.dart
│     └─ local/
│        └─ local_data_service.dart
├─ routing/
│  ├─ router.dart
│  ├─ router.g.dart
│  └─ routes.dart
├─ ui/
│  └─ foo/
│     ├─ providers/
│     │  ├─ foo_provider.dart
│     │  └─ foo_provider.g.dart
│     ├─ view_models/
│     │  └─ foo_viewmodel.dart
│     └─ widgets/
│        └─ foo_screen.dart
├─ main.dart
├─ main_development.dart
└─ main_staging.dart
pubspec.yaml 

(何かとよく見るかっこいいディレクトリツリーを自分も書きたいなーと思って調べてみたら『Dir Maker』というサービスを見つけまして、上記のディレクトリツリーはこれを使って書きました!めちゃくちゃ使いやすかったです😭)
https://zenn.dev/praha/articles/b2e225ae091ae3
https://dir-maker.netlify.app/

Data layerの実装

Service

  • データの取得先を切り替えるケースを想定し、以下の2種類のクラスを定義しています。
    • ローカルで生成したデータを取得する(LocalDataService)
    • APIからデータを取得する(ApiClient)
  • 簡単のためどちらも単純に文字列を返すようにしています。
lib/data/services/local/local_data_service.dart
class LocalDataService {
  Future<String> getFoo() async {
    return 'Foo from local';
  }
}
lib/data/services/api/api_client.dart
class ApiClient {
  Future<String> getFoo() async {
    // 簡単のためAPIを叩く代わりに文字列を返すようにしています。
    return 'Foo from API';
  }
}

Repository

抽象クラス

  • Repositoryの抽象クラスとそのプロバイダーを定義しています。
  • このプロバイダーは使いまわす想定であり不要な再生成を回避するため、@Riverpod(keepAlive: true)を使うことでプロバイダーが自動で破棄されないようにしています。
  • 後述のlib/config/dependencies.dartでRepositoryの依存注入の設定をしていない場合に気付けるようにするため、プロバイダーの定義の中身はthrow UnimplementedError()にしてエラーを出すようにしています。
  • ファイル作成後、$ dart run build_runner build --delete-conflicting-outputsを実行してプロバイダーを生成します。
foo_repository.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'foo_repository.g.dart';

abstract class FooRepository {
  Future<String> getFoo();
}

(keepAlive: true)
FooRepository fooRepository(Ref ref) {
  throw UnimplementedError();
}

具象クラス

  • データの取得先を切り替えるケースを想定し、以下の2種類のRepositoryの具象クラスを定義しています。
    • ローカルで生成したデータを取得する(FooRepositoryLocal)
    • APIからデータを取得する(FooRepositoryRemote)
  • 必要な依存(Service)を引数で注入できるようにしています。
foo_repository_local.dart
import 'package:flutter_sample_dependency_injection_with_riverpod/data/repositories/foo/foo_repository.dart';
import 'package:flutter_sample_dependency_injection_with_riverpod/data/services/local/local_data_service.dart';

class FooRepositoryLocal implements FooRepository {
  FooRepositoryLocal({
    required LocalDataService localDataService,
  }) : _localDataService = localDataService;

  final LocalDataService _localDataService;

  
  Future<String> getFoo() async {
    final foo = _localDataService.getFoo();
    return foo;
  }
}
foo_repository_remote.dart
import 'package:flutter_sample_dependency_injection_with_riverpod/data/repositories/foo/foo_repository.dart';
import 'package:flutter_sample_dependency_injection_with_riverpod/data/services/api/api_client.dart';

class FooRepositoryRemote implements FooRepository {
  FooRepositoryRemote({
    required ApiClient apiClient,
  }) : _apiClient = apiClient;

  final ApiClient _apiClient;

  
  Future<String> getFoo() async {
    final foo = _apiClient.getFoo();
    return foo;
  }
}

UI layerの実装

Provider

  • Repositoryを使って取得した文字列データをViewに反映できるようにする用にNotifierプロバイダーを定義しています。
  • デフォルト値はHello World!にしており、Viewでは最初にこの値が表示されるようにしています。
  • ファイル作成後、$ dart run build_runner build --delete-conflicting-outputsを実行してプロバイダーを生成します。
lib/ui/foo/providers/foo_provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'foo_provider.g.dart';


class FooNotifier extends _$FooNotifier {
  
  String build() => 'Hello World!';

  void update(String word) {
    state = word;
  }
}

ViewModel

  • Flutterアプリにおいて推奨されるアーキテクチャについて述べられたドキュメントを参考にViewModelはViewに対して1つだけ用意しています。
  • 必要な依存(Repository)を引数で注入できるようにしています。
  • またViewModelではViewの状態を更新できるようにしたいため、WidgetRefオブジェクトを引数で受け取れるようにしています。
  • Repositoryを使って取得した文字列データを前述のNotifierプロバイダーに入れるようにしています。
lib/ui/foo/view_models/foo_viewmodel.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_sample_dependency_injection_with_riverpod/data/repositories/foo/foo_repository.dart';
import 'package:flutter_sample_dependency_injection_with_riverpod/ui/foo/providers/foo_provider.dart';

class FooViewModel {
  FooViewModel({
    required this.ref,
    required this.fooRepository,
  }) : _fooNotifier = ref.read(fooNotifierProvider.notifier);

  final WidgetRef ref;
  final FooRepository fooRepository;
  final FooNotifier _fooNotifier;

  Future<void> getFoo() async {
    final String result = await fooRepository.getFoo();
    _fooNotifier.update(result);
  }
}

View

  • 必要な依存(ViewModel)を引数で注入できるようにしています。
  • ボタンを押下するとViewModelを実行するようにしています。
  • ViewModelの処理によって取得した文字列データを画面に反映されるようにしています。
    • main_development.dartを使って起動した場合
      • ボタン押下すると Hello World!Foo from local に変わる。
    • main_staging.dartを使って起動した場合
      • ボタン押下すると Hello World!Foo from API に変わる。
最初の状態 ボタン押下後
lib/ui/foo/widgets/foo_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_sample_dependency_injection_with_riverpod/ui/foo/providers/foo_provider.dart';
import 'package:flutter_sample_dependency_injection_with_riverpod/ui/foo/view_models/foo_viewmodel.dart';

class FooScreen extends ConsumerWidget {
  const FooScreen({super.key, required this.viewModel});

  final FooViewModel viewModel;

  
  Widget build(BuildContext context, WidgetRef ref) {
    return MaterialApp(
      home: Scaffold(
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Text(
                ref.watch(fooNotifierProvider),
              ),
              ElevatedButton(
                child: const Text('Get Foo'),
                onPressed: () => viewModel.getFoo(),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

ルーティングの設定(View, ViewModelへ依存を注入)

lib/routing/routes.dart
class Routes {
  static const foo = '/foo';
}
  • GoRouterの設定を返すプロバイダーを定義しています。
    • Viewが必要な依存(ViewModel)を引数として渡し、さらにそのViewModelが必要な依存(Repository)を渡しています。
    • ViewModelにはWidgetRefオブジェクトも渡したいため、WidgetをビルドするときはConsumerを使います。
lib/routing/router.dart
import 'package:flutter_sample_dependency_injection_with_riverpod/data/repositories/foo/foo_repository.dart';
import 'package:flutter_sample_dependency_injection_with_riverpod/routing/routes.dart';
import 'package:flutter_sample_dependency_injection_with_riverpod/ui/foo/view_models/foo_viewmodel.dart';
import 'package:flutter_sample_dependency_injection_with_riverpod/ui/foo/widgets/foo_screen.dart';
import 'package:go_router/go_router.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'router.g.dart';


GoRouter router(Ref ref) {
  return GoRouter(
    initialLocation: Routes.foo,
    routes: [
      GoRoute(
        path: Routes.foo,
        builder: (context, state) {
          return Consumer(
            builder: (context, ref, child) {
              return FooScreen(
                viewModel: FooViewModel(
                  ref: ref,
                  fooRepository: ref.read(fooRepositoryProvider),
                ),
              );
            },
          );
        },
      ),
    ],
  );
}
  • 上記で定義したプロバイダーをrouterConfigに設定します。
lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_sample_dependency_injection_with_riverpod/routing/router.dart';

import 'main_development.dart' as development;

// デフォルトのエントリーポイントはdevelopment
void main() async {
  development.main();
}

class MainApp extends ConsumerWidget {
  const MainApp({super.key});

  
  Widget build(BuildContext context, WidgetRef ref) {
    return MaterialApp.router(
      routerConfig: ref.watch(routerProvider),
    );
  }
}

エントリーポイントの設定(Repositoryへ依存を注入)

Repositoryの依存注入の設定ファイル

  • overrideWithValueを使って、RepositoryのプロバイダーをRepositoryの具象クラスでオーバーライドしています。
    • これによりRepositoryのプロバイダーを定義したときに書いた throw UnimplementedError()によるエラーが発生しなくなり、代わりに具象クラスに実装した処理が実行されるようになります。
    • Repositoryの具象クラスが必要な依存(Service)を引数として渡しています。
lib/config/dependencies.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_sample_dependency_injection_with_riverpod/data/repositories/foo/foo_repository.dart';
import 'package:flutter_sample_dependency_injection_with_riverpod/data/repositories/foo/foo_repository_local.dart';
import 'package:flutter_sample_dependency_injection_with_riverpod/data/repositories/foo/foo_repository_remote.dart';
import 'package:flutter_sample_dependency_injection_with_riverpod/data/services/api/api_client.dart';
import 'package:flutter_sample_dependency_injection_with_riverpod/data/services/local/local_data_service.dart';

List<Override> get providersRemote {
  return [
    fooRepositoryProvider.overrideWithValue(
      FooRepositoryRemote(
        apiClient: ApiClient(),
      ),
    ),
  ];
}

List<Override> get providersLocal {
  return [
    fooRepositoryProvider.overrideWithValue(
      FooRepositoryLocal(
        localDataService: LocalDataService(),
      ),
    ),
  ];
}

エントリーポイントとなるファイル

  • データの取得先を切り替えるケースを想定し、以下の2種類のエントリーポイントとなるファイルを作成しています。
    • ローカルで生成したデータを取得する(lib/main_development.dart)
    • APIからデータを取得する(lib/main_staging.dart)
  • ProviderScopeのoverridesにlib/config/dependencies.dartで定義した設定を渡すことで、Repositoryへ依存を注入しています。
lib/main_development.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_sample_dependency_injection_with_riverpod/config/dependencies.dart';
import 'package:flutter_sample_dependency_injection_with_riverpod/main.dart';

void main() async {
  runApp(
    ProviderScope(
      overrides: providersLocal,
      child: const MainApp(),
    ),
  );
}
lib/main_staging.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_sample_dependency_injection_with_riverpod/config/dependencies.dart';
import 'package:flutter_sample_dependency_injection_with_riverpod/main.dart';

void main() async {
  runApp(
    ProviderScope(
      overrides: providersRemote,
      child: const MainApp(),
    ),
  );
}

動作確認

lib/main_development.dart又はlib/main_staging.dartを使ってアプリを起動して動作確認してください。

上手くいっていればlib/main_development.dartを使った場合と、lib/main_staging.dartを使った場合とで、ボタンを押下したときに反映される文字が変わっているはずです。

  • lib/main_development.dartを使って起動する場合
    • flutter runコマンドに--target=lib/main_development.dartオプションを付与してコマンド実行する
  • lib/main_staging.dartを使って起動する場合
    • flutter runコマンドに--target=lib/main_staging.dartオプションを付与してコマンド実行する
launch.json
{
  "version": "0.2.0",
  "configurations": [
      {
          "name": "development",
          "request": "launch",
          "type": "dart",
          "args": [
              "--hot",
              "--target=lib/main_development.dart"
          ]
      },
      {
          "name": "staging",
          "request": "launch",
          "type": "dart",
          "args": [
              "--hot",
              "--target=lib/main_staging.dart"
          ]
      },
  ]
}

おわりに

Riverpod (riverpod_annotation、riverpod_generator) を使って依存を注入する方法をまとめました。この記事で説明したコードを使ったGitHubリポジトリも用意していますので必要に応じてご覧ください。

余談ですが、依存の注入について理解を深めるにあたり『なぜ依存を注入するのか DIの原理・原則とパターン』という本が大変勉強になりましたのでペタリしておきます📝
https://book.mynavi.jp/ec/products/detail/id=143373

参考

https://docs.flutter.dev/app-architecture
https://github.com/flutter/samples/tree/main/compass_app
https://riverpod.dev/ja/
https://pub.dev/packages/provider
https://pub.dev/packages/riverpod
https://pub.dev/packages/riverpod_annotation
https://pub.dev/packages/riverpod_generator
https://zenn.dev/minma/articles/05ca5f9690b732
https://qiita.com/hukusuke1007/items/a2725d6111e2d04156fa

Discussion