お願い
本記事の内容に誤りや改善点がございましたら、コメント等でご指摘いただけますと幸いです。
なお、本記事は個人の学習記録として作成したものです。業務でご利用の際は、公式ドキュメントもあわせてご確認ください。
1. Riverpodとは
Riverpodとは、Flutterで使用される状態管理の仕組みをもつフレームワークです。
公式のフレームワークではありませんが、人気でデファクトスタンダードになっているようです。
以下の役割を持ちます。
-
状態管理:
アプリケーション全体で共有される状態(データ)を一元管理します。
ProvidrScopeというWidgetにProviderという状態管理をするオブジェクトが複数管理されています。
使用例として、カウンターの値、ユーザー情報、ログイン状態などを管理し、状態が変更されると自動的にUIが更新されます。
-
依存性注入(DI):
実装(クラス)の依存関係を外から渡す仕組みのことです。
これにより、実装(クラス)の結合度を下げることができます。
この実装の外側で管理する仕組みを持っているのが、RiverpodのProviderです。
実際の実装を触らないで、クラスの切り替えがしやすくなるので、開発環境と本番環境の処理の切り替え、テストのモック化などがしやすくなります。
-
アーキテクチャ層どうしを橋渡しする役割:
Providerで値や状態の提供を行い、各層(Presentation、Domain、Data)をつなぐ役割を持っています。
例えば、UI層はUseCaseProviderを通じてDomain層にアクセスし、Domain層はRepositoryProviderを通じてData層にアクセスします。
2. アーキテクチャ
開発で3層アーキテクチャと呼ばれる構成を使うことが多いと思います。
各層が独立しているため、APIやDB実装が変更されてもビジネスロジックに影響を与えないのが特徴です。
この記事でもこの構成のプロジェクトというのを前提に説明をしていきます。
2-1. 役割
Presentation層
- 役割: UIの表示と操作
- 責任: ユーザーとのやり取り
Domain層
- 役割: ビジネスロジック
- 責任: アプリの中核的な処理
Data層
- 役割: データアクセス
- 責任: データの取得・保存
2-2. 依存関係
※矢印は依存関係
UI (Presentation層)
↓
UseCase (Domain層)
↓
Repository (Data層)
3. Providerの使い方
Providerは2種類あります。
- 状態を持たないもの(値を提供するだけ)
- Provider(単純な値を提供)
- FutureProvider(非同期で値を提供)
- 状態を持つもの
- StateProvider(シンプルな状態管理)
- StateNotifierProvider ※Riverpod 1.x で利用
- NotifierProvider ※Riverpod 2.x で利用
3-1. 状態を持たないもの(Provider、FutureProvider)
これらは値を提供するだけで、状態を変更しません。
値は外部から受け取るか、内部で生成して返します。
ProviderはProvider<戻り値の型>という形で定義します。
Provider:単純な値を提供
// Data層
final messageRepositoryProvider = Provider<MessageRepository>((ref) {
return MessageRepositoryImpl();
});
FutureProvider:非同期で値を提供(メッセージ取得の例)
// UseCaseを提供するProvider(DI)
final getMessageUseCaseProvider = Provider<GetMessageUseCase>((ref) {
final repository = ref.read(messageRepositoryProvider);
return GetMessageUseCase(repository);
});
// メッセージを非同期で取得するProvider
final messageProvider = FutureProvider<Message>((ref) async {
final useCase = ref.read(getMessageUseCaseProvider);
return await useCase.execute(); // 非同期処理
});
FutureProviderの利用時の状態
FutureProviderはloading、data、errorの3つの状態を自動的にみてくれます。
class MessagePage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final messageAsync = ref.watch(messageProvider);
return messageAsync.when(
loading: () => const CircularProgressIndicator(), // 読み込み中
data: (message) => Text(message.content), // データ取得成功
error: (err, st) => Text('エラー: $err'), // エラー
);
}
}
3-2. 状態を持つもの
これは状態を保持し、更新できます。
状態が変化するProviderは、Notifierクラスを使用します。
Notifierクラスで状態を持ち、そのクラスのコンストラクタの参照をProviderが受け取ってインスタンスの管理をします。
Notifierはstateという変数に状態を持ち、Notifier<stateの型>という形で定義します。
NotifierProvider:状態を持つ(カウント数を持つ例)
// providers/counter_provider.dart
class CounterNotifier extends Notifier<int> {
@override
int build() {
return 0; // 初期値
}
void increment() {
state = state + 1; // 状態を更新 → 自動的にUIが再描画
}
void decrement() {
state = state - 1;
}
}
final counterProvider = NotifierProvider<CounterNotifier, int>(
CounterNotifier.new,
);
NotifierProvider利用例
class CounterPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// 状態を監視(値が変わると再描画)
final count = ref.watch(counterProvider);
return Column(
children: [
Text('Count: $count'),
ElevatedButton(
onPressed: () {
// Notifierのメソッドを呼び出し(再描画なし)
ref.read(counterProvider.notifier).increment();
},
child: const Text('+'),
),
],
);
}
}
3-3. ref.watch() と ref.read()
Riverpodを使用するWidgetは、ConsumerWidgetを継承して作成します。
ConsumerWidgetを継承すると、build メソッドにWidgetRefを受け取ることができ、ref.watch()とref.read()を使うことができるようになります。
使い分け:
| メソッド | 用途 | 機能 | 使用場所 |
|---|---|---|---|
ref.watch() |
値の監視 | 値が変わると再描画 | build()メソッド内 |
ref.read() |
値の取得 | 再描画なし | イベントハンドラ内 |
※ref.watch(provider名.select((state) => state.プロパティ名)) で特定の値だけを監視することもできる。
例:
- ボタンが押される
-
ref.read()でNotifierを取得して、increment()を呼び出す
※この時点では再描画されていない -
increment()が呼び出されたことで、state(状態)が変化する -
ref.watch()で状態の変更を検知して再描画
class CounterPage extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
// => ref.watch():値が変わると検知され、再描画される
final count = ref.watch(counterProvider);
return ElevatedButton(
onPressed: () {
// => ref.read():値を取得して メソッドを実行。再描画しない
ref.read(counterProvider.notifier).increment();
},
child: Text('Count: $count'),
);
}
}
4. go_routerの使い方
go_routerとは、アプリのルーティングを行うパッケージです。
URLベースで宣言して使用できるので、コードが見やすく、webアプリでの遷移もしやすくなります。
Riverpodと必ずセットで使うというわけではありませんが、よく使われるものらしく、後続の「5. 実装サンプル」でも使用するのでざっくりと使い方をご紹介します!
4-1. 画面遷移の方法
- パスで遷移
context.go('/second')
- 名前付きルートで遷移
context.goNamed('userDetail', pathParameters: {'id': '1'})
- スタックにプッシュ(スタックに積んで、画面を戻るときに使用する)
context.push('/second')
- 戻る(スタックを利用して前の画面に戻る)
context.pop()
4-2. go と push の違い
go_routerは画面遷移をスタックで管理します。
| メソッド | スタックの動作 | pop() で戻れるか |
|---|---|---|
go() |
スタックを 置き換える | ルートの入れ子定義に依存 |
push() |
スタックに 積む | 戻れる |
go() を使う場合、ルートを入れ子で定義しておくと親画面がスタックに残り、pop() で戻れるようになります。
入れ子定義の例
GoRoute(
path: '/',
builder: (context, state) => Screen1(),
routes: [
GoRoute(
path: 'second',
builder: (context, state) => Screen2(),
routes: [
GoRoute(
path: 'third',
builder: (context, state) => Screen3(),
),
],
),
],
)
入れ子定義を使ったスタックの図解
遷移前 遷移後
│ │ │ │
│ │ ├─────────────────┤
│ │ │ /second │
│ │ go(/second) ├─────────────────┤
│ / │ ─────────────► │ / │
└────────────┘ └─────────────────┘
※ 一度 / は消えて 入れ子の定義に従って 置き換わる
※ この状態で pop() すると /second が消えて / に戻る
遷移前 遷移後
│ │ │ │
│ │ │ /second/third │
│ │ ├─────────────────┤
│ │ │ /second │
│ │ go(/second/third) ├─────────────────┤
│ / │ ─────────────► │ / │
└────────────┘ └─────────────────┘
※ 一度 / は消えて 入れ子の定義に従って 置き換わる
※ この状態で pop() すると /second/third が消えて /second に戻る
遷移前 遷移後
│ │ │ │
│ │ ├─────────────────┤
│ │ │ /second │
│ │ push(/second) ├─────────────────┤
│ / │ ─────────────► │ / │
└────────────┘ └─────────────────┘
※ / に加えて /second が積まれる
※ この状態で pop() すると /second が消えて / に戻る
フラット定義の例
GoRoute(path: '/', builder: (context, state) => Screen1()),
GoRoute(path: '/second', builder: (context, state) => Screen2()),
GoRoute(path: '/second/third', builder: (context, state) => Screen3()),
フラット定義を使ったスタックの図解
遷移前 遷移後
│ │ │ │
│ │ │ │
│ │ go(/second) │ │
│ / │ ─────────────► │ /second │
└────────────┘ └─────────────────┘
※ 入れ子定義とは異なり、 /second のみに置き換わる
※ この状態で pop() すると スタックが空になるため、エラーになる
遷移前 遷移後
│ │ │ │
│ │ │ │
│ │ │ │
│ / │ go(/second/third) │ /second/third │
└────────────┘ ─────────────► └─────────────────┘
※ 入れ子定義とは異なり、 /second/third のみに置き換わる
※ この状態で pop() すると スタックが空になるため、エラーになる
遷移前 遷移後
│ │ │ │
│ │ ├─────────────────┤
│ │ │ /second │
│ │ push(/second) ├─────────────────┤
│ / │ ─────────────► │ / │
└────────────┘ └─────────────────┘
※ フラット定義でも push() なら / の上に積まれる
※ この状態で pop() すると /second が消えて / に戻る
5. 実装サンプル
6. コード生成
Riverpodでは、riverpod_annotationとriverpod_generatorというパッケージを使用して、ボイラーテンプレートと呼ばれる毎回ほぼ同じ形で書かなければいけない定型コードを省略できる仕組みがあります。
final getMessageUseCaseProvider = Provider<GetMessageUseCase>((ref) {
final repository = ref.read(messageRepositoryProvider);
return GetMessageUseCase(repository);
});
↓ アノテーションを使う
@riverpod
GetMessageUseCase getMessageUseCase(Ref ref) {
final repository = ref.read(messageRepositoryProvider);
return GetMessageUseCase(repository);
}
上記のような省略に加えて、コード生成を使用すると下記の2つ仕組みもデフォルトで有効になっています。
-
.autoDispose
Providerが不要になったとき(画面が閉じたときなど)に自動でキャッシュを破棄する仕組み。final userProvider = FutureProvider.autoDispose<User>((ref) async { return await repository.getUser(); });
-
.family
Providerを作る際にref以外の引数を持ちたい場合に使うもの。
※引数がrefだけの場合は、自動生成されませんfinal userProvider = FutureProvider.family<User, String>((ref, userId) async { return await repository.getUser(userId); });
サンプルはflutter-generator-sampleブランチで実装しています。
7. まとめ
- Riverpodを使用したUIを作りたい ⇒ ConsumerWidgetを継承
- 状態監視がしたい ⇒ Providerを扱えるWidgetRefを使用
- 値が変更されないデータを扱いたい ⇒ Provider
- 非同期データを扱いたい ⇒ FutureProvider
- 値が変更されるデータを扱いたい ⇒ NotifierProvider