StreamBuilder en Flutter: ¿Cuándo y por qué usarlo?


Avatar de Pedro Cortez

En este artículo, exploraremos cómo funciona el StreamBuilder, en qué casos es relevante utilizarlo y cuáles son las mejores prácticas para gestionar eficazmente los flujos de datos en Flutter.


Streambuilder Flutter

¿Para qué sirve el StreamBuilder y en qué se diferencia del FutureBuilder?

El StreamBuilder es un widget de Flutter diseñado para escuchar flujos continuos de datos (o «streams»). A diferencia del FutureBuilder, que se utiliza para realizar una tarea puntual y muestra un resultado una vez que el Future se completa, el StreamBuilder reacciona a cada cambio en el flujo. Esto le permite actualizar la interfaz de usuario en tiempo real cada vez que se emite un nuevo dato.

Resumen de la diferencia principal entre ambos:

  • FutureBuilder: Recupera datos una sola vez, por ejemplo, para una llamada a una API o una consulta a una base de datos.
  • StreamBuilder: Escucha y muestra flujos continuos de datos, ideal para actualizaciones en tiempo real como notificaciones o mensajes.

Ejemplos de aplicaciones que pueden usar un StreamBuilder

El StreamBuilder es indispensable cuando tienes datos que cambian frecuentemente y deseas que la interfaz de usuario se actualice automáticamente sin intervención adicional. Algunos casos de uso típicos son:

  • Mensajes en tiempo real: Ideal para aplicaciones de chat donde los mensajes nuevos deben aparecer de inmediato o desaparecer cuando se eliminan.
  • Datos de sensores: Si escuchas datos de sensores (como GPS), el StreamBuilder puede actualizar la posición en tiempo real.
  • Notificaciones: Cada nueva notificación puede mostrarse al usuario de inmediato sin necesidad de recargar manualmente la interfaz.

¿Cómo implementar un StreamBuilder en Flutter?

Hay varias formas de configurar un StreamBuilder según el flujo (stream) que desees escuchar. Puedes, por ejemplo, usar una variable almacenada localmente o conectarte a una colección de documentos en Firebase.

StreamBuilder con un Stream desde una variable

Un ejemplo simple: una lista de datos que evoluciona en tiempo real. Puedes convertir esta lista en un Stream y usar el StreamBuilder para mostrar los cambios cada vez que la lista se actualiza.

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

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'StreamBuilder Example',
      home: ListStreamScreen(),
    );
  }
}

class ListStreamScreen extends StatefulWidget {
  @override
  _ListStreamScreenState createState() => _ListStreamScreenState();
}

class _ListStreamScreenState extends State<ListStreamScreen> {
  final StreamController<List<String>> _streamController = StreamController();
  List<String> _items = [];

  @override
  void dispose() {
    _streamController.close(); // Cierra el StreamController cuando ya no se usa
    super.dispose();
  }

  void _addItem() {
    _items.add('Elemento ${_items.length + 1}');
    _streamController.sink.add(_items); // Envía la lista actualizada al stream
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('StreamBuilder Example'),
      ),
      body: Column(
        children: [
          Expanded(
            child: StreamBuilder<List<String>>(
              stream: _streamController.stream,
              builder: (context, snapshot) {
                if (!snapshot.hasData || snapshot.data!.isEmpty) {
                  return Center(child: Text('Sin elementos'));
                }
                return ListView.builder(
                  itemCount: snapshot.data!.length,
                  itemBuilder: (context, index) {
                    return ListTile(
                      title: Text(snapshot.data![index]),
                    );
                  },
                );
              },
            ),
          ),
          Padding(
            padding: const EdgeInsets.all(16.0),
            child: ElevatedButton(
              onPressed: _addItem, // Agrega un elemento al presionar el botón
              child: Text('Agregar elemento'),
            ),
          ),
        ],
      ),
    );
  }
}

Explicación del código:

  1. StreamController: Utilizamos un StreamController<List<String>> para manejar la lista de elementos. Este controlador permite enviar datos al flujo mediante sink.add().
  2. Agregar elemento: Cada vez que se presiona el botón, se añade un nuevo elemento a la lista _items, y la lista actualizada se envía al StreamBuilder.
  3. StreamBuilder: Escucha los cambios en el flujo y reconstruye la interfaz para mostrar los nuevos datos.

StreamBuilder con un Stream desde Firebase

