Riverpod (riverpod_annotation、riverpod_generator) を使って依存を注入する方法
はじめに
2024年11月中旬頃、Flutter公式からFlutterアプリにおいて推奨されるアーキテクチャについて述べられたドキュメントが公開されました。
画像引用元: https://docs.flutter.dev/app-architecture/guide
ドキュメント内では推奨されるアーキテクチャの内容のほとんどが適用されたFlutter公式サンプルGitHubリポジトリも紹介されています。
このFlutter公式サンプルGitHubリポジトリを参考に推奨されるアーキテクチャを真似してみようと思ったのですが、Flutter公式サンプルGitHubリポジトリでは状態管理にProviderが使われており、一方で自分はRiverpod (riverpod_annotation、riverpod_generator)が使いたかったため、よしなに変換する必要がありました。
その際にRiverpodを使ってどのようにして依存を注入すればよいか試行錯誤したため、「同じように真似をしてみようとされている方の参考になるかも」と思い、Riverpodを使って依存を注入する方法を記事にまとめました。
※「コードを見た方が速い」という方は、この記事で説明したコードを使ったGitHubリポジトリを用意していますので、そちらをご覧ください。
前提
- ディレクトリ構造やファイル名はできる限りFlutter公式サンプルGitHubリポジトリを踏襲しています。
- 簡単のため依存の注入に関わる部分以外は最低限のコードにしています。
- 例えばFlutter公式サンプルGitHubリポジトリでは以下のようになっていますが、今回説明に使うコードでは単純に文字列を返すようにしています。
- RepositoryクラスはDomain modelのデータを返す。
- ServiceクラスはAPI modelのデータを返す。
- 例えばFlutter公式サンプルGitHubリポジトリでは以下のようになっていますが、今回説明に使うコードでは単純に文字列を返すようにしています。
言語、フレームワーク、パッケージのバージョン
各バージョンは以下の通りです。
- Dart: 3.6.0
- Flutter: 3.27.0
- flutter_riverpod: 2.6.1
- go_router: 14.6.2
- riverpod_annotation: 2.6.1
- 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
この記事に記載したバージョンとズレないようにするため、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』というサービスを見つけまして、上記のディレクトリツリーはこれを使って書きました!めちゃくちゃ使いやすかったです😭)
Data layerの実装
Service
- データの取得先を切り替えるケースを想定し、以下の2種類のクラスを定義しています。
- ローカルで生成したデータを取得する(
LocalDataService
) - APIからデータを取得する(
ApiClient
)
- ローカルで生成したデータを取得する(
- 簡単のためどちらも単純に文字列を返すようにしています。
class LocalDataService {
Future<String> getFoo() async {
return 'Foo from local';
}
}
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
を実行してプロバイダーを生成します。
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)を引数で注入できるようにしています。
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;
}
}
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
を実行してプロバイダーを生成します。
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プロバイダーに入れるようにしています。
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
に変わる。
- ボタン押下すると
-
最初の状態 | ボタン押下後 |
---|---|
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へ依存を注入)
class Routes {
static const foo = '/foo';
}
- GoRouterの設定を返すプロバイダーを定義しています。
- Viewが必要な依存(ViewModel)を引数として渡し、さらにそのViewModelが必要な依存(Repository)を渡しています。
- ViewModelにはWidgetRefオブジェクトも渡したいため、WidgetをビルドするときはConsumerを使います。
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に設定します。
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)を引数として渡しています。
- これによりRepositoryのプロバイダーを定義したときに書いた
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へ依存を注入しています。
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(),
),
);
}
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
オプションを付与してコマンド実行する
-
{
"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の原理・原則とパターン』という本が大変勉強になりましたのでペタリしておきます📝
参考
Discussion