Main featured image

FlutterでsetState() or markNeedsBuild() called during buildエラーが発生した場合のトラブルシューティング

Flutter
Dart

Flutter 初学者の筆者が setState() or markNeedsBuild() called during build エラーに遭遇し???の状態からエラー解消を解消できたので記録として残します。

エラー発生箇所

Todo アプリで Todo 一覧から Todo 作成画面に遷移して、Todo 作成後に一覧画面へ遷移する実装をしていました。

Todo 作成画面から一覧画面へ戻る遷移するのに、 Navigator.pop を使用しています。

使用箇所は、useProvider から AsyncValue を取得し、 when スコープ内で Navigation.pop している箇所で setState() or markNeedsBuild() called during build エラーが発生しました。

class _UpsertTodoView extends HookWidget {
  @override
  Widget build(BuildContext context) {
    final isNew =
        ModalRoute.of(context).settings.arguments as TodoEntity == null;
    useProvider(upsertTodoViewModelProvider.state).when(
      data: (todo) {
        if (todo != null) {
          EasyLoading.dismiss();
          Navigator.pop(context, '${todo.title}を${isNew ? '作成' : '更新'}しました'); // <- Error occurred!!!
        }
      },
      loading: () async {
        await EasyLoading.show();
      },
      error: (error, _) {
        EasyLoading.dismiss();
        _errorView(error.toString());
      },
    );
    return _TodoForm();
  }
}
  • error log
======== Exception caught by animation library =====================================================
The following assertion was thrown while notifying status listeners for AnimationController:
setState() or markNeedsBuild() called during build.

This Overlay widget cannot be marked as needing to build because the framework is already in the process of building widgets.  A widget can be marked as needing to be built during the build phase only if one of its ancestors is currently building. This exception is allowed because the framework builds parent widgets before children, which means a dirty descendant will always be built. Otherwise, the framework might not visit this widget during this build phase.
The widget on which setState() or markNeedsBuild() was called was: Overlay-[LabeledGlobalKey<OverlayState>#8e684]
  dependencies: [_EffectiveTickerMode]
  state: OverlayState#84b7b(entries: [OverlayEntry#07a4d(opaque: true; maintainState: false), OverlayEntry#82e92(opaque: false; maintainState: true), OverlayEntry#257e9(opaque: false; maintainState: false), OverlayEntry#9c6b1(opaque: false; maintainState: true)])
原因

エラーログを Google 翻訳する限り、Widget のビルド中に状態を変更しちゃ駄目ですよ、と強引に意訳。

setState() or markNeedsBuild() called during build.

This Overlay widget cannot be marked as needing to build because the framework is already in the process of building widgets.
------------------------------------------------------------------------------------
ビルド中に呼び出されるsetState()またはmarkNeedsBuild()。

フレームワークはすでにウィジェットを構築中であるため、このオーバーレイウィジェットを構築する必要があるとしてマークすることはできません。

ビルド中といっても、Navigator.pop する時点では Todo 作成画面の Widget のビルドは終わってるはず・・・と思ったのですが、その手前で EasyLoading.dismiss(); を呼んでいました。

    EasyLoading.dismiss();
    Navigator.pop(context, '${todo.title}を${isNew ? '作成' : '更新'}しました'); // <- Error occurred!!!

この EasyLoading というのは非同期処理中にローディングを表示する用途の package です。

ローディング中はこのように show() メソッドを call してローディングを表示します。

  loading: () async {
    await EasyLoading.show();
  },

そしてローディングが終わったタイミングで dismiss() を call しています。

  data: (todo) {
    if (todo != null) {
      EasyLoading.dismiss();
      Navigator.pop(context, '${todo.title}を${isNew ? '作成' : '更新'}しました'); // <- Error occurred!!!
    }
  },

この EasyLoading.dismiss のソースを覗いてみたら、戻り値は Future になっていました。

  /// dismiss loading
  static Future<void> dismiss({
    bool animation = true,
  }) {
    // cancel timer
    _getInstance()._cancelTimer();
    return _getInstance()._dismiss(animation);
  }

原因は EasyLoading 自体が非同期で動いており、dismiss() のローディング終了中(Widget リビルド中)に Navigator.pop で前の画面遷移をしようとして、 setState() or markNeedsBuild() called during build.エラーが発生していたのです。

解決方法
  • 解決方法 1

await キーワードで非同期処理が終わるのを待ち合わせてから、Navigation.pop する方法です。

      data: (todo) async {
        if (todo != null) {
          await EasyLoading.dismiss();
          Navigator.pop(context, '${todo.title}を${isNew ? '作成' : '更新'}しました');
        }
      },

ちゃんと dismiss() の終了アニメーションが終わるのを待ってから(Widget のリビルド完了してから)Navigation.pop して画面遷移(画面状態変更)すればエラーは発生しません。

  • 解決方法 2

Navigator.pop を WidgetsBinding.instance.addPostFrameCallback で囲む方法です。

      data: (todo) {
        if (todo != null) {
          EasyLoading.dismiss();
          WidgetsBinding.instance.addPostFrameCallback((_) {
            Navigator.pop(context, '${todo.title}を${isNew ? '作成' : '更新'}しました');
          });
        }

WidgetsBinding.instance.addPostFrameCallback は全ての Widget のビルドが終わったタイミングで呼ばれる callback です。

addPostFrameCallback が call されるタイミングはビルドが終わっていることが保証されます。

こちらは Flutter 1.8.4 で追加された機能で筆者は存在を知りませんでした。

こちらはどの Widget のビルドが終わるのを待てばいいか分からない場合でも、状態変更する対象を囲めばいいのでどのユースケースでも対応できそうです。

おわりに

setState() or markNeedsBuild() called during build.エラーはビルド中に画面状態を変更しようとすると発生するエラーでした。

このエラーのおかげで、WidgetsBinding.instance.addPostFrameCallback の存在を知ったので収穫がありました。

非同期処理が複数走るユースケースなどで特に発生しそうなエラーなので、同じエラーに遭遇された方の参考になれば幸いです。

Written by ZUMA a.k.a. Kazuma. Web/Mobile App developer.  My profile.
Tags
Archives
2021-072021-062021-052021-042021-032021-022021-01
Recent Posts