Almacenar los datos de los usuarios en un backend permite enviarlos al frontend mediante un StreamBuilder. Aquí tienes un ejemplo de cómo configurar un StreamBuilder para mostrar documentos desde Firebase:

import 'package:flutter/material.dart';
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:tutoflutter/firebase_options.dart';

void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Firebase StreamBuilder',
      home: FirebaseStreamScreen(),
    );
  }
}

class FirebaseStreamScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Lista de documentos Firebase'),
      ),
      body: StreamBuilder<QuerySnapshot>(
        stream: FirebaseFirestore.instance.collection('tasks').snapshots(),
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return Center(child: CircularProgressIndicator());
          }
          if (snapshot.hasError) {
            return Center(child: Text('Error: ${snapshot.error}'));
          }
          if (!snapshot.hasData || snapshot.data!.docs.isEmpty) {
            return Center(child: Text('No hay documentos disponibles'));
          }

          return ListView.builder(
            itemCount: snapshot.data!.docs.length,
            itemBuilder: (context, index) {
              var task = snapshot.data!.docs[index].data() as Map<String, dynamic>;
              return ListTile(
                title: Text(task['name']),
                subtitle: Text(task['description']),
              );
            },
          );
        },
      ),
    );
  }
}

Aquí, el StreamBuilder:

  • Maneja errores potenciales y casos donde no hay documentos disponibles.
  • Actualiza dinámicamente la interfaz de usuario con cada cambio de datos, sin intervención manual.
  • Escucha los cambios en tiempo real en la colección ‘tasks’ de Firestore.
  • Muestra un indicador de carga mientras se recuperan los datos.

¿Debo usar .get o .snapshots para recuperar datos en Firebase?

Cuando recuperas datos en Firebase, puedes usar los métodos .snapshots() o .get(). Sin embargo, tienen roles diferentes:

  • .snapshots() devuelve un Stream que escucha cambios en tiempo real en Firestore. Usado con un StreamBuilder, permite actualizar automáticamente la interfaz con cada cambio en los datos (adiciones, modificaciones, eliminaciones). Es ideal para aplicaciones que necesitan actualizaciones dinámicas y continuas.
  • .get() devuelve un Future que recupera una instantánea de los datos una sola vez, sin escuchar futuros cambios. Usado con un FutureBuilder, es adecuado para escenarios donde los datos no cambian con frecuencia o donde una actualización manual es suficiente.

En resumen:

  • Usa .snapshots() con StreamBuilder para datos en tiempo real.
  • Usa .get() con FutureBuilder para datos estáticos o consultas puntuales.

Manejo de errores en el StreamBuilder

Cuando trabajas con streams, pueden ocurrir errores (por ejemplo, problemas de red, datos corruptos o fallos de autenticación). Es esencial manejar estos errores en un StreamBuilder para evitar que la aplicación se bloquee o muestre información incorrecta.

En el StreamBuilder, el objeto snapshot tiene una propiedad hasError que puedes usar para verificar si ocurrió un error en el flujo. Es importante gestionar estos errores para informar al usuario o tomar medidas correctivas.

if (snapshot.hasError) {
  return Text('Error: ${snapshot.error}');
}

En este caso, devuelvo un mensaje personalizado para indicar que hubo un problema al cargar los datos.

Gestión de estados de conexión en el StreamBuilder

Los streams pueden tener diferentes estados de conexión, como estar en espera (waiting), estar activo (active) o estar completado (done). El StreamBuilder permite reaccionar a estos estados mediante la propiedad connectionState de snapshot.

if (snapshot.connectionState == ConnectionState.waiting) {
  return CircularProgressIndicator();  // Esperando datos
}

Esto permite controlar mejor lo que el usuario ve mientras el flujo de datos se está preparando o cargando.

Definir un dato inicial en el StreamBuilder

Antes de que el stream emita su primer dato, puede ser útil mostrar un valor por defecto para evitar una pantalla vacía. Puedes mostrar un valor inicial con el parámetro initialData.

StreamBuilder<int>(
  stream: myStream,
  initialData: 0,  // Valor por defecto antes de los datos reales
  builder: (context, snapshot) {
    return Text('Valor actual: ${snapshot.data}');
  },
);

Esto permite proporcionar una primera información al usuario antes de que lleguen los datos en tiempo real.

Uso de un Stream en modo broadcast

Un Stream clásico está diseñado para manejar una única suscripción a la vez. Esto significa que si varios widgets, clases o componentes de tu aplicación quieren escuchar los mismos datos de un Stream, solo un listener puede acceder a la vez. Si un segundo listener se suscribe, el primero se desconecta automáticamente.

