StreamBuilder dans Flutter : Quand et pourquoi l’utiliser ?


Avatar de Pierre Courtois

Dans cet article, nous allons explorer comment fonctionne le StreamBuilder, dans quels cas il est pertinent de l’utiliser, et quelles sont les bonnes pratiques à suivre pour gérer efficacement les flux de données en Flutter.


Streambuilder flutter

À quoi sert le StreamBuilder et en quoi est-il différent du FutureBuilder ?

Le StreamBuilder est un widget de Flutter conçu pour écouter des flux continus de données (ou « streams »). Contrairement au FutureBuilder, qui est utilisé pour une tâche ponctuelle et affiche un résultat une fois que le Future est complété, le StreamBuilder réagit à chaque changement dans le flux. Ceci lui permet de mettre à jour l’interface utilisateur en temps réel à chaque émission de nouvelle donnée.

Pour résumer la différence majeure entre les deux :

FutureBuilder : Récupère des données une seule fois, par exemple pour un appel API ou une requête en base de données.

StreamBuilder : Écoute et affiche des flux continus de données, idéal pour des mises à jour en temps réel, comme des notifications ou des messages.

Exemples d’applications pouvant utiliser un StreamBuilder

Le StreamBuilder est un widget indispensable lorsque vous avez des données qui changent fréquemment et que vous souhaitez que votre interface utilisateur se mette à jour automatiquement sans intervention supplémentaire. Voici quelques cas d’utilisation, typiques :

  • Messages en temps réel : Parfait pour des applications de chat où les nouveaux messages doivent apparaître instantanément, ou disparaitre quand on les supprime.
  • Données de capteurs : Si vous écoutez des données provenant de capteurs (comme le GPS), le StreamBuilder mettra à jour la position en direct.
  • Notifications : Chaque nouvelle notification reçue peut être affichée immédiatement à l’utilisateur sans rechargement manuel de l’interface.

Comment mettre en place un StreamBuilder dans Flutter

Il existe plusieurs manière de mettre en place un StreamBuilder, selon le canal (stream), que vous allez écouter. Vous pouvez, par exemple, utiliser une variable stocké localement, ou utiliser une collection de documents dans Flutter.

Faire un StreamBuilder avec un Stream issu d’une variable

Prenons un exemple simple : une liste de données qui évolue en temps réel. Vous pouvez convertir cette liste en un Stream et utiliser le StreamBuilder pour afficher les modifications à chaque fois que la liste change.

Voici un exemple où on ajoute un élément dans une liste à chaque fois que l’utilisateur appuie sur le bouton. La liste est alors mise à jour en temps réel grâce au StreamBuilder :

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();  // Fermer le StreamController lorsqu'il n'est plus utilisé
    super.dispose();
  }

  void _addItem() {
    _items.add('Item ${_items.length + 1}');
    _streamController.sink.add(_items);  // Envoyer la liste mise à jour dans le 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('Aucun élément'));
                }
                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,  // Ajouter un élément lorsqu'on appuie sur le bouton
              child: Text('Ajouter un élément'),
            ),
          ),
        ],
      ),
    );
  }
}

Explication du code :

  1. StreamController : On utilise un StreamController<List<String>> pour gérer la liste des éléments. Le StreamController permet d’envoyer des données dans un stream (via sink.add()).
  2. Ajout d’élément : Lorsqu’on appuie sur le bouton, un nouvel élément est ajouté à la liste (_items), et la liste mise à jour est envoyée au StreamBuilder via le sink.
  3. StreamBuilder : Le StreamBuilder écoute les changements du stream. Chaque fois qu’un élément est ajouté à la liste, il reconstruit l’interface pour afficher les nouvelles données.

Faire un StreamBuilder avec un Stream issu de Firebase

L’intérêt de conserver les données de vos utilisateurs dans un backend est de pouvoir les renvoyer en front grâce au Streambuilder. Voici un exemple de code pour mettre en place un StreamBuilder afin d’afficher ces documents :

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('Liste de documents 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('Erreur : ${snapshot.error}'));
          }
          if (!snapshot.hasData || snapshot.data!.docs.isEmpty) {
            return Center(child: Text('Aucun document disponible'));
          }

          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']),
              );
            },
          );
        },
      ),
    );
  }
}

Ici, le StreamBuilder :

  • Met à jour dynamiquement l’interface utilisateur à chaque changement de données, sans intervention manuelle.
  • Écoute les changements en temps réel dans la collection 'tasks' de Firestore.
  • Affiche un indicateur de chargement pendant la récupération des données.
  • Gère les erreurs potentielles et les cas où il n’y a pas de documents.

Dois je utiliser .get ou .snapshot pour récupérer mes données dans Firebase ?

Lorsque vous récupérez vos données dans Firebase, vous pouvez utiliser les méthodes .snapshot(), ou bien .get(). Toutefois, les deux ne remplissent pas le même rôle :

.snapshots() renvoie un Stream qui écoute les modifications en temps réel dans Firestore. Utilisé avec un StreamBuilder, il permet de mettre à jour automatiquement l’interface à chaque changement dans les données (ajouts, modifications, suppressions). C’est idéal pour les applications qui nécessitent des mises à jour dynamiques et continues.

.get() renvoie une Future qui récupère une instantanée des données une seule fois, sans écouter les changements futurs. Utilisé avec un FutureBuilder, il convient pour les scénarios où les données ne changent pas fréquemment ou où un rafraîchissement manuel est suffisant.

En résumé, utilisez .snapshots() avec StreamBuilder pour les données en temps réel, et .get() avec FutureBuilder pour les données statiques ou ponctuelles.

Gestion des erreurs dans le StreamBuilder

