MOB-LOG

モブおじの記録 (Programming, 統計・機械学習)

Flutterで setState() or markNeedsBuild called during build →StatefulWidgetの@override build() が完了する前にMaterialPageRouteによる遷移を実行してしまったときのエラー

TL;DR

StatefulWidget@override build() 内でMaterialPageRouteによる遷移やsetState()を実行してしまうと、setState() or markNeedsBuild called during build 例外が発生するので、そういった処理はbuildが完了した後に実行されるようにWidgetsBinding.instance.addPostFrameCallbackへ記述しよう。

いきさつ

FirebaseAuthのログイン状態でLoginのページにアクセスしたら、アプリのホームに飛ぶようにしたかった。

ログイン状態の判断は、

  • FirebaseAuth.instance.**currentUser**が非nullで(ログイン済み)、
  • FirebaseAuth.instance.currentUser.**emailVerified**trueでEmailアドレスの検証済み(登録後、有効化どうか、本人かどうかの確認)、

かどうかで行う (以下のisEmailVerified)。

import 'package:firebase_auth/firebase_auth.dart';
class AuthTool {
    final FirebaseAuth _auth = FirebaseAuth.instance;
    ///
    Future<bool?> isEmailVerified() async {
    User? user = _auth.currentUser;
    if (user != null) {
      await user.reload();
      user = _auth.currentUser;

      return Future.value(user!.emailVerified);
    } else {
      return Future.value(null);
    }
  }
}

メジャーで効率的な手続きかはわからないけれど、ユーザーが一気に、「アカウントを作成→(作成時に送信される verification message のリンクから)メールアドレスの認証→ログイン画面に戻る」を行う可能性があり、メール検証の情報をリロードするためemailVerifiedフラグを確認前にFirebaseAuth.instance.currentUser.**reload()**している。reload()asyncなので完了までawaitするため、この確認手続きもasyncとなる。

これを使用して、そもそもあるLogin用のページに「ログイン状態の確認→ログイン状態ならホームへ遷移、ログイン状態じゃないならログイン画面の読込(メール検証前ならメッセージ出したり)」とNavigatorで遷移するプロセスロジックを追加しようとしていた。

というようないきさつ。

問題

setState()に遷移のロジックを書く→initState()asyncにしてはいけない

まず考えたのが、ログインのシステムが現れる前にこのロジックを検査して遷移を済ませるというもので、LoginのページのinitState()に追加した。するとemailVerified自体がasyncなのでawaitを使用して、initState()asyncにせざるを得なかった。するとSyntax自体はバグがなかったものの、デバッグ時に例外State.initState() must be a void method without an async keyword.が発生してLoginページにもホームにも飛べずビルドエラーとなった。setState()asyncにしてはいけないらしい。

(おそらくemailVerifiedawaitせずにsetState()をasyncにしなければ何とかなるはず。ただ、いつロジックの検査が終わって遷移が開始するかわからない不安定な動作になるし、次に示すエラーが発生して、どちらにせよ対処せざるを得なくなるはず。)

@override build()に遷移ロジックを書く→ setState() or markNeedsBuild called during build

次に@override build()のログインページビルド前に遷移を持ってこようとFutureBuilderで処理したところ(buildasyncにしてはいけない)、setState() or markNeedsBuild called during build例外が発生した。

@override
build(BuildContext context){
    /// ここにログイン状態確認のロジックと遷移
    if ()
  return LoginWidget() /// ログインのページ本体
}

意味はsetState()とかbuildを使って再構築が必要な処理をbuildが完了する前に書くんじゃないよ、という話。setState()等が呼び出されると再構成してくださいという要求がされて(markNeedsBuild)、UIが再構成するらしいので(@override build()が呼ばれる)、再構成の無限ループみたいなものを避けるためにこの例外がある様子(想像)。

setState()自体はこちらで明示的にしていないけど、FutureBuilder内にMaterialPageRoute で遷移するのがmarkNeedsBuildにあたるのだと思う。

FutureBuilder内でMaterialPageRoute を使用すると例外が発生するか確かめる必要があるがやっていない)

解決策

調べる限り、WidgetsBinding.instance.addPostFrameCallback((_) {}) に処理を書くと、@override buildによるビルドが完了し次第それを実行してくれるらしい (https://stackoverflow.com/questions/47592301/setstate-or-markneedsbuild-called-during-build)。

以下の様に、isEmailVerifiedの結果によってMaterialPageRouteによる遷移をWidgetsBinding.instance.addPostFrameCallback に任せるようにした。

               @override
  Widget build(BuildContext context) {
    return FutureBuilder<bool?>(
        future: AuthTool().isEmailVerified(),
        builder: (context, snapshot) {
          if (snapshot.connectionState != ConnectionState.done) {
            return Container(
              padding: const EdgeInsets.all(24),
              child: const Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: <Widget>[
                  Text(
                    'Loading...',
                    style: TextStyle(fontSize: 20, color: Colors.black),
                  ),
                  CircularProgressIndicator()
                ],
              ),
            );
          } else {
            /// done
            bool isVerified = snapshot.data ?? false;
            if (isVerified) {
              /// ビルド後にホームへ遷移する処理を実行してくれとお願いする
              WidgetsBinding.instance.addPostFrameCallback((_) {
                Navigator.of(context).pop;
                Navigator.of(context).push(MaterialPageRoute(
                  builder: (context) {
                    return AppHomePage(
                    );
                  },
                ));
              });
            } else {}
                        /// 本来のログインページ
            return _loginPage();
}

結果buildの中身が複雑になってしまったので、

  • ログイン状態を確認してaddPostFrameCallbackに分岐により適切な遷移先を選択、遷移実行するランディングウィジェット (LoginStateCheckLandingPage とか名づけた)
  • 既存のログインページウィジェット
  • 既存のアプリのホームウィジェット

でクラスを分けて記述したほうがいい(わざわざ既存のログインページを改変する必要もない)。

学び

StatefulWidgetではsetState()@override buildasyncにすることはできない (どうにか実行できないか試行錯誤しても無駄なので、FutureBulderとかに任せよう)。

ログイン状態をチェックして遷移先を決定し、遷移を実行する、という処理は、

  • ログイン状態を確認してaddPostFrameCallbackに分岐により適切な遷移先を選択、遷移実行するランディングウィジェット (場合によってはStatelessWidgetで済むはず)
  • 既存のログインページウィジェット
  • 既存のアプリのホームウィジェット

でクラスを分けて記述したほうがいい(わざわざ既存のログインページを改変する必要もない)。

参考