Ejemplo práctico

Supongamos que tu aplicación de mensajería tiene una pantalla principal que muestra la lista de conversaciones y otra pantalla que muestra los detalles de una conversación individual. Si ambas pantallas se suscriben al mismo Stream de mensajes:

  • Al navegar a la pantalla de detalles, esta se suscribe al Stream y desconecta automáticamente la pantalla principal.
  • Al volver a la pantalla principal, esta debe volver a suscribirse, desconectando la pantalla de detalles. Este comportamiento puede causar problemas en la experiencia del usuario.

¿Para qué sirve el broadcast?

Un Stream en broadcast es un tipo especial de Stream que permite que varios listeners se suscriban y reciban los mismos eventos simultáneamente. En cambio, un Stream clásico (unicast) solo admite un listener a la vez.


Diferencias entre Stream clásico y Stream broadcast:

  • Stream clásico: Solo permite un listener. Si otro listener se suscribe, el primero es desconectado.
  • Stream broadcast: Permite que varios listeners se suscriban y reciban eventos al mismo tiempo.

¿Cuándo usar un Stream en broadcast?

Los Streams en broadcast son útiles cuando varias partes de tu aplicación necesitan escuchar los mismos datos o eventos en paralelo. Ejemplos concretos:

  1. Notificaciones en tiempo real: Permitir que varios widgets de la interfaz escuchen el mismo flujo de notificaciones.
  2. Actualización global de datos: Varios componentes de la interfaz reaccionan a los mismos eventos (por ejemplo, actualización de un estado global).
  3. Gestión de eventos múltiples: Múltiples suscriptores reaccionan a eventos externos (como actualizaciones de sensores o cambios de red).

¿Cómo implementar un Stream en broadcast?

Para crear un stream broadcast en Dart, solo necesitas agregar .asBroadcastStream() a un stream existente. Aquí tienes un ejemplo simple:

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

void main() {
  runApp(MyApp());
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Ejemplo Simple de Broadcast Stream',
      home: CounterScreen(),
    );
  }
}

class CounterScreen extends StatefulWidget {
  @override
  _CounterScreenState createState() => _CounterScreenState();
}

class _CounterScreenState extends State<CounterScreen> {
  final StreamController<int> _controller = StreamController<int>.broadcast();
  int _counter1 = 0;
  int _counter2 = 0;

  @override
  void initState() {
    super.initState();

    // Escuchar los eventos del Broadcast Stream
    _controller.stream.listen((value) {
      setState(() {
        _counter1 = value;
        _counter2 = value;
      });
    });
  }

  @override
  void dispose() {
    _controller.close(); // Cerrar el StreamController
    super.dispose();
  }

  void _incrementCounter() {
    _controller.add(_counter1 + 1); // Emitir un nuevo evento
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Ejemplo Simple de Broadcast Stream'),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            Text(
              'Counter 1:',
            ),
            Text(
              '$_counter1',
            ),
            SizedBox(height: 20),
            Text(
              'Counter 2:',
            ),
            Text(
              '$_counter2',
            ),
            SizedBox(height: 40),
            ElevatedButton(
              onPressed: _incrementCounter,
              child: Text('Incrementar Contadores'),
            ),
          ],
        ),
      ),
    );
  }
}

Cuando presionas el botón «Incrementar Contadores», ambos contadores (_counter1 y _counter2) se actualizan simultáneamente, mostrando cómo un Stream broadcast puede usarse para difundir datos a varias partes de la interfaz de usuario.

Explicación:

Creación del StreamController:

final StreamController<int> _controller = StreamController<int>.broadcast();

Creamos un StreamController en modo broadcast para permitir que varios oyentes escuchen los eventos.

Escucha del flujo:

_controller.stream.listen((value) {
  setState(() {
    _counter1 = value;
    _counter2 = value;
  });
});

Solo un oyente se agrega al flujo para actualizar ambos contadores simultáneamente.

Emisión de eventos:

void _incrementCounter() {
  _controller.add(_counter1 + 1);
}

Cuando se presiona el botón, se agrega un nuevo evento al flujo, lo que desencadena la actualización de ambos contadores.

Limpieza:

@override
void dispose() {
  _controller.close();
  super.dispose();
}

El StreamController se cierra durante la destrucción del widget para liberar recursos.

Avatar de Pedro Cortez