Diverse developer blog

株式会社Diverse(ダイバース) 開発者ブログです。

Flutterの状態管理ツールをproviderからriverpodに移行しました

id:kikuchy です。

婚活サービスyoubrideのスマートフォンアプリは以前からFlutterを採用しています。
developer.diverse-inc.com

このアプリでは、始めはscoped_model、次にproviderを状態管理ツールとして採用してきました。
この度、通常の開発を大きく止めることなくproviderからriverpodへと移行できたので、どのように移行したのかをお話したいと思います。

前提:なぜriverpodにしたのか

providerパッケージ(以下、provder)もriverpodパッケージ(正確にはflutter_riverpodパッケージ。以下、riverpod)も同じ作者(Remi Rousseletさん)による状態管理&依存性注入のためのライブラリです。

両者で実現できる機能はほとんど変わりません。
できることは主に以下のとおりです。

  • ChangeNotifierStateNotifierによる状態の変更を検知し、Widgetの再ビルドを自動的に行う
  • 任意のインスタンスを遅延生成して任意のWidgetでの使用を可能にする

riverpodへの乗り換えメリット

同じならば乗り換える必要がないのではないかと思うかもしれません。しかし、riverpodならではのメリットがあるのです。

  • コンパイルタイムでの依存性解決ができる(実行してみたらWidgetツリーの上部にProviderがない、といったことが起こらない)
    • それによる実行時エラーを減らすことができる
    • Widgetのコードの見通しが良くなるので保守性が向上する
  • コンストラクタから状態の更新ができる
    • ProxyProviderを使う必要がない。ref.watch()さえ書ければ依存関係にあるインスタンスを作り直すことができる
  • インスタンス参照のキーが型名だけではないので柔軟な管理が可能
    • 同じ型のproviderをいくつも用意できる
    • familyを使えばユーザーIDでインスタンスを分けることも可能でとても良い

特に一番上の、コンパイルタイムでの依存性解決が可能、というのはモバイルアプリ開発においてとても重要です。
モバイルアプリの場合、不具合が見つかってもストアの審査などがあるためすぐには修正版のリリースができません。
ランタイムではなく、コンパイルタイムで不具合発見の可能性を上げられるというのはありがたいことなのです。

riverpodのデメリット(providerの方が良いこと)

実際に使用してみて、逆にproviderの方が良いこともありました。

  • 記述はどうしても長くなる
    • 型推論が効く箇所での context.watch() はすごく記述量が少なくて済むんだと実感した
    • 型推論のせいでどんなインスタンスを取得しているのかわかりづらいケースもある
  • ConsumerWidgetなど特別なWidgetを使用する必要がある


総合的に見て、メリットの方が大きいと判断したため導入を決めました。

移行のステップ

導入を決めたとは言え、一息に導入できるわけではありません。
すでにproviderで管理していたクラスはたくさんありますし、他にもやりたい改修はいっぱいあります。
一度に置き換えるのは無理と判断し、順序を踏んで移行することにしました。

前提

youbrideアプリはレイヤードアーキテクチャを指向しています。
以下の層は下から上方法の依存関係を持ちます。

  • Repository層…通信、永続化(APIクライアントなど)
  • UseCase層…ドメインロジックの抽象化
  • View層…画面やアプリのWidget
    • View層内は変形MVVMによる状態管理
      • ViewModel…画面の状態を表現するデータ(freezedで作ったValueObject)
      • Controller…画面が行える処理の抽象化と状態保持(StateNotifier継承クラス)
      • Page / Component…画面を表現するWidgetとパーツ単位のWidget

このうち、Repository層とUseCase層は簡単に移行できると判断しました。
Repositoryはほとんど何にも依存していないですし、UseCaseはRepositoryに依存するだけ(しかも依存性注入はコンストラクタから行うようにしている)でした。
View層については、providerの特性上、各画面のWidgetをproviderのProviderでラップする作りになっています。したがって、画面ごとに移行する計画が立てられそうです。

そうなると段階的な移行が必要になります。
riverpodで管理しているインスタンスをproviderから使用する、ということはできるのでしょうか。
調査した結果がこちらになります。

zenn.dev

調査の結果、段階的な移行は可能と判断し、実行に移されることになりました。

実際の移行

Repository層 -> UseCase層 -> View層 の順で移行することにしました。

Repository層とUseCase層についてはひたすらにriverpod用のhogeRepositoryProviderを書いてゆき、先の記事に書いたブリッジ用のクラスでproviderでも使用できるようにしていきました。

View層は、始めは画面単位で移行することを計画していました。
が、複数画面間で共通して使用するProvider(Global State)と、特定の画面のみで使用するProvider(Local State)があり、Local Stateの一部はGlobal Stateに依存しているという関係ができあがっていました。

そのため、まずはGlobal Stateから移行し、次にLocal Stateを移行するという手順に変更。

Global Stateの変更はアプリ全体に影響が出るため、リリース前のテストを念入りに行う必要がある。そうなると、全部置き換えてしまってからテストした方が効率が良い、ということになっため、View層の置き換えは(他のタスクの手を止めて3-4日止めて)一息にやることになりました。

全部で1週間程度で終了することができました!

pubspec.yamlからproviderを削除したときの達成感がすごかったです。

providerからriverpodへ移行したことでインスタンスのライフサイクルが変わったことによる不具合がいくつか見つかりましたが、随時修正を行い、現在では問題なくなっています。

実装時に使える小技

providerの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;

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    toBeInstantiated.forEach((p) => ref.read(p));
    return child;
  }
}

/*
使い方
ProviderScope(
  child: Instantiater(
    toBeInstantiated: [
      // アプリ起動時にインスタンス化される
      hogeProvider,
    ],
    child: MyPage(),
  ),
);
*/

runAppより前に初期化しておきたいインスタンスの注入方法

shared_preferencesなど、Futureでしかリソースを取得できないものの取り扱い方は大きく下の二通りがあります。

  1. FutureProviderでインスタンスを管理する
  2. main内でawaitしてインスタンスを取得、runApp時にアプリケーションに引数から渡す

1. のFutureProviderだとインスタンスがAsyncValueでラップされてしまうため、他のクラスのコンストラクタから注入したいケースでは不便になります。
ref.read(sharedPreferencesProvider)とという形で使用できるようにしつつ 2. の方法を実現するにはどうしたら良いでしょうか。

riverpodにはProviderのoverride(Providerに紐づくインスタンスをProviderScope外から指定する)を行う機能があります。
これを使ってrunAppより前に初期化したいインスタンスを注入することにしました。

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: ...
    );
  }
}

移行してみて

「このHoge型はProviderで参照できると思ったのに、参照できなかった!」というランタイムエラーに遭遇する心配がなくなり、体感でも開発中にランタイムエラーに遭遇する頻度が減りました。

また、注入するインスタンスの依存関係がWidgetのツリー構造に縛られなくなったため、設計の自由度も向上しました。
現在はサーバーから受信したデータの新しい取り扱い方を検討している最中です。

ちゃんと最新のライブラリを使えているという気分の良さもあります。
(開発環境が新しい、というのはエンジニアの士気にも採用にも関わってきますからね!)

アプリの根幹に関わるライブラリを交換するのは手間ですが、交換によるメリットは大きいです。


youbrideではFlutterをもっと活用することを計画しています。
その上で得られる理解や開発するものはなるべく発信していく所存です。

今後もDiverseはコミュニティに知見を還元してゆきます\\ ٩( 'ω' )و //