📵

【Riverpod】keepAlive なProvider でも破棄されることがある話

2025/02/03に公開

3行で

  • @Riverpod(keepAlive: true) を「Provider を 絶対に破棄しない」という理解で使っていた
  • 実際には、Provider を再計算する際に keepAlive を付与していても破棄状態となるケースがあるとわかった
  • 絶対に破棄したくない Provider は 早期初期化するようにします

環境情報

Riverpod: 2.6.1

[✓] Flutter (Channel stable, 3.24.3, on macOS 14.6.1 23G93 darwin-arm64, locale ja-JP)
    • Framework revision 2663184aa7 (5 months ago), 2024-09-11 16:27:48 -0500
    • Engine revision 36335019a8
    • Dart version 3.5.3
    • DevTools version 2.37.3

やりたかったこと

permission_handler と Rivderpod の NotifierProvider を組み合わせて通知権限の許可状態を管理する Provider を作っていました。

NotifierProvider の listenSelf で変更を検知して、最新の許可状態を Firebase に反映させたかった次第です。

通知の許可状態は次の2つのケースで変化します。

  • アプリ内で許可を求めた時
  • OSの設定を開いて、アプリごとの権限設定を変更した時

OSレイヤーの変更を検知するのは難しそうだったので、次のロジックで擬似的な最新の値の管理を検討しました。

  • Provider が初期化された時、現在の設定を取得する
  • アプリ内で許可を求めた時、現在設定を再取得する
  • アプリの foreground状態 が変化した時、現在の設定を再取得する

OSのレイヤーの設定を変更するには設定アプリを開く必要があるので、アプリの foreground / background 状態のの切り替わりを検知すればいけんじゃね!という目論見です。

が、実際に実装してみると、作成した切り替わり検知機構は何も検知してくれませんでした。

だめだった実装

うまく動かなかったコードは下記です。

(keepAlive: true, dependencies: [appIsResumed])
class PermissionNotifier extends _$PermissionNotifier {
  
  Future<PermissionState> build(Permission permission) async {
    ref.watch(appIsResumedProvider);

    listenSelf((prev, cur) async {
      .. 変化した時の処理を個々に書く
    });

    return PermissionState(...);
  }
}

riverpod generator を使って keepAlive な NotifierProvider を生成しています。また、 appIsResumed という別の Provider に依存しています。

Widget から依存させる予定はなかっっため、NotifierProvider の起動には、watch ではなく read を使いました。こちらのコードは省略します。

watch することで Provider を保持し続けるやり方を知ってはいたものの、「keepAlive を設定しているのだから不要だろう」と高をくくって read で開発を進めました。

結論から言うと、この NotifierProvider は appIsResumed が変化したときに破棄されます。なぜなら、appIsResumed が変化した時点でリスナーが存在せず、リスナーの存在しない NotifierProvider は再計算の対象から外れるからです。

なぜ keepAlive な Provider が破棄されるのか

依存先の Provider が変化すると、自身の再計算が走ります。再計算処理は、大きく分けると

  1. 現状態の破棄
  2. 新しい状態の再計算

という2つの処理から構成されていて、「新しい状態の計算」の処理では、リスナーが0件な Provider に対しては再計算をスキップしているんですね。

https://github.com/rrousselGit/riverpod/blob/3d3e57f070d16addf9f35af4bc57d7ae65da90a8/packages/riverpod/lib/src/framework/scheduler.dart#L100

一方で、前処理としての現状態の破棄に「再計算が予定されているかどうか」を加味するロジックはありませんref.keepAlive() のパターンにおいても、 keepAliveLink を初期化してから破棄に進みます。

リスナーが1人もいない Provider はリスナーが0件だと再計算が省略される一方で、事前処理としての破棄は実行されるため、結果として、「keepAlive を付与してるのに破棄される」という状況が生まれます。

振り返り

Riverpod の Issue を漁ったところ3ヶ月前に Issue が作成されていました。

https://github.com/rrousselGit/riverpod/issues/3773

最初は「invalidate しても Provider が更新されない」という不具合報告でしたが、コメントが進む中で議論の中で「invalidate しても再計算されないことがあることをドキュメントに記載する」というものにかわっています。実際、2025/02/02 現在では invalidate のドキュメントに「If the provider is not listened to, the provider will be fully destroyed.」の説明があります

ところで、keepAlive のドキュメントには 「Using keepAlive: true will prevent the state from getting destroyed when all listeners are removed.」という説明もあります

一見すると今回の状況と矛盾しているように見えますが、今回引っかかったのはあくまで「NotifierProvider 再計算の前処理としての破棄」であり、「リスナーが0人になったの破棄」とは別ものと考えることができるため、矛盾はしてないと思います(ぐぬぬ顔)。

どちらかといえば、僕は上記の invalidate に関する説明や、早期初期化の説明 を読んでから実装を開始べきだった、みたいな振り返りが良さそうです。

とはいえ、上記を正としてしまうと keepAlive の使い所が難しくなってしまうのでは?とも思います。次のような具合で、

参照が0件になった時の破棄 再計算の前処理としての破棄
早期初期化 防げる 防げる
keepAlive 防げる 防げない

破棄の種類を区別して実装しないとミスに繋がってしまうような状況なんですよね。「何があろうとも俺はメモリから解放しないんだ!」という強い意志のもと設計する Provider は keepAlive ではなく早期初期化で保持したほうが考えることが少なく済みます。そうなると、keepAlive を使うべきタイミングはいつなんだろう...となりました(オチなし)。

おわりに

keepAlive を付与した Provider であっても、依存先の Provider が変化したときにリスナーが0件だと破棄状態となります。ref.keepAlive() も同様です。

そう言えば、Riverpod の難しさに関する記事を先週公開しました。

https://zenn.dev/niwatly/articles/bb8a6d3460ed56?redirected=1

超々個人的に僕は「Providerを絶対に破棄したくない」だったり「Widgetとは独立して裏側で動き続ける処理を Riverpod で管理したい」という使い方を「Riverpodの思想とは少しズレたユースケース」だと思ってて、このズレを押し通す、つまりある種のハック的なやり方で Riverpod を使いつ付けることを選択した人は「Riverpod は難しい」と感じそうだなぁと思いました。特に根拠があるわけでもない超々個人的な肌感です。

「Providerを絶対に破棄したくない」に関してはハックなどではなく、公式から案内されている早期初期化で解決できますので一度見てみてください。

toridori tech blog

Discussion