Introduction aux Completers: Guide Pratique

Compatibilité
Date révision
16 oct. 2023
Publié le
16 oct. 2023
Flutter
v 3.13.x
Dart
v 3.1.x

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 ?

1
2//
3// Initialisation
4//
5final Completer<T> completer = Completer<T>();
6
7//
8// Retour du "future" associé
9//
10final Future<T> future = completer.future;
11
12//
13// Vérification si le "future" est déjà complété
14//
15bool completer.isCompleted
16
17//
18// Compléter un "future"
19//
20completer.complete(...);
21
22//
23// Rejeter un "future" avec une erreur
24//
25completer.completeError(...);
26

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 :

1
2void externalFunction(Function(String result) callback);
3

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.

1
2Future<String> callAPIWithTimeout() async {
3   final Completer<String> completer = Completer<String>();
4   Timer? timer;
5
6   ///
7   /// Initialisation du timer de gestion du timeout
8   ///
9   timer = Timer(const Duration(seconds: 10), () {
10      // Timeout => on retourne une erreur
11      if (!completer.isCompleted) {
12         completer.completeError("Timeout");
13      }
14   });
15
16   ///
17   /// Appel de l'API
18   ///
19   externalFunction((String result) {
20      timer?.cancel();
21      if (!completer.isCompleted) {
22         completer.complete(result);
23      }
24   });
25
26   return completer.future;
27}
28

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:

1
2Future<void> _onWaitForAPIResult() async {
3  String result = "en cours";
4  result = await callAPIWithTimeout().catchError((error) {
5    return error;
6  });
7  print("résultat: $result");
8}
9

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.

1
2Future<String> callAPIWithTimeout2() async {
3    ///
4    /// Initialisation du Future de gestion du timeout
5    ///
6    final Future<String> timeoutFuture = Future.delayed(
7      const Duration(seconds: 10),
8      () => throw ("Timeout"),
9    );
10
11    ///
12    /// Initialisation du Future d'appel de l'API
13    ///
14    final Future<String> apiCallFuture = Future(() {
15      final Completer<String> completer = Completer<String>();
16
17      externalFunction((String result) {
18        if (!completer.isCompleted) {
19          completer.complete(result);
20        }
21      });
22
23      return completer.future;
24    });
25
26    ///
27    /// Appel
28    ///
29    try {
30      return await Future.any([apiCallFuture, timeoutFuture]);
31    } catch (e) {
32      rethrow;
33    }
34}
35

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.

1
2class DownloadService {
3  final Completer<void> _downloadCompleter = Completer<void>();
4
5  Future<void> startDownload() async {
6    try {
7      // Simulons un téléchargement qui prend 20 secondes
8      await Future.delayed(const Duration(seconds: 20));
9
10      // Tout s'est bien passé
11      if (!_downloadCompleter.isCompleted) {
12        _downloadCompleter.complete();
13      }
14    } catch (e) {
15      if (!_downloadCompleter.isCompleted) {
16        _downloadCompleter.completeError("Téléchargement raté: $e");
17      }
18    }
19  }
20
21  // Méthode à appeler pour annuler
22  void cancelDownload() {
23    if (!_downloadCompleter.isCompleted) {
24      _downloadCompleter.completeError("Annulé par l'utilisateur");
25    }
26  }
27
28  Future<void> get download => _downloadCompleter.future;
29}
30

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.

1
2class DownloadApp extends StatefulWidget {
3  
4  _DownloadAppState createState() => _DownloadAppState();
5}
6
7class _DownloadAppState extends State<DownloadApp> {
8  final DownloadService _downloadService = DownloadService();
9  bool _isDownloading = false;
10  String _message = "Prêt à télécharger";
11
12  
13  Widget build(BuildContext context) {
14    return MaterialApp(
15      home: Scaffold(
16        appBar: AppBar(title: Text("Téléchargement")),
17        body: Center(
18          child: Column(
19            mainAxisAlignment: MainAxisAlignment.center,
20            children: [
21              Text(_message),
22              ElevatedButton(
23                child: Text("Démarrer le téléchargement"),
24                onPressed: _isDownloading ? null : _startDownload,
25              ),
26              ElevatedButton(
27                child: Text("Annuler le téléchargement"),
28                onPressed: _isDownloading ? _cancelDownload : null,
29              ),
30            ],
31          ),
32        ),
33      ),
34    );
35  }
36
37  void updateInfo({
38    required bool isDownloading,
39    required String message,
40  }) {
41    if (mounted) {
42      setState(() {
43        _isDownloading = isDownloading;
44        _message = message;
45      });
46    }
47  }
48
49  void _startDownload() async {
50    updateInfo(isDownloading: true, message: "Téléchargement en cours...");
51
52    try {
53      await _downloadService.startDownload();
54
55      updateInfo(isDownloading: false, message: "Téléchargement réussi");
56    } catch (e) {
57      updateInfo(isDownloading: false, message: "Erreur : $e");
58    }
59  }
60
61  void _cancelDownload() {
62    _downloadService.cancelDownload();
63    updateInfo(isDownloading: false, message: "Téléchargement annulé");
64  }
65}
66

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.

