Passer au contenu
Il y a quelques années, lorsque j'ai abandonné Java et commencé à utiliser Kotlin, j'ai réalisé à quel point ce langage était efficace et puissant. Il comprend de nombreuses fonctionnalités supplémentaires qui simplifient l'écriture de notre code. L'une de ces fonctionnalités est la notion de Coroutines, ajoutée au langage Kotlin 1.3, qui simplifie le code asynchrone.

Grâce à ce blog, nous aurons un aperçu de :
  • Définition des Coroutines;
  • Pourquoi choisir les coroutines plutôt que d'autres méthodes;
  • Les concepts de coroutines;
  • Les flux dans les Coroutines.
Et enfin, nous verrons comment utiliser les Coroutines dans une application mobile réelle en utilisant la clean architecture + Retrofit.

En tant que développeur de logiciels mobiles, il est assez courant d'effectuer des tâches asynchrones, comme faire un appel API puis attendre le résultat du backend, récupérer des données de la base de données locale ou toute autre tâche de longue haleine. Dans la plupart des langages de programmation, l'écriture de code asynchrone est un véritable casse-tête. Dans ce blog, nous allons apprendre à gérer le code qui s'exécute de manière asynchrone en utilisant les Coroutines dans Kotlin.

image-000.png

 

Il existe de nombreuses alternatives, mais pourquoi les Coroutines?

Nous disposons déjà d'outils pour gérer la programmation asynchrone comme les Callbacks, RxJava, AsyncTask et Threads, alors pourquoi utiliser les Coroutines?

Nous avons commencé à le gérer en utilisant le mécanisme de callback, qui aide à exécuter une fonction après qu'une autre soit terminée. Et s'il y a une série de logique à faire, alors nous serons confrontés à un enfer de callback. Croyez-moi, au fur et à mesure que votre projet se développe, cela pourrait nous conduire à une ambiguïté dans la compréhension du code.

Une autre alternative est RxJava, mais la courbe d'apprentissage de RxJava est également trop importante. AsyncTask peut facilement introduire des fuites de mémoire dans notre application.

C'est là que les coroutines entrent en jeu. Il s'agit tout simplement de threads légers. Les coroutines nous offrent un moyen facile de faire de la programmation synchrone et asynchrone.

Extrait de la documentation Kotlin:

"On peut considérer une coroutine comme un thread léger. Comme les threads, les coroutines peuvent fonctionner en parallèle, s'attendre les unes les autres et communiquer. La plus grande différence est que les coroutines sont très bon marché, presque gratuites : nous pouvons en créer des milliers et payer très peu en termes de performances. Les véritables threads, en revanche, sont coûteux à démarrer et à maintenir. Un millier de threads peut représenter un sérieux défi pour une machine moderne."

image-001.png

En d'autres termes:

  • Chaque Coroutine est une petite unité d'exécution assignée à un thread; 
  • Il n'y a pas de limitation, on peut lancer autant de Coroutines que l'on veut;
  • Nous pouvons démarrer et arrêter les Coroutines à tout moment;
  • Nous pouvons avoir des Coroutines enfants à l'intérieur de chaque Coroutine;
  • Chaque Coroutine peut être lancée dans un Scope spécifique.


Coroutine Scope

Il crée, exécute et garde la trace de toutes vos coroutines. Il fournit également des événements de cycle de vie comme le démarrage et la pause d'une coroutine.

image-002.png

Delay est une fonction de suspension spéciale. Elle suspend la Coroutine pendant un temps spécifique. La suspension d'une Coroutine ne bloque pas le thread sous-jacent, mais permet aux autres Coroutines de s'exécuter et d'utiliser le thread sous-jacent pour leur code. Nous verrons plus en détail les fonctions de suspension dans la section suivante.

Voici le résultat lorsque nous exécutons le code ci-dessus:

image-003.png
  1. runBlocking { ... } : crée une Coroutine de manière bloquante. Elle bloquera le thread principal ou le thread dans lequel elle est utilisée. Dans l'exemple ci-dessus, l'impression "Program execution will now continue" sera exécutée après la fin du bloc runBlocking.
  2. GlobalScope.lauch{ ... } : crée une nouvelle Coroutine, le scope sera le cycle de vie de l'application.
  3. coroutineScope { ... } : Crée un nouveau scope personnalisé et ne se termine pas tant que toutes les Coroutines enfants ne sont pas terminées ;
  • Si le parent est annulé, tous les enfants sont annulés.
  • Le parent attendra toujours l'achèvement de ses fils.

Je ne recommande pas l'utilisation de GlobalScope car le parent n'attendra pas l'achèvement de ses fils et une fois que le parent est annulé, les autres jobs vont continuer à s'exécuter séparément. Cela signifie que c'est maintenant la responsabilité du développeur de garder la trace de la durée de vie des coroutines car il n'y a pas de synchronisation avec les jobs fils.
 

