Zenn
🌲

RiverpodのkeepAliveを理解する

2025/01/28に公開
4
5

はじめに

本稿ではRiverpodのkeepAliveの使い方と、keepAliveを使う際の注意点について解説します。keepAliveはRiverpodの中でも利用にコツが必要であり、運用で誤解を招きやすい機能です。特徴を把握することで、より良くRiverpodを利用できるようになることを目指します。

keepAliveの濫用について

すべてのProviderkeepAliveを指定するのであれば、おそらく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のautoDisposeWidgetのライフサイクルに合わせて状態を管理するのに適した仕組みです。このため、筆者はautoDisposeこそがRiverpodの最も重要な機能であり、またautoDisposeを前提にRiverpodを利用することが重要だと考えています。[1]

ApplicationとWidget

Flutterでアプリケーションを開発する場合、開発者は2つのライフサイクルを意識することになります。1つはWidget、もう1つはApplicationです。厳密にいうと、Flutterの Everything is a Widget という言葉が示すように、ApplicationWidgetの一種です。しかし、ここでは議論の都合上ApplicationをWidgetとは別のものとして扱います。

Applicationのライフサイクルは、アプリケーションが起動してから終了するまでの間のライフサイクルです。
Method Channelを持つようなライブラリでは、Singletonが多用されます。FirebaseAuth.instanceSharedPreferences.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を表示する瞬間と閉じる瞬間を考慮する必要が生じます。具体的には、StateinitStateでViewModelの初期化や更新メソッドを呼び出し、disposeメソッドで処理の終了メソッドを呼びだすような実装が必要です。考慮が必要なのは、ViewModelそのものがWidgetの破棄に結びつかないため、Widgetを複数回表示したときに意図しない副作用を発生させないようにする点です。[2]この観点はアプリケーションが大きくなり、機能が複雑になればなるほど、管理が難しくなるでしょう。

対して、ViewModelをWidgetのライフサイクルに合わせて管理すると、この問題は生じません。Widgetの生成時にViewModelが生成され、Widgetの破棄時にViewModelも破棄されるため、意図しない副作用が発生しにくくなります。これはAndroidにおいてActivityFragmentに対応するViewModelを実装したことがある方であれば、馴染み深い話になるでしょう。

autoDisposeとWidgetのライフサイクル

Riverpodは、Applicationのライフサイクルで扱われる状態を、Providerとして管理できます。そして、そのProviderを参照するProviderNotifierを作ることで、接続のための状態を実現します。この時、ProviderにautoDisposeを付与することで、Widgetのライフサイクルに合わせて破棄されるようになります。

SingletonオブジェクトをautoDisposeProviderで配布しても、実装と動作上問題はありません。またWidgetから見たとき、Singletonオブジェクトを配布するProviderautoDisposeであっても、問題は生じません。言い換えると、Singletonオブジェクトを参照するWidgetが存在しないとき、不必要なProviderNotifierが存在しないことになります。設計の観点から見ても、autoDisposeはRiverpodの中でも最も重要な機能であると言えるでしょう。

なぜkeepAliveが重要なのか

keepAliveは、autoDisposeの利便性の上に成り立つ仕組みです。一見不要そうなkeepAliveですが、実は設計における無理なく例外パターンを実現するための重要な機能です。

keepAliveの概要

keepAliveは、autoDisposeが付与されたProviderNotifierApplicationのライフサイクルで管理するための仕組みです。

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が再利用されます。

この辺りの細かい話は、以前まとめたと言えるほどではないものの、コードを追える程度に整理した以前の記事を参照していただけると嬉しいです。

https://zenn.dev/koji_1009/articles/fa972b070eb2f4#riverpodとprovider

AutoDisposeProviderAutoDisposeProviderのみを参照できる、と言うルールがあります。このルールは、Providerの破棄を自動化するためのものです。このため、AutoDisposeProviderをコードに追加すると、Providerを削除する必要が生じます。

このルールの中で、Applicationのライフサイクルで状態を管理できるようにする、AutoDisposeProviderProviderのように扱う機能が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は、userProvidervalueを持っていることを前提に呼び出すProviderです。userProvierが通信中は、userSyncProviderはエラーをスローします。しかし、一度userProvierがUserのキャッシュを作った後であれば、userSyncProviderはエラーをスローせずにUserを返します。

仕様上、ある画面が表示されるときにはユーザー情報が取得されていることが保証されている場合、keepAliveによるキャッシュが有効です。注意する点としては、DeepLinkにより画面が直接開かれる、ブラウザ上で画面のリロードが発生する、などのケースがあります。コードから非同期処理を外すことができ、非常に書きやすくなるテクニックではありますが、十分に検討しながら利用する必要があります。

重い計算結果のキャッシュ

keepAliveを使うべきケースとして、重い計算結果のキャッシュがあります。

たとえばcryptowebcryptoを使って複合やハッシュ計算をするケースです。その計算結果をキャッシュしておくことで、同じ計算を繰り返す必要がなくなります。このようなケースでは、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を呼び出すタイミングを調整しています。この実装は、hashProviderref.readで呼び出されることを想定しています。詳細は次のIssueに認めたのですが、RiverpodのauthDisposeはFlutterのフレーム更新時に破棄のチェックがなされます。このためref.readで非同期処理を呼び出すと、keepAliveの指定がなされる前に破棄されてしまい、keepAliveを指定できないことがあります。

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

keepAliveは非同期処理で利用される[4]ため、時たまRiverpodの実装を把握して対応する必要が生じます。

APIレスポンスのキャッシュ

https://riverpod.dev/docs/essentials/auto_dispose#example-keeping-state-alive-for-a-specific-amount-of-time

公式ドキュメントに例があるパターンです。

記事一覧リストから、記事詳細に遷移するようなケースで、『記事詳細画面を開いてから一定時間は、記事詳細の情報をキャッシュする』ようなパターンが当てはまるでしょう。このようなケースでは、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によるアプリケーション設計の中心であるのか、その理由を考察しました。そしてkeepAliveautoDisposeを組み合わせることで、アプリケーションの様々な状態を管理できることを紹介しました。

筆者は、トップダウンの状態管理を実現するのがProvider、ボトムアップの状態管理を実現するのがRiverpodだと考えています。この特徴を実現するのが、autoDisposeが付与されたProviderです。ProviderからRiverpodに利用するパッケージを変更したが、しかし設計思想を変えていないケースでは、この特徴の差が難しさに見えるかもしれません。
確かにautoDisposeを付与しないProviderを利用すれば、Providerと同じようにトップダウンの設計を実現できます。が、これはRiverpodのメリットを大きく損なっているように感じます。コードを上から下に読むだけでなく、下から上に読み、より見通しの良い設計を目指してみるのはどうでしょうか?

脚注
  1. riverpod_generatorriverpod_lintを利用すると、lintでautoDisposeの指定漏れを検知できます。便利。 ↩︎

  2. 筆者は、とある環境でそんな不具合に悩んでいる人を見たことがあります。 ↩︎

  3. 繰り返しになりますがkeepAliveを使う場合には、お近くのテックリードなどにご相談の上でご利用ください。 ↩︎

  4. 同期処理であれば、都度計算してOKのはずです。 ↩︎

GitHubで編集を提案
5

Discussion

ござパイセンござパイセン

Future<User> user(Ref ref) async {}でAutoDisposeにしつつも、関数内で ref.keepAlive();で明示的にkeepAliveするなら、@Riverpod(keepAlive:true)と同じように思いました。

これはもちろん、意図があってref.keepAliveされていると思うのですが、もし差し支えなければコメントいただければと思います。

当方もautoDispose派です。WidgetTreeから消えたのに状態を保持する必要がないことが多いため。

keepAliveの事例としてあったのが、検索条件の保持です。一覧画面と検索条件設定画面が別れており、設定画面はpopで消えちゃうので、WidgetTreeから消えます。ですが、もう1回検索条件設定画面を開いた時に、ユーザーがセットした条件を復元する必要があり、これはkeepAliveで実装しました。

あと、閲覧履歴。これもkeepAliveで実装した。

koji-1009koji-1009

@riverpodを指定した場合はAutoDisposeProviderが生成されますが、@Riverpod(keepAlive: true)を指定した場合はProviderが生成されます。この違いがあるため、記事内ではref.keepAlive()を利用しています。


簡単に試しますと、providers.dartファイルを生成した時、

import 'package:riverpod/riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'providers.g.dart';


int counter(Ref ref) => 0;

(keepAlive: true)
int keepAliveCounter(Ref ref) => 0;

providers.g.dartは次のようになります。

// GENERATED CODE - DO NOT MODIFY BY HAND

part of 'providers.dart';

// **************************************************************************
// RiverpodGenerator
// **************************************************************************

String _$counterHash() => r'784ece48cb20fcfdec1553774ecfbd381d1e081f';

/// See also [counter].
(counter)
final counterProvider = AutoDisposeProvider<int>.internal(
  counter,
  name: r'counterProvider',
  debugGetCreateSourceHash:
      const bool.fromEnvironment('dart.vm.product') ? null : _$counterHash,
  dependencies: null,
  allTransitiveDependencies: null,
);

('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef CounterRef = AutoDisposeProviderRef<int>;
String _$keepAliveCounterHash() => r'319d0f8a3bf79c1d7f4bf14d65d1b58de62739a1';

/// See also [keepAliveCounter].
(keepAliveCounter)
final keepAliveCounterProvider = Provider<int>.internal(
  keepAliveCounter,
  name: r'keepAliveCounterProvider',
  debugGetCreateSourceHash: const bool.fromEnvironment('dart.vm.product')
      ? null
      : _$keepAliveCounterHash,
  dependencies: null,
  allTransitiveDependencies: null,
);

('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
typedef KeepAliveCounterRef = ProviderRef<int>;
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

利用パッケージバージョン

  • riverpod: 2.6.1
  • riverpod_annotation: 2.6.1
  • riverpod_generator: 2.6.4
  • build_runner: 2.4.14
koji-1009koji-1009

すいません。上記だと説明不足ですね。

意図があってref.keepAliveされている

これは、大きく分けて2つの意図があります。

  1. ref.keepAlive() で返却されるlinkをコントロールすることで、より細かな状態コントロールができる
    • 記事中のTimerによるキャッシュ時間のコントロールのことなどを指しています
  2. 全てのAutoDisposeProviderがAutoDisposeProviderに依存する関係にすることで、不要になったProviderが破棄されうる状況を作る
    • keepAliveしているため実際はある箇所から破棄されなくなりますが、その理由がProviderが挟まるからなのか、keepAliveしているAutoDisposeProviderがあるからなのかは、見通しが違う印象です
    • keepAliveを外した時に、依存しているクラスに違いが生まれないため、大枠の振る舞いが変わらないこともアーキテクチャの見通しやすさに影響すると感じています
ござパイセンござパイセン

なるほど・・・・!
お手間かけてすいません、意図が非常によくわかりました。
ありがとうございましたm(_ _)m

ログインするとコメントできます