Lorsque vous travaillez avec des streams, des erreurs peuvent se produire (par exemple, des problèmes de réseau, des données corrompues ou des échecs d’authentification). Il est donc essentiel de savoir comment gérer ces erreurs dans un StreamBuilder pour éviter que l’application ne se bloque ou affiche des informations incorrectes.

Dans le StreamBuilder, l’objet snapshot possède une propriété hasError que vous pouvez utiliser pour vérifier s’il y a eu une erreur dans le flux. Il est important de traiter ces erreurs afin d’informer l’utilisateur ou de prendre des mesures pour y remédier.

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

En cas d’erreur, je retourne ici un message d’erreur personnalisé pour indiquer qu’il y a eu un problème de chargement des données.

Gestion des états de connexion dans le StreamBuilder

Les streams peuvent avoir différents états de connexion, comme être en attente (waiting), être actif (active), ou être complété (done). Le StreamBuilder vous permet de réagir à ces différents états via la propriété connectionState de snapshot.

if (snapshot.connectionState == ConnectionState.waiting) {
  return CircularProgressIndicator();  // En attente de données
}

Cela vous permet de mieux contrôler ce que l’utilisateur voit pendant que le flux de données est encore en train de se préparer ou de charger.

Définir une donnée initiale dans le StreamBuilder

Avant que le stream n’émette sa première donnée, il peut être utile d’afficher une donnée par défaut pour ne pas laisser un écran vide. Il vous est possible d’afficher une valeur initiale avec le paramètre initialData.

StreamBuilder<int>(
  stream: myStream,
  initialData: 0,  // Valeur par défaut avant la première donnée
  builder: (context, snapshot) {
    return Text('Valeur actuelle : ${snapshot.data}');
  },
);

Cela permet de donner à l’utilisateur une première information avant que les données en temps réel ne commencent à arriver.

Utilisation d’un Stream en broadcast

Un Stream classique est conçu pour gérer un seul abonnement à la fois. Cela signifie que si plusieurs widgets, classes ou composants de ton application veulent écouter les mêmes données provenant d’un Stream, un seul listener peut y accéder à la fois. Si un deuxième listener s’abonne, le premier est automatiquement déconnecté.

Supposons que votre application de messagerie dispose d’un écran principal qui affiche la liste des conversations et un autre écran qui affiche les détails d’une conversation individuelle. Si les deux écrans s’abonnent au même Stream de messages pour suivre les nouveaux messages en temps réel :

Lorsque l’utilisateur navigue vers l’écran de détails, ce dernier s’abonne au Stream, et l’écran principal est automatiquement déconnecté. Puis, quand il revient à l’écran principal, il doit se ré-abonner, ce qui coupe l’écoute de l’écran de détails. Ce comportement peut, s’il est mal géré, bloquer l’usage de l’application et forcer l’utilisateur à la relancer.

À quoi sert le broadcast ?

Un Stream en broadcast est un type particulier de stream qui permet à plusieurs écouteurs (listeners) de s’abonner et de recevoir les mêmes événements simultanément. En revanche, un stream classique (ou unicast) ne peut avoir qu’un seul listener à la fois.

Différences entre un Stream classique et un Stream broadcast :

  • Stream classique : Ne supporte qu’un seul listener. Si un autre listener s’abonne, le premier est automatiquement déconnecté.
  • Stream broadcast : Permet à plusieurs listeners de s’abonner et de recevoir les événements en même temps.

Quand utiliser un Stream en broadcast ?

Les streams en broadcast sont utiles lorsque plusieurs parties de votre application doivent écouter les mêmes données ou événements en parallèle. Voici quelques exemples concrets :

  1. Notifications en temps réel : Vous voulez permettre à plusieurs widgets de votre interface d’écouter le même flux de notifications.
  2. Mise à jour globale des données : Plusieurs composants de votre interface utilisateur doivent réagir aux mêmes événements (par exemple, la mise à jour d’un état global, comme une liste de produits ou des informations sur l’utilisateur).
  3. Gestion d’événements multiples : Plusieurs abonnés doivent réagir à des événements externes (comme les mises à jour d’un capteur ou des changements de réseau).

Comment mettre en place un Stream en broadcast ?

Pour créer un broadcast stream en Dart, il suffit d’ajouter .asBroadcastStream() à un stream existant. Voici un exemple 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: 'Simple Broadcast Stream Example',
      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();

    // Écouter les événements du Broadcast Stream
    _controller.stream.listen((value) {
      setState(() {
        _counter1 = value;
        _counter2 = value;
      });
    });
  }

  @override
  void dispose() {
    _controller.close();  // Fermer le StreamController
    super.dispose();
  }

  void _incrementCounter() {
    _controller.add(_counter1 + 1);  // Émettre un nouvel événement
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Simple Broadcast Stream Example'),
      ),
      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('Increment Counters'),
            ),
          ],
        ),
      ),
    );
  }
}

Lorsque tu appuies sur le bouton « Increment Counters », les deux compteurs (_counter1 et _counter2) se mettent à jour simultanément, montrant comment un Broadcast Stream peut être utilisé pour diffuser des données à plusieurs parties de l’interface utilisateur.

Explication :

Création du StreamController :

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

On crée un StreamController en mode broadcast pour permettre à plusieurs abonnés d’écouter les événements.

Écoute du flux :

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

Un seul écouteur est ajouté au flux pour mettre à jour les deux compteurs simultanément.

Émission d’événements :

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

Lorsque le bouton est pressé, un nouvel événement est ajouté au flux, ce qui déclenche la mise à jour des deux compteurs.

Nettoyage :

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

Le StreamController est fermé lors de la destruction du widget pour libérer les ressources.

Avatar de Pierre Courtois