providerからriverpodに漸次的に移行する(providerとriverpodを1つのプロジェクト内で共存させる)
この記事は Flutter Advent Calendar 2021 カレンダー2の8日目の記事です。
実務のアプリ、providerからriverpodに移行しよう!…できるか!?
つい1ヶ月ほど前の11/5に riverpod のバージョン 1.0.0 がリリースされました。
riverpod は provider の欠点を解消するモチベーションで作成されたものなので、 provider を使用しているプロジェクトではぜひ riverpod を使用したいものです。
私が所属している会社では古くからFlutterアプリ開発をしており、かなり以前から provider を使用して依存性注入と状態管理を行ってきました。
山のようにある provider.Provider や flutter_state_notifer.StateNotifierProvider を全部一気に移行するのは、null-safety対応ほどではないにしろ骨が折れそうです。
null-safety対応の際は一人が2-3日かけてマイグレーションから動作確認などを行いましたし、QAにも大きな労力がかかりました。
あのときと同じ苦労はもうしたくないものです。
大方針
そこでぼんやりと以下の方針を立てて、漸次的に移行することにしました。
- 新規に作成する機能に関する状態管理は
riverpodを使って行うこととする - 全機能から共通して利用される傾向があるクラス(APIクライアントなど)は
riverpodを使って依存性注入を行えるように早いうちに整備を行う
そこで必要になってくるのが、 provider と riverpod の共存になる、というわけです。
// イメージとしてはこんな感じ
// riverpodで依存を注入できるようにする
final hogeProvider = riverpod.Provider<Hoge>(...);
// 既存のprovider使っている箇所はそのままで(StateNotifier編)
flutter_state_notifier.StateNotifierProvider<FugaController, Fuga>(
create: (context) => FugaController(...[何かしらのマジックでHogeを参照]...)
);
// providerを使っている箇所はそのままで(Widget編)
class UseHogeFugaWidget extends StatelessWidget {
Widget build(BuildContext context) {
final fuga = context.watch<Fuga>();
...
}
表記の説明
provider と riverpod の間では、同じ名前なのに働きが全然違うクラスがたくさん存在します。
この記事ではなるべく混乱を避けるため、基本的に以下の表記ルールを用います。
パッケージ名
この記事において、パッケージ名は小文字で表記します。
provider riverpod とかそんな感じ。
クラス名
この記事において、クラス名はパッケージ名のプレフィクスを持ちます。
-
provider.Provier->providerのProviderクラス -
riverpod.Provider->riverpodのProviderクラス
riverpodについての理解
どう共存させるかの方針を立てるため、 riverpod の理解を深めることからはじめました。
まだ riverpod を使い始めて2日しか経ってないので、認識違いなどありましたら教えて下さい。
riverpodにおけるProviderはインスタンスの作り方を知っているだけ
riverpod と provider で大きく違うところは、グローバル空間にインスタンスの作り方を記述する、という点だと思います。
final hogeProvider = riverpod.Provider(
(ref) => Hoge(ref.read(fugaProvider)),
);
グローバル空間に値を置くことには本能的な抵抗があるエンジニアは多いかと思います(私もそうです)。
説明やソースコードを読んだ感じ、 riverpod.Provider はあくまでインスタンスの作り方のみを記述したもののようです。
コンストラクタパラメータにどんなインスタンスが必要なのか、そのインスタンスはどの riverpod.Provier に作り方が示されているものなのかを明示するためのもの、ということ。
provider.Ref の read や watch の引数に riverpod.Provider の値が要求されるのも、riverpodがインスタンスの作り方を知っているもの(riverpod.Provider の値が存在すれば確実に作り方がわかる)に限定するためなんですね。かしこい。
riverpod.Provider 自体がインスタンスを抱えたり管理したりすることはないので、仕組みがわかればグローバルに値を置いても(すこし)安心できます。
riverpodは生成したインスタンスをどう管理しているのか/取得するのか
では生成したインスタンスはどこで管理されているのでしょう。
インストールドキュメントには、(riverpod の) Provider の状態を保持してくれる flutter_riverpod.ProviderScope をapplicationより上に置くように、と書かれています。
// https://riverpod.dev/docs/getting_started より引用
void main() {
runApp(
// For widgets to be able to read providers, we need to wrap the entire
// application in a "ProviderScope" widget.
// This is where the state of our providers will be stored.
ProviderScope(
child: MyApp(),
),
);
}
生成されたインスタンス自体は flutter_riverpod.ProviderScope (が抱えている riverpod.ProviderContainer)が管理しており、 provider.Provider.of(BuildContext) と似た感じで flutter_riverpod.ProviderScope.containerOf(BuildContext) で(riverpod.ProviderContainer を)取得できるようです。
既存のインスタンスを flutter_riverpod.ProviderScope に追加するには、 riverpod.Provider.overrideWithValue() の結果を flutter_riverpod.ProviderScope のコンストラクタから渡せば良いようです。
riverpod.Override 関連だと riverpod.ProviderContainer.updateOverrides() というメソッドがありますが、
It is not possible, to remove or add new overrides, only update existing ones.
(既にあるoverrideの上書きだけであって、新しいoverrideを足したり消したりはできないよ)
とあるので、コンストラクタ以外からインスタンスを追加するのは無理そうです。
共存の方針
整理するとこんな感じかと思います。
| 観点 | riverpod |
provider |
|---|---|---|
| 生成したインスタンスの管理 | Widget Tree上位の方にある flutter_riverpod.ProviderScope で一括管理 |
Widget Tree内にある privider.Provider 単位でそれぞれ管理 |
| 生成済みのインスタンスを注入する位置 |
flutter_riverpod.ProviderScope の overrides コンストラクタパラメータのみなので、任意の位置では注入不可、Widget Treeの上位のみ |
provider.Provider.value() コンストラクタを使えば良いので任意の位置で注入可能 |
そういうわけで provider から riverpod を参照するのは簡単そうだが、逆は難しそう ということがわかりました。
(インスタンスのライフサイクルには注意する必要はありつつも)
現在メンテナンスしているアプリの依存関係は以下の図のようになっており、
+-------------------------------------------+
| <Repository> |
| API Client, Key-Value Store, etc... |
+-------------------------------------------+
↑
+-------------------------------------------+
| <UseCase> |
| Abstracted features of this app |
+-------------------------------------------+
↑
+-------------------------------------------+
| <View> |
|Pages (including states & etc), Components |
+-------------------------------------------+
Repository層とUseCase層は全て provider.Provider で依存関係を追加、View層は state_notifier.StateNotifier で状態を保持し flutter_state_notifier.StateNotifierProvider で注入と状態変更通知を行なっていました。
View層の flutter_state_notifier.StateNotifierProvider の数が一番多いため、これを一度に riverpod.StateNotifierProvider に乗り換えるのは大変そうです。
今回はRepository層とUseCase層を先に riverpod.Provider で注入するようにし、View層では生成したインスタンスを provider.Provider として取得できるようにすれば良さそうだと目星がつきました。
橋渡し
flutter_riverpod.ProviderScope で管理しているインスタンスを provider.Provider から取得できるように、以下のクラスを作って今までの provider.Provider と置き換えました。
class RiverpodProvider<T> extends SingleChildStatelessWidget {
const RiverpodProvider({
required this.riverpodProvider,
this.lazy = true,
Widget? child,
Key? key,
}) : super(child: child, key: key);
final riverpod.AlwaysAliveProviderBase<T> riverpodProvider;
final bool lazy;
Widget buildWithChild(BuildContext context, Widget? child) {
return provider.Provider(
create: (context) => flutter_riverpod.ProviderScope.containerOf(context, listen: false)
.read(riverpodProvider),
lazy: lazy,
child: child,
);
}
}
provider.Provider が掴んているインスタンスが勝手に消えないように、 riverpod.AlwaysAliveProviderBase (アプリが生きてる間はインスタンスも生きてる)継承クラスのみ受け付けるようにしています。
provider.Provider の onDispose から、 riverpod.Ref の onDispose を呼び出すのは難しそう(riverpod.ProviderElementBase.dispose() を叩くことはできるけれど、通常は使うなと書かれているので)なのと、現状は任意のタイミングでdisposeしたいものが無いので、一旦これで用を足せそうです。
flutter_state_notifier.StateNotifierProvider はStateとStateNotifierの両方を注入するので、これに対しては似たような別クラスを用意して置き換えを進めています。
// イメージとしてはこんな感じになる
// riverpodで依存を注入できるようにする
final hogeProvider = riverpod.Provider<Hoge>(...);
// riverpodからproviderへのブリッジを作る
RiverpodProvider<Hoge>(
riverpodProvider: hogeProvider,
);
// 既存のprovider使っている箇所はそのままで(StateNotifier編)
flutter_state_notifier.StateNotifierProvider<FugaController, Fuga>(
create: (context) => FugaController(context.read<Hoge>())
);
// providerを使っている箇所はそのままで(Widget編)
class UseHogeFugaWidget extends StatelessWidget {
Widget build(BuildContext context) {
final fuga = context.watch<Fuga>();
...
}
今の所問題なく動作しています。
今後
riverpod で管理しているインスタンスを provider でも使用するようにできたので、以下の方針で移行を進められそうです。
- 新規に作成する機能は
riverpodで依存関係を管理する - 必要あれば先述の橋渡しクラスを使って
providerからも使用できるようにする - エンジニアの稼働を見つつ、余裕が出そうなタイミングで
providerを使用している箇所をriverpodを使用するように置き換える(共存ができているので、細切れに進めても大丈夫) -
providerへの依存を解消すると同時に先述のクラスも消す
なるべくモダンで快適に開発できる環境を維持していきたいですね!
来年もFlutterで楽しい開発をしていきましょう! 👋
Discussion