Asynchrone et isolates
Dart exécute votre code sur un seul thread par isolate, piloté par une boucle d'événements. Comprendre cette boucle explique la majorité des bugs asynchrones et des saccades (jank) d'une application Flutter. C'est aussi le terrain favori des questions « que affiche ce code ? » en entretien.
Objectifs
- Prédire l'ordre de sortie d'un programme qui mélange code synchrone, microtasks et events.
- Gérer les erreurs de Future et de Stream sans en perdre aucune.
- Écrire un debounce et un throttle sans IA, avec Timer puis sur un Stream.
- Savoir quand un isolate est utile, ce qu'il coûte, et l'utiliser avec
Isolate.run. - Expliquer la différence entre concurrence et parallélisme en Dart.
L'event loop de Dart
Un isolate Dart possède un thread, une zone mémoire et une boucle d'événements (event loop). Cette boucle gère deux files :
- la microtask queue : tâches courtes et prioritaires, créées par
scheduleMicrotask,Future.microtasket les callbacksthensur un Future déjà complété ; - l'event queue : événements extérieurs (entrées-sorties, timers, gestes, messages entre isolates, frames Flutter) et
Future(...),Future.delayed,Timer.
La règle : le code synchrone en cours s'exécute jusqu'au bout, sans interruption. Ensuite, la boucle vide entièrement la microtask queue, puis prend un seul événement de l'event queue, puis vide de nouveau les microtasks, et ainsi de suite.
import 'dart:async';
void main() {
print('1');
Future(() => print('2')); // event queue
scheduleMicrotask(() => print('3')); // microtask
Future.value().then((_) => print('4')); // then sur Future complété : microtask
Future.microtask(() => print('5')); // microtask
Timer.run(() => print('7')); // event queue, après le Future
print('6');
}
// Sortie : 1 6 3 4 5 2 7
Une fonction async s'exécute de façon synchrone jusqu'à son premier await. C'est seulement à ce moment qu'elle rend la main à l'appelant.
Future<void> charger() async {
print('A'); // synchrone
await Future<void>.delayed(Duration.zero);
print('B'); // plus tard, via l'event queue
}
void main() {
print('1');
charger();
print('2');
}
// Sortie : 1 A 2 B
« Un calcul lourd dans une fonction async ne bloque pas l'UI, n'est-ce pas ? » Faux. async ne crée pas de thread. Un parsing de 200 ms dans une fonction async bloque l'isolate principal pendant 200 ms, donc une douzaine de frames à 60 Hz sont perdues. Autre piège : une microtask qui en planifie une autre en boucle affame l'event queue, et l'application ne dessine plus aucune frame.
« Dart est mono-thread par isolate. La boucle d'événements exécute le code synchrone, vide toute la microtask queue, puis traite un événement de l'event queue. async/await apporte de la concurrence, pas du parallélisme : pendant qu'on attend une réponse réseau, le thread est libre pour autre chose. Pour un vrai calcul CPU, il faut un autre isolate. »
Future
Un Future<T> représente une valeur ou une erreur disponible plus tard. On le consomme avec then / catchError ou avec async / await. Les deux sont équivalents, mais await se lit comme du code séquentiel et permet un try/catch classique. Préférez-le, sauf pour une transformation d'une ligne.
Future<int> solde() async => 125000;
// Style callbacks
void afficherV1() {
solde()
.then((s) => print('Solde : $s'))
.catchError((Object e) => print('Erreur : $e'));
}
// Style séquentiel
Future<void> afficherV2() async {
try {
final s = await solde();
print('Solde : $s');
} catch (e) {
print('Erreur : $e');
}
}
Paralléliser des attentes
Deux await successifs exécutent les requêtes l'une après l'autre. Pour les lancer ensemble, on démarre les Futures puis on attend l'ensemble. Depuis Dart 3, l'extension wait sur un record conserve le type de chaque résultat.
Future<Map<String, Object?>> profil() async => {'fullName': 'Amina'};
Future<int> soldeCentimes() async => 125000;
Future<void> accueil() async {
// types hétérogènes conservés : (Map, int)
final (p, s) = await (profil(), soldeCentimes()).wait;
print('${p['fullName']} : $s');
// types homogènes
final soldes = await Future.wait([soldeCentimes(), soldeCentimes()]);
print(soldes.length);
}
Par défaut, Future.wait attend que tous les Futures soient terminés, puis échoue avec la première erreur survenue. Avec eagerError: true, il échoue dès la première erreur. (a, b).wait lève une ParallelWaitError qui donne accès aux résultats partiels et aux erreurs.
Erreurs non awaitées et unawaited
Un Future qui échoue sans que personne ne l'écoute produit une erreur non gérée (unhandled error). Elle remonte à la zone courante et, en Flutter, au gestionnaire PlatformDispatcher.instance.onError. Elle n'interrompt pas le try/catch de l'appelant, puisque celui-ci n'a pas attendu.
Future<void> envoyerStatistiques() async => throw Exception('réseau');
Future<void> valider() async {
try {
envoyerStatistiques(); // pas d'await : le catch ne verra rien
} catch (e) {
print('jamais exécuté');
}
}
import 'dart:async';
Future<void> envoyerStatistiques() async => throw Exception('réseau');
void valider() {
// "fire and forget" assumé, avec gestion d'erreur locale
unawaited(envoyerStatistiques().catchError((Object e) {
print('statistiques ignorées : $e');
}));
}
Activez les lints unawaited_futures et discarded_futures : ils signalent chaque Future ignoré par accident. unawaited (de dart:async) ne change rien à l'exécution, il documente l'intention et fait taire le lint.
Délais et FutureBuilder
Un appel réseau sans délai maximal peut rester suspendu indéfiniment sur un réseau mobile dégradé. future.timeout(duree) lève une TimeoutException si le Future n'est pas terminé à temps. Attention : le travail sous-jacent continue, seul l'appelant cesse d'attendre. Pour une requête HTTP, on configure plutôt les délais du client (Dio a connectTimeout et receiveTimeout), qui ferment réellement la connexion.
Côté widgets, FutureBuilder affiche l'état d'un Future (ConnectionState, hasData, hasError). Son piège est le même que celui de StreamBuilder, décrit plus bas : si le Future est créé dans build, chaque rebuild relance la requête.
import 'package:flutter/widgets.dart';
Future<int> chargerSolde() async => 125000;
class Solde extends StatelessWidget {
const Solde({super.key});
@override
Widget build(BuildContext context) {
return FutureBuilder<int>(
future: chargerSolde(), // nouvel appel à chaque build
builder: (context, snap) => Text('${snap.data ?? '...'}'),
);
}
}
import 'package:flutter/widgets.dart';
Future<int> chargerSolde() async => 125000;
class Solde extends StatefulWidget {
const Solde({super.key});
@override
State<Solde> createState() => _SoldeState();
}
class _SoldeState extends State<Solde> {
late final Future<int> _solde = chargerSolde(); // créé une fois
@override
Widget build(BuildContext context) {
return FutureBuilder<int>(
future: _solde,
builder: (context, snap) => Text('${snap.data ?? '...'}'),
);
}
}
Dans une vraie application, ce chargement vit dans un FutureProvider ou un AsyncNotifier (chapitre 06), qui met en cache le résultat, gère le rafraîchissement et expose un AsyncValue à trois états.
Completer
Un Completer fabrique un Future que l'on complète soi-même. Il sert à adapter une API à callbacks, ou à faire attendre plusieurs appelants sur une même opération. C'est la brique de base de la file d'attente d'un intercepteur de refresh token (chapitre 09).
import 'dart:async';
class RafraichisseurJeton {
Completer<String>? _enCours;
Future<String> obtenir(Future<String> Function() appelRefresh) {
// si un refresh est déjà en cours, tous les appelants attendent le même
final existant = _enCours;
if (existant != null) return existant.future;
final completer = Completer<String>();
_enCours = completer;
appelRefresh().then(completer.complete, onError: completer.completeError)
.whenComplete(() => _enCours = null);
return completer.future;
}
}
Compléter deux fois un Completer lève une StateError. Et un Completer jamais complété laisse ses appelants suspendus pour toujours, sans erreur ni log. Toute branche d'erreur doit appeler completeError, et un délai (timeout) est souvent prudent.
Stream
Un Stream<T> est une suite d'événements asynchrones : des valeurs, des erreurs, puis éventuellement un signal de fin (done). Là où un Future livre une valeur, un Stream en livre zéro, une ou plusieurs.
| Single-subscription | Broadcast | |
|---|---|---|
| Abonnés | un seul, une seule fois | plusieurs |
| Bufferise avant l'écoute | oui | non : un abonné tardif rate les événements passés |
| Exemples | lecture de fichier, réponse HTTP, async* | événements UI, bus d'événements, connectivité |
| Création | StreamController() | StreamController.broadcast(), stream.asBroadcastStream() |
import 'dart:async';
// Générateur : le corps s'exécute à l'écoute, se suspend à chaque yield
Stream<int> compteARebours(int depart) async* {
for (var i = depart; i >= 0; i--) {
await Future<void>.delayed(const Duration(seconds: 1));
yield i;
}
}
class Connectivite {
final _controller = StreamController<bool>.broadcast();
Stream<bool> get enLigne => _controller.stream;
void signaler(bool valeur) => _controller.add(valeur);
Future<void> dispose() => _controller.close();
}
Transformations
Les Streams se composent comme des collections : map, where, distinct, expand, take, skip, asyncMap (transformation asynchrone, séquentielle) et asyncExpand. Pour une logique sur mesure, StreamTransformer.fromHandlers ou un StreamController intermédiaire. Les opérateurs plus riches (switchMap, debounceTime, combineLatest) viennent de rxdart ou de stream_transform.
await for et erreurs dans un Stream
Dans une fonction async, await for consomme un Stream comme une boucle. La boucle se termine quand le Stream est fermé ; un break annule l'abonnement. Une erreur émise par le Stream est levée dans la boucle et s'attrape avec un try/catch classique. Point important : await for ne se termine jamais sur un Stream qui ne se ferme pas, comme un flux de connectivité. Le code placé après la boucle n'est alors jamais exécuté.
Stream<int> montants() async* {
yield 15000;
yield 2500;
throw const FormatException('ligne corrompue');
}
Future<int> total() async {
var somme = 0;
try {
await for (final m in montants()) {
somme += m;
}
} on FormatException catch (e) {
print('Arrêt : ${e.message}');
}
return somme; // 17500
}
Avec listen, une erreur n'interrompt pas le flux par défaut : elle arrive dans onError et les valeurs suivantes continuent d'arriver. Sans onError, l'erreur devient une erreur non gérée de la zone. cancelOnError: true annule l'abonnement à la première erreur. Dans une chaîne, handleError filtre ou journalise les erreurs sans casser le flux. Retenez la différence : un Future porte une valeur ou une erreur ; un Stream peut alterner valeurs et erreurs avant de se fermer.
« Je choisis un Future pour une réponse unique et un Stream pour une suite de valeurs dans le temps : position, connectivité, changements d'une table locale. Un Stream single-subscription garde les événements jusqu'à l'écoute, un broadcast les diffuse sans mémoire. Je gère les erreurs dans le flux avec onError ou handleError, et je confie le cycle de vie de l'abonnement à un provider ou à StreamBuilder pour ne jamais oublier un cancel. Pour un état qui doit rejouer sa dernière valeur à chaque nouvel abonné, un simple broadcast ne suffit pas : j'utilise un provider ou un BehaviorSubject de rxdart. »
sync* et Iterable paresseux
Le pendant synchrone de async* est sync* : il produit un Iterable paresseux dont les éléments sont calculés à la demande. Utile pour générer une suite potentiellement longue (les dates d'un échéancier, les pages d'un export) sans tout allouer. Il n'a rien d'asynchrone et ne libère pas le thread.
StreamSubscription, annulation et fuites mémoire
listen renvoie une StreamSubscription qui permet de mettre en pause, reprendre et annuler. Un abonnement non annulé maintient en vie le callback et tout ce qu'il capture, souvent un State entier avec son BuildContext. C'est la fuite mémoire la plus courante d'une application Flutter, souvent accompagnée d'un setState() called after dispose().
import 'dart:async';
import 'package:flutter/widgets.dart';
class BandeauConnexion extends StatefulWidget {
const BandeauConnexion({super.key, required this.enLigne});
final Stream<bool> enLigne;
@override
State<BandeauConnexion> createState() => _BandeauConnexionState();
}
class _BandeauConnexionState extends State<BandeauConnexion> {
StreamSubscription<bool>? _abonnement;
bool _enLigne = true;
@override
void initState() {
super.initState();
_abonnement = widget.enLigne.listen((v) => setState(() => _enLigne = v));
}
@override
void dispose() {
_abonnement?.cancel(); // sans cela : fuite et setState après dispose
super.dispose();
}
@override
Widget build(BuildContext context) =>
_enLigne ? const SizedBox.shrink() : const Text('Hors ligne');
}
Annuler l'abonnement à un générateur async* l'arrête au prochain yield, et ses blocs finally s'exécutent : c'est l'endroit où libérer une ressource. En pratique, avec Riverpod (chapitre 06), un StreamProvider gère l'abonnement et l'annulation pour vous, et StreamBuilder fait de même dans l'arbre de widgets.
Écouter deux fois un Stream single-subscription lève Bad state: Stream has already been listened to. Cas typique : un StreamBuilder dont le paramètre stream est créé dans build (stream: repo.watch()). À chaque rebuild, un nouveau Stream est créé et l'abonnement repart de zéro. Créez le Stream une fois, dans initState ou dans un provider.
Debounce et throttle
Debounce (anti-rebond) : n'agir qu'après un silence d'une durée donnée. Usage typique : la recherche à la frappe, pour n'envoyer qu'une requête quand l'utilisateur s'arrête de taper. Throttle (limitation de débit) : agir au plus une fois par intervalle. Usage typique : un bouton de paiement martelé, un défilement, un envoi de position.
import 'dart:async';
class Debouncer {
Debouncer(this.delai);
final Duration delai;
Timer? _timer;
void run(void Function() action) {
_timer?.cancel(); // chaque appel repousse l'échéance
_timer = Timer(delai, action);
}
void dispose() => _timer?.cancel();
}
class Throttler {
Throttler(this.intervalle);
final Duration intervalle;
DateTime? _dernier;
void run(void Function() action) {
final maintenant = DateTime.now();
final dernier = _dernier;
if (dernier == null || maintenant.difference(dernier) >= intervalle) {
_dernier = maintenant;
action(); // les appels intermédiaires sont ignorés
}
}
}
La version Stream est plus composable : elle s'insère dans une chaîne de transformations et gère pause, annulation et fin du flux.
import 'dart:async';
extension DebounceStream<T> on Stream<T> {
Stream<T> debounce(Duration delai) {
late StreamController<T> controller;
StreamSubscription<T>? source;
Timer? timer;
T? enAttente;
var aUneValeur = false;
void emettreEnAttente() {
if (aUneValeur) {
aUneValeur = false;
controller.add(enAttente as T);
}
}
controller = StreamController<T>(
onListen: () {
source = listen(
(valeur) {
enAttente = valeur;
aUneValeur = true;
timer?.cancel();
timer = Timer(delai, emettreEnAttente);
},
onError: controller.addError,
onDone: () {
timer?.cancel();
emettreEnAttente(); // ne pas perdre la dernière valeur
controller.close();
},
);
},
onPause: () => source?.pause(),
onResume: () => source?.resume(),
onCancel: () {
timer?.cancel();
return source?.cancel();
},
);
return controller.stream;
}
}
Future<List<String>> rechercher(String q) async => ['$q-1', '$q-2'];
Stream<List<String>> resultats(Stream<String> saisie) => saisie
.map((t) => t.trim())
.where((t) => t.length >= 2)
.distinct()
.debounce(const Duration(milliseconds: 300))
.asyncMap(rechercher);
asyncMap traite les requêtes une par une, dans l'ordre. Si une requête lente arrive après une requête plus récente, l'utilisateur peut quand même voir des résultats périmés selon votre logique d'affichage. Les solutions : switchMap de rxdart (abandonne la requête précédente), ou un compteur de génération qui ignore toute réponse dont le numéro n'est pas le dernier émis.
Annulation
Un Future Dart ne s'annule pas. On peut seulement cesser de s'intéresser à son résultat. Ce qui s'annule réellement : un Timer, une StreamSubscription, une requête HTTP via un jeton dédié (le CancelToken de Dio), un isolate via kill. Pour exposer une opération annulable, package:async propose CancelableOperation.
class RechercheBeneficiaire {
var _generation = 0;
List<String> resultats = const [];
Future<void> chercher(String q, Future<List<String>> Function(String) api) async {
final maGeneration = ++_generation;
final r = await api(q);
if (maGeneration != _generation) return; // une recherche plus récente existe
resultats = r;
}
}
« Un Future ne s'annule pas en Dart, donc je distingue deux choses : arrêter le travail, avec un CancelToken Dio ou l'annulation d'un abonnement, et ignorer le résultat, avec un compteur de génération ou ref.mounted en Riverpod 3. Sur un écran de recherche, je fais les deux : debounce de 300 ms, annulation de la requête précédente, et je n'affiche que la réponse la plus récente. »
Zones, en bref
Une Zone est un contexte d'exécution qui suit le code asynchrone : les callbacks planifiés dans une zone s'exécutent dans cette même zone. On s'en sert pour intercepter les erreurs non gérées (runZonedGuarded), transporter des valeurs implicites (zoneValues), ou contrôler le temps dans les tests (fakeAsync de package:fake_async repose sur une zone).
En Flutter, la recommandation actuelle pour la remontée d'erreurs est de configurer deux gestionnaires plutôt qu'une zone : FlutterError.onError pour les erreurs du framework (build, layout, paint) et PlatformDispatcher.instance.onError pour les erreurs asynchrones non gérées. C'est ce que font les intégrations Crashlytics et Sentry.
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
FlutterError.onError = (details) {
FlutterError.presentError(details);
// envoyer details.exception et details.stack au crash reporting
};
PlatformDispatcher.instance.onError = (error, stack) {
debugPrint('Non gérée : $error');
return true; // erreur traitée
};
runApp(const SizedBox());
}
Isolates
Un isolate est une unité d'exécution avec son propre tas mémoire (heap) et sa propre boucle d'événements. Les isolates ne partagent pas d'objets mutables : ils communiquent uniquement par messages. Ce modèle supprime toute une classe de bugs (verrous, conditions de course sur la mémoire) et permet à chaque isolate d'avoir son propre ramasse-miettes, sans pause globale.
| Notion | En Dart |
|---|---|
| Concurrence | plusieurs tâches progressent en alternance sur un thread : async/await, Streams |
| Parallélisme | plusieurs tâches s'exécutent au même instant sur plusieurs cœurs : isolates |
Isolate.run : le cas courant
Isolate.run (Dart 2.19) crée un isolate, exécute une fonction, renvoie son résultat et détruit l'isolate. Le résultat est renvoyé avec Isolate.exit, qui transfère la mémoire sans la copier. La fonction compute de Flutter fait la même chose et s'appuie aujourd'hui sur Isolate.run ; la documentation recommande Isolate.run dans le code nouveau, sauf besoin de compatibilité web (voir plus bas).
import 'dart:convert';
import 'dart:isolate';
final class Ligne {
const Ligne(this.id, this.montant);
final String id;
final int montant;
}
// Fonction de niveau supérieur : ne capture rien de superflu
List<Ligne> _parser(String brut) {
final data = jsonDecode(brut) as Map<String, Object?>;
final items = data['items'] as List<Object?>;
return [
for (final item in items)
if (item case {'id': String id, 'amount': int montant}) Ligne(id, montant),
];
}
Future<List<Ligne>> parserEnArrierePlan(String brut) {
// décodage ET conversion en modèles dans l'isolate
return Isolate.run(() => _parser(brut));
}
Le coût du message passing
Les messages entre isolates sont copiés (sauf objets immuables partagés comme les chaînes ou les constantes, selon la VM, et le résultat de Isolate.exit). Envoyer 5 Mo à un isolate pour un calcul de 2 ms coûte plus cher que le calcul lui-même. Démarrer un isolate coûte aussi quelques millisecondes. Les règles pratiques :
- Mesurez d'abord avec DevTools (onglet Performance) : un isolate se justifie au-delà d'environ une frame (16 ms) de travail CPU.
- Faites le maximum dans l'isolate : décoder et convertir en modèles, renvoyer le résultat final.
- Pour de gros tableaux d'octets (images, fichiers chiffrés),
TransferableTypedDataévite la copie. - Attention aux closures :
Isolate.run(() => this.calcul())tente d'envoyerthiset tout ce qu'il référence, ce qui échoue si l'objet contient unSocket, unReceivePortou un objet natif.
Cas d'usage légitimes : parsing JSON volumineux (historique complet, export), chiffrement et hachage (dérivation de clé PBKDF2, AES sur un fichier), traitement d'image (compression avant envoi d'une pièce KYC), recherche plein texte ou tri sur des dizaines de milliers d'éléments.
Isolates de longue durée
Quand un même travail revient souvent (chiffrer chaque message, indexer en continu), recréer un isolate à chaque fois coûte cher. On démarre un isolate avec Isolate.spawn et on dialogue par ports : un ReceivePort reçoit, son SendPort se transmet à l'autre isolate. Le premier message échangé sert de poignée de main (handshake).
import 'dart:async';
import 'dart:isolate';
class Worker {
Worker._(this._isolate, this._versWorker, this._reponses);
final Isolate _isolate;
final SendPort _versWorker;
final ReceivePort _reponses;
final _enAttente = <int, Completer<String>>{};
var _prochainId = 0;
static Future<Worker> demarrer() async {
final reponses = ReceivePort();
final isolate = await Isolate.spawn(_boucle, reponses.sendPort);
final portWorker = Completer<SendPort>();
late Worker worker;
reponses.listen((message) {
if (message is SendPort) {
portWorker.complete(message); // handshake
} else if (message case (int id, String resultat)) {
worker._enAttente.remove(id)?.complete(resultat);
}
});
worker = Worker._(isolate, await portWorker.future, reponses);
return worker;
}
Future<String> traiter(String texte) {
final id = _prochainId++; // corrèle requête et réponse
final completer = Completer<String>();
_enAttente[id] = completer;
_versWorker.send((id, texte));
return completer.future;
}
void arreter() {
_reponses.close();
_isolate.kill(priority: Isolate.immediate);
}
// Point d'entrée du worker : fonction statique ou de niveau supérieur
static void _boucle(SendPort versPrincipal) {
final entrees = ReceivePort();
versPrincipal.send(entrees.sendPort);
entrees.listen((message) {
if (message case (int id, String texte)) {
final resultat = texte.toUpperCase(); // travail lourd ici
versPrincipal.send((id, resultat));
}
});
}
}
Isolates et plugins
Un isolate secondaire n'a pas, par défaut, accès aux platform channels. Depuis Flutter 3.7, BackgroundIsolateBinaryMessenger le permet : on récupère un RootIsolateToken dans l'isolate principal et on l'initialise dans l'isolate secondaire. Certains plugins restent liés au thread principal côté natif : vérifiez leur documentation.
import 'dart:isolate';
import 'package:flutter/services.dart';
Future<bool?> verifierEnArrierePlan() async {
final token = RootIsolateToken.instance!; // dans l'isolate principal
return Isolate.run(() async {
BackgroundIsolateBinaryMessenger.ensureInitialized(token);
const canal = MethodChannel('app/securite');
return canal.invokeMethod<bool>('estRoote');
});
}
Flutter web
dart:isolate n'est pas disponible sur le web, ni avec dart2js ni avec dart2wasm. compute fonctionne sur le web mais exécute la fonction sur le thread principal : le code compile, sans gain de fluidité. Pour du vrai parallélisme sur le web, il faut des Web Workers via package:web. Si l'application cible aussi le web, isolez l'appel derrière une abstraction et utilisez compute ou un import conditionnel.
« Je modifie une variable globale dans Isolate.run, pourquoi l'isolate principal ne voit-il rien ? » Parce que chaque isolate a sa propre copie des variables globales et statiques. Un singleton n'est unique que par isolate. Pour la même raison, un cache, une instance Dio ou un ProviderContainer créés dans l'isolate principal n'existent pas dans le secondaire : il faut passer les données, pas les services.
« Je n'utilise un isolate qu'après avoir mesuré un travail CPU qui dépasse une frame. Pour un traitement ponctuel, Isolate.run ; pour un traitement récurrent, un isolate de longue durée avec SendPort et ReceivePort. Je fais le maximum dans l'isolate pour limiter les copies, et je garde en tête que sur le web il n'y a pas d'isolates. »
Questions d'entretien
Expliquez l'event loop de Dart.
Chaque isolate a un thread et deux files. Après le code synchrone, la boucle vide toute la microtask queue, puis traite un seul événement de l'event queue (timers, I/O, gestes, frames, Future()), puis revient aux microtasks. Les microtasks sont donc toujours prioritaires, ce qui permet de terminer un travail interne avant de traiter le monde extérieur.
async/await rend-il le code parallèle ?
Non. C'est de la concurrence sur un seul thread : pendant l'attente d'une I/O, le thread exécute d'autres tâches. Un calcul CPU dans une fonction async bloque toujours l'isolate. Le parallélisme passe par les isolates.
Quelle différence entre un Stream single-subscription et un broadcast ?
Le premier n'accepte qu'un abonné, une seule fois, et bufferise les événements avant l'écoute (fichier, réponse HTTP). Le second accepte plusieurs abonnés mais ne garde rien : un abonné tardif rate les événements passés (événements UI, connectivité). Réécouter un single-subscription lève une StateError.
Comment éviter les fuites mémoire avec les Streams ?
Annuler chaque StreamSubscription dans dispose, fermer les StreamController qu'on possède, ne pas créer le Stream dans build. Mieux : déléguer l'abonnement à StreamBuilder ou à un StreamProvider Riverpod qui gère le cycle de vie.
Que devient l'erreur d'un Future qu'on n'attend pas ?
Elle n'est pas attrapée par le try/catch de l'appelant. Elle devient une erreur non gérée de la zone courante et arrive, en Flutter, à PlatformDispatcher.instance.onError. Les lints unawaited_futures et discarded_futures détectent ces oublis ; unawaited marque un fire-and-forget volontaire.
À quoi sert un Completer ?
À créer un Future que l'on complète manuellement : adapter une API à callbacks, ou faire attendre plusieurs appelants sur une seule opération, comme un refresh token partagé par toutes les requêtes en échec 401. Il faut le compléter exactement une fois, y compris en cas d'erreur.
Debounce ou throttle : lequel pour un champ de recherche, lequel pour un bouton de paiement ?
Debounce pour la recherche : on attend que l'utilisateur ait fini de taper. Pour le bouton de paiement, un throttle évite les doubles clics, mais la vraie protection est ailleurs : bouton désactivé pendant l'envoi et clé d'idempotence côté serveur, pour ne jamais débiter deux fois.
Isolate.run ou compute ?
Les deux exécutent une fonction dans un isolate éphémère ; compute s'appuie désormais sur Isolate.run. Isolate.run est dans dart:isolate, utilisable hors Flutter et accepte une closure. compute garde l'avantage de compiler et fonctionner sur le web, sur le thread principal.
Pourquoi les isolates ne partagent-ils pas la mémoire, et quel en est le coût ?
Pour éliminer verrous et conditions de course, et permettre un ramasse-miettes par isolate. Le coût : les messages sont copiés, et démarrer un isolate prend quelques millisecondes. On limite les allers-retours, on renvoie le résultat final, et on utilise TransferableTypedData pour les gros buffers.
Peut-on appeler un plugin depuis un isolate secondaire ?
Oui depuis Flutter 3.7 : on passe RootIsolateToken.instance à l'isolate et on appelle BackgroundIsolateBinaryMessenger.ensureInitialized(token). Certains plugins exigent toutefois le thread principal côté natif.
Comment annuler un Future ?
On ne peut pas. On annule ce qui produit le travail (Timer, abonnement, CancelToken Dio, Isolate.kill) ou on ignore le résultat (compteur de génération, CancelableOperation, ref.mounted).
Pourquoi mon FutureBuilder relance-t-il la requête à chaque rebuild ?
Parce que le Future est créé dans build (future: api.charger()). Chaque rebuild crée un nouveau Future, et FutureBuilder repart de l'état initial. Il faut créer le Future une fois, dans initState ou en late final dans le State, ou mieux le confier à un FutureProvider / AsyncNotifier.
Exercices
Sur papier, prédisez la sortie de ce programme, puis vérifiez avec dart run.
import 'dart:async';
Future<void> tache(String nom) async {
print('$nom début');
await null;
print('$nom fin');
}
void main() {
print('A');
Timer.run(() => print('timer'));
tache('T1');
scheduleMicrotask(() => print('micro'));
tache('T2');
print('B');
}
Critères : réponse juste du premier coup, et capacité à justifier chaque ligne par la règle microtask / event. Écrivez ensuite deux variantes de votre cru et faites-les deviner à un collègue.
Écrivez de mémoire un Debouncer à base de Timer, puis une extension debounce sur Stream<T>. Testez-la avec fakeAsync (package:fake_async) : 4 frappes à 100 ms d'intervalle puis un silence de 300 ms doivent produire une seule valeur.
Critères : la dernière valeur n'est pas perdue à la fermeture du flux ; l'annulation de l'abonnement annule le Timer ; aucun !.
Générez un fichier JSON de 5 Mo (par exemple 150 000 transactions au format du contrat). Dans une app Flutter minimale avec un CircularProgressIndicator qui tourne, parsez-le une fois sur l'isolate principal, puis avec Isolate.run.
Critères : capture DevTools des deux versions montrant les frames perdues dans la première et pas dans la seconde ; la conversion en modèles se fait dans l'isolate ; vous savez expliquer pourquoi renvoyer des Map brutes serait moins efficace.
Dans l'app Wallet (voir le projet) :
- Écran Bénéficiaires : champ de recherche branché sur un
Stream<String>avecdebouncede 300 ms, filtre de longueur minimale etdistinct. Seule la réponse de la recherche la plus récente s'affiche. La recherche filtre localement la liste deGET /beneficiariespour l'instant. - Historique : une fonction
parseTransactions(String body)exécutée avecIsolate.run, qui renvoie lesTransactionde l'étape 01 et lenextCursorsous forme de record. - Testez la recherche avec l'en-tête de chaos
X-Chaos-Latency: 2000plus tard (chapitre 09) : vérifiez dès maintenant que votre logique ignore les réponses périmées.
Critères : aucun abonnement non annulé (vérifiez avec un test qui démonte le widget) ; le parsing ne crée aucune frame au-dessus de 16 ms en mode profile.
Quiz
12 questions. Corrigez, relisez les explications, refaites-le 48 h plus tard.