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
にしてはいけないらしい。
(おそらくemailVerified
をawait
せずにsetState()をasyncにしなければ何とかなるはず。ただ、いつロジックの検査が終わって遷移が開始するかわからない不安定な動作になるし、次に示すエラーが発生して、どちらにせよ対処せざるを得なくなるはず。)
@override build()
に遷移ロジックを書く→ setState() or markNeedsBuild called during build
次に@override build()
のログインページビルド前に遷移を持ってこようとFutureBuilder
で処理したところ(build
もasync
にしてはいけない)、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 build
をasync
にすることはできない (どうにか実行できないか試行錯誤しても無駄なので、FutureBulder
とかに任せよう)。
ログイン状態をチェックして遷移先を決定し、遷移を実行する、という処理は、
- ログイン状態を確認して
addPostFrameCallback
に分岐により適切な遷移先を選択、遷移実行するランディングウィジェット (場合によってはStatelessWidget
で済むはず) - 既存のログインページウィジェット
- 既存のアプリのホームウィジェット
でクラスを分けて記述したほうがいい(わざわざ既存のログインページを改変する必要もない)。
参考
- 公式ドキュメント: https://firebase.flutter.dev/docs/auth/usage/#authentication-state
setState() or markNeedsBuild called during build
の対処が書かれたStackOverflowスレッド: https://stackoverflow.com/questions/47592301/setstate-or-markneedsbuild-called-during-build