FutureBuilder et StreamBuilder en Flutter
Introduction
Les widgets FutureBuilder et StreamBuilder sont des outils essentiels pour gérer les données asynchrones dans Flutter. Ils permettent de construire l'interface utilisateur de manière réactive en fonction de l'état des opérations asynchrones.
FutureBuilder
Le FutureBuilder est utilisé pour construire un widget en fonction de l'état d'un Future. Il reconstruit automatiquement l'interface lorsque le Future change d'état.
Syntaxe de base
FutureBuilder<T>(
future: monFuture,
builder: (context, snapshot) {
// Construction du widget basé sur snapshot
},
)
Les états du snapshot
Le AsyncSnapshot contient des informations sur l'état actuel du Future :
| Propriété | Type | Description |
|---|---|---|
connectionState | ConnectionState | État de la connexion (none, waiting, active, done) |
hasData | bool | Vrai si des données sont disponibles |
hasError | bool | Vrai si une erreur s'est produite |
data | T? | Les données retournées par le Future |
error | Object? | L'erreur si elle existe |
États de connexion
enum ConnectionState {
none, // Pas de Future assigné
waiting, // Future en cours d'exécution
active, // Pour les Streams uniquement
done, // Future complété (avec succès ou erreur)
}
Exemple simple
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'FutureBuilder Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const ProductPage(),
);
}
}
class ProductPage extends StatefulWidget {
const ProductPage({super.key});
State<ProductPage> createState() => _ProductPageState();
}
class _ProductPageState extends State<ProductPage> {
late Future<String> _futureProduit;
void initState() {
super.initState();
_futureProduit = recupererNomProduit();
}
Future<String> recupererNomProduit() async {
await Future.delayed(const Duration(seconds: 2));
return "iPhone 15 Pro";
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Produit")),
body: Center(
child: FutureBuilder<String>(
future: _futureProduit,
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
} else if (snapshot.hasError) {
return Text("Erreur : ${snapshot.error}");
} else if (snapshot.hasData) {
return Text(
snapshot.data!,
style: Theme.of(context).textTheme.headlineMedium,
);
} else {
return const Text("Aucune donnée");
}
},
),
),
);
}
}
Exemple avec API REST
import 'package:flutter/material.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'API REST FutureBuilder',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const ProductListPage(),
);
}
}
class Product {
final int id;
final String title;
final double price;
final String image;
Product({
required this.id,
required this.title,
required this.price,
required this.image,
});
factory Product.fromJson(Map<String, dynamic> json) {
return Product(
id: json['id'] as int,
title: json['title'] as String,
price: (json['price'] as num).toDouble(),
image: json['image'] as String,
);
}
}
class ProductListPage extends StatelessWidget {
const ProductListPage({super.key});
Future<List<Product>> recupererProduits() async {
final response = await http.get(
Uri.parse('https://fakestoreapi.com/products'),
);
if (response.statusCode == 200) {
final List<dynamic> jsonList = jsonDecode(response.body) as List;
return jsonList.map((json) => Product.fromJson(json as Map<String, dynamic>)).toList();
} else {
throw Exception('Erreur ${response.statusCode}');
}
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Produits")),
body: FutureBuilder<List<Product>>(
future: recupererProduits(),
builder: (context, snapshot) {
// État de chargement
if (snapshot.connectionState == ConnectionState.waiting) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
CircularProgressIndicator(),
SizedBox(height: 16),
Text("Chargement des produits..."),
],
),
);
}
// État d'erreur
if (snapshot.hasError) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error_outline, size: 64, color: Colors.red),
const SizedBox(height: 16),
Text("Erreur : ${snapshot.error}"),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () {
(context as Element).markNeedsBuild();
},
child: const Text("Réessayer"),
),
],
),
);
}
// État avec données
if (snapshot.hasData) {
final produits = snapshot.data!;
if (produits.isEmpty) {
return const Center(child: Text("Aucun produit disponible"));
}
return ListView.builder(
itemCount: produits.length,
itemBuilder: (context, index) {
final produit = produits[index];
return ListTile(
leading: Image.network(
produit.image,
width: 50,
height: 50,
fit: BoxFit.cover,
),
title: Text(produit.title),
subtitle: Text('\$${produit.price.toStringAsFixed(2)}'),
trailing: const Icon(Icons.arrow_forward_ios),
onTap: () {},
);
},
);
}
// État par défaut
return const Center(child: Text("Aucune donnée"));
},
),
);
}
}
Bonne pratique : Future en dehors du build
Mauvais : Le Future est recréé à chaque rebuild
Widget build(BuildContext context) {
return FutureBuilder(
future: recupererDonnees(), // Recréé à chaque rebuild !
builder: (context, snapshot) {
// ...
},
);
}
Bon : Le Future est créé une seule fois
class MyWidget extends StatefulWidget {
_MyWidgetState createState() => _MyWidgetState();
}
class _MyWidgetState extends State<MyWidget> {
late Future<List<Product>> _futureProduits;
void initState() {
super.initState();
_futureProduits = recupererDonnees(); // Créé une seule fois
}
Widget build(BuildContext context) {
return FutureBuilder(
future: _futureProduits,
builder: (context, snapshot) {
// ...
},
);
}
}
StreamBuilder
Le StreamBuilder est utilisé pour construire un widget en fonction des données émises par un Stream. Contrairement au FutureBuilder qui gère une seule valeur, le StreamBuilder gère un flux continu de données.
Syntaxe de base
StreamBuilder<T>(
stream: monStream,
initialData: valeurInitiale, // Optionnel
builder: (context, snapshot) {
// Construction du widget basé sur snapshot
},
)
Exemple simple
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'StreamBuilder Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
useMaterial3: true,
),
home: const CounterPage(),
);
}
}
class CounterPage extends StatelessWidget {
const CounterPage({super.key});
// Stream qui émet un nombre chaque seconde
Stream<int> compteur() async* {
for (int i = 1; i <= 10; i++) {
await Future.delayed(const Duration(seconds: 1));
yield i;
}
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Compteur")),
body: Center(
child: StreamBuilder<int>(
stream: compteur(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const CircularProgressIndicator();
} else if (snapshot.hasError) {
return Text("Erreur : ${snapshot.error}");
} else if (snapshot.hasData) {
return Text(
'${snapshot.data}',
style: Theme.of(context).textTheme.displayLarge,
);
} else {
return const Text("En attente...");
}
},
),
),
);
}
}
Exemple : Mises à jour en temps réel
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Météo StreamBuilder',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.orange),
useMaterial3: true,
),
home: const WeatherPage(),
);
}
}
class WeatherUpdate {
final double temperature;
final String condition;
final DateTime timestamp;
WeatherUpdate({
required this.temperature,
required this.condition,
required this.timestamp,
});
}
class WeatherPage extends StatelessWidget {
const WeatherPage({super.key});
Stream<WeatherUpdate> meteoEnTempsReel() async* {
while (true) {
await Future.delayed(const Duration(seconds: 3));
// Simule des données météo aléatoires
yield WeatherUpdate(
temperature: 15 + (DateTime.now().second % 10).toDouble(),
condition: ['Ensoleillé', 'Nuageux', 'Pluvieux'][DateTime.now().second % 3],
timestamp: DateTime.now(),
);
}
}
IconData _getWeatherIcon(String condition) {
switch (condition) {
case 'Ensoleillé':
return Icons.wb_sunny;
case 'Nuageux':
return Icons.cloud;
case 'Pluvieux':
return Icons.umbrella;
default:
return Icons.help;
}
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Météo en temps réel")),
body: Center(
child: StreamBuilder<WeatherUpdate>(
stream: meteoEnTempsReel(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
CircularProgressIndicator(),
SizedBox(height: 16),
Text("Connexion à la station météo..."),
],
);
}
if (snapshot.hasError) {
return Text("Erreur : ${snapshot.error}");
}
if (!snapshot.hasData) {
return const Text("En attente de données...");
}
final meteo = snapshot.data!;
return Card(
elevation: 4,
margin: const EdgeInsets.all(16),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_getWeatherIcon(meteo.condition),
size: 100,
color: Colors.blue,
),
const SizedBox(height: 16),
Text(
'${meteo.temperature.toStringAsFixed(1)}°C',
style: Theme.of(context).textTheme.displaySmall,
),
Text(
meteo.condition,
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 8),
Text(
'Mis à jour: ${meteo.timestamp.hour}:${meteo.timestamp.minute}:${meteo.timestamp.second}',
style: const TextStyle(color: Colors.grey),
),
],
),
),
);
},
),
),
);
}
}
Stream avec données initiales
StreamBuilder<int>(
stream: compteur(),
initialData: 0, // Valeur affichée avant la première émission
builder: (context, snapshot) {
return Text('Valeur: ${snapshot.data}');
},
)
Exemple : Chat en temps réel
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Chat StreamBuilder',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
home: const ChatPage(),
);
}
}
class Message {
final String auteur;
final String texte;
final DateTime date;
Message({required this.auteur, required this.texte, required this.date});
}
class ChatPage extends StatelessWidget {
const ChatPage({super.key});
Stream<List<Message>> messagesStream() async* {
final messages = <Message>[];
while (true) {
await Future.delayed(const Duration(seconds: 2));
messages.add(Message(
auteur: 'Utilisateur ${messages.length + 1}',
texte: 'Message numéro ${messages.length + 1}',
date: DateTime.now(),
));
yield List.from(messages); // Émettre une copie de la liste
}
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Chat")),
body: StreamBuilder<List<Message>>(
stream: messagesStream(),
initialData: const [],
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(child: Text("Erreur : ${snapshot.error}"));
}
final messages = snapshot.data ?? [];
if (messages.isEmpty) {
return const Center(child: Text("Aucun message"));
}
return ListView.builder(
reverse: true, // Afficher les nouveaux messages en bas
itemCount: messages.length,
itemBuilder: (context, index) {
final message = messages[messages.length - 1 - index];
return ListTile(
leading: CircleAvatar(
child: Text(message.auteur[0]),
),
title: Text(message.auteur),
subtitle: Text(message.texte),
trailing: Text(
'${message.date.hour}:${message.date.minute}',
style: const TextStyle(fontSize: 12),
),
);
},
);
},
),
);
}
}
Comparaison FutureBuilder vs StreamBuilder
| Aspect | FutureBuilder | StreamBuilder |
|---|---|---|
| Données | Une seule valeur | Flux continu de valeurs |
| Utilisation | Requêtes HTTP, chargement unique | Temps réel, mises à jour continues |
| ConnectionState | none, waiting, done | none, waiting, active, done |
| Cas d'usage | API REST, lecture fichier | Chat, notifications, capteurs |
Bonnes pratiques
1. Initialiser les Future/Stream dans initState
void initState() {
super.initState();
_futureDonnees = chargerDonnees();
_streamController = StreamController<int>();
}
2. Gérer tous les états
builder: (context, snapshot) {
// 1. État de chargement
if (snapshot.connectionState == ConnectionState.waiting) {
return CircularProgressIndicator();
}
// 2. État d'erreur
if (snapshot.hasError) {
return Text("Erreur : ${snapshot.error}");
}
// 3. État avec données
if (snapshot.hasData) {
return Text("Données : ${snapshot.data}");
}
// 4. État par défaut
return Text("Aucune donnée");
}
3. Fermer les StreamController
void dispose() {
_streamController.close();
super.dispose();
}
4. Utiliser initialData pour éviter les états vides
StreamBuilder<List<Item>>(
stream: itemsStream,
initialData: [], // Évite les vérifications null
builder: (context, snapshot) {
final items = snapshot.data!; // Toujours non-null
return ListView.builder(...);
},
)
5. Optimiser les reconstructions
Pour les Streams, utilisez distinct() pour éviter les reconstructions inutiles :
stream: myStream.distinct(), // Émet uniquement si la valeur change
Points à retenir
- FutureBuilder : Pour les opérations uniques (GET API, chargement fichier)
- StreamBuilder : Pour les flux continus (notifications, temps réel, WebSocket)
- Toujours gérer : waiting, error, hasData, et l'état par défaut
- Initialiser dans initState : Éviter les recréations inutiles
- Fermer les Streams : Dans dispose() pour éviter les fuites mémoire
- initialData : Utile pour afficher quelque chose immédiatement