一覧画面がスケルトン表示のまま、いつまでも進まない。RTDB のルールもネットワークも App Check も疑いましたが、どれも違いました。原因は Flutter 側の書き方、それも多くの入門記事どおりに書いたコードでした。
やってしまった書き方
stream: にサービス呼び出しを直接書く
StreamBuilder(
stream: DiscoverService.instance.watchOnline(), // ← これ
builder: (context, snapshot) { ... },
)
一見なんの問題もありません。実際、画面がリビルドされなければ正しく動きます。
問題は build() が呼ばれるたびに watchOnline() が実行され、
そのたびに新しい Stream(=新しい RTDB のリスナー)が作られることです。
なぜデータが消えるのか
リビルドの速さがデータ到着を追い越す
この画面は initState() でブロックリストとトークルームも購読していました。
そのどちらかが一度でも発火すると setState() が走り、build() が再実行されます。
すると StreamBuilder.didUpdateWidget が古いリスナーを破棄し、新しいリスナーに差し替えます。
このとき AsyncSnapshot は connectionState だけを waiting に戻します。
画面側が「connectionState == waiting ならスケルトンを出す」と書いていると、
せっかく届いたデータがスケルトンの判定に飲み込まれて捨てられます。
RTDB への実際の往復よりリビルドのほうが速い、あるいは両者が競合すると、
最初のイベントが永久に「無かったこと」にされ続けます。
修正
Stream は initState で一度だけ作る
late final Stream<List<User>> _onlineStream;
@override
void initState() {
super.initState();
_onlineStream = DiscoverService.instance.watchOnline();
}
// build() では作らずに参照するだけ
StreamBuilder(stream: _onlineStream, builder: ...)
同じアンチパターンがチャット画面(メッセージ送信のたびに setState が走る)、
トーク一覧、ブロックリストにもありました。合計4ファイルを同じ方針で直しています。
トーク一覧とブロックリストは StatelessWidget でしたが、
initState() を使うために StatefulWidget に変えました。
「自分では setState していないから安全」とは限らず、親経由でリビルドされます。
覚えておくこと
「画面を離れたら cancel」だけでは足りない
リスナー管理の注意点としてよく言われるのは「画面を離れるときに必ず cancel() する」ですが、
それだけでは今回の症状は防げません。もう一つ、
同じ画面が生きているあいだは Stream を作り直さないという原則が要ります。
リアルタイムデータベースを扱う画面を追加するときは、この2つをセットで確認しています。
この不具合はとりまトークの Web 版で見つけたものです。
こういう記録を置いています
個人開発でつまずいた話や、作りかけのものは Instagram(@kenpiro1012)にも置いています。 記事の一覧は記事ページにまとまっています。