Suspendre les fonctions:

Les fonctions de suspension sont comme l'épine dorsale des Coroutines. Il est donc très important de bien comprendre ce concept avant d'aller plus loin.


image-004.png

Une fonction de suspension est simplement une fonction qui peut être mise en pause et reprise ultérieurement. Elle peut exécuter une opération de longue durée et attendre qu'elle se termine sans se bloquer. La syntaxe d'une fonction suspensive est similaire à celle d'une fonction régulière, à l'exception de l'ajout du mot-clé suspend.

Notez que les fonctions de suspension sont automatiquement synchronisées avec les autres variables et fonctions du thread principal.

image-005.png

Voici le résultat lorsque nous exécutons le code ci-dessus:

image-006.png


Le contexte

Les coroutines s'exécutent toujours dans un certain contexte qui est représenté par une valeur du type CoroutineContext.

image-007.png


Le contexte de la Coroutine est un ensemble de divers éléments. Les principaux éléments sont le Job de la Coroutine, son dispatcher et aussi son gestionnaire d'exception.


Le Job

Selon la documentation officielle, "un job est une chose annulable avec un cycle de vie qui aboutit à son achèvement. Les coroutines sont créées avec le constructeur de coroutines de lancement. Il exécute un bloc de code spécifié et se termine à l'achèvement de ce bloc."
 
Voici quelques propriétés du Job:
  • Il est créé avec le constructeur de coroutine "launch". Il exécute un bloc de code spécifique et se termine à la fin de ce bloc;
  • Une fois créée, la tâche est automatiquement lancée;
  • Permet de manipuler le cycle de vie de la coroutine;
  • Ils ont une hiérarchie, nous pouvons avoir des jobs parents et fils;
  • Un job est annulé à l'aide de la fonction cancel();
  • Si un job est annulé, tous ses parents et fils le seront également;
  • L'exécution d'un job ne produit pas de valeur de résultat. Nous devrions utiliser une interface Deferred pour un job qui produit un résultat.

image-008.png

Voici le résultat lorsque nous exécutons le code ci-dessus:

image-009.png


Comment récupérer la valeur d'un Job à la fin de son exécution?

Comme nous l'avons mentionné juste avant, nous allons utiliser Deferred qui est un Job avec un résultat. Il attendra et bloquera le thread actuel jusqu'à ce que nous récupérions le résultat.

En fait, il est créé avec le constructeur asynchrone Coroutine et le résultat peut être récupéré par la méthode await(), qui lève une exception si le Deferred a échoué.

Voici un exemple d'utilisation:

image-010.png

Voici le résultat lorsque nous exécutons le code ci-dessus:

image-011.png


Le Dispatcher

En Kotlin, toutes les coroutines doivent être exécutées dans un Dispatcher, même si elles sont exécutées sur le thread principal. Les coroutines peuvent se suspendre, et le Dispatcher est la chose qui sait comment les reprendre.

Pour spécifier l'endroit où les coroutines doivent s'exécuter, Kotlin fournit trois Dispatcher que vous pouvez utiliser :
 
  • Dispatchers.Main: thread principal sur Android, interagit avec l'interface utilisateur et effectue un travail léger.
➢ Appel des fonctions de suspension;
➢ Appelez les fonctions de l'interface utilisateur;
➢ Mise à jour de LiveData.
 
  • Dispatchers.IO: Optimisé pour les entrées/sorties de disque et de réseau en dehors du thread principal.
➢Demandes de bases de données;
➢ Lecture/écriture de fichiers;
➢ Mise en réseau.
 
  • Répartiteurs. Défaut: Optimisé pour le travail intensif du CPU en dehors du thread principal.
➢ Trier une liste;
➢ Analyse de JSON;
➢ Utils


Mais attendez, que se passe-t-il si nous avons lancé une Coroutine en utilisant Dispatchers. Main et que nous voulions effectuer une autre action en utilisant Dispatchers.IO.
 

Comment changer le contexte d'une Coroutine?

Ceci peut être facilement réalisé en utilisant la fonction suspend withContext(Dispatcher). Cela permet de changer facilement le contexte, de démarrer une Coroutine et de passer d'un Dispatcher à l'autre.

Voici un exemple de la façon dont nous pouvons l'utiliser pour passer du Dispatcher par défaut au Dispatcher IO:


image-012.png


Gestionnaire d'exceptions

La gestion correcte des exceptions a un impact énorme sur la façon dont les utilisateurs perçoivent notre application. Si votre application ne cesse de planter, l'utilisateur sera déçu et ne l'utilisera peut-être plus jamais.

