Riverpod は何を DI しているのか
Riverpod の provider は、依存を指すトークンと作り方を一つの宣言にする。override と ProviderScope、ref.read の見え方、非同期初期化の選択肢をつなぎ、Riverpod が DI を担う場所を整理する。連載「Riverpod と依存性注入」の最終回。
前回は Riverpod から離れ、DI を依存を外から受け取る設計として整理しました。DI コンテナは その組み立てを助ける道具であり、Service Locator は利用側が依存を取りに行く形でした。では、 Riverpod に戻ると、どのコードが「外から与える」を担っているのでしょうか。
今回は provider の宣言から ProviderScope までを一本につなぎます。途中には、同期的な create へ
await が必要な依存をどう渡すかという境界があります。そして最後に、ref.read(fooProvider) が
Service Locator に見えるという、前回の末尾に残した問いを回収します。
§ 01TOKENprovider は値ではなく宣言
次の settingsStoreProvider は、SettingsStore そのものではありません。
final settingsStoreProvider = Provider<SettingsStore>((ref) {final preferences = ref.watch(preferencesProvider);return SettingsStore(preferences);});
この宣言は、「この名前で SettingsStore を要求できる」ことと、「要求されたらどう作るか」を
一つにしています。変数 settingsStoreProvider が依存を指すトークンで、関数が作り方です。
前々回に保留した「Provider を DI に使うとき、何をどう注入しているのか」への答えはここにあります。
注入されるものは provider 自身ではなく、provider が公開する SettingsStore や Preferences です。
その実装を選び、依存先をコンストラクタへ渡す処理を create が担います。
SettingsStore は Riverpod を知りません。普通の constructor injection のままです。Riverpod が
受け持つのは、その外側でトークンから作り方を選び、依存グラフをたどってインスタンスを渡す部分です。
§ 02CREATE組み立ては同期の境界に置く
Provider<T> の create は同期関数です。ここには、別の provider から依存を読み、実装を選び、
コンストラクタを呼ぶ処理を置きます。
final preferencesProvider = Provider<Preferences>((ref) {final client = ref.watch(storageClientProvider);return DevicePreferences(client);});
反対に、時間のかかる処理や、通信のように失敗しうる副作用を、その場で完了させようとはしません。
create の中では await できないため、次の形は書けません。
final preferencesProvider = Provider<Preferences>((ref) {final value = await SharedPreferences.getInstance(); // 書けないreturn DevicePreferences(value);});
これは「非同期の依存を注入できない」という意味ではありません。待つ場所を provider の種類、 依存の実装、アプリの起動処理のどこに置くかを選ぶ必要がある、という設計上の境界です。
§ 03OVERRIDE登録済みの定義へ上書きする
一般的な DI コンテナでは、型と実装を先に登録し、あとから型を指定して解決します。Riverpod の
provider は宣言された時点ですでに作り方を持っています。overrides は空の台帳への登録ではなく、
その既定の定義へ別の定義を被せる操作です。
final container = ProviderContainer(overrides: [clockProvider.overrideWithValue(FixedClock(2026, 10, 16)),],);
overrideWith は新しい初期化関数へ差し替えるので、provider が読まれたときにその関数を呼びます。
overrideWithValue は完成済みの値へ差し替え、元の create を呼びません。既定の振る舞いそのものを
外す上書きだからです1overrideWith は新しい初期化関数へ、overrideWithValue は既定の振る舞いを外して完成済みの値へ provider を上書きする(flutter_riverpod の Provider API)。。
この違いにより、テスト用の偽物を渡すだけでなく、Riverpod の外で先に作った値をグラフへ持ち込めます。 どの定義を選ぶかは利用クラスから離れ、スコープを組み立てる側へ集まります。
§ 04ROOTmain() と ProviderScope で組み立てる
Flutter では、ProviderScope が Widget ツリーに Riverpod のスコープを作ります。その内部にある
ProviderContainer が provider の状態と上書きを保持し、Widget に依存しないテストでは
ProviderContainer を直接作れます2ProviderScope は Flutter の Widget で、内部に ProviderContainer を持つ。ProviderContainer は Widget に依存せず、provider の状態を保持する(flutter_riverpod の ProviderScope API)。。
void main() {final clock = SystemClock();runApp(ProviderScope(overrides: [clockProvider.overrideWithValue(clock)],child: const App(),),);}
前回の Composition Root を Flutter のコードへ移すと、場所は main() の周辺、組み立てを支える
境界が ProviderScope です。ここで具体的な実装を選び、必要なら先に生成し、上書きとして渡します。
ProviderScope を置くだけで設計が DI になるわけではありません。利用クラスが依存をコンストラクタで
受け取り、その外側にある provider がグラフを組み立て、入口の scope が実装の選択を確定する。
この三つがつながって、設定する場所と使う場所が分かれます。
§ 05AWAIT非同期初期化をどこで待つか
SharedPreferences.getInstance() のように await が必要な依存には、主に三つの置き場所があります。
選択肢ごとに、保てる性質と失う性質が違います。
一つ目は FutureProvider にする方法です。
final preferencesProvider = FutureProvider<Preferences>((ref) async {final value = await SharedPreferences.getInstance();return DevicePreferences(value);});
初期化中・成功・失敗を AsyncValue として表せる一方、依存する側も非同期境界を引き受けます。
同期的な Preferences をコンストラクタへそのまま渡せる形は失われ、待機と失敗を扱う層が増えます。
二つ目は 遅延実装にする方法です。provider からは実装を同期的に返し、その実装が最初の操作で 本体を解決します。
class LazyPreferences implements Preferences {Future<SharedPreferences>? _value;Future<SharedPreferences> _load() {return _value ??= SharedPreferences.getInstance();}@overrideFuture<String?> readTheme() async {return (await _load()).getString('theme');}}
この形を選べるのは、Preferences が Future<String?> のような非同期メソッドを許す場合だけです。
前回の String? loadTheme() のような同期メソッドでは、値がない時点で await できないため
成立しません。同期インターフェースを保つ必要があるなら、この案ではなく三つ目へ進みます。
注入時点の同期性は保てますが、待ち時間と失敗は消えず、最初の利用箇所へ移ります。依存がいつ使用可能に なるかを Composition Root だけでは保証できず、実装の内側に初期化状態と再試行の責任が残ります。
三つ目は 起動時に解決して overrideWithValue で渡す方法です。
final preferencesProvider = Provider<Preferences>((ref) {throw UnimplementedError('Override preferencesProvider in main()');});
Future<void> main() async {WidgetsFlutterBinding.ensureInitialized();final value = await SharedPreferences.getInstance();final preferences = DevicePreferences(value);runApp(ProviderScope(overrides: [preferencesProvider.overrideWithValue(preferences),],child: const App(),),);}
これが成立するのは、overrideWithValue が元の create を呼ばないからです。素の provider が
例外を投げても、正しく上書きされたスコープではその経路へ入りません。依存側は同期的な
Preferences を受け取れます。
代わりに、初期化が終わるまで最初の画面を出せず、失敗時の起動画面や終了方針も main() 側の責任に
なります。また、上書きを忘れた構成は実行時に失敗します。同期的に使える保証と引き換えに、
起動時間と Composition Root の完全性を要求する選択です。
§ 06LOCATORref.read は取りに行く形に見える
ここで、前回から残していた問いへ戻ります。
onPressed: () {ref.read(checkoutServiceProvider).checkout();},
Widget が ref へトークンを渡し、自分で CheckoutService を要求しています。形だけを見れば、これは
Service Locator の「利用側が窓口へ取りに行く」に近い読み方です。ref.watch には変更を購読して
Widget を再構築する役割もありますが、依存の取得方向だけなら同じです。
一方、Riverpod が DI を担う場所は read という一つのメソッドではありません。provider の宣言が
トークンと作り方を示し、create が依存をコンストラクタへ渡し、scope の上書きが具体的な実装を
外から選びます。宣言・組み立て・スコープの組み合わせが DI の仕組みです。
境界を保つ目安は、ドメインのクラスへ Ref を渡さないことです。CheckoutService 自身が
ref.read(clockProvider) を呼べば、必要な依存がクラスの API から消え、Riverpod という窓口へ
結合します。provider 側で Clock を読み、CheckoutService(clock) と渡せば、クラスは普通の
constructor injection のままです。
Widget での ref.read は Riverpod と UI を接続する境界に置かれます。そこに取得が見えることと、
アプリのすべてのクラスが locator を知ることは同じではありません。取りに行くコードを provider と
Widget の境界へ閉じ込め、再利用したいクラスには依存を与える。この線引きが、Riverpod を使うことと
Service Locator へ設計全体を寄せることを分けます。
§ 07GLOBALトップレベルでもグローバル値ではない
provider はトップレベル変数として宣言するため、見た目はグローバル変数に似ています。しかし、 トップレベルにあるのは値ではなくトークンと作り方です。実際の値は container のスコープに属します。
違いは三つあります。
- スコープ …
ProviderScopeを入れ子にすれば、その部分木だけ定義を上書きできる - 破棄 … 値は provider のライフサイクルに従い、自動破棄や再計算で手放される
- テスト間の分離 …
ProviderContainerをテストごとに作り直せば、状態を持ち越さない
グローバル変数の値をテストで書き換えると、元へ戻す責任が残り、別のテストへ漏れる可能性があります。 provider の宣言は共有しても、値を持つ container は共有しない。この分離が、トップレベル宣言を グローバルな可変状態とは別のものにしています。
§ 08SERIESRiverpod と依存性注入を終えて
4 回を振り返ると、Provider という語の整理 → 状態を更新し作り直す仕組み → DI とコンテナの区別 → Riverpod による組み立てという順で進んできました。
最初に分けた Riverpod の provider は、状態を公開する API の総称でした。その provider が持つ ライフサイクルを追い、DI は状態管理とは別の設計だと切り分けたことで、ようやく今回の答えを一語で 済ませずに書けます。
Riverpod が注入するのは provider ではなく、provider が公開する値です。トークンが何を要求するか、
create がどう組み立てるか、ProviderScope と main() がどの実装を使うかを決めます。
ref.read は Service Locator の形に見えても、その取得を境界へ閉じ込めれば、利用クラスは依存を
外から受け取る設計を保てます。
Riverpod は、DI そのものでも、ただのグローバルな値置き場でもありません。状態のライフサイクルと 依存グラフの組み立てを同じ provider の仕組みで扱える道具です。だからこそ、便利な API の名前では なく、誰が選び、どこで作り、どちら向きに渡しているかを読み分けることが設計になります。
- [1]
overrideWithは新しい初期化関数へ、overrideWithValueは既定の振る舞いを外して完成済みの値へ provider を上書きする(flutter_riverpod の Provider API)。 ↩ - [2]
ProviderScopeは Flutter の Widget で、内部にProviderContainerを持つ。ProviderContainerは Widget に依存せず、provider の状態を保持する(flutter_riverpod の ProviderScope API)。 ↩