RiverpodのkeepAliveを理解する
はじめに
本稿ではRiverpodのkeepAlive
の使い方と、keepAlive
を使う際の注意点について解説します。keepAlive
はRiverpodの中でも利用にコツが必要であり、運用で誤解を招きやすい機能です。特徴を把握することで、より良くRiverpodを利用できるようになることを目指します。
keepAlive
の濫用について
すべてのProvider
にkeepAlive
を指定するのであれば、おそらくRiverpodは適切な選択肢ではありません。SingletonやServiceLocatorのような設計を考えているので、static
なプロパティを利用したり、get_itを採用したりする方が良いでしょう。
Riverpodの公式や関連ドキュメントは非常に充実しています。本稿も日本語でドキュメントを探す人に向けた、ひとつの解説記事です。ぜひ、納得のいくまでドキュメントを確認してみてください。もしもドキュメントでは不十分な場合には、チームのテックリードや、Flutter開発者コミュニティで質問してみることをお勧めします。
autoDispose
が重要なのか
なぜFlutterはAndroidやiOS、Webアプリケーションを開発するフレームワークです。このため、その仕組みはGUIアプリケーションを作ることを前提としています。記事を執筆している2025年時点では、AndroidやiOSにおいて画面を持たないアプリケーションをリリースできません。よって、Flutterを利用してアプリケーションを利用する場合、必ずディスプレイに表示されるViewを持つことになります。
このため筆者は、Flutterアプリケーションが必要とする状態は、すべてWidgetに紐づいていると考えるのが自然だと感じています。つまりFlutterアプリケーションを作る上で主として扱うべきは、Widgetの表示に影響する状態である、という立場です。この立場から見ると、アプリケーションの状態をWidgetのライフサイクルに合わせて管理することが自然である、という結論に至ります。
一方で、Widgetのライフサイクルに紐づかない状態は、Flutterのフレームワーク内を含めて存在します。ただ、それらはアプリケーションを構築するために必要であるものの、それらの管理をアプリケーションの状態管理の議論の中心におく必要性は低いように思われます。
RiverpodのautoDispose
はWidgetのライフサイクルに合わせて状態を管理するのに適した仕組みです。このため、筆者はautoDispose
こそがRiverpodの最も重要な機能であり、またautoDispose
を前提にRiverpodを利用することが重要だと考えています。[1]
ApplicationとWidget
Flutterでアプリケーションを開発する場合、開発者は2つのライフサイクルを意識することになります。1つはWidget、もう1つはApplicationです。厳密にいうと、Flutterの Everything is a Widget という言葉が示すように、Application
もWidget
の一種です。しかし、ここでは議論の都合上ApplicationをWidgetとは別のものとして扱います。
Applicationのライフサイクルは、アプリケーションが起動してから終了するまでの間のライフサイクルです。
Method Channelを持つようなライブラリでは、Singletonが多用されます。FirebaseAuth.instance
やSharedPreferences.getInstance()
などがその例です。これらのライブラリは、アプリケーション全体で共有されるべき状態を管理し、ネイティブ側のAPIと必要に応じて通信します。このため、必ずしもApplicationのライフサイクルで状態を管理してはいけない、というわけではありません。
ここで強調してきたいのは、Applicationのライフサイクルで状態を管理した方が良いケースとよくないケースの両方がある、という点です。Widgetのライフサイクルで状態を管理した方が良いケースとよくないケースの両方がある、とも言えます。
たとえば、FirebaseAuth.instance
はアプリケーション全体で共有されるべき状態です。一方で、TextEditingController
はWidgetのライフサイクルに合わせて管理されるべき状態です。この2つが質として異なるという見解は、多くの開発者が共有できるものでしょう。つまりFirebaseAuth.instance
で管理されるのは、Firebaseサービスに対してアプリケーションとアプリケーションを利用しているユーザーがどのような認証状態にあるかです。TextEditingController
で管理されるのは、ユーザーが入力したテキストです。
入力用のWidgetが破棄されれば、TextEditingController
も破棄されるべきであるように見えます。一方で、入力用のWidgetが破棄されたからといって、FirebaseAuth.instance
が破棄されるとは考えにくいはずです。
2つのライフサイクルの接続
多くの場合、Applicationのライフサイクルレベルの状態と、Widgetのライフサイクルレベルの状態を接続する必要があります。アプリケーションにおける状態管理の問題は、おおよそこの接続の問題です。
筆者の理解では、2つの状態を繋ぐための状態、つまり接続のための状態が発生します。
接続のための状態は、ApplicationとWidgetのどちらに紐づけて管理するべきでしょうか。筆者の意見としては、Widgetのライフサイクルに合わせて管理することが適切です。Widgetのライフサイクルに合わせて管理することで、状態が不要になったとき自動的に破棄される仕組みを構築でき、接続のための状態をシンプルに実装できると考えます。
接続のための状態をApplicationのライフサイクルで管理した場合、接続のための状態の更新に苦労することとなります。たとえば、先述のMVVMアーキテクチャのViewModelをSingletonで管理するとしましょう。
ViewModelがSingletonで管理すると、対応するWidgetを表示する瞬間と閉じる瞬間を考慮する必要が生じます。具体的には、State
のinitState
でViewModelの初期化や更新メソッドを呼び出し、dispose
メソッドで処理の終了メソッドを呼びだすような実装が必要です。考慮が必要なのは、ViewModelそのものがWidgetの破棄に結びつかないため、Widgetを複数回表示したときに意図しない副作用を発生させないようにする点です。[2]この観点はアプリケーションが大きくなり、機能が複雑になればなるほど、管理が難しくなるでしょう。
対して、ViewModelをWidgetのライフサイクルに合わせて管理すると、この問題は生じません。Widgetの生成時にViewModelが生成され、Widgetの破棄時にViewModelも破棄されるため、意図しない副作用が発生しにくくなります。これはAndroidにおいてActivity
やFragment
に対応するViewModelを実装したことがある方であれば、馴染み深い話になるでしょう。
autoDispose
とWidgetのライフサイクル
Riverpodは、Applicationのライフサイクルで扱われる状態を、Provider
として管理できます。そして、そのProvider
を参照するProvider
やNotifier
を作ることで、接続のための状態を実現します。この時、ProviderにautoDispose
を付与することで、Widgetのライフサイクルに合わせて破棄されるようになります。
SingletonオブジェクトをautoDispose
なProvider
で配布しても、実装と動作上問題はありません。またWidgetから見たとき、Singletonオブジェクトを配布するProvider
がautoDispose
であっても、問題は生じません。言い換えると、Singletonオブジェクトを参照するWidgetが存在しないとき、不必要なProvider
やNotifier
が存在しないことになります。設計の観点から見ても、autoDispose
はRiverpodの中でも最も重要な機能であると言えるでしょう。
keepAlive
が重要なのか
なぜkeepAlive
は、autoDispose
の利便性の上に成り立つ仕組みです。一見不要そうなkeepAlive
ですが、実は設計における無理なく例外パターンを実現するための重要な機能です。
keepAlive
の概要
keepAlive
は、autoDispose
が付与されたProvider
やNotifier
をApplicationのライフサイクルで管理するための仕組みです。
Riverpodには、autoDispose
をつけないProviderが存在します。たとえば、次のような実装により生成されるProviderには、autoDispose
がついていません。
final userProvider = Provider((ref) {
return User();
});
(keepAlive: true)
User user((Ref ref) {
return User();
});
これらのProviderは、autoDispose
をつけた場合と異なり、Applicationのライフサイクルに合わせて管理されます。このため、このProvider
を参照するWidgetが破棄されても、Provider
は破棄されません。そして、次にProvider
を参照するWidgetが生成されたとき、破棄されなかったProvider
が再利用されます。
この辺りの細かい話は、以前まとめたと言えるほどではないものの、コードを追える程度に整理した以前の記事を参照していただけると嬉しいです。
AutoDisposeProvider
はAutoDisposeProvider
のみを参照できる、と言うルールがあります。このルールは、Provider
の破棄を自動化するためのものです。このため、AutoDisposeProvider
をコードに追加すると、Provider
を削除する必要が生じます。
このルールの中で、Applicationのライフサイクルで状態を管理できるようにする、AutoDisposeProvider
をProvider
のように扱う機能がkeepAlive
です。
keepAlive
の使いどころ
keepAlive
の使い所は、使ったことがあれば浮かぶものの、なければ浮かびにくいタイ喰いの仕組みです。keepAlive
を使う必要のないケースは多くあります。
以下に挙げるのは、筆者が考えた典型的な利用ケースです。
このケースが全てを網羅しているわけではなく、また、それぞれのケースでkeepAlive
のちょっとした使い方のテクニックを紹介します。筆者が『このケースだと、このテクニックが必要になるのでは?』と考えたものであるため、そこそこツッコミどころがある点はご容赦ください。[3]
一部の非同期処理を同期的に処理
アプリケーションの初期化処理でログイン状態を確認し、ユーザー情報をネットワーク経由で取得するケースがあったとします。これらの処理は非同期処理で行われるため、ユーザー情報を参照するWidgetは非同期処理に対応する必要が生じます。
しかし、アプリケーションの画面遷移パターン的に、ログイン状態の確認後には『ユーザー情報を必ず保持している』ことが保証されます。このような時にkeepAlive
を使うことで、ユーザー情報のキャッシュや同期処理化を行うことができます。
Future<User> user(Ref ref) async {
final isLogin = await ref.watch(isLoginProvider);
if (!isLogin) {
trow Exception('Not login !');
}
final user = await ref.watch(authRepositoryProvider).fetchUser();
ref.keepAlive(); // cache user
return auth;
}
User userSync(Ref ref) {
return ref.watch(userProvider).requireValue;
}
userProvider
は非同期処理のProviderです。そして、keepAlive
を呼び出されたProviderになります。呼び出しタイミングをユーザー情報を取得できた後にすることで、ユーザー情報を取得できた後であれば、キャッシュされた(=Applicationのライフサイクルで管理される)ユーザー情報を参照できます。
そしてuserSyncProvider
は、userProvider
がvalueを持っていることを前提に呼び出すProviderです。userProvier
が通信中は、userSyncProvider
はエラーをスローします。しかし、一度userProvier
がUserのキャッシュを作った後であれば、userSyncProvider
はエラーをスローせずにUserを返します。
仕様上、ある画面が表示されるときにはユーザー情報が取得されていることが保証されている場合、keepAlive
によるキャッシュが有効です。注意する点としては、DeepLinkにより画面が直接開かれる、ブラウザ上で画面のリロードが発生する、などのケースがあります。コードから非同期処理を外すことができ、非常に書きやすくなるテクニックではありますが、十分に検討しながら利用する必要があります。
重い計算結果のキャッシュ
keepAlive
を使うべきケースとして、重い計算結果のキャッシュがあります。
たとえばcryptoやwebcryptoを使って複合やハッシュ計算をするケースです。その計算結果をキャッシュしておくことで、同じ計算を繰り返す必要がなくなります。このようなケースでは、keepAlive
を使うことで、計算結果をキャッシュするProviderを再利用できます。
import 'package:convert/convert.dart' show hex;
import 'package:webcrypto/webcrypto.dart';
Future<String> hash(Ref ref, String text) async {
final link = ref.keepAlive();
final Uint8List data;
try {
data = await Hash.sha1.digestBytes(utf8.encode(text));
} on Exception catch (e) {
link.close();
rethrow;
}
return hex.encode(data);
}
このコードは、text
をSHA-1でハッシュ化するProviderです。このProviderはkeepAlive
を呼び出しており、計算結果をキャッシュしています。このため、同じ計算を繰り返す必要がなくなります。
ここではkeepAlive
を呼び出すタイミングを調整しています。この実装は、hashProvider
がref.read
で呼び出されることを想定しています。詳細は次のIssueに認めたのですが、RiverpodのauthDispose
はFlutterのフレーム更新時に破棄のチェックがなされます。このためref.read
で非同期処理を呼び出すと、keepAlive
の指定がなされる前に破棄されてしまい、keepAlive
を指定できないことがあります。
keepAlive
は非同期処理で利用される[4]ため、時たまRiverpodの実装を把握して対応する必要が生じます。
APIレスポンスのキャッシュ
公式ドキュメントに例があるパターンです。
記事一覧リストから、記事詳細に遷移するようなケースで、『記事詳細画面を開いてから一定時間は、記事詳細の情報をキャッシュする』ようなパターンが当てはまるでしょう。このようなケースでは、keepAlive
を使うことで、一定時間キャッシュされた情報を再利用できます。すると、『一度開いたページを再度開いた時に、APIリクエストを待たずにページを表示できる』といった効果が得られます。
そのほかAPIリクエストの数を削減したい場合にも、keepAlive
が利用できます。クライアント側で設定するTTLのようなイメージが近いかもしれません。Android向けのライブラリであれば、Storeのメモリーキャッシュが近いでしょう。
extension CacheForExtension on Ref {
/// Keeps the provider alive for [duration].
void cacheFor(Duration duration) {
// Immediately prevent the state from getting destroyed.
final link = keepAlive();
// After duration has elapsed, we re-enable automatic disposal.
final timer = Timer(duration, link.close);
// Optional: when the provider is recomputed (such as with ref.watch),
// we cancel the pending timer.
onDispose(timer.cancel);
}
}
このパターンでは、Timer
を使うことで、一定時間が経過したらkeepAlive
を解除する実装がされています。このようにすることで、keepAlive
を使ってキャッシュを行う際に、キャッシュの寿命を設定できます。記事のように『もしかすると更新されるかもしれない』情報をキャッシュする際には、このような実装が有用です。
keepAlive
の困りどころ
keepAlive
を指定した場合、その状態はアプリケーションが起動している最中、残り続けることになります。大抵の場合、状態として管理されるオブジェクトはサイズが小さく、アプリケーションの動作に大きな影響を与えるものではありません。これはAutomaticKeepAliveClientMixinを多用したときと同様の問題です。大抵の場合は問題あありませんが、時たまメモリー不足を引き起こし、アプリケーションをクラッシュさせることがあります。
筆者がなぜautoDispose
を前提にしているか、という話でもあるのですが、keepAlive
を多用するケースに対して、筆者が感じているリスクは2つあります。
- 万が一メモリーが逼迫した際の挙動が想定できず、不具合が発生した場合に調査と対応が難しくなる
- ある瞬間を見たとき、アプリケーションが保持する状態の総数が多くなり、アプリケーション全体の振る舞いが予測しにくくなる
これらのリスクは、keepAlive
を使う際には常に意識しておくべきです。keepAlive
を使う際には、なせ状態をキャッシュするのか、どのような状態をキャッシュするか、どのようなタイミングでキャッシュが破棄されうるのかを考慮することが重要です。
おわりに
autoDispose
がなぜRiverpodによるアプリケーション設計の中心であるのか、その理由を考察しました。そしてkeepAlive
とautoDispose
を組み合わせることで、アプリケーションの様々な状態を管理できることを紹介しました。
筆者は、トップダウンの状態管理を実現するのがProvider、ボトムアップの状態管理を実現するのがRiverpodだと考えています。この特徴を実現するのが、autoDispose
が付与されたProvider
です。ProviderからRiverpodに利用するパッケージを変更したが、しかし設計思想を変えていないケースでは、この特徴の差が難しさに見えるかもしれません。
確かにautoDispose
を付与しないProvider
を利用すれば、Providerと同じようにトップダウンの設計を実現できます。が、これはRiverpodのメリットを大きく損なっているように感じます。コードを上から下に読むだけでなく、下から上に読み、より見通しの良い設計を目指してみるのはどうでしょうか?
-
riverpod_generatorとriverpod_lintを利用すると、lintで
autoDispose
の指定漏れを検知できます。便利。 ↩︎ -
筆者は、とある環境でそんな不具合に悩んでいる人を見たことがあります。 ↩︎
-
繰り返しになりますが
keepAlive
を使う場合には、お近くのテックリードなどにご相談の上でご利用ください。 ↩︎ -
同期処理であれば、都度計算してOKのはずです。 ↩︎
Discussion
Future<User> user(Ref ref) async {}
でAutoDisposeにしつつも、関数内でref.keepAlive();
で明示的にkeepAliveするなら、@Riverpod(keepAlive:true)
と同じように思いました。これはもちろん、意図があって
ref.keepAlive
されていると思うのですが、もし差し支えなければコメントいただければと思います。当方もautoDispose派です。WidgetTreeから消えたのに状態を保持する必要がないことが多いため。
keepAliveの事例としてあったのが、検索条件の保持です。一覧画面と検索条件設定画面が別れており、設定画面はpopで消えちゃうので、WidgetTreeから消えます。ですが、もう1回検索条件設定画面を開いた時に、ユーザーがセットした条件を復元する必要があり、これはkeepAliveで実装しました。
あと、閲覧履歴。これもkeepAliveで実装した。
@riverpod
を指定した場合はAutoDisposeProviderが生成されますが、@Riverpod(keepAlive: true)
を指定した場合はProviderが生成されます。この違いがあるため、記事内ではref.keepAlive()
を利用しています。簡単に試しますと、
providers.dart
ファイルを生成した時、providers.g.dart
は次のようになります。利用パッケージバージョン
すいません。上記だと説明不足ですね。
これは、大きく分けて2つの意図があります。
ref.keepAlive()
で返却されるlink
をコントロールすることで、より細かな状態コントロールができるTimer
によるキャッシュ時間のコントロールのことなどを指していますkeepAlive
しているため実際はある箇所から破棄されなくなりますが、その理由がProviderが挟まるからなのか、keepAlive
しているAutoDisposeProviderがあるからなのかは、見通しが違う印象ですkeepAlive
を外した時に、依存しているクラスに違いが生まれないため、大枠の振る舞いが変わらないこともアーキテクチャの見通しやすさに影響すると感じていますなるほど・・・・!
お手間かけてすいません、意図が非常によくわかりました。
ありがとうございましたm(_ _)m