Introduzione agli Stream in Flutter

Gli Stream sono uno dei pilastri della programmazione asincrona in Dart. Mentre un Future rappresenta un singolo valore disponibile in futuro, uno Stream rappresenta una sequenza di eventi asincroni nel tempo: pensa a un termometro che invia letture ogni secondo, a una connessione WebSocket o agli aggiornamenti di un database reattivo.

In questo articolo vedremo come creare, manipolare e consumare gli Stream nelle nostre interfacce Flutter in modo pulito e performante.

Single-subscription vs Broadcast

Dart distingue due tipi di Stream:

  • Single-subscription: può avere un solo ascoltatore. È il tipo predefinito, adatto a flussi di dati come la lettura di un file.
  • Broadcast: può avere più ascoltatori contemporaneamente. Utile quando più widget devono reagire allo stesso evento.
// Single-subscription (default)
final controller = StreamController<int>();

// Broadcast
final broadcastController = StreamController<int>.broadcast();

Creare uno Stream con StreamController

Lo StreamController è il modo più diretto per produrre eventi manualmente:

class Contatore {
  final _controller = StreamController<int>();
  int _valore = 0;

  Stream<int> get stream => _controller.stream;

  void incrementa() {
    _valore++;
    _controller.sink.add(_valore); // emette un nuovo evento
  }

  void errore() {
    _controller.sink.addError('Qualcosa è andato storto');
  }

  void dispose() {
    _controller.close(); // sempre chiudere il controller!
  }
}

In alternativa, possiamo generare Stream in modo dichiarativo con le funzioni async* e la parola chiave yield:

Stream<int> contaAllaRovescia(int da) async* {
  for (int i = da; i >= 0; i--) {
    await Future.delayed(const Duration(seconds: 1));
    yield i;
  }
}

Consumare uno Stream con StreamBuilder

Il widget StreamBuilder ricostruisce parte dell'interfaccia ogni volta che lo Stream emette un nuovo evento. È lo strumento ideale per collegare flussi di dati alla UI senza gestire manualmente i listener.

StreamBuilder<int>(
  stream: contaAllaRovescia(10),
  initialData: 10,
  builder: (context, snapshot) {
    if (snapshot.hasError) {
      return Text('Errore: ${snapshot.error}');
    }
    switch (snapshot.connectionState) {
      case ConnectionState.waiting:
        return const CircularProgressIndicator();
      case ConnectionState.active:
        return Text('Mancano ${snapshot.data} secondi');
      case ConnectionState.done:
        return const Text('Tempo scaduto!');
      default:
        return const SizedBox.shrink();
    }
  },
)

Lo snapshot in dettaglio

L'oggetto AsyncSnapshot fornito al builder espone:

  • connectionState: lo stato della connessione (none, waiting, active, done).
  • hasData / data: presenza e valore dell'ultimo evento.
  • hasError / error: presenza e dettaglio di un eventuale errore.

Trasformare gli Stream

Dart offre numerosi metodi per manipolare i flussi, simili a quelli delle collezioni:

final stream = contaAllaRovescia(10)
    .where((n) => n.isEven)       // solo numeri pari
    .map((n) => 'Valore: $n')      // trasforma in stringa
    .take(3);                       // primi 3 eventi

Per combinare più Stream o applicare logiche più complesse (debounce, throttle, combineLatest) si può ricorrere al pacchetto rxdart, che estende le API native:

import 'package:rxdart/rxdart.dart';

final ricerca = textController.stream
    .debounceTime(const Duration(milliseconds: 300))
    .distinct()
    .switchMap((query) => apiService.cerca(query));

Best practice

Per evitare memory leak e bug sottili, tieni a mente queste regole:

  • Chiudi sempre gli StreamController nel metodo dispose() del tuo widget o della classe che li gestisce.
  • Cancella le sottoscrizioni manuali create con stream.listen(), salvando lo StreamSubscription e chiamando .cancel().
  • Fornisci initialData allo StreamBuilder quando possibile, per evitare uno stato di caricamento iniziale non necessario.
  • Usa Stream broadcast solo quando servono più ascoltatori, perché impedisce il buffering automatico degli eventi.
  • Non creare lo Stream dentro il metodo build: verrebbe ricreato a ogni ricostruzione. Inizializzalo in initState o in un provider.
class _MyWidgetState extends State<MyWidget> {
  late final StreamSubscription<int> _sub;

  @override
  void initState() {
    super.initState();
    _sub = mioStream.listen((evento) {
      // gestione evento
    });
  }

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

Conclusione

Gli Stream e lo StreamBuilder rappresentano un meccanismo nativo, potente e leggero per costruire interfacce reattive in Flutter, senza dipendere necessariamente da librerie di gestione dello stato. Comprenderne il funzionamento è fondamentale anche perché molte soluzioni più evolute (come BLoC) si basano proprio su questi concetti. Padroneggiare gli Stream ti permetterà di scrivere codice asincrono più chiaro, manutenibile e privo di leak.