Il est impossible de créer des applications mobiles modernes sans code asynchrone. La plupart des tâches qu’une application doit effectuer nécessitent, à un degré ou à un autre, une longue attente du résultat de l’opération : requêtes réseau, travail avec des bases de données, lecture des entrées utilisateur. Une approche asynchrone (exécution d’un processus sans le bloquer) permet une utilisation plus rationnelle des ressources de l’appareil. Compte tenu des limitations des appareils mobiles, le développement asynchrone sous Android et iOS est indispensable.
Dans cet article, nous allons examiner la mise en œuvre de l’asynchronisme à l’aide des fonctionnalités du langage Dart et du package ‘dart : async’. Il s’agit principalement des classes Future et Stream.
Mécanisme d’exécution du code Dart
Dart, le langage d’écriture des applications Flutter, est un langage mono-thread. Cependant, il offre des outils tels que les flux (streams), les futures, les opérateurs async/await. Ils sont similaires aux mêmes éléments dans d’autres langages de programmation, notamment Java et Javascript, mais présentent plusieurs particularités. Par conséquent, il faut d’abord comprendre le mécanisme d’exécution du code Dart.
Le code Dart est exécuté dans des blocs de mémoire séparés appelés isolats (isolates). En théorie, toutes les applications Dart peuvent s’exécuter dans un seul isolat. Cependant, si vous avez besoin de créer plusieurs threads supplémentaires, la machine virtuelle Dart peut créer plusieurs isolats supplémentaires avec une mémoire allouée. Contrairement à de nombreux langages de programmation, tels que Java ou C++, dans Dart, les différents threads ne peuvent pas accéder directement au même bloc de mémoire. Ils peuvent échanger des messages pour utiliser les données des uns et des autres, mais ils ne peuvent pas travailler directement avec la mémoire. Il s’agit d’une implémentation de multithreading plus robuste qui ne nécessite pas de verrouillage et simplifie la gestion de la mémoire. À l’intérieur de chaque isolat, il y a une boucle d’événements (event loop) responsable du traitement des événements. Elle gère les événements entrants et a la possibilité de différer l’exécution des événements qui nécessitent une approche asynchrone. Cela résout le dilemme du multithreading dans un environnement nativement mono-thread.
Comment notre application Dart doit-elle indiquer à la boucle d’événements que ce morceau de code sera exécuté de manière asynchrone ? Examinons deux des approches les plus courantes de programmation asynchrone dans Flutter.
Futures
Le fonctionnement de Future est très similaire à Promise en Javascript. Il a 2 états : inachevé et terminé. Un Future terminé aura soit une valeur (en cas de succès) soit une erreur (en cas d’échec). La classe possède plusieurs constructeurs :
- Future.delayed (accepte un objet Duration indiquant l’intervalle de temps et une fonction à exécuter après le report).
- Cache mémoire codé (stocke des images compressées dans leur état d’origine en mémoire).
- Future.error (crée un Future qui se termine avec une erreur).
- Future.microtask (renvoie un Future avec le résultat du calcul spécifié par scheduleMicrotask).
- Future.sync (Future qui se termine immédiatement).
- Future.value (Future qui renvoie la valeur spécifiée).
Dans le développement commercial, parmi les exemples ci-dessus, Future.delayed est le plus souvent utilisé pour implémenter des tâches différées.
La manière la plus courante et la plus simple de créer un Future est d’utiliser le mot-clé async dans une fonction. Celle-ci renvoie alors la valeur encapsulée dans un Future.
Exemple d’utilisation de async
Future getUserName() async {
return await userRepository.getUserName;
}
Il existe plusieurs options pour utiliser le résultat de cette fonction dans Flutter.
1) Le plus simple est d’obtenir le résultat de la fonction à l’aide du mot-clé await. Point important : await ne peut être utilisé que dans des fonctions avec le mot-clé async.
assignName() async {
String userName = await getUserName();
}
2) Utilisation des callbacks .then et .catchError. Avec cette option, le travail avec Dart Future devient aussi similaire que possible à l’utilisation de Promise en Javascript. Cet exemple imprime le nom d’utilisateur dans la console en cas de succès ou une erreur en cas d’échec.
handleUserNameWithCallbacks() {
Future nameFuture = getUserName();
nameFuture
.then((name) => print(name))
.catchError((error) => print(error));
}
Dart Future possède également plusieurs méthodes statiques qui facilitent le travail avec le code asynchrone : wait (prend une liste de Futures en argument, renvoie un Future qui attend qu’ils se terminent), any (prend une liste de Futures en argument, renvoie un Future qui attend le premier qui se termine), forEach (prend une liste de Futures en argument, applique la fonction spécifiée à chaque élément), doWhile (prend une fonction qui renvoie un booléen, s’exécute jusqu’à ce que la fonction renvoie false).
Il existe également 2 fonctions non statiques : whenComplete (s’exécute lorsque le Future se termine, quel que soit le succès ou l’échec) et timeout (exécute la fonction après un intervalle de temps). Toutes ces fonctions peuvent être combinées pour créer des chaînes complexes.
3) Utilisation du widget FutureBuilder du SDK Flutter. Son avantage est la capacité à prendre en compte l’état du Future lors de la construction de l’interface utilisateur. Dans l’exemple ci-dessous, nous allons créer 3 options d’interface utilisateur pour chaque état de nameFuture : une barre de progression en attente, du texte simple en cas de succès et une notification en cas d’erreur.
import 'package:flutter/material.dart';
class FutureBuilderExample extends StatefulWidget {
FutureBuilderExample({Key key, @required UserRepository repository}) : super(key: key);
@override
_FutureBuilderExampleState createState() => _FutureBuilderExampleState(repository);
}
class _FutureBuilderExampleState extends State {
Future _nameFuture = _userRepository.getUserName;
_FutureBuilderExampleState(UserRepository repository);
Widget build(BuildContext context) {
return FutureBuilder(
future: _nameFuture,
builder: (BuildContext context, AsyncSnapshot snapshot) {
List children;
if (snapshot.hasData) {
children = [
Padding(
padding: const EdgeInsets.only(top: 16),
child: Text('Username: ${snapshot.data}'),
)
];
} else if (snapshot.hasError) {
children = [
Padding(
padding: const EdgeInsets.only(top: 16),
child: Text('Error: ${snapshot.error}'),
)
];
} else {
children = [
SizedBox(
child: CircularProgressIndicator(),
width: 50,
height: 50,
)
];
}
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: children,
),
);
},
);
}
}
Streams
La deuxième partie de la programmation asynchrone en Dart et Flutter concerne les Streams. Ce sont des séquences d’événements asynchrones. Ils sont identiques en nom et en contenu aux flux d’autres langages de programmation, tels que Java. En Dart, les flux sont divisés en 2 types : à abonnement unique (permet d’avoir un seul abonné et émet des événements uniquement s’il y en a un) et à diffusion (broadcast) (permet d’avoir plusieurs abonnés et émet des événements indépendamment de leur présence). Comme Future, Stream contient une valeur potentielle. Cette classe contient un large ensemble de méthodes pour transformer les flux et manipuler leurs éléments. Vous pouvez le créer, comme dans le cas d’un Future, de plusieurs manières :
1) Utilisation du constructeur : Stream() par défaut, Stream.empty (crée un flux vide), Stream.error (crée un flux qui lève une erreur), Stream.eventTransformed (prend un flux et modifie tous ses éléments), Stream.fromFuture (transforme un seul Future en flux), Stream.fromFutures (idem, mais accepte un Iterable de Futures), Stream.fromIterable (convertit une liste d’objets en flux), Stream.periodic (crée un flux qui émet des événements périodiquement), Stream.value (crée un flux qui renvoie un élément et se termine). Dans le développement commercial, cette méthode de création de flux n’est pas courante.
2) Utilisation des mots-clés async * (prononcé [əˈsɪŋk stɑː], renvoie un flux), yield (renvoie un élément d’un flux) et yield * (prononcé [jiːld stɑː], renvoie un flux).
Exemple d’autorisation qui renvoie un flux d’états
Stream signInWithCredentials(String email, String password) async* {
yield LoginState.loading();
try {
await _userRepository.signInWithCredentials(email, password);
yield LoginState.success();
} catch (_) {
yield LoginState.failure();
}
}
3) Utilisation de StreamController. Cette classe est spécialement conçue pour manipuler les flux et possède 2 variations : la version par défaut (contrôle les flux à abonnement unique) et la version broadcast (contrôle respectivement les flux broadcast).
Dans cet exemple de création d’un flux à l’aide d’un contrôleur, nous créons un nouveau contrôleur à abonnement unique qui opère sur le type de données String. Si un écouteur y est ajouté, le message “listening has started” s’affichera dans la console. En utilisant la fonction add(), nous envoyons de nouvelles données au flux, qui iront à l’abonné. Enfin, nous fermons le contrôleur, ce qui provoque l’annulation du thread et la désinscription de l’écouteur. Le message “stream was closed” s’affichera dans la console.
StreamController controller = StreamController(onListen: () => print("listening has started"));
controller.add("Message 1");
controller.add("Message 2");
controller.add("Message 3");
controller.close().then((value) => print("stream was closed"));
StreamController possède un certain nombre de fonctions et d’attributs utiles pour manipuler les flux. Ceux-ci incluent les fonctions add() et close() déjà mentionnées, ainsi que addError (le flux lève une erreur), addStream (ajoute des événements d’un autre flux), stream (lien vers le flux créé), sink (lien vers l’objet responsable de la réception des nouvelles données), etc.
4) En convertissant un Future en Stream. Il est rarement utilisé.
Future userNameFuture = getUserName();
Stream userNameStream = userNameFuture.asStream();
Méthodes de travail avec les flux
Une fois un flux créé de l’une des 4 manières ci-dessus, vous devez apprendre à utiliser ses données. Flutter offre 2 options principales pour travailler avec les flux.
La première consiste à utiliser des abonnements. Pour ce faire, nous créons un abonnement à un flux spécifique et l’utilisons pour gérer les données.
Exemple d’abonnement
StreamController controller = StreamController();
StreamSubscription subscription = controller.stream.listen((event) {
print(event);
saveToDatabase(event);
});
subscription.cancel();
Les abonnements fournissent un arsenal complet pour contrôler vos flux. Avec leur aide, vous pouvez mettre en pause ou reprendre le travail d’un thread, définir des fonctions qui seront déclenchées lors d’un nouvel événement (onData), d’une erreur (onError) ou d’une interruption (onDone). Et surtout, les abonnements vous permettent de vous désabonner d’un flux à l’aide de la fonction cancel().
La deuxième option de travail avec les flux est fournie par le SDK Flutter. Il s’agit de StreamBuilder. Il offre un certain nombre d’avantages : il lie l’interface utilisateur aux données transférées, garantit que le widget est redessiné avec une nouvelle donnée, se désabonne automatiquement du flux lorsque le widget est supprimé et permet de définir des données initiales.
Exemple StreamBuilder
Nous créons une liste de test avec des noms. La méthode getNameStream() renvoie un flux qui envoie un nouveau nom de la liste chaque seconde. La classe StreamBuilderTest surveille l’état du flux et l’affiche sous forme de texte. Chaque donnée sera affichée sous forme de texte à l’écran.
List names = ["Rincewind", "Esme", "Gytha", "Magrat", "Sam", "Carrot", "Death"];
Stream getNameStream() async* {
for (int i = 0; i < names.length; i++) {
await Future.delayed(Duration(seconds: 1));
yield names[i];
}
}
class StreamBuilderTest extends StatelessWidget {
@override
Widget build(BuildContext context) {
return StreamBuilder(
stream: getNameStream(),
builder: (BuildContext context, AsyncSnapshot snapshot) {
if (snapshot.hasError) {
return Text('Error: ${snapshot.error}');
}
switch (snapshot.connectionState) {
case ConnectionState.waiting:
return const Text('Loading...');
case ConnectionState.done:
return const Text('Names have finished');
default:
if (snapshot.data.isEmpty) {
return Text('No data');
}
return Text(snapshot.data);
}
},
);
}
}
Ainsi, Flutter offre un riche arsenal pour écrire du code asynchrone en utilisant les fonctionnalités de base du langage Dart. Le concept général de travail avec l’asynchronisme y ressemble à l’approche Javascript : un thread de base avec une boucle d’événements, des Futures similaires à Promise et l’utilisation des mots-clés async/await. Pour travailler avec des opérations asynchrones uniques, vous devriez utiliser Future du package ‘dart : async’. Combinez-le avec FutureBuilder du SDK Flutter pour fournir rapidement et de manière fiable un lien entre le résultat d’une opération asynchrone et l’état de l’interface utilisateur. Stream peut être utilisé pour gérer des chaînes d’événements asynchrones. Flutter propose un widget StreamBuilder similaire pour combiner l’interface utilisateur et les flux de données. Ensemble, cet ensemble d’outils asynchrones fait de Dart et Flutter une pile très efficace pour implémenter des applications asynchrones.