Blog / Flutter

StreamBuilder に Stream を直接書くと、画面が永久に読み込み中になる

Firebase RTDB のリスナーが毎回作り直されていた

2026年08月16日 公開 / Flutter ・ Firebase ・ RTDB ・ StreamBuilder

一覧画面がスケルトン表示のまま、いつまでも進まない。RTDB のルールもネットワークも App Check も疑いましたが、どれも違いました。原因は Flutter 側の書き方、それも多くの入門記事どおりに書いたコードでした。

01 — やってしまった書き方

stream: にサービス呼び出しを直接書く

StreamBuilder(
  stream: DiscoverService.instance.watchOnline(),  // ← これ
  builder: (context, snapshot) { ... },
)

一見なんの問題もありません。実際、画面がリビルドされなければ正しく動きます。 問題は build() が呼ばれるたびに watchOnline() が実行され、 そのたびに新しい Stream(=新しい RTDB のリスナー)が作られることです。

02 — なぜデータが消えるのか

リビルドの速さがデータ到着を追い越す

この画面は initState() でブロックリストとトークルームも購読していました。 そのどちらかが一度でも発火すると setState() が走り、build() が再実行されます。 すると StreamBuilder.didUpdateWidget が古いリスナーを破棄し、新しいリスナーに差し替えます。

このとき AsyncSnapshotconnectionState だけを waiting に戻します。 画面側が「connectionState == waiting ならスケルトンを出す」と書いていると、 せっかく届いたデータがスケルトンの判定に飲み込まれて捨てられます。 RTDB への実際の往復よりリビルドのほうが速い、あるいは両者が競合すると、 最初のイベントが永久に「無かったこと」にされ続けます

03 — 修正

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 していないから安全」とは限らず、親経由でリビルドされます。

04 — 覚えておくこと

「画面を離れたら cancel」だけでは足りない

リスナー管理の注意点としてよく言われるのは「画面を離れるときに必ず cancel() する」ですが、 それだけでは今回の症状は防げません。もう一つ、 同じ画面が生きているあいだは Stream を作り直さないという原則が要ります。 リアルタイムデータベースを扱う画面を追加するときは、この2つをセットで確認しています。

この不具合はとりまトークの Web 版で見つけたものです。

Follow

こういう記録を置いています

個人開発でつまずいた話や、作りかけのものは Instagram(@kenpiro1012)と note にも置いています。 記事の一覧は記事ページにまとまっています。