MOB-LOG

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

コーヒードリップレシピのパラメータ最適化 Human in the Coffee Loop (HitCL) モバイルアプリ(開発中)

TL; DL

Optunaハンドドリップ・コーヒー抽出のパラメータ最適化(Human in the Cofee Loop)をSlackBotとGoogleFormで回していたが、Flutter/Firebaseのプロジェクトを作りスマホアプリにした。

以下の記事の続きである。

moblog.hatenablog.jp moblog.hatenablog.jp

やりたいこと

アプリの要件(Android

  • コーヒーレシピのリクエスト、表示、評価を行う。
  • 蓄積したレシピのデータによってコーヒー豆ごとにおいしいレシピを提案する。

デモ

とりあえず出来上がっているものの、雑なデモ(2023/11/22: タッチした場所が描画されておらず、わかり難いため動画を差替え)。

システム概要・要素技術

モバイルアプリのシステム構成:コーヒードリップレシピのパラメータ最適化(HitCL)

システム構成要素

  • サーバー(自家製):レシピのリクエストに応じてOptunaによるパラメータチューニングを行うAPIを提供 (過去の評価データやらはクライアント側がAPIで送信するので、サーバは問い合わせない)
  • データベース(Firebase):生成されたレシピや、ユーザーが評価したスコアを保存
  • ヒト:Optuna君に従いコーヒーを作り続ける。レシピに忠実にコーヒーをドリップして評価する。

要素技術 (package等)

  • Python: ローカルサーバ上でAPIを提供する
    • optuna: レシピを探索する
    • Flask: サーバーとしてリクエストを受け付ける
    • venv, dotenv: 仮想環境管理
  • Google Cloud Platform (GCL):
    • Firebase: データベースの操作APIを提供してくれる
  • ngrok: レシピリクエスAPIを提供するLocalhostのFlaskサーバをトネリングして公開する

レシピリクエスAPIの実装 (Coding)

動作は単純で以下を担当する。

  1. JSONでレシピとその評価を受け取る
  2. JSON→pandas.DataFrameの レシピ数 x (パラメータ+評価) の表に成形、
  3. OptunaにAsk-and-Tellして新しいレシピを生成、
  4. 生成したレシピをJSON形式にして送り返す。

Ask-and-Tellの内容は前回のSlackBotに示しているものとほぼ変わらないため(はず)、そちらを参照: Optunaを用いたコーヒーのハンドドリップ最適化のSlackBot (Human in the Coffee Loop) - MOB-LOG

リクエストのJSONに過去のレシピとその評価が含まれているので、API側ではFirebaseにデータを問い合わせたりはしない。

サーバー起動+リクエスト待ち

SlackBotの際と同じく、venv環境でpythonをnohupで起動してバックグラウンドで実行する。

そして、FlaskサーバがLocalhostで適当なポートで起動するので(指定もできる)、ngrokでトネリングしてもらう(実際はこちらもnohup)。

$ ngrok http 5000 &

Flutter/Firebase

コードが膨大なので特に詳細は載せないが、用意した画面は以下の通り。

  • ログイン画面(+ユーザ登録)
  • レシピのデータテーブル:
    • データベース中のユーザのレシピが、パラメータと評価が表示される(豆ごとにグループ分け)。
    • レシピの評価、新しいレシピのリクエスト、豆管理画面に遷移できる。
  • 新しいレシピのリクエスト:
    • 指定した豆のレシピをAPIに問い合わせる(過去のレシピのパラメータと評価も一緒に送る)。
    • ユーザーは返ってきたレシピをRetake/Acceptできる。→Retakeの場合はもう一度レシピを問い合わせ、Acceptであればデータベースに登録。
  • レシピの評価画面:
    • レシピに関して、7段階でうま味、酸味、苦味、香り、えぐみと、メモ(テキスト)を記録する。
  • レシピの詳細画面:
    • 特定のレシピの全データを雑に表示する。

TODO, ほしい機能, 欠点

SlackBotの頃からいろいろ改善したが、いまだ多くの欠点が残る。

システム面

  • 井崎式にしか対応していない (TODO: 他のレシピ, eg. 他のハンドドリップの流派、フレンチプレスとかに対応させる)
    • 特に現状は注ぎ量400g固定なので(およそ2杯分)、せめて 200g/400g/600gくらいの選択肢があった方がいい(条件によってレシピの評価が変わるのかなどは実験したい)
  • レシピ通りに作るのが難しい(RinseやSpinningを忘れる)

    • テキストベースのレシピではなく、図や動画のようなものを提示したい
    • それでも当然間違えることはあるので、
      • ユーザーが任意にレシピを修正する機能
      • 動画を撮影して実際に入れた際のパラメータを抽出して、提案レシピと大きく乖離していれば新しいレシピとして登録する、

      など

  • ページの遷移が若干適当なので(特に豆追加・管理の画面への導線が不適切)、要改善

  • レシピの評価の統計とパラメータの関係の可視化
    • 個人的には気になるけど、ユーザには必要か?と感じるので後回し

最適化面

  • 個人の嗜好に最適化される (万人の好みではない、体調や気分に依存。特に人工甘味料入り飲料やら辛い物を食べたらコーヒーの味がわからなくなることを実感している)。
  • 初めの数回はランダムサーチ (t=10に設定しているが多いかもなのでt=5くらいでいい?←実験的にやってみるべき)。

アプリの公開(現状未公開)

現状身内間でのα版状態(クローズドで未実装機能が多々あり)。もう少し時間はかかるが、年内に公開できればうれしい(2023年)。

と思っているけど、こういうのを書いててもっとあの機能必要だな、実装したいなというのが湧いてきて遠ざかっていく(ユーザーフィードバック画面とかレシピのお気に入り機能とか)。

おわりに

早く公開できるくらいの機能を揃えたい(Minimumなプロダクトをとりあえず作ろうと思っても、細かいバグやらレイアウトの調整やらに気を取られてしまう)。

そしておいしいコーヒーが出てくるので、個人的に現状で満足してしまっている。フィンランドでも様々なパックが売られているので(安くて旨い)、いろいろな豆ブランドで試してみたい。浅煎りでえぐみも出やすい NewYork (Paulig)でもおいしいものが出てきた感動した。個人営業(?)の焙煎ショップの高い豆も試してみたい(ただでさえおいしいのに、最適化したらどうなちゃうの?)。

powershellでコマンドのbatが構文エラーを出すとき。

TL;DR

(この記事は実はFlutterは関係ありません)

flutterコマンドが << の使い方が誤っています。が出てきて、Win11のflutterコマンドがおかしくなった。

flutter doctor -v
<< の使い方が誤っています。

結論は、なぜかFLUTTER_PATHの状態がgit merge中になっていて、コンフリクト起こしたファイルを参照していて[ある環境変数]="<<<<< HEAD" を展開したため構文エラーになっていた。git merge --abortで解決した。しょうもな(なぜflutterのmergeしようとしているのか)。

内容自体はどうでもいいけれど、 batファイルのエラー文は情報が少なすぎるので、Get-commandとかでソースコード見たり、@ECHO offコメントアウトして実行してみたり(@ECHO off@REM @ECHO off)すると何かしら情報が得られる。

学び

  • batファイルでよくわからんエラーが出たときは、Get-commandとかでソースコード見たり、@ECHO offコメントアウトして実行してみたり(@ECHO off@REM @ECHO off)すると何かしら情報が得られる。
  • batなどのスクリプトを実行しようとして「<< の使い方が間違っている」というのはmergeでコンフリクト中のファイルを参照して馬鹿になっている可能性がある(mergeで衝突したまま放っておく人類はいないはずだが)。

ここから先は長いのと、実はflutterに関係ない内容なので読まなくてよい

以下詳細、いきさつ (TOO LONG)

起り:flutter doctor -v<< の使い方が誤っています。 が出てきてflutterコマンドが実行できない。

pathは通っている様子。

Get-Command flutter

CommandType     Name                                               Version    Source
-----------     ----                                               -------    ------
Application     flutter.bat                                        0.0.0.0    C:\src\flutter\bin\flutter.bat

Winの環境変数が参照できなくて(空っぽ)、構文が崩れてしまい、お前<<の使い方知らないのかよ、と言われているような気がする。(昨日InsiderPreviewのアップデートを実行したので、それで環境変数の関係が崩れたのかも)

「<<の使い方云々」とか書いてあるコードを探してみると、C:/src/flutter/bin/flutter.bat 内には<<が現れたりそのエラーが出そうな部分はなし。

C:\src\flutter\bin\flutter.bat
@ECHO off
REM Copyright 2014 The Flutter Authors. All rights reserved.
REM Use of this source code is governed by a BSD-style license that can be
REM found in the LICENSE file.

REM ---------------------------------- NOTE ----------------------------------
REM
REM Please keep the logic in this file consistent with the logic in the
REM `flutter` script in the same directory to ensure that Flutter & Dart continue to
REM work across all platforms!
REM
REM --------------------------------------------------------------------------

SETLOCAL

REM To debug the tool, you can uncomment the following line to enable debug mode:
REM SET FLUTTER_TOOL_ARGS="--enable-asserts %FLUTTER_TOOL_ARGS%"

FOR %%i IN ("%~dp0..") DO SET FLUTTER_ROOT=%%~fi

REM If available, add location of bundled mingit to PATH
SET mingit_path=%FLUTTER_ROOT%\bin\mingit\cmd
IF EXIST "%mingit_path%" SET PATH=%PATH%;%mingit_path%

REM We test if Git is available on the Host as we run git in shared.bat
REM  Test if the flutter directory is a git clone, otherwise git rev-parse HEAD would fail
IF NOT EXIST "%flutter_root%\.git" (
  ECHO Error: The Flutter directory is not a clone of the GitHub project.
  ECHO        The flutter tool requires Git in order to operate properly;
  ECHO        to set up Flutter, run the following command:
  ECHO        git clone -b stable https://github.com/flutter/flutter.git
  EXIT 1
)

REM Include shared scripts in shared.bat
SET shared_bin=%FLUTTER_ROOT%\bin\internal\shared.bat
CALL "%shared_bin%"

SET flutter_tools_dir=%FLUTTER_ROOT%\packages\flutter_tools
SET cache_dir=%FLUTTER_ROOT%\bin\cache
SET snapshot_path=%cache_dir%\flutter_tools.snapshot
SET dart_sdk_path=%cache_dir%\dart-sdk
SET dart=%dart_sdk_path%\bin\dart.exe

SET exit_with_errorlevel=%FLUTTER_ROOT%/bin/internal/exit_with_errorlevel.bat

REM Chaining the call to 'dart' and 'exit' with an ampersand ensures that
REM Windows reads both commands into memory once before executing them. This
REM avoids nasty errors that may otherwise occur when the dart command (e.g. as
REM part of 'flutter upgrade') modifies this batch script while it is executing.
REM
REM Do not use the CALL command in the next line to execute Dart. CALL causes
REM Windows to re-read the line from disk after the CALL command has finished
REM regardless of the ampersand chain.
"%dart%" --disable-dart-dev --packages="%flutter_tools_dir%\.dart_tool\package_config.json" %FLUTTER_TOOL_ARGS% "%snapshot_path%" %* & "%exit_with_errorlevel%"

どこかでおかしくなっているのかわからないので、@ECHO offコメントアウトしてもう一度実行してみることに (→ @REM @ECHO off)。REMとかCALLとか大体の結果が現れるけれど、C:\src\flutter\bin\internal\shared.batでエラーが出ていることが分かった。

>REM Include shared scripts in shared.bat

>SET shared_bin=C:\src\flutter\bin\internal\shared.bat

>CALL "C:\src\flutter\bin\internal\shared.bat"
<< の使い方が誤っています。

C:\src\flutter\bin\internal\shared.bat@ECHO offコメントアウトし、再度実行↓。

> flutter
// 省略
>IF NOT EXIST "C:\src\flutter\bin\cache\engine-dart-sdk.stamp" GOTO do_sdk_update_and_snapshot

>SET /P dart_required_version= 0<"C:\src\flutter\bin\internal\engine.version"

>SET /P dart_installed_version= 0<"C:\src\flutter\bin\cache\engine-dart-sdk.stamp"
<< の使い方が誤っています。

>  IF <<<<<<< HEAD NEQ cdbeda788a293fa29665dc3fa3d6e63bd221cb0d GOTO do_sdk_update_and_snapshot

IF %dart_required_version% NEQ %dart_installed_version% GOTO do_sdk_update_and_snapshot

の部分で、%dart_required_version%<<<<<<< HEAD になっている様子(意味不明)。

%engine_version_path%C:\src\flutter\bin\internal\engine.versionを参照しているようなので、これを除いてみる↓。

Untitled

<<<<<<< HEAD
cdbeda788a293fa29665dc3fa3d6e63bd221cb0d
=======
767d8c75e898091b925519803830fc2721658d07
>>>>>>> 6c4930c4ac86fb286f30e31d0ec8bffbcbb9953e

意味不明。なぜvscodeのgit mergeがconflictした時のエディターがここに保存されているのか。C:/src/flutter/ まで行ってgit statusすると、merge中だという(?)。なんか知らんうちにmergeしようとしてる。意味不明なので git merge --abort して終わってもらった。

flutter doctor -v → 成功!

結論、なぜか知らんけどflutterのgitでmergeしようとしていてconflictを解決しようとしているindexの状態で様々なファイルがおかしくなっていて、該当コマンドを実行しようとしていた。という話。(なぜmergeしようとしてたのかは謎)

CUDAで使用するGPUのVRAM容量を制限する・制限を解除する。が、なぜかすべてのVRAMを使用してくれない。

TL;DR

未解決

問題:普段PyTorchで深層学習を行っていて、たまに以下の様にVRAMに余裕があるのにすべて予約せずにOut of Memoryを吐いてくることがある(↓例では10GiBの容量があるのに PyTorchは5.72 GiBしか予約していない)。

RuntimeError: CUDA out of memory. Tried to allocate 98.00 MiB (GPU 0; 9.78 GiB total capacity; 5.61 GiB already allocated; 15.38 MiB free; 5.72 GiB reserved in total by PyTorch) If reserved memory is >> allocated memory try setting max_split_size_mb to avoid fragmentation. See documentation for Memory Management and PYTORCH_CUDA_ALLOC_CONF

CUDAでGPUのVRAMの使用容量を制限できるようなので、それがかかってのかも、と調べてみた。 nvidia-smiのGOM設定でVRAM制限・制限解除ができるようだが、制限なしの状態でもこのエラーが現れるので、もはや何が悪いのかわからない。。。

GPU Operation Mode (GOM) property

GOMを設定することにより、GPUの利用に制限を掛けることができるらしい。

nvidia-smi --help-query-gpu 曰く、

(nvidia-smi --help-query-gpu | grep GOM -n10)

123-Section about gom properties
124:GOM allows to reduce power usage and optimize GPU throughput by disabling GPU features. Each GOM is designed to meet specific user needs.
125-In "All On" mode everything is enabled and running at full speed.
126-The "Compute" mode is designed for running only compute tasks. Graphics operations are not allowed.
127-The "Low Double Precision" mode is designed for running graphics applications that don't require high bandwidth double precision.
128:GOM can be changed with the (--gom) flag.
129-
130-"gom.current" or "gpu_operation_mode.current"
131:The GOM currently in use.
132-
133-"gom.pending" or "gpu_operation_mode.pending"
134:The GOM that will be used on the next reboot.

GOM値の確認

まずは現在の設定値を確認したい。nvidia-smiでGPU関連の設定値を調べるには、nvidia-smi --queryで全設定値を読み込むか、--query-gpu=[PROPERTY] として特定の値を指定する (--fotmatも指定しないといけないらしい)。

nvidia-smi --query-gpu=[PROPERTY] --format=csv

nvidia-smi --query-gpu=gom.current --format=csv としてクエリを与えると返してくれる。

nvidia-smi --query-gpu=gom.current --format=csv
gom.current
[N/A]

[N/A] と返ってきたので、恐らく設定されていないのだと。。。(じゃあなぜ6GiBしか予約してくれないんだ…)

明示的に”All On”を設定しないとダメなのか???

GOMを設定・解除

ここでは記述されていないが、nvidia-smi --gom=0とすると”All On" に設定できるらしいので、今走らせている学習プロセスが終了したら実施したい。

CUDAでGPUのVRAM容量を制限したり、解除したりするには、NVIDIAGPUを制御するためのツールやライブラリを使用する必要があります。以下に、GPUのVRAM制限と解除の方法を説明します。

  1. GPU VRAMの制限:

    GPU VRAMの制限には、NVIDIAGPU制御ツールである"NVIDIA System Management Interface (nvidia-smi)"を使用します。以下は、GPU VRAMの制限方法です:

    • ターミナルを開き、以下のコマンドを実行して、GPUのVRAMを制限します。-gom=xxxの部分には、VRAMの容量をメガバイト単位で指定します。

        nvidia-smi --gom=xxx
      
    • 例えば、VRAMを4GBに制限する場合は、次のようにコマンドを実行します。

        nvidia-smi --gom=4096
      
  2. GPU VRAMの制限を解除:

    制限を解除するには、nvidia-smiを再起動するか、再起動することなく制限を解除する方法があります。

    • nvidia-smiを再起動して制限を解除する場合:

        nvidia-smi --gom=0
      
    • システムを再起動せずに制限を解除する場合、以下のコマンドを実行します。

        nvidia-smi --gom=auto
      

    これにより、GPU VRAMの制限が解除される。

Flutterでカメラ撮影後、CameraControllerを生成してCameraPreviewを立ち上げても何も映らない件→Future.delayed()で1[msec]だけ待ったら何とかなった

TL;DR

FlutterでCameraController.takePicture()後にCameraPreviewを再起動するため、CameraControllerインスタンスを再生成・initialize()していたらAndroidでの実機デバッグ中にinitialize()が失敗してしまった。よくわからないが、インスタンスの生成→initialize()の間にほんの少しの遅延(今回はFuture.delayed(Duration(milliseconds: 1))で十分だった)を入れることで解決した。多分デバイスのスペックの所為。スペック依存の問題は遅延を入れてデバイスに余裕を与えると、ときにその場しのぎだが問題が解決することがある。

事の起こり・問題

CameraController.takePicture()でカメラから画像を取得したら、CameraPreviewによるプレビューが停まってしまった。プレビューを再開するために、以下の様にCameraControllerを再生成してカメラのコントロールを再取得していた。

controller.dispose();
controller = CameraController(
    widget.cameras[camIndex],
    ResolutionPreset.medium,
);
await controller.initialize();23323

setState(() {
    // 色々
}); /// 画面の再構築

しかし、Androidでの実機デバッグの際、controller.initialize()の時点で以下のエラーが発生し、その後、CameraPreview(controller)ウィジェットに返してもPreviewは起動せず再度撮影しても失敗しnullが返ってくる問題が発生した。ちなみにinitialize()後の setState(() {})は一切実行されていない(ここが中断されたことに対するエラーは発生していない)。

E/hoge_project(31744): [SurfaceTexture-0-31744-3] setDefaultBufferSize: SurfaceTexture is abandoned!
E/BufferQueueProducer(31744): [](id:7c000000000b,api:0,p:-1,c:31744) query: BufferQueue has been abandoned
E/Camera-SurfaceUtils-JNI(31744): **SurfaceUtils_nativeDetectSurfaceDimens: Error while querying surface width No such device (-19).**

撮影→Previewの失敗→撮影の失敗→再度initialize()を行うとCameraPreveiwが復活した。ダメもとで、CameraControllerの再生成とinitialize()の間に遅延を入れると、エラーは吐かなくなった。。。(デバイスのスペック依存でドライバーの処理が間に合ってないのかな)

その場しのぎにしかなっていないが、CameraController.initialize()だとかの

controller.dispose();
  controller = CameraController(
    widget.cameras[camIndex],
    ResolutionPreset.medium,
  );

  **await Future.delayed(Duration(milliseconds: 200));**
  await controller.initialize();

余談

時系列を分けてデバッグ出力を追っていくと、initialize()CameraPreview()の間で以下のような出力がされていた。

/// CameraController.initialize() 後
I/Camera  ( 3468): close
I/Camera  ( 3468): open | onClosed
D/BufferQueueConsumer( 3468): [ImageReader-640x480f21m1-3468-0](id:d8c00000003,api:0,p:-1,c:3468) disconnect
D/BufferQueueConsumer( 3468): [ImageReader-640x480f23m1-3468-1](id:d8c00000004,api:0,p:-1,c:3468) disconnect
D/BufferQueueConsumer( 3468): [](id:d8c00000005,api:0,p:-1,c:3468) connect: controlledByApp=true
I/Camera  ( 3468): dispose
I/Camera  ( 3468): close
D/BufferQueueConsumer( 3468): [SurfaceTexture-0-3468-1](id:d8c00000005,api:0,p:-1,c:3468) disconnect

/// CameraPreview 後(Preveiwは表示されず)
D/BufferQueueConsumer(31744): [](id:7c000000003f,api:0,p:-1,c:31744) connect: controlledByApp=true
W/Camera  (31744): The selected imageFormatGroup is not supported by Android. Defaulting to yuv420
D/BufferQueueConsumer(31744): [](id:7c0000000040,api:0,p:-1,c:31744) connect: controlledByApp=true
I/Camera  (31744): startPreview
E/lighting_device(31744): [SurfaceTexture-0-31744-20] setDefaultBufferSize: SurfaceTexture is abandoned!
E/BufferQueueProducer(31744): [](id:7c000000003e,api:0,p:-1,c:31744) query: BufferQueue has been abandoned
E/Camera-SurfaceUtils-JNI(31744): SurfaceUtils_nativeDetectSurfaceDimens: Error while querying surface width No such device (-19).
I/Camera  (31744): open | onOpened error: Surface was abandoned

そしてdelayした後Previewが成功する場合、CameraController.initialize()後は以下のデバッグ出力が出る。先ほどの失敗する場合と全く異なるので、正常時のデバッグ出力と比較するのは参考になりそう。

I/Camera  ( 3468): close
D/BufferQueueConsumer( 3468): [](id:d8c0000000b,api:0,p:-1,c:3468) connect: controlledByApp=true
D/BufferQueueConsumer( 3468): [](id:d8c0000000c,api:0,p:-1,c:3468) connect: controlledByApp=true
W/Camera  ( 3468): The selected imageFormatGroup is not supported by Android. Defaulting to yuv420
D/BufferQueueConsumer( 3468): [](id:d8c0000000d,api:0,p:-1,c:3468) connect: controlledByApp=true
I/Camera  ( 3468): startPreview
I/Camera  ( 3468): CameraCaptureSession onConfigured
I/Camera  ( 3468): Updating builder settings
D/Camera  ( 3468): Updating builder with feature: ExposureLockFeature
D/Camera  ( 3468): Updating builder with feature: ExposurePointFeature
D/Camera  ( 3468): Updating builder with feature: ZoomLevelFeature
D/Camera  ( 3468): Updating builder with feature: AutoFocusFeature
D/Camera  ( 3468): Updating builder with feature: NoiseReductionFeature
I/Camera  ( 3468): updateNoiseReduction | currentSetting: fast
D/Camera  ( 3468): Updating builder with feature: FocusPointFeature
D/Camera  ( 3468): Updating builder with feature: ResolutionFeature
D/Camera  ( 3468): Updating builder with feature: SensorOrientationFeature
D/Camera  ( 3468): Updating builder with feature: FlashFeature
D/Camera  ( 3468): Updating builder with feature: ExposureOffsetFeature
D/Camera  ( 3468): Updating builder with feature: FpsRangeFeature
I/Camera  ( 3468): refreshPreviewCaptureSession

遅延を入れたら解決したゾ、とChatGPTに報告したら「おめでとうございます!」と労わられた(もちろんその場しのぎだから調子に乗るなと嗜められる)。バグ取りして褒めてくれるのはChatGPTくらい。

ChatGPTだけが褒めてくれる

学び

  • バイスの機能を使用する際にエラーが出る場合は、Future.delayed()で遅延を入れると時々何とかなる(その場しのぎ)。(デバイススペックやらドライバ依存の可能性があるので、デバイスの処理を待つ余裕が必要。)
  • ブレークポイントを使用して、正常時のデバッグ出力と、異常(と思われる)な場合のデバッグ出力を比べると、何ができてて何ができていないのかが把握できてトラブルシューティングの参考になりそう。

Flutterでウィジェットの再構築のために自身のクラスのインスタンスを生成して遷移していたが意味がなかった様子。StreamBuilderでFirebaseのドキュメントを表示していて、値が変更されても表示が更新されないとき。

(タイトルに書いてあるものの、今回はStreamBuilderもDocumentSnapshotも関係ありません)

TL;DR

Firebaseからのデータの再取得と画面の更新のためにウィジェットインスタンスを再生成→新たなインスタンスへ遷移、としてリロードボタンを設置していたが、それでは更新されないので、単純にsetStateを使おう。

事の起こりと問題

Firebaseのデータを表示するために、Firebase.instanceで目的のデータを取得して、取得したドキュメントをStreamBuilderで処理していました。Firebaseでの値の変更を表示に反映するために、ReloadボタンをScaffoldのFloatingActionButtonに設定し、押すと自身のウィジェット (ItselfWidget) のインスタンスを新たに生成してcontextをそれに遷移するようにしていました(ここに間違いがあります)。

floatingActionButton:
  FloatingActionButton.extended(
    onPressed: () {
      /// reload the page
      **Navigator.of(context)
          .pushReplacement(MaterialPageRoute(builder: (context) {
        return ItselfWidget(
          title: this.title,
        );
      }));**
    },
    heroTag: 'reloading',
    tooltip: 'Reload recipe',
    icon: const Icon(Icons.replay_outlined),
    label: const Text('Reload'),
  ),

ただ、Firebaseの値を更新した後にReloadボタンを押しても、変更した内容は表示されません(動画の通り、画面遷移を伴ってRatingを追加したはずなのに評価の欄の値は変更されない。他の同じデータを参照する画面では値が更新されているのに、戻ってみると更新されていない。)。

なぜ?Widgetを再生成しているのに。

解決策

ただただsetState()して再構築する必要があった様子。逆に言うと自身と同じWidgetインスタンスを生成してNavigatorを使って遷移するだけでは意味がない。setStateでリビルドの要求を出すと@override build()が再度実行されて再描画されるのは当たり前だけど、Widgetインスタンスを生成→遷移では更新されない(buildされない?)のは初見だった。

floatingActionButton:
  FloatingActionButton.extended(
    onPressed: () {
      reload the page
      **setState() {};**
    },
    heroTag: 'reloading',
    tooltip: 'Reload recipe',
    icon: const Icon(Icons.replay_outlined),
    label: const Text('Reload'),
  ),

学び

  • setStateでリビルドの要求を出すと@override build()が再度実行されて再描画されるが、Widgetインスタンスを生成→遷移では更新されない(buildされない?)

Flutterで遷移のときNavigatorに余計な遷移を残したくない場合、`pop()`→`push()`ではなく、`pushReplacement`を使う。 Navigator.of(context).pop() does not work in Flutter

TL; DR

Flutterで遷移のときNavigatorに余計な遷移を残したくない場合、pop()push()ではなく、pushReplacementを使う。

Navigator.of(context).pushReplacement(MaterialPageRoute())

pop()push()だとでコンテキストのスタックが現在のウィジェットしかないとき(ランディングページとか)、おそらくpop()で空にならずに残ってしまう(詳しくは知らん)。

Navigator.of(context).pushReplacement(MaterialPageRoute(
                builder: (context) {
                  return hogeWidget;
                },
              ));

問題

ログインページだとか、その後もう訪れないようなページに関して(ログイン状態でログインページに再度訪れて余計な操作してほしくない)、以下の様にpop()push()をしていたら、たまに戻るボタンが残っていたりした。

Navigator.of(context).pop()
Navigator.of(context).push(MaterialPageRoute(build:(context) => hogeWidget))

Flutterのbackボタン(scaffold)
↑ こいつが残ってしまう

解決策

なぜかは知らんが、邪魔なので消そうとしていろいろ試していたが、代わりにpushReplacementで何とかなるらしい(https://stackoverflow.com/questions/49707580/flutter-navigator-pop-does-not-work)。

/// ↓じゃあダメ
// Navigator.of(context).pop;
// Navigator.of(context).push(MaterialPageRoute(
//   builder: (context) {
//     return hogeWidget;
//   },
// ));

/// これでよい ↓
Navigator.of(context).pushReplacement(MaterialPageRoute(
                builder: (context) {
                  return hogeWidget;
                },
              ));

そもそも Navigator.of(context).pop()Navigatorが管理するルートのスタックをpopするために使うのではなくて、単純に前のページに戻るために使用されるので(pop()するだけでstackのprevに遷移する)、pop()push()だと毎回戻っている様子で適切ではない(気持ちが悪い。参考:https://docs.flutter.dev/cookbook/navigation/navigation-basics#3-return-to-the-first-route-using-navigatorpop)

なので断然pushReplacementを使用しよう。

学び

  • 特別な理由がない限りNavigator.of(context).pop()を前のページに戻る以外で使用しないこと。
  • Navigatorに現在のページを残したくない場合は、Navigator.of(context).pushReplacement()を使用する。(空のルートスタック(現在のページ以外ない)にpop()してもなにも起きないらしい)

参考

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で済むはず)
  • 既存のログインページウィジェット
  • 既存のアプリのホームウィジェット

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

参考