1
2class PriorityDataService {
3  final Completer<List<String>> _aggregateCompleter = Completer<List<String>>();
4  final List<String> _dataResults = [];
5  final List<String> minorErrors = [];
6  int responseCount = 0;
7
8  //
9  // Appel des 3 services en parallèle
10  //
11  Future<List<String>> fetchData() async {
12    reset();
13    Future.wait(
14      [
15        _fetchHighPriorityService(),
16        _fetchMediumPriorityService(),
17        _fetchLowPriorityService(),
18      ],
19      eagerError: true,
20    );
21
22    return _aggregateCompleter.future;
23  }
24
25  void reset() {
26    _dataResults.clear();
27    minorErrors.clear();
28    responseCount = 0;
29  }
30
31  //
32  // Si ce service rate, cela correspond à une erreur critique
33  //
34  Future<void> _fetchHighPriorityService() async {
35    try {
36      _dataResults.add(await highPriorityService());
37    } catch (e) {
38      _handleCriticalError(e);
39    }
40    _handleCompletion();
41  }
42
43  //
44  // Services de moindre importance
45  //
46  Future<void> _fetchMediumPriorityService() async {
47    try {
48      _dataResults.add(await mediumPriorityService());
49    } catch (e) {
50      _handleMinorError(e);
51    }
52    _handleCompletion();
53  }
54
55  Future<void> _fetchLowPriorityService() async {
56    try {
57      _dataResults.add(await lowPriorityService());
58    } catch (e) {
59      _handleMinorError(e);
60    }
61    _handleCompletion();
62  }
63
64  //
65  // Dès que nous avons les 3 réponses, on termine
66  //
67  void _handleCompletion() {
68    responseCount++;
69    if (responseCount == 3 && !_aggregateCompleter.isCompleted) {
70      _aggregateCompleter.complete(_dataResults);
71    }
72  }
73
74  //
75  // Enregistrement de l'erreur
76  //
77  void _handleMinorError(dynamic error) {
78    minorErrors.add("Service erreur: $error");
79  }
80
81  //
82  // Dans le cas d'une erreur critique, on termine directement
83  //
84  void _handleCriticalError(dynamic error) {
85    if (!_aggregateCompleter.isCompleted) {
86      _aggregateCompleter.completeError("Erreur critique: $error, erreurs mineures: $minorErrors");
87    }
88  }
89}
90

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 :

1
2Future<void> _onInvokeServices() async {
3  final PriorityDataService priorityDataService = PriorityDataService();
4  final List<String> result = await priorityDataService.fetchData().catchError((error) {
5    print("erreurs: $error");
6    return <String>[];
7  });
8  print("résultat: $result ---> erreurs mineures: ${priorityDataService.minorErrors}");
9}
10

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.

1
2import "dart:async";
3
4class FlexibleSemaphore {
5  Completer<String> _completer = Completer<String>();
6
7  Future<String> get wait => _completer.future;
8
9  void release(String message) {
10    _completer.complete(message);
11    _completer = Completer<String>(); // Permet une réutilisation
12  }
13}
14
15Future<void> taskA(FlexibleSemaphore semaphore) async {
16  print("Task A: Démarrage");
17  await Future.delayed(Duration(seconds: 2));
18  print("Task A: En attente de signal");
19  String message = await semaphore.wait;
20  print("Task A: Redémarrage avec le message: $message");
21}
22
23Future<void> taskB(FlexibleSemaphore semaphore) async {
24  print("Task B: Démarrage");
25  await Future.delayed(Duration(seconds: 1));
26  print("Task B: Envoi du signal");
27  semaphore.release("Signal envoyé par Task B");
28}
29
30Future<void> taskC(FlexibleSemaphore semaphore) async {
31  print("Task C: Démarrage");
32  await Future.delayed(Duration(seconds: 3));
33  print("Task C: Envoi du signal");
34  semaphore.release("Signal envoyé par Task C");
35}
36
37void main() async {
38  FlexibleSemaphore semaphore = FlexibleSemaphore();
39
40  taskA(semaphore);
41  taskB(semaphore);
42
43  await Future.delayed(Duration(seconds: 4));
44  taskC(semaphore);
45
46  print("Fin de démarrage");
47}
48

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 !

0 Commentaires
Soyez le premier à faire un commentaire...
© 2024 - Flutteris
email: info@flutteris.com

Flutteris



Quand l'excellence rencontre l'innovation
"votre satisfaction est notre priorité"

© 2024 - Flutteris
email: info@flutteris.com