Introduction
La programmation asynchrône ne se réduit pas à une simple bonne pratique ou à une technique avancée. En réalité, elle est essentielle pour concevoir des applications en Flutter qui répondent instantanément aux interactions de l'utilisateur, tout en maximisant l'efficacité des ressources disponibles.
Dans cet esprit, Dart/Flutter a intégré des outils puissants et intuitifs pour la gestion de l'asynchronicité. Les notions de Future, Isolate et Stream sont les piliers de cette approche, permettant aux développeurs de garantir une expérience utilisateur fluide et réactive.
Cet article introduit une notion souvent méconnue : le Completer au travers de quelques cas d'utilisation parmi les plus fréquents et adaptés.
Dans le cadre de la programmation asynchrone avec Dart, le Completer peut être envisagé comme un complément aux Futures. Il offre aux développeurs un contrôle fin sur le moment et la manière dont un Future se "termine", offrant ainsi une flexibilité accrue dans la gestion des tâches asynchrones.
Bien qu'il soit souvent (si ce n'est toujours) possible de développer sans utiliser de Completer, l'utilisation d'un Completer permet souvent d'obtenir un code plus modulaire, facile à lire et donc, plus maintenable.
Qu'est-ce qu'un Completer ?
Un Completer est un moyen de produire un Future, qui peut être complété (ou rejeté) à un moment ultérieur.
En d'autres termes, le Completer nous permet de contrôler manuellement la réussite ou l'échec d'un Future.
Quelles sont les fonctions de base du Completer ?
//
// Initialisation
//
final Completer<T> completer = Completer<T>();
//
// Retour du "future" associé
//
final Future<T> future = completer.future;
//
// Vérification si le "future" est déjà complété
//
bool completer.isCompleted
//
// Compléter un "future"
//
completer.complete(...);
//
// Rejeter un "future" avec une erreur
//
completer.completeError(...);
En quoi est-ce utile d'utiliser un Completer ?
Dans de nombreux scénarios, il existe plusieurs façons d'atteindre un résultat et très certainement d'éviter d'utiliser un Completer.
Cependant, le Completer se distingue comme une solution simple et efficace pour une synchronisation précise, une gestion manuelle des résultats futurs, et pour des cas où nous avons besoin d'un contrôle total sur le flux asynchrone.
Afin de mieux faire comprendre à quoi pourrait servir un Completer, considérons une série de cas d'utilisation.
Cas 1: Attente d'un callback avec Timeout
Pour ce premier exemple, supposons que nous devions appeler une API externe (donc, que nous ne pouvons modifier) et qui utilise un callback pour fournir une réponse. Ce genre de comportement pourrait arriver avec des plugins natifs ou tout simplement un package.
Considérons la signature de l'API suivante :
void externalFunction(Function(String result) callback);
Le cas d'utilisation est le suivant : Nous devons appeler cette API et attendre soit une réponse via le callback ou un timeout (pour complexifier un peu). Si un timeout est rencontré, la fonction retourne une erreur.
Voici une solution, utilisant un Completer.
Future<String> callAPIWithTimeout() async {
final Completer<String> completer = Completer<String>();
Timer? timer;
///
/// Initialisation du timer de gestion du timeout
///
timer = Timer(const Duration(seconds: 10), () {
// Timeout => on retourne une erreur
if (!completer.isCompleted) {
completer.completeError("Timeout");
}
});
///
/// Appel de l'API
///
externalFunction((String result) {
timer?.cancel();
if (!completer.isCompleted) {
completer.complete(result);
}
});
return completer.future;
}
Explications:
- ligne 3 : Nous initialisons le Completer et définissons que le Future retournera un String.
- lignes 11-13 : Si aucune réponse n'a été reçue après le timeout, nous retournons une erreur.
- ligne 19 : Nous invoquons l'API et attendons la réponse.
- ligne 20 : Puisque nous avons reçu une réponse, nous annulons le timer (au cas où il n'aurait pas encore déclenché un timeout).
- lignes 21-23 : Si aucun timeout n'a été déclenché, nous fournissons la réponse.
- ligne 26 : nous retournons le Future afin de pouvoir l'attendre.
Voici un exemple d'appel:
Future<void> _onWaitForAPIResult() async {
String result = "en cours";
result = await callAPIWithTimeout().catchError((error) {
return error;
});
print("résultat: $result");
}
Note complémentaire:
Nous pourrions obtenir le même résultat sans utiliser de Timer mais via un Future.any, comme ci-dessous, mais le code est un peu moins compréhensible, selon moi.
Future<String> callAPIWithTimeout2() async {
///
/// Initialisation du Future de gestion du timeout
///
final Future<String> timeoutFuture = Future.delayed(
const Duration(seconds: 10),
() => throw ("Timeout"),
);
///
/// Initialisation du Future d'appel de l'API
///
final Future<String> apiCallFuture = Future(() {
final Completer<String> completer = Completer<String>();
externalFunction((String result) {
if (!completer.isCompleted) {
completer.complete(result);
}
});
return completer.future;
});
///
/// Appel
///
try {
return await Future.any([apiCallFuture, timeoutFuture]);
} catch (e) {
rethrow;
}
}
Cas 2: Annulation
La gestion de l'annulation est un défi courant lors de la programmation asynchrone. Dart ne supporte pas nativement l'annulation des Future, mais un Completer peut être utilisé pour simuler ce comportement, fournissant ainsi un moyen d'interrompre une opération asynchrone.
Pour illustrer ce cas, considérons un service de téléchargement qui permet aux utilisateurs d'annuler le téléchargement en cours.
class DownloadService {
final Completer<void> _downloadCompleter = Completer<void>();
Future<void> startDownload() async {
try {
// Simulons un téléchargement qui prend 20 secondes
await Future.delayed(const Duration(seconds: 20));
// Tout s'est bien passé
if (!_downloadCompleter.isCompleted) {
_downloadCompleter.complete();
}
} catch (e) {
if (!_downloadCompleter.isCompleted) {
_downloadCompleter.completeError("Téléchargement raté: $e");
}
}
}
// Méthode à appeler pour annuler
void cancelDownload() {
if (!_downloadCompleter.isCompleted) {
_downloadCompleter.completeError("Annulé par l'utilisateur");
}
}
Future<void> get download => _downloadCompleter.future;
}
Pourquoi Completer est essentiel ici ?
Le Completer nous donne le pouvoir de terminer prématurément le Future avec une erreur spécifique, simulant ainsi le comportement d'annulation. Sans cela, nous n'aurions pas de moyen simple de signaler une annulation.
Voici un exemple de code d'appel. J'espère que le code est suffisamment explicite.
class DownloadApp extends StatefulWidget {
@override
_DownloadAppState createState() => _DownloadAppState();
}
class _DownloadAppState extends State<DownloadApp> {
final DownloadService _downloadService = DownloadService();
bool _isDownloading = false;
String _message = "Prêt à télécharger";
@override
Widget build(BuildContext context) {
return MaterialApp(
home: Scaffold(
appBar: AppBar(title: Text("Téléchargement")),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(_message),
ElevatedButton(
child: Text("Démarrer le téléchargement"),
onPressed: _isDownloading ? null : _startDownload,
),
ElevatedButton(
child: Text("Annuler le téléchargement"),
onPressed: _isDownloading ? _cancelDownload : null,
),
],
),
),
),
);
}
void updateInfo({
required bool isDownloading,
required String message,
}) {
if (mounted) {
setState(() {
_isDownloading = isDownloading;
_message = message;
});
}
}
void _startDownload() async {
updateInfo(isDownloading: true, message: "Téléchargement en cours...");
try {
await _downloadService.startDownload();
updateInfo(isDownloading: false, message: "Téléchargement réussi");
} catch (e) {
updateInfo(isDownloading: false, message: "Erreur : $e");
}
}
void _cancelDownload() {
_downloadService.cancelDownload();
updateInfo(isDownloading: false, message: "Téléchargement annulé");
}
}
Cas 3: Futures dépendants avec une logique spécifique
Supposons que vous ayez une application qui doit interroger trois services différents.
À des fins de performances, les 3 services sont appelés en parallèle, mais chaque service a une priorité différente.
Supposons que si le service à haute priorité échoue (erreur critique), nous voulons arrêter immédiatement toute autre demande et notifier quelque chose de spécifique. Pour les autres services, s'ils échouent, nous voulons juste enregistrer l'erreur mais ne pas interrompre les autres services. C'est un scénario où le Completer peut être particulièrement utile.
class PriorityDataService {
final Completer<List<String>> _aggregateCompleter = Completer<List<String>>();
final List<String> _dataResults = [];
final List<String> minorErrors = [];
int responseCount = 0;
//
// Appel des 3 services en parallèle
//
Future<List<String>> fetchData() async {
reset();
Future.wait(
[
_fetchHighPriorityService(),
_fetchMediumPriorityService(),
_fetchLowPriorityService(),
],
eagerError: true,
);
return _aggregateCompleter.future;
}
void reset() {
_dataResults.clear();
minorErrors.clear();
responseCount = 0;
}
//
// Si ce service rate, cela correspond à une erreur critique
//
Future<void> _fetchHighPriorityService() async {
try {
_dataResults.add(await highPriorityService());
} catch (e) {
_handleCriticalError(e);
}
_handleCompletion();
}
//
// Services de moindre importance
//
Future<void> _fetchMediumPriorityService() async {
try {
_dataResults.add(await mediumPriorityService());
} catch (e) {
_handleMinorError(e);
}
_handleCompletion();
}
Future<void> _fetchLowPriorityService() async {
try {
_dataResults.add(await lowPriorityService());
} catch (e) {
_handleMinorError(e);
}
_handleCompletion();
}
//
// Dès que nous avons les 3 réponses, on termine
//
void _handleCompletion() {
responseCount++;
if (responseCount == 3 && !_aggregateCompleter.isCompleted) {
_aggregateCompleter.complete(_dataResults);
}
}
//
// Enregistrement de l'erreur
//
void _handleMinorError(dynamic error) {
minorErrors.add("Service erreur: $error");
}
//
// Dans le cas d'une erreur critique, on termine directement
//
void _handleCriticalError(dynamic error) {
if (!_aggregateCompleter.isCompleted) {
_aggregateCompleter.completeError("Erreur critique: $error, erreurs mineures: $minorErrors");
}
}
}
Ainsi, avec un Completer, nous avons la flexibilité d'ajouter des logiques spécifiques en fonction de la nature de l'erreur, chose que nous ne pourrions pas faire simplement avec Future.wait.
Voici un exemple d'invocation :
Future<void> _onInvokeServices() async {
final PriorityDataService priorityDataService = PriorityDataService();
final List<String> result = await priorityDataService.fetchData().catchError((error) {
print("erreurs: $error");
return <String>[];
});
print("résultat: $result ---> erreurs mineures: ${priorityDataService.minorErrors}");
}
Cas 4: Utilisation des Completers comme Sémaphores pour la synchronisation des tâches
Dans cet exemple, nous explorerons comment les Completer peuvent être utilisés comme des Sémaphores pour contrôler l'exécution de tâches asynchrones indépendantes.
Considérons 3 tâches asynchrones A, B, C.
Le cours d'exécution de la tâche A s'arrête à un certain moment et attend qu'au moins une des 2 autres tâches se termine pour continuer avec le résultat de la première tâche qui s'est terminée.
import "dart:async";
class FlexibleSemaphore {
Completer<String> _completer = Completer<String>();
Future<String> get wait => _completer.future;
void release(String message) {
_completer.complete(message);
_completer = Completer<String>(); // Permet une réutilisation
}
}
Future<void> taskA(FlexibleSemaphore semaphore) async {
print("Task A: Démarrage");
await Future.delayed(Duration(seconds: 2));
print("Task A: En attente de signal");
String message = await semaphore.wait;
print("Task A: Redémarrage avec le message: $message");
}
Future<void> taskB(FlexibleSemaphore semaphore) async {
print("Task B: Démarrage");
await Future.delayed(Duration(seconds: 1));
print("Task B: Envoi du signal");
semaphore.release("Signal envoyé par Task B");
}
Future<void> taskC(FlexibleSemaphore semaphore) async {
print("Task C: Démarrage");
await Future.delayed(Duration(seconds: 3));
print("Task C: Envoi du signal");
semaphore.release("Signal envoyé par Task C");
}
void main() async {
FlexibleSemaphore semaphore = FlexibleSemaphore();
taskA(semaphore);
taskB(semaphore);
await Future.delayed(Duration(seconds: 4));
taskC(semaphore);
print("Fin de démarrage");
}
Cet exemple démontre que le Completer permet un contrôle plus fin et une réutilisation grâce à sa capacité à être complété de plusieurs endroits dans le code, et aussi à être réinitialisé pour de futures utilisations.
Cela peut être particulièrement utile dans des scénarios complexes avec des dépendances variables entre les tâches asynchrones.
Conclusion
Opter pour l'utilisation d'un Completer dans vos développements n’est pas une nécessité, mais cela se révèle être un atout précieux pour rendre le code plus ordonné et maniable.
Je voulais partager avec vous cette technique car elle a grandement facilité ma gestion des processus asynchrones, et je pensais qu’elle pourrait aussi vous être bénéfique.
Les Completers ne sont pas la réponse universelle aux défis de l'asynchronicité, mais ils apportent une flexibilité et une puissance appréciables dans la gestion des tâches asynchrones. Cela vaut la peine de les intégrer à vos outils de développement.
D’autres méthodes sont également disponibles, mais le Completer possède une efficacité particulière. N’hésitez pas à le tester et à découvrir par vous-même ses bénéfices !
Restez à l’écoute pour d'autres conseils utiles. Je vous souhaite un excellent codage !