À cette fin, nous avons deux options :
  • Utilisation de try/catch;
  • Utilisation de CoroutineExceptionHandler

Le CoroutineExceptionHandler est un élément optionnel d'un CoroutineContext permettant de gérer les exceptions non attrapées.

Voici comment nous pouvons définir un CoroutineExceptionHandler, chaque fois qu'une exception est attrapée, vous avez des informations sur le CoroutineContext où l'exception s'est produite et l'exception elle-même.

image-013.png

Voici le résultat lorsque nous exécutons le code ci-dessus:

image-014.png

Lorsque la Coroutine échoue avec une exception, elle propage cette exception à son parent. Ensuite, le parent va :
  • Annulez le reste de ses fils;
  • Annuler son exécution;
  • Propager l'exception vers son parent jusqu'à la racine de la hiérarchie et toutes les Coroutines déjà lancées dans le CoroutineScope seront également annulées
image-015.png

Cette approche n'est pas toujours une bonne idée à utiliser. Par exemple, nous avons la fonction init() qui initialise les interactions de l'utilisateur avec les composants de l'interface utilisateur en utilisant une CoroutineScope. Imaginez que si une coroutine enfant échoue, le scope sera automatiquement annulé et l'interface utilisateur ne répondra plus car le scope entier sera annulé.

Pour y remédier, nous pouvons utiliser SupervisorJob qui est une implémentation différente d'un job.

image-016.png
Nous pouvons créer un CoroutineScope en utilisant l'une des méthodes suivantes :
  • ❖ val uiScope = CoroutineScope(SupervisorJob())
  • supervisorScope { ... }
 

Les flux dans les coroutines

Un autre aspect intéressant des Coroutines est la possibilité d'utiliser des flux. Il s'agit d'un flux de valeurs qui sont calculées de manière asynchrone à partir d'une Coroutine.
  • Les flux émettent des valeurs avec la fonction emit();
  • Flows reçoit les valeurs à l'aide de la fonction collect();
  • La fonction de construction des flux est la fonction flow{..};
  • Un flux est annulé lorsque la coroutine est annulée;
  • Les flux sont des flux froids, en d'autres termes, le code à l'intérieur d'un constructeur de flux ne s'exécute pas tant que le flux n'est pas collecté.
Dans cet exemple, nous allons essayer d'émettre les valeurs d'une liste de nombres. Chaque fois que nous émettons une valeur, nous suspendons la coroutine pour un délai spécifique en utilisant la fonction delay:

image-017.png

Voici le résultat lorsque nous exécutons le code ci-dessus:

image-018.png
  • Une liste peut également être convertie en un flux à l'aide de la fonction asFlow() :
image-019.png
 
  • Nous pouvons créer un flux directement à partir de tout type d'objets en utilisant la fonction flowOf() :

image-020.png

 

Opérateurs de débit


Flow dispose d'un tas d'opérateurs sympas pour nous faciliter le codage:
  • Map: mettre en correspondance un flux avec un autre flux
Dans cet exemple, nous allons faire correspondre un flux de Int à un autre flux de String. Chaque élément de la liste sera transformé en String suivant : "mapping (valeur du numéro de la liste)".

image-021.png

Voici le résultat:

image-022.png
  • Filter: Filtre les valeurs de débit avec une condition spécifique

Dans cet exemple, nous allons prendre un flux de Int et le convertir en un flux filtré qui ne contient que des valeurs paires:

image-023.png

Voici le résultat:

image-024.png
  • transform: Opérateur de transformation général qui peut émettre n'importe quelle valeur en tout point.
Au lieu de n'émettre qu'un seul type, nous pouvons utiliser l'opérateur transform pour émettre également d'autres valeurs de n'importe quel type. Dans l'exemple suivant, nous aurons un flux Int, puis nous émettrons à chaque fois une chaîne "Emitting string value (value of the number)" suivie du nombre lui-même en même temps:

image-025.png

Voici le résultat imprimé:

image-026.png
  • take: N'utilise qu'un certain nombre de valeurs, ne tient pas compte du reste des valeurs émises

Dans l'exemple suivant, nous avons un flux de Int de 1 à 10. En utilisant l'opérateur take, nous pourrons prendre seulement les deux premiers éléments
 
image-027.png

Voici le résultat imprimé:

image-028.png
  • toList : convertit un flux en une liste;
  • toSet -> convertit un flux en un ensemble qui n'a que des valeurs uniques (pas de valeurs dupliquées);
  •  flowOn : permet de basculer le contexte du flux.
Dans l'exemple suivant, nous allons utiliser flowOn afin de définir le contexte actuel sur Dispatchers.IO:

image-029.png


Coroutines dans une application Android réelle

Dans le cadre du développement d'Android, il s'agit essentiellement d'effectuer une requête réseau et de renvoyer le résultat au thread principal, où l'application peut ensuite afficher le résultat à l'utilisateur.

En utilisant l'architecture clean, le composant ViewModel appelle la couche Use Case sur le thread principal pour déclencher la requête réseau de la couche référentiel. Notre objectif est d'utiliser des coroutines pour que le thread principal ne soit pas bloqué.

Dans l'exemple suivant, nous allons voir comment utiliser les Coroutines dans une architecture propre avec Retrofit:

image-030.png

  • J'ai créé une application de démonstration qui recherche les artistes par nom. Une fois que votre artiste est trouvé, vous pouvez cliquer dessus pour voir ses albums qui peuvent être cliqués pour afficher tous les détails à son sujet. N'hésitez pas à me contacter si vous souhaitez obtenir le code source complet.

image-032.jpgimage-033.jpgimage-034.jpg


Dans cette section, nous allons nous concentrer sur l'obtention des détails de l'album.
  • Avant de commencer, notre projet doit contenir les dépendances suivantes pour avoir le support de Coroutine:

image-035.png
  • Result.kt est l'enveloppe pour tous les appels réseau dans l'application. Le résultat peut être une valeur, une erreur ou une donnée vide:
image-036.png
  • Couche source de données : MusicApiService.kt

Commençons par la source de donnéesRetrofit est principalement utilisé pour les requêtes réseau. Depuis la sortie de la version 2.6.0, Retrofit supporte les Coroutines et rend les appels API beaucoup plus faciles.

La fonction suspend pour obtenir les détails des albums s'écrit brièvement comme ceci avec le mot clé suspend:

image-037.png
  • Couche de référentiel : MusicRepositoryImpl.kt

À partir de notre référentiel, la fonction suspend qui obtient les détails de l'album sera appelée et nous mappons le résultat récupéré à un objet Album.

image-038.png


BaseDataSource.getResult() est simplement une enveloppe pour tout appel d'API dans l'application.

Comme nous pouvons le voir, nous créons un flux de NetworkResponse. À l'intérieur de celui-ci, nous appelons la fonction suspend dataSource pour obtenir les détails de l'album. Puis nous mappons le résultat récupéré à un objet Album.

Enfin, nous émettons cette réponse à travers notre flux.
  • Couche de cas d'utilisation : GetAlbumDetailsUseCase.kt

Dans cette couche, nous récupérons le flux de la couche du référentiel et nous le mappons à un autre flux de l'objet résultat. Dans cette couche, nous avons toutes les mains sur le résultat récupéré et c'est à ce stade que nous pouvons y apporter toute modification avant de le livrer au viewModel.

image-039.png
  • Couche de ViewModel : GetAlbumDetailsViewModel.kt

Le composant ViewModel possède un ensemble d'extensions KTX qui fonctionnent directement avec les coroutines. Il s'appelle "lifecycle-viewmodel-ktx".

image-040.png

Un ViewModelScope est défini pour chaque ViewModel. La partie cool est que nous n'avons pas besoin de faire un clear des coroutines créées à l'intérieur de notre ViewModel. Le ViewModelScope fait tout le travail pour nous. Toute coroutine lancée dans ce scope est automatiquement annulée si le ViewModel est supprimé, évitant ainsi toute consommation supplémentaire de ressources.

Voici à quoi ressemble notre viewModel:

image-041.png

Nous commençons par annuler le travail en cours et en démarrons un nouveau en utilisant Dispatcher.IO pour les appels API. Si le nom de l'artiste et le nom de l'album ne sont pas correctement définis, nous envoyons une réponse vide via le liveData de l'album. Sinon, nous appelons notre couche useCase et nous collectons les valeurs du flux à l'aide de la fonction collect. Enfin, nous affichons les détails de l'album à travers le liveData de l'album.
 
  • Voir la couche : SearchAlbumDetailsFragment.kt

Depuis notre vue, nous allons observer les objets Livedata de la couche viewModel.

image-042.png

Une fois que l'utilisateur arrive à l'écran des détails de l'album, le viewModel sera déclenché à l'aide de cette fonction:

image-043.png

Ensuite, la vue mettra à jour l'interface utilisateur en fonction du résultat de la liveData.

image-044.png


C'est tout, j'espère que vous avez apprécié ce blog. N'hésitez pas à me contacter si vous avez des questions ou pour avoir accès au code source complet.

Bon codage les gars :)
Othmane  Lamrani
Othmane Lamrani Perfil en Linkedin

A Moroccan Software Engineer graduated from the National School of Applied Sciences in Kenitra. My passion is programming, especially developing mobile applications. I love discovering new technologies and working with the latest best practices to make my work scalable and maintainable for my Team. I also love sharing my knowledge with the other developers and learning from them too.

Plus de posts par Othmane Lamrani