0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Flutter×Riverpod:非同期処理でハマるUnmountedRefExceptionの対処法

0
Posted at

はじめに

Riverpodで非同期処理を扱う際、画面遷移のタイミングによっては
UnmountedRefException が発生することがあります。

本記事では、この問題の原因と対処方法について解説します。

ユースケース

本記事では、以下のような典型的な画面構成を例に説明します。

  • 一覧画面
  • 登録画面

実現したい動作は次の通りです。

  • 登録画面でデータを登録
  • 一覧画面に戻る
  • 一覧画面が最新の状態に更新される

demo.gif

問題があるコード

まずは問題が発生するコードを見ていきます。
状態管理はMVVM + Riverpodを想定しています。
(UIコードは省略)

一覧画面のViewModel

/// サンプル一覧画面の状態モデルクラス
@freezed
abstract class SampleListPageState with _$SampleListPageState {
  /// コンストラクタ
  const factory SampleListPageState({required List<String> sampleList}) =
      _SampleListPageState;
}

/// サンプル一覧画面のVM
@riverpod
class SampleListPageViewModel extends _$SampleListPageViewModel {
  /// 初期化
  @override
  Future<SampleListPageState> build() async {
    // APIでサンプル一覧を取得
    final sampleList = await sampleRepository.listSample();
    return SampleListPageState(sampleList: sampleList);
  }
}

登録画面のViewModel

/// サンプル登録画面の状態モデルクラス
@freezed
abstract class SampleCreatePageState with _$SampleCreatePageState {
  /// コンストラクタ
  const factory SampleCreatePageState({
    String? inputData,
    @Default(false) bool isCreating,
  }) = _SampleCreatePageState;
}

/// サンプル登録画面のVM
@riverpod
class SampleCreatePageViewModel extends _$SampleCreatePageViewModel {
  /// 初期化
  @override
  SampleCreatePageState build() {
    return SampleCreatePageState();
  }

  /// 入力値を更新
  void updateInputData({required String newInputData}) {
    state = state.copyWith(inputData: newInputData);
  }

  /// サンプル登録
  ///
  /// 戻り値は登録結果(true: 成功 false: 失敗)
  Future<bool> create() async {
    if (state.isCreating) {
      return false;
    }

    final inputData = state.inputData;
    if (inputData == null) {
      return false;
    }

    state = state.copyWith(isCreating: true);
    try {
      // サンプルデータを登録
      await sampleRepository.createSample(inputData: inputData);
      // 登録できたらサンプル一覧画面を更新する
      ref.invalidate(sampleListPageViewModelProvider);
      return true;
    } on Exception catch (e) {
      print(e);
      return false;
    } finally {
      state = state.copyWith(isCreating: false);
    }
  }
}

問題の本質(UnmountedRefException)

このコードには次の問題があります。

  • 非同期処理中に画面遷移が発生する

  • Providerが破棄される(autoDispose)

  • 破棄後にstate更新しようとして例外発生

await sampleRepository.createSample(inputData: inputData);

この処理中に画面遷移が起きると、Providerは破棄されます。

その後の

state = state.copyWith(isCreating: false);

で、破棄済みのstateにアクセスしてしまい
UnmountedRefException が発生します。

以下の部分です。

  Future<bool> create() async {
    ...

    state = state.copyWith(isCreating: true);
    try {
      // ** この処理中に画面遷移を行うとUnmountedRefExceptionが出る
      await sampleRepository.createSample(inputData: inputData);
      ref.invalidate(sampleListPageViewModelProvider);
      return true;
    } on Exception catch (e) {
      print(e);
      return false;
    } finally {
      state = state.copyWith(isCreating: false);
    }
  }

自動破棄しないように設定も可能です。
riverpod_generatorを使っている場合は、下記のようのkeepAliveをtrueにすると参照がなくなっても保持されます。

/// サンプル登録画面のVM
@Riverpod(keepAlive: true)
class SampleCreatePageViewModel extends _$SampleCreatePageViewModel {

注意点として、参照が保持されるので、画面遷移して再度同じ画面に遷移しても表示が更新されません。
なので、どのタイミングで更新が必要か考え、更新処理を実装する必要があります。

解決①:ref.mountedでクラッシュを防ぐ

非同期処理後にProviderがまだ有効か確認することで、例外を防げます。

  /// サンプル登録
  ///
  /// 戻り値は登録結果(true: 成功 false: 失敗)
  Future<bool> create() async {
    ...

    state = state.copyWith(isCreating: true);
    try {
      // サンプルデータを登録
      await sampleRepository.createSample(inputData: inputData);
      // 登録できたらサンプル一覧画面を更新する
+     if(ref.mounted) {
        ref.invalidate(sampleListPageViewModelProvider);
+     }
      return true;
    } on Exception catch (e) {
      print(e);
      return false;
    } finally {
+     if(ref.mounted) {
        state = state.copyWith(isCreating: false);
+     }
    }
  }

しかし、新たな問題が発生する

ref.mountedを追加したことで、新たな問題が発生します。

  /// サンプル登録
  ///
  /// 戻り値は登録結果(true: 成功 false: 失敗)
  Future<bool> create() async {
    ...

    state = state.copyWith(isCreating: true);
    try {
      await sampleRepository.createSample(inputData: inputData);
      // **createSample中に別画面に遷移すると参照が破棄されるので、
      // **ref.mountedがfalseになり、一覧画面が更新されない
      if(ref.mounted) {
        ref.invalidate(sampleListPageViewModelProvider);
      }
    ...
  }

list.gif

解決②:ref.keepAliveでライフサイクルを制御

この問題は ref.keepAlive() で解決できます。
ref.keepAlive()では一時的に破棄されるのを防ぐ役割があります。

/// サンプル登録
  ///
  /// 戻り値は登録結果(true: 成功 false: 失敗)
  Future<bool> create() async {
    if (state.isCreating) {
      return false;
    }

    final inputData = state.inputData;
    if (inputData == null) {
      return false;
    }

+   final link = ref.keepAlive();
    state = state.copyWith(isCreating: true);
    try {

      // サンプルデータを登録
      await sampleRepository.createSample(inputData: inputData);
      // 登録できたらサンプル一覧画面を更新する
      if(ref.mounted) {
        ref.invalidate(sampleListPageViewModelProvider);
      }
      return true;
    } on Exception catch (e) {
      print(e);
      return false;
    } finally {
      if(ref.mounted) {
        state = state.copyWith(isCreating: false);
      }
+     link.close();
    }
  }

keepAliveのポイント

  • ref.keepAlive() で破棄を防ぐ

  • link.close() で通常のライフサイクルに戻す

  • 必要な期間だけ生存させるのが重要

記事全体のポイント

  • 非同期処理の後に、refやstateを参照する場合、必ずref.mountedで確認する
  • 非同期処理中に破棄されるのを防ぐにはref.keepAlive()を使用する
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?