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