providerからriverpodに移行した話の雑感(その内記事にする)
providerからriverpodに移行する理由
以下のメリットを享受できるため
- コンパイルタイムでの依存性解決ができる(実行してみたらWidgetツリーの上部にProviderがない、といったことが起こらない)
- それによる実行時エラーを減らすことができる
- Widgetのコードの見通しが良くなるので保守性が向上する
- コンストラクタから状態の更新ができる
- ProxyProviderを使う必要がない。ref.watchさえ書ければ依存関係にあるインスタンスを作り直すことができる
- インスタンス参照のキーが型名だけではないので柔軟な管理が可能
- 同じ型のproviderをいくつも用意できる
- familyを使えばユーザーIDでインスタンスを分けることも可能でとても良い
デメリット(providerの方が良いこと)はあるのか
ある
- 記述はどうしても長くなる
- 型推論が効く箇所での
context.watch()
とかすごく記述量が少なくて済むんだと実感した
- 型推論が効く箇所での
- ConsumerWidgetなど特別なWidgetを使用する必要がある
- 依存関係の管理のしくみが異なるので同居させるのに工夫が必要
どんなステップで移行したのか
前提
- providerで管理していたクラス類がたくさんあった
- 一度に全部乗りかえるのは無理と判断、順序を踏んで行けるようにした
- アーキテクチャとしてレイヤードアーキテクチャを採用している
- Repository層…通信、永続化(APIクライアントなど)
- UseCase層…ドメインロジックの抽象化
- View層…画面やアプリのWidget
- View層内は変形MVVMによる状態管理
- ViewModel…画面の状態を表現するデータ(freezedで作ったValueObject)
- Controller…画面が行える処理の抽象化と状態保持(StateNotifier継承クラス)
- Page / Component…画面を表現するWidgetとパーツ単位のWidget
- View層内は変形MVVMによる状態管理
順番
- riverpodとproviderを相互運用できるようにする
- Repository層をriverpodで管理するように変更
- UseCase層をriveropdで管理するように変更
- 残りは画面単位でriverpodで管理するように変更
小技集
riverpodとproviderの相互運用
とはいえproviderからriverpod管理下にあるインスタンスを呼んでくることは可能だが、逆はしない(最終的にproviderは外すため)
方法はこちら
lazy: false
の実現方法
アプリ起動時にすぐにインスタンスが出来上がって欲しいやつとかに使う。
以下のようなクラスを作って、ProviderScope直下に置くことで対応。
class Instantiater extends ConsumerWidget {
const Instantiater({
required this.child,
required this.toBeInstantiated,
Key? key,
}) : super(key: key);
final Widget child;
final List<ProviderBase> toBeInstantiated;
Widget build(BuildContext context, WidgetRef ref) {
toBeInstantiated.forEach((p) => ref.read(p));
return child;
}
}
toBeInstantiate
に初期化させたい(riverpodの)Providerを並べておいておくと lazy: false
と同じタイミングで初期化できる。
ProviderScope(
child: Instantiater(
toBeInstantiated: [
// lazy: falseするのと同じタイミング、つまりアプリ起動時に初期化できる
hogeProvider,
],
child: MyPage(),
),
);
runAppより前に初期化しておきたいインスタンスの注入方法
shared_preferencesなど、Futureでしかリソースを取得できないものの取り扱い方は大きく下の二通り
- main内でawaitしてインスタンスを取得、runApp時にアプリケーションに引数から渡す
- FutureProviderでインスタンスを管理する
FutureProviderだとインスタンスがAsyncValueでラップされてしまうため、他のクラスのコンストラクタから注入したいケースでは不便(Widgetで取り扱う分には良いけれど)。
なので1.の方法で行くことに
ただし他のクラスからは ref.read(sharedPreferencesProvider)
といった形で使用したいためProviderは用意したい。
ので、ダミーのProviderを用意してProviderScopeのoverrideでインスタンスを設定することにした
Future<void> main() async {
// インスタンスはmainで生成してしまう。SharedPreferencesならそんなに時間かからないのでawaitしても問題ない
final sp = await SharedPreferences.getInstance();
retuen runApp(
MyApp(
sharedPreferences: sp,
),
);
}
// ダミーのProviderを用意する
final sharedPreferencesProvider = Provider<SharedPreferences>((_) {
throw throw UnimplementedError("アプリケーション起動時にmainでawaitして生成したインスタンスを使用する");
});
class MyApp extends StatelessWidget {
...
Widget build(BuildContext context) {
return ProviderScope(
overrides : [
// Providerが使用するインスタンスを指定する
sharedPreferencesProvider.overrideWithValue(sharedPreferences),
],
child: ...
);
}
}
実際やったこと
riverpodとproviderを相互運用できるようにする
以下の記事参照。この調査で段階的に進められる確証を得た
Repository層をriverpodで管理するように変更
ひたすらに hogehogeRepositoryProvider
を書いていく。
チームメンバーが教えてくれたFlutter Riverpod Snippetsが便利だった
UseCase層をriveropdで管理するように変更
こちらも変わらず。Repositoryと一緒にやってしまわなくてもいい安心感がある。
残りは画面単位でriverpodで管理するように変更
ここが曲者。
一応画面単位で乗り換えはできるものの、一筋縄では行かない画面が複数。
アプリとライフサイクルを一にするControllerとViewModelの組(Global State)と、画面とライフサイクルを一にする組(Local State)があり、いくつかのLocal StateはGlobal Stateに依存しているという関係になっていた。
のでまずはGlobal Stateから移行を開始。
次に各画面のLocal Stateを移行。
Global Stateの移行はアプリ全体に影響が出る可能性があるためQAをしっかり行わないと安心してリリースできない。そうなると全部置き換えてからQAしてリリースした方が効率が良い、ということになり、結局この工程は一気通貫で行うことに。
全部で一週間くらいかかった。
最後はproviderをpubspec.yamlから削除して終了。
会社のBlogに書いた