🫧

【Flutter】ref.watch してる state がなぜか破棄されたので調べてみる

2023/10/23に公開

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() を呼び出すようになっています。

つまり、想定としては

  1. FloatingActionButton をタップすると
  2. counterProvider がインクリメントされ
  3. counterProvider に依存している articlesProvider がリビルドされ
  4. build() の最後で生成している List<Article> の要素数も増えるので
  5. 最終的に UI 上に表示される行が増える

という挙動になるはずです。

しかし、実際は FloatingActionButton をタップしても画面の内容は変わりません 。これがなぜなのか、というのがこの記事の本題です。


先に結論を書くと、 articlesProvider が途中で await している間に counterProvider が autoDispose によって破棄されてしまっているから ということになるのですが、これだけではなぜそのような挙動になるのか、どのように修正すれば良いのかが見えてきません。

この手の問題は何となく問題を回避する方法だけ知ってもあまり意味がありませんので、どうせならじっくりと Riverpod の仕組みを調査して理解を深める材料にしてみましょう。

依存関係の確認

今回のコードでは、articlesProvidercounterProviderref.watch によって監視しています。

Providerの依存関係

autoDispose は簡単に言えば provider が watch されなくなったら自動的に破棄される仕組み ということは認識の通りかと思います。つまり、今回も articleProvider が生存している限りは counterProvider も生存し続けるように思えます。

さらに articlesProviderArticlesPage によって ref.watch されているため、このページが表示されている限りは articlesProvider も破棄されないはずです。

WidgetとProviderの依存関係

しかし、実際の挙動として counterProvider はボタンがタップされるごとに破棄されています。このことから、厳密には articlesProvider が破棄されたかどうかに関わらず counterProvider は破棄される ようです。言い換えると、「依存元が破棄されたら破棄される」という説明は autoDispose の説明として正しくない ことが見えてきます。

依存関係はリビルドごとにリセットされる

ここで、provider がリビルドされる際の Riverpod の内部のコードを追ってみましょう。実際にブレークポイントを張って StackTrace を遡ったりしながらコードを読み進めると、 element.dart に以下のような処理が見つかります。[1]

element.dart
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 のついた articlesProviderbuild() 処理が再開されるような順序になります。

ここでポイントになるのが 「同期的に処理される内容がすべて完了してから」 という部分です。

Riverpod は Widget のリビルド時、以下のように どこからも依存されていない provider がないかをチェック することによって破棄すべき provider を見つけて破棄しています。

element.dart

void mayNeedDispose() {
  final links = _keepAliveLinks;

  if (!maintainState && !hasListeners && (links == null || links.isEmpty)) {
    _container._scheduler.scheduleProviderDispose(this);
  }
}

そしてこのチェックはリビルド時に同期的に処理されるため、

  1. await がついた先ほどのデータ取得処理よりも前に判定が行われ
  2. ref.watch している proivider は無いと判断され
  3. 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 が実行され、counterProviderarticlesProvider に依存されている状態を維持 できます。依存元が存在すれば、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 を毎回判断し、不要な監視はすぐに停止する」という方針のようです。

https://www.youtube.com/watch?v=BJtQ0dfI-RA&t=4320s

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 標準の場合はこうだから」で考えると予期せぬ挙動が発生するリスクがあります。

公式ドキュメントを読んだり必要に応じてソースコードを確認しつつ正確な挙動が予想できるようになると、より安全にプロダクトに取り入れられると思います。


脚注
  1. assert 処理は割愛しています。また、コメントを追記しています。 ↩︎

GitHubで編集を提案

Discussion