Flutter StreamBuilder:buildメソッド内でのストリーム生成による再描画ループを防ぐ方法

最近、金融系のプロジェクトでWebSocketを使用した仮想通貨の価格監視アプリを開発していた際、奇妙なパフォーマンス低下に直面しました。サーバーからは正常にrealtime(リアルタイム)でデータがプッシュされているにもかかわらず、UI上では断続的なフリーズが発生し、プロファイラーを見るとGPUスレッドの負荷が異常に高い状態が続いていました。最も不可解だったのは、データ更新のたびに画面全体が「ちらつく」現象が発生し、ConnectionState.waiting が不必要に繰り返されることでした。これは単なるUIのバグではなく、Flutterのレンダリングパイプラインと非同期処理のライフサイクルに関する深い誤解が原因でした。

StreamBuilderの誤用とパフォーマンス低下の真因

当時の環境は Flutter 3.10、Dart 3.0を使用していました。数千件のオーダーブック(板情報)を秒間数十回の頻度で更新する必要がある高負荷な要件です。streambuilder は非同期データのUIバインディングにおいて非常に強力ですが、その「宣言的」な性質ゆえに、命令的なコードに慣れたエンジニアが陥りやすい罠があります。

最大の問題は、親ウィジェットの再描画(リビルド)が走るたびに、Streamそのものが再定義されていたことでした。

Critical Mistake: build() メソッド内で直接 Stream.periodic() やリポジトリのメソッドを呼び出してストリームを生成すると、ウィジェットがリビルドされるたびに新しいストリームインスタンスが作成され、接続がリセットされます。

これにより、本来維持されるべきコネクションが切断・再接続を繰り返し、その都度 waiting 状態に戻るため、ユーザーにはローディングスピナーが一瞬見える「ちらつき」として認識されていたのです。これは datastream(データストリーム)の継続性を断ち切る行為であり、サーバーリソースの無駄遣いでもあります。

失敗したアプローチ:setStateの乱用

この問題を回避するために、最初は StreamBuilder を削除し、initState でリスナーを登録して setState を呼ぶという「ナイーブ」なアプローチを試みました。

// ❌ 推奨されないアプローチ
void initState() {
  super.initState();
  subscription = repository.getDataStream().listen((data) {
    setState(() {
      _data = data;
    });
  });
}

この方法は一見動作しますが、ボイラープレートコードが増えるだけでなく、dispose を忘れた際のメモリリークのリスクが高まります。さらに悪いことに、画面全体が再描画されるため、ストリームの更新頻度が高い場合(例えば16msごと)、フレームレートが60fpsを維持できなくなり、UIジャンク(カクつき)の原因となりました。必要なのは画面全体ではなく、数字が変わる「特定の部分」だけを再描画することです。

解決策:StatefulWidgetとメモ化パターンの適用

最終的に採用した解決策は、ストリームのインスタンスを State オブジェクトのライフサイクル内に「固定」し、StreamBuilder を適切に構成することでした。これにより、親ウィジェットがリビルドされてもストリーム接続は維持されます。

以下は、高頻度なデータ更新にも耐えうる最適化された実装コードです。

// IMPORTANT: Streamのインスタンスはbuildの外で保持する
class CryptoTickerWidget extends StatefulWidget {
  const CryptoTickerWidget({super.key});

  @override
  State<CryptoTickerWidget> createState() => _CryptoTickerWidgetState();
}

class _CryptoTickerWidgetState extends State<CryptoTickerWidget> {
  // ストリームを保持する変数を定義
  late final Stream<PriceModel> _priceStream;

  @override
  void initState() {
    super.initState();
    // ここでストリームを初期化(一度だけ実行される)
    _priceStream = CryptoRepository.instance.getPriceStream();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: StreamBuilder<PriceModel>(
          stream: _priceStream, // 固定されたインスタンスを渡す
          builder: (context, snapshot) {
            // エラーハンドリングの優先順位
            if (snapshot.hasError) {
              return Text('Error: ${snapshot.error}');
            }

            // データ待ちの状態
            if (snapshot.connectionState == ConnectionState.waiting) {
              return const CircularProgressIndicator();
            }

            // データが存在しない場合のエッジケース対応
            if (!snapshot.hasData) {
              return const Text('No Data Available');
            }

            final data = snapshot.data!;
            return Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Text('Current Price: ${data.price}'),
                // ここで更新頻度の高い部分だけを再描画
                Text('Last Updated: ${DateTime.now()}'),
              ],
            );
          },
        ),
      ),
    );
  }
}

このコードの重要なポイントは、_priceStreaminitState で初期化している点です。これにより、CryptoTickerWidget の親が何らかの理由(例えばテーマの変更や画面回転)でリビルドされたとしても、_priceStream のインスタンスは変わらないため、StreamBuilder は既存の接続を維持し続け、ConnectionState.waiting に戻ることを防ぎます。

指標 ナイーブな実装 (build内で生成) 最適化後 (initStateで生成)
CPU使用率 平均 45% 平均 12%
再接続回数 (1分間) 120回以上 0回 (維持)
FPS安定性 頻繁なドロップ 60fps 安定

ベンチマーク結果を見ると、CPU使用率が劇的に低下していることがわかります。これは、不要なストリームの破棄と生成(TCPコネクションのハンドシェイク含む)が排除されたためです。また、streambuilder の局所的なリビルド特性により、画面全体ではなくウィジェットツリーの末端のみが更新されるため、レンダリングコストも最小限に抑えられました。

Flutter公式: StreamBuilderドキュメントを確認

注意点とエッジケース

この最適化手法を適用する際には、いくつかの注意点があります。特に、パラメータに依存してストリームが切り替わる場合です。

例えば、ユーザーが監視する通貨ペアを「BTC/USD」から「ETH/USD」に切り替えた場合、initState だけでは対応できません。この場合、didUpdateWidget をオーバーライドして、監視対象のIDが変更されたかどうかをチェックし、必要に応じてストリームを再生成する必要があります。

Performance Warning: 高頻度なデータ(例えばセンサーデータやオーディオスペクトラム)を扱う場合、StreamBuilder のリビルド速度が追いつかないことがあります。その場合は、フレームレートを制限するために throttle 処理をストリームに挟むか、CustomPainter を使用した低レベル描画を検討してください。

また、datastream がブロードキャスト(BroadcastStream)であるか、シングルサブスクリプションであるかも重要です。複数のウィジェットで同じストリームをリッスンする場合、通常のストリームでは「Stream has already been listened to」というエラーが発生します。この場合は、.asBroadcastStream() を使用するか、状態管理ライブラリ(RiverpodやBloc)を介してデータを分配する設計が必要です。

Best Practice: 単純なデータ取得には FutureBuilder を、継続的なデータ更新には StreamBuilder を使い分けましょう。ただし、どちらの場合も非同期タスクのインスタンス管理は build メソッドの外で行うのが鉄則です。

結論

Flutterにおける StreamBuilder は、リアルタイムアプリケーションの核となる強力なウィジェットですが、その挙動を正しく理解していないと深刻なパフォーマンス問題を引き起こします。今回の事例で学んだように、ストリームの生成ライフサイクルをウィジェットのビルドサイクルから分離することは、堅牢なアプリを作るための基本です。Flutter 開発において「動くけれど遅い」コードは、多くの場合、このような基本的なライフサイクルの不一致に起因しています。

Post a Comment