【Flutter】ref.watch してる state がなぜか破棄されたので調べてみる
Riverpod を使っていて、 ref.watch
していたはずの state が気づいたら破棄されていた という事象を ashdik さんから伺いまして、その事象と理由が Riverpod を理解する上で重要そうと思ったためこの記事にまとめていきたいと思います。
実際の仕事の中で問題を発見してこう解決した、という話までを ashdik さんに教えていただきつつ、そこから深掘りして内部的にどうなっているから破棄されてしまうのか、一般化して Riverpod 利用時に気をつけるべきところはあるか、という点を書いています。
Riverpod の autoDispose を安全に活用するのに役立てばということで、以下本文です。
発生した事象
まずは発生した事象を最小限で再現させたコードがこちらです。
/// カウンターの数値を管理する provider
class Counter extends _$Counter {
int build() => 0;
void increment() => state += 1;
}
/// [counterProvider] を監視してデータを取得する provider
class Articles extends _$Articles {
Future<List<Article>> build() async {
await Future.delayed(const Duration(milliseconds: 500), () {
// データ取得処理のつもり
});
return List.generate(
ref.watch(counterProvider),
(index) => 'article no.$index',
);
}
}
典型的なカウンターを表す counterProvider
が上にあり、それを ref.watch
する articlesProvider
がある、という構成です。
articlesProvider
は非同期な処理(データベースから記事一覧を取得する処理)を行なった後に ref.watch(counterProvider)
で取得した数字を使って state を生成します。
最後に articlesProvider
が生成した state を使って UI を構築する ArticlesPage
を定義してサンプルコード完成です。
class ArticlesPage extends ConsumerWidget {
const ArticlesPage({super.key});
Widget build(BuildContext context, WidgetRef ref) {
final articles = ref.watch(articlesProvider);
return Scaffold(
body: // 一覧画面を構築する Widget
floatingActionButton: FloatingActionButton(
onPressed: () {
ref.read(counterProvider.notifier).increment();
},
),
);
}
}
なお FloatingActionButton
はタップするとカウントをインクリメントする ref.read(conterProvider.notifier).increment()
を呼び出すようになっています。
つまり、想定としては
-
FloatingActionButton
をタップすると -
counterProvider
がインクリメントされ -
counterProvider
に依存しているarticlesProvider
がリビルドされ -
build()
の最後で生成しているList<Article>
の要素数も増えるので - 最終的に UI 上に表示される行が増える
という挙動になるはずです。
しかし、実際は FloatingActionButton
をタップしても画面の内容は変わりません 。これがなぜなのか、というのがこの記事の本題です。
先に結論を書くと、 articlesProvider
が途中で await している間に counterProvider
が autoDispose によって破棄されてしまっているから ということになるのですが、これだけではなぜそのような挙動になるのか、どのように修正すれば良いのかが見えてきません。
この手の問題は何となく問題を回避する方法だけ知ってもあまり意味がありませんので、どうせならじっくりと Riverpod の仕組みを調査して理解を深める材料にしてみましょう。
依存関係の確認
今回のコードでは、articlesProvider
は counterProvider
を ref.watch
によって監視しています。
autoDispose は簡単に言えば provider が watch されなくなったら自動的に破棄される仕組み ということは認識の通りかと思います。つまり、今回も articleProvider
が生存している限りは counterProvider
も生存し続けるように思えます。
さらに articlesProvider
は ArticlesPage
によって ref.watch
されているため、このページが表示されている限りは articlesProvider
も破棄されないはずです。
しかし、実際の挙動として counterProvider
はボタンがタップされるごとに破棄されています。このことから、厳密には articlesProvider
が破棄されたかどうかに関わらず counterProvider
は破棄される ようです。言い換えると、「依存元が破棄されたら破棄される」という説明は autoDispose
の説明として正しくない ことが見えてきます。
依存関係はリビルドごとにリセットされる
ここで、provider がリビルドされる際の Riverpod の内部のコードを追ってみましょう。実際にブレークポイントを張って StackTrace を遡ったりしながらコードを読み進めると、 element.dart
に以下のような処理が見つかります。[1]
void _performBuild() {
// 既存の依存関係を退避
_previousDependencies = _dependencies;
_dependencies = HashMap();
final previousStateResult = _state;
// build() を呼び出し。ただし await はしない。
buildState();
if (!identical(_state, previousStateResult)) {
_notifyListeners(_state!, previousStateResult);
}
// 既存の依存先に対して「自分が依存している」ことを表す情報を削除
for (final sub in _previousDependencies!.entries) {
sub.key
.._providerDependents.remove(this)
.._onRemoveListener();
}
_previousDependencies = null;
}
確認したいのは、下から 6 行目で provider のリビルドごとに 依存先に対して「自分はあなたに依存しているぞ」という情報を削除している という部分です。
この部分を言い換えると、build()
メソッド内で別の provider を ref.watch
したとしても、build()
が呼ばれるごとに一度依存関係はリセットされてしまう ということです。
ただし通常はこちらが特に何も気にせずとも監視は続いているような挙動になります。これは、古い依存関係がリセットされる一方で build()
メソッド内で改めて ref.watch
が実行され依存関係が構築し直されるためです。
非同期処理を伴うリビルド
ただし、今回は少し事情が異なります。
Articles
クラスの build()
メソッドを見返してみると
class Articles extends _$Articles {
Future<List<Article>> build() async {
await Future.delayed(const Duration(milliseconds: 500), () {
// データ取得処理のつもり
});
return List.generate(
ref.watch(counterProvider),
(index) => 'article no.$index',
);
}
}
コードからわかる通り ref.watch
の実行前に await
を伴う非同期のデータ取得処理が挟まります。
先ほどのコードの buildState()
の呼び出しをみるとわかる通り、 provider 内部ではこの build()
の呼び出し自体は await
していません。そのため、Flutter の仕組み上 await
のついたデータ取得処理よりも先に buildState()
以降のコードが優先的に処理される 流れになっています。
つまり、先述の通りに依存関係がリセットされ、さらにその先で同期的に処理される内容がすべて完了してからようやく await
のついた articlesProvider
の build()
処理が再開されるような順序になります。
ここでポイントになるのが 「同期的に処理される内容がすべて完了してから」 という部分です。
Riverpod は Widget のリビルド時、以下のように どこからも依存されていない provider がないかをチェック することによって破棄すべき provider を見つけて破棄しています。
void mayNeedDispose() {
final links = _keepAliveLinks;
if (!maintainState && !hasListeners && (links == null || links.isEmpty)) {
_container._scheduler.scheduleProviderDispose(this);
}
}
そしてこのチェックはリビルド時に同期的に処理されるため、
-
await
がついた先ほどのデータ取得処理よりも前に判定が行われ -
ref.watch
しているproivider
は無いと判断され -
counterProvider
の state が破棄される
という挙動になっているわけです。
解決方法
端的に言うと、今回の原因は await
がついた処理は後回しにされる ために ref.watch が間に合わず「counterProvider
がもう誰からも依存されてない」と判定されてしまった ことです。
つまり、ref.watch
を伴う処理を await
よりも先に出してあげれば良いでしょう。実際に冒頭の ashdik さんのプロジェクトでもこの方法で解決しています。
class Articles extends _$Articles {
Future<List<Article>> build() async {
// 先に ref.watch してカウンターの数値を格納
final count = ref.watch(counterProvider);
await Future.delayed(const Duration(milliseconds: 500), () {
// データ取得処理のつもり
});
return List.generate(
count,
(index) => 'article no.$index',
);
}
}
こうしておけば、await
を伴う処理の前に ref.watch
が実行され、counterProvider
が articlesProvider
に依存されている状態を維持 できます。依存元が存在すれば、counterProvider
が自動的に破棄されることもありません。
他にも @Riverpod(keepAlive: true)
をつけて autoDispose を無効にするという手もあるにはありますが、counterProvider
のライフサイクル自体が大きく変わってしまうため、それで良いかどうかは状況次第で検討が必要です。
条件分岐による ref.watch
ここまで説明したように、provider の依存関係はリビルドごとにリセットされます。
つまり、build()
メソッド内で 条件分岐によって ref.watch
する provider を変更する ようなコードを書いている場合も要注意です。
// condition の値によって状態の取得先を切り替える
final count = condition
? ref.watch(someCounterProvider)
: ref.watch(anotherCounterProvider);
この場合、条件分岐の都合で選ばれなかった方の provider はリビルド終了時に破棄されることになります。
Remi さんによると、「一度監視を開始したら監視元が Widget ツリーから外れるまで監視が止まらない InheritedWidget
とは違い、Riverpod ではリビルドごとに監視を継続するべき state を毎回判断し、不要な監視はすぐに停止する」という方針のようです。
condition
がコロコロ変わっても両方の provider の状態を維持したい場合は
final someCount = ref.watch(someCounterProvider);
final anotherCount = ref.watch(anotherCounterProvider);
final count = condition ? someCount : anotherCount;
というように、 ref.watch
が条件分岐にかかわらず呼び出される コードにしてあげる必要があります。
まとめ
まとめると、Riverpod の autoDispose は「依存元が破棄されたら破棄される」仕組みではありません。正確には 「依存元が監視をやめたら破棄される」仕組み と言えます。そして、「監視をやめた」という判断は条件分岐や非同期処理によって「監視元の破棄」とは関係のないタイミングで起こり得ます。
先ほど紹介した Observable<Flutter> の動画で Remi さんも話していましたが、Riverpod は魔法のようなことをしているわけではありません。 あたりまえですが、 Riverpod パッケージも Dart コードで実装された通りの処理をしているだけです。
われわれアプリ開発者がコード上で ref.watch
を書いたからと言ってそれを勝手に検知してくれるようなことはなく、なんらかの都合で ref.watch
ステップにたどりつかない場合は「監視している」ことを Riverpod は把握できません。
また、不要な provider をなるべく早いタイミングで確実に破棄するために、Riverpod では リビルドごとに provider の要不要を判断して破棄 しています。
ここまでをまとめると、条件分岐や非同期処理の都合で ref.watch
のステップに到達しない場合に状態が意図せず破棄される事故は十分に発生しうるといえるでしょう。
Riverpod は良くも悪くも Flutter 標準の挙動や仕組みにとらわれない方針で作られているパッケージです。そのため、「Flutter 標準の場合はこうだから」で考えると予期せぬ挙動が発生するリスクがあります。
公式ドキュメントを読んだり必要に応じてソースコードを確認しつつ正確な挙動が予想できるようになると、より安全にプロダクトに取り入れられると思います。
-
assert 処理は割愛しています。また、コメントを追記しています。 ↩︎
Discussion