İçeriğe geç / Skip to content / Zum Inhalt

Flutter: Echtzeit-Datenströme mit StreamBuilder

Ahmet Balaman

Zuletzt aktualisiert:

9 Min. Lesezeit

FlutterStreamBuilderStreamStreamControllerAsyncRealtime
Flutter: Echtzeit-Datenströme mit StreamBuilder

StreamBuilder verbindet eine Quelle, die im Lauf der Zeit viele Werte liefert (Chatnachrichten, ein Bestellstatus, Sensordaten, eine Firestore-Abfrage), mit dem Widget-Baum. Das Widget abonniert den übergebenen Stream, ruft bei jedem neuen Wert erneut builder auf und nimmt Ihnen die Verwaltung von State und setState ab. Für ein einzelnes Ergebnis reicht der FutureBuilder; sobald Sie sagen „die Oberfläche soll sich aktualisieren, während Daten eintreffen“, ist dies das richtige Werkzeug. Neben den Grundlagen konzentriert sich dieser Beitrag auf die drei Stolpersteine, über die am häufigsten gestolpert wird: den Stream in build erzeugen, denselben Stream zweimal abhören und vergessen, den StreamController zu schließen.

Stream und Future im Vergleich

Auf einer Zeitachse liefert ein Future einen Wert und endet, ein Stream liefert viele Werte und build läuft für jeden

Merkmal Future Stream
Anzahl der Werte Ein Wert Null, ein oder viele Werte
Ende Sobald der Wert da ist Wenn die Quelle schließt (done)
Beispiel HTTP-Anfrage, Datei lesen WebSocket, Firestore snapshots(), Timer
Widget FutureBuilder StreamBuilder

Ein Future ist das Versprechen, dass ein Ergebnis kommt; ein Stream ist ein offener Kanal: Werte treffen ein, dazwischen kann ein Fehler auftauchen, und irgendwann wird der Kanal geschlossen, oder eben nie. Wenn async/await und Future noch nicht sitzen, lesen Sie zuerst Fehlerbehandlung und asynchrone Abläufe in Dart; Streams bauen auf dieser Grundlage auf.

Live-Demo

Sie können dieses Widget im interaktiven Beispiel unten ausprobieren:

💡 Falls das Beispiel oben nicht lädt, klicken Sie auf DartPad, um es in einem neuen Tab auszuführen.

Grundlegende Verwendung

Wir binden einen Stream, der jede Sekunde eine Zahl liefert, an die Oberfläche. Der Stream wird einmal im State erzeugt; egal wie oft build läuft, es wird dasselbe Objekt verwendet:

class CounterStreamPage extends StatefulWidget {
  const CounterStreamPage({super.key});

  @override
  State<CounterStreamPage> createState() => _CounterStreamPageState();
}

class _CounterStreamPageState extends State<CounterStreamPage> {
  // Der Stream wird einmal erzeugt; jeder build-Aufruf nutzt dasselbe Objekt
  late final Stream<int> _counter = _createCounter();

  Stream<int> _createCounter() async* {
    for (var i = 1; i <= 10; i++) {
      await Future.delayed(const Duration(seconds: 1));
      yield i; // Wert in den Stream schicken
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Zähler-Stream')),
      body: Center(
        child: StreamBuilder<int>(
          stream: _counter,
          builder: (context, snapshot) {
            if (snapshot.hasError) {
              return Text('Fehler: ${snapshot.error}');
            }
            switch (snapshot.connectionState) {
              case ConnectionState.none:
              case ConnectionState.waiting:
                return const Text('Startet...');
              case ConnectionState.active:
                return Text(
                  '${snapshot.data}',
                  style: const TextStyle(fontSize: 72, fontWeight: FontWeight.bold),
                );
              case ConnectionState.done:
                return Text('Fertig, letzter Wert: ${snapshot.data}');
            }
          },
        ),
      ),
    );
  }
}

Eine mit async* geschriebene Funktion liefert beim Aufruf einen Stream; ihr Rumpf läuft erst, wenn jemand zuhört, und jedes yield sendet einen Wert. Ist die Schleife durch, schließt sich der Stream von selbst und der Snapshot wechselt zu done.

Wichtige Eigenschaften

Eigenschaft Beschreibung
stream Der abzuhörende Stream; bei null steht der Snapshot auf none
builder Wird bei jedem neuen Wert, Fehler und Zustandswechsel aufgerufen
initialData Wird als snapshot.data angezeigt, bis der erste Wert eintrifft

connectionState bei einem Stream lesen

Beim FutureBuilder genügten waiting und done; bei einem Stream haben alle vier Zustände eine Bedeutung:

Zustand Bedeutung
none stream ist null, kein Abonnement
waiting Abonniert, aber noch kein Wert eingetroffen
active Mindestens ein Wert (oder Fehler) ist da, der Kanal ist noch offen
done Die Quelle wurde geschlossen; das letzte data oder error bleibt im Snapshot

Zwei Details sind wichtig. Erstens löscht done den letzten Wert nicht; wenn Sie „die Übertragung ist vorbei, aber der Endzustand bleibt sichtbar“ wollen, lesen Sie weiter snapshot.data. Zweitens: Übergeben Sie in stream ein anderes Objekt, kündigt StreamBuilder das alte Abonnement, abonniert das neue und behält dabei das bisherige data; die Oberfläche wird beim Quellenwechsel also nicht leer.

initialData: den ersten Frame füllen

Streams liefern ihren ersten Wert oft erst nach einer Weile. Wenn Sie in dieser Lücke keinen Spinner zeigen möchten, übergeben Sie initialData:

StreamBuilder<int>(
  stream: _counter,
  initialData: 0,
  builder: (context, snapshot) {
    // snapshot.data ist ab dem ersten Frame 0, danach die eintreffenden Werte
    return Text('${snapshot.data}');
  },
)

Vorsicht: Mit initialData ist hasData schon im ersten Frame true. Den Ladezustand erkennen Sie dann über connectionState == ConnectionState.waiting, nicht über !snapshot.hasData.

Eigene Streams mit StreamController erzeugen

Erzeugen Sie die Daten selbst (Button-Tap, Socket-Nachricht, Timer), verwenden Sie einen StreamController. Das folgende Beispiel hält eine Nachrichtenliste; bei jedem Hinzufügen schickt es eine frische Kopie der Liste in den Stream, und StreamBuilder zeichnet sie:

import 'dart:async';
import 'package:flutter/material.dart';

class MessageStreamPage extends StatefulWidget {
  const MessageStreamPage({super.key});

  @override
  State<MessageStreamPage> createState() => _MessageStreamPageState();
}

class _MessageStreamPageState extends State<MessageStreamPage> {
  final _controller = StreamController<List<String>>();
  final List<String> _messages = [];

  void _addMessage(String text) {
    _messages.add(text);
    _controller.add(List.unmodifiable(_messages)); // neuen Wert veröffentlichen
  }

  @override
  void dispose() {
    _controller.close(); // verhindert hängende Abonnements und Speicherlecks
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Nachrichten-Stream')),
      body: Column(
        children: [
          Wrap(
            spacing: 8,
            children: [
              FilledButton(onPressed: () => _addMessage('Hallo!'), child: const Text('Hallo')),
              FilledButton(onPressed: () => _addMessage('Wie geht es dir?'), child: const Text('Wie geht es dir?')),
            ],
          ),
          Expanded(
            child: StreamBuilder<List<String>>(
              stream: _controller.stream,
              initialData: const [],
              builder: (context, snapshot) {
                final messages = snapshot.data ?? const [];
                if (messages.isEmpty) {
                  return const Center(child: Text('Noch keine Nachrichten'));
                }
                return ListView.builder(
                  itemCount: messages.length,
                  itemBuilder: (context, index) => ListTile(
                    leading: const Icon(Icons.message),
                    title: Text(messages[index]),
                  ),
                );
              },
            ),
          ),
        ],
      ),
    );
  }
}

Der builder zeichnet nur den eintreffenden Wert und hängt selbst nichts an die Liste an. Diese Trennung ist wichtig: Flutter darf builder so oft aufrufen, wie es will; wer darin eine Liste verändert, bekommt dieselbe Nachricht mehrfach.

Broadcast- und Single-Subscription-Streams

Der Stream eines StreamController() ist ein Single-Subscription-Stream: Er akzeptiert genau einen Zuhörer, ein zweiter löst einen Fehler aus. Streams aus async*-Funktionen verhalten sich genauso. Sollen mehrere Widgets dieselbe Quelle abhören, verwenden Sie broadcast:

// Ein Zuhörer: der zweite listen()-Aufruf wirft einen Fehler
final single = StreamController<int>();

// Mehrere Zuhörer: beliebig viele StreamBuilder und listen()-Aufrufe
final shared = StreamController<int>.broadcast();

Der Preis eines Broadcast-Streams: Werte, die gesendet werden, während niemand zuhört, gehen verloren, und ein später hinzukommender Abonnent sieht frühere Werte nicht. Brauchen Sie eine Quelle, die sich „den letzten Wert merkt“, halten Sie ihn zusätzlich in einem Feld und übergeben ihn als initialData; genau das macht das Szenario unten. Haben Sie nur einen Single-Subscription-Stream, macht asBroadcastStream() ihn teilbar; die sauberere Lösung ist aber meist, einen einzigen StreamBuilder weiter oben im Baum zu platzieren.

Streams transformieren

Vor dem Abhören können Sie einen Stream mit Methoden wie map, where und take umformen; jede liefert einen neuen Stream:

final ticks = Stream.periodic(const Duration(seconds: 1), (count) => count).take(10);

final evenOnly = ticks.where((n) => n.isEven);
final labels = ticks.map((n) => 'Sekunde: $n');

Auch die Transformation gehört nicht in build; stream: ticks.map(...) erzeugt bei jedem Build einen neuen Stream und läuft direkt in den ersten Fehler unten.

StreamBuilder oder listen() in initState?

Beide hören denselben Stream ab; der Unterschied liegt in der Absicht. StreamBuilder macht aus dem eintreffenden Wert Oberfläche und verwaltet das Abonnement selbst (abonnieren, bei Stream-Wechsel neu abonnieren, beim Tod des Widgets kündigen). listen() ist dafür da, bei einem Wert einen Nebeneffekt auszulösen: eine SnackBar zeigen, navigieren, in eine lokale Datenbank schreiben. Nebeneffekte gehören nicht in builder, denn builder läuft während des Zeichnens, und Navigator.push oder showSnackBar führen dort zu Fehlern.

late final StreamSubscription<OrderStatus> _sub;

@override
void initState() {
  super.initState();
  _sub = tracker.status.listen((status) {
    if (!mounted) return;
    if (status == OrderStatus.delivered) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Ihre Bestellung wurde geliefert')),
      );
    }
  });
}

@override
void dispose() {
  _sub.cancel(); // Abonnement freigeben
  super.dispose();
}

Wollen Sie den Wert aus listen() heraus auch anzeigen, rufen Sie setState auf; dann liegen die mounted-Prüfung und das cancel in Ihrer Verantwortung. Warum die Reihenfolge in dispose wichtig ist, erkläre ich im Beitrag zum Lebenszyklus eines StatefulWidget.

Wann verwenden – und wann nicht?

Wählen Sie StreamBuilder dort, wo ein einzelner Screen eine Live-Quelle direkt widerspiegelt: eine Chatliste, ein Bestellstatus, ein Zähler, Firestore snapshots() oder WebSocket-Nachrichten. Verschwindet der Screen zusammen mit der Quelle und nutzt sonst niemand die Daten, brauchen Sie nicht mehr.

In diesen Fällen passt ein anderes Werkzeug besser:

  • Die Quelle liefert ein einziges Ergebnis (API-Anfrage, Datei lesen): Sie in einen Stream zu packen ist sinnlos; nehmen Sie den FutureBuilder.
  • Mehrere Screens hören denselben Stream ab, oder der letzte Wert muss über Screens hinweg erhalten bleiben: Das Abonnement an den State eines Widgets zu binden wird brüchig. StreamProvider in Riverpod (oder im Paket provider) hält das Abonnement unabhängig vom Screen und liefert dasselbe Trio „lädt, Fehler, Daten“.
  • Der Wert ändert sich nur innerhalb desselben Widgets (Zähler, gewählter Tab): Dafür ist ein Stream zu schwer; ValueNotifier mit ValueListenableBuilder oder schlichtes setState reicht.
  • Sie müssen als Reaktion auf einen Wert navigieren oder eine Benachrichtigung zeigen: Das ist listen(), nicht StreamBuilder; siehe den Abschnitt oben.

Häufige Fehler

1. Den Stream in build erzeugen

Die klassische Falle, die auch in der alten Fassung dieses Beitrags steckte:

// FALSCH: der Getter erzeugt bei jedem Lesen einen neuen Stream
Stream<int> get counterStream async* { /* ... */ }

// in build
StreamBuilder<int>(stream: counterStream, ...)

Symptom: Der Zähler beginnt bei jedem setState, beim Öffnen der Tastatur oder beim Drehen des Geräts von vorn; im Log startet derselbe Vorgang immer wieder. Bei jedem build liefert counterStream ein neues Stream-Objekt, StreamBuilder sieht „der Stream hat sich geändert“, lässt den alten fallen, abonniert den neuen, und die Produktion beginnt von vorn. Die Lösung: den Stream einmal in einem late final-Feld oder in initState erzeugen; der Code im Abschnitt zur grundlegenden Verwendung macht das. Dieselbe Regel gilt für Transformationen wie stream: controller.stream.map(...).

2. Denselben Stream an zwei Stellen abhören

Symptom: Der zweite StreamBuilder oder listen()-Aufruf bricht ab mit:

Bad state: Stream has already been listened to.

Ursache: Der Stream ist Single-Subscription. Lösung: entweder StreamController<T>.broadcast() verwenden oder, besser, den Stream mit einem einzigen StreamBuilder weiter oben abhören und die Daten als Parameter an die Kind-Widgets weiterreichen. Denselben Fehler können Sie auch beim Hot Reload sehen; stellen Sie sicher, dass der Stream im State und nicht in build erzeugt wird.

3. Den StreamController nicht schließen oder nach dem Schließen schreiben

Zwei Symptome. Schließen Sie ihn nie, laufen die Zuhörer weiter, nachdem die Seite verschwunden ist; ein setState in listen() erzeugt die Warnung „setState() called after dispose()“ und Speicher läuft aus. Rufen Sie nach dem Schließen add auf, erhalten Sie:

Bad state: Cannot add event after closing

Lösung: _controller.close() in dispose und cancel() für jede StreamSubscription. Kann der Code, der die Quelle schließt, von mehreren Stellen laufen, fügen Sie eine isClosed-Prüfung hinzu: if (!_controller.isClosed) _controller.add(value).

4. data! ohne Prüfung von hasError verwenden

Symptom: Sendet die Quelle einen Fehler, stürzt der Screen ab mit:

Null check operator used on a null value

In einem Stream muss ein Fehler den Fluss nicht beenden: StreamController.addError lässt den Kanal offen, der nächste Wert löscht den Fehler wieder. Beim Eintreffen eines Fehlers wird snapshot.data zu null; Code, der ohne Blick auf hasError data! schreibt, fällt um. Erst hasError, dann die Daten, und geben Sie dem Nutzer einen Weg zum erneuten Versuch. In async*-Funktionen schließt ein throw den Stream mit einem Fehler, danach folgt done.

Mini-Szenario: Bestellung live verfolgen

Stellen Sie sich einen Screen für eine Essensbestellung vor: Der Status durchläuft „eingegangen, in Zubereitung, unterwegs, geliefert“. Der Screen zeichnet die Schritte mit StreamBuilder und zeigt bei der Lieferung über listen() eine SnackBar. Die Quelle ist ein Broadcast-StreamController; der aktuelle Status wird zusätzlich in einem Feld gehalten und als initialData übergeben, sodass ein spät abonnierendes Widget nie einen leeren Frame sieht.

import 'dart:async';
import 'package:flutter/material.dart';

enum OrderStatus {
  received('Bestellung eingegangen'),
  preparing('In Zubereitung'),
  onTheWay('Unterwegs'),
  delivered('Geliefert');

  const OrderStatus(this.label);
  final String label;
}

/// In einer echten App wäre das ein WebSocket- oder Firestore-Listener.
class OrderTracker {
  final _controller = StreamController<OrderStatus>.broadcast();
  Timer? _timer;
  OrderStatus _current = OrderStatus.received;

  Stream<OrderStatus> get status => _controller.stream;
  OrderStatus get current => _current;

  void start() {
    _timer = Timer.periodic(const Duration(seconds: 3), (timer) {
      _current = OrderStatus.values[_current.index + 1];
      _controller.add(_current);
      if (_current == OrderStatus.delivered) {
        timer.cancel();
        _controller.close(); // Fluss beendet: StreamBuilder wechselt zu done
      }
    });
  }

  void dispose() {
    _timer?.cancel();
    if (!_controller.isClosed) _controller.close();
  }
}

class OrderPage extends StatefulWidget {
  const OrderPage({super.key});

  @override
  State<OrderPage> createState() => _OrderPageState();
}

class _OrderPageState extends State<OrderPage> {
  final _tracker = OrderTracker();
  late final StreamSubscription<OrderStatus> _sub;

  @override
  void initState() {
    super.initState();
    _sub = _tracker.status.listen((status) {
      if (!mounted || status != OrderStatus.delivered) return;
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(content: Text('Guten Appetit, Ihre Bestellung ist da!')),
      );
    });
    _tracker.start();
  }

  @override
  void dispose() {
    _sub.cancel();
    _tracker.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    final scheme = Theme.of(context).colorScheme;
    return Scaffold(
      appBar: AppBar(title: const Text('Bestellverfolgung')),
      body: StreamBuilder<OrderStatus>(
        stream: _tracker.status,
        initialData: _tracker.current,
        builder: (context, snapshot) {
          if (snapshot.hasError) {
            return Center(child: Text('Verbindungsfehler: ${snapshot.error}'));
          }
          final status = snapshot.data ?? OrderStatus.received;
          final finished = snapshot.connectionState == ConnectionState.done;
          return ListView(
            children: [
              for (final step in OrderStatus.values)
                ListTile(
                  leading: Icon(
                    step.index <= status.index
                        ? Icons.check_circle
                        : Icons.radio_button_unchecked,
                    color: step.index <= status.index ? scheme.primary : null,
                  ),
                  title: Text(step.label),
                ),
              if (finished)
                const Padding(
                  padding: EdgeInsets.all(16),
                  child: Text('Verfolgung abgeschlossen.'),
                ),
            ],
          );
        },
      ),
    );
  }
}

Hier stecken vier Entscheidungen. Die Quelle ist Broadcast, weil sowohl StreamBuilder als auch listen() denselben Stream abonnieren; bei einem Single-Subscription-Stream würde der zweite einen Fehler auslösen. Dank initialData: _tracker.current ist der erste Frame nicht leer, der aktuelle Schritt wird sofort gezeichnet. Bei der Lieferung wird close() aufgerufen; StreamBuilder wechselt zu done, aber weil der letzte Wert (delivered) im Snapshot bleibt, bleiben alle Schritte abgehakt. Auch die Reihenfolge in dispose ist bewusst gewählt: erst das Abonnement kündigen, dann die Quelle schließen, zuletzt super.dispose().

Häufig gestellte Fragen

Warum startet StreamBuilder immer wieder von vorn?

Weil Sie in stream ein Objekt übergeben, das in build erzeugt wird (ein Getter, ein async*-Aufruf oder eine .map(...)-Transformation). Jeder Build erzeugt einen neuen Stream, und StreamBuilder abonniert ihn erneut. Erzeugen Sie den Stream einmal in initState oder in einem late final-Feld.

Was bedeutet „Stream has already been listened to“?

Sie versuchen, einen Single-Subscription-Stream ein zweites Mal abzuhören. Brauchen Sie mehrere Zuhörer, verwenden Sie StreamController<T>.broadcast(), oder hören Sie mit einem einzigen StreamBuilder weiter oben zu und reichen die Daten an die Kind-Widgets weiter.

Was ist der Unterschied zwischen StreamBuilder und FutureBuilder?

FutureBuilder wartet auf ein Ergebnis und ändert sich nach done nicht mehr; StreamBuilder hört einen Kanal ab, baut bei jedem neuen Wert neu und wird done, wenn der Kanal schließt. Für eine einmalige Anfrage den ersten, für Live-Daten den zweiten.

Wann sollte ich den StreamController schließen?

Wenn das Objekt stirbt, das ihn erzeugt hat: in der Methode dispose, wenn er in einem State lebt, oder in der eigenen dispose-Methode des Service, wenn er in einer Service-Klasse lebt. Schließen Sie ihn nicht, laufen die Zuhörer weiter und Speicher läuft aus.

Kommentare