Android : Introduction aux Coroutines de Kotlin

Partager cet article

Temps estimé pour la lecture de cet article : 52 min

Si vous faites du développement android depuis un petit moment, vous avez surement déjà rencontré des cas bloquants, comme la gestion du réseau et de l’affichage de son résultat. Par le passé, cela pouvait être un peu complexe à gérer, on avait le choix entre les asynctask et les threads java. C’est là, que les coroutines vont rentrer en jeu, elles vont permettre de simplifier la syntaxe et la manipulation des threads. Je vous propose une introduction à ce merveilleux outil !

Les coroutines, kezako ?

android coroutine kezako

La librairie coroutines a été développée par JetBrains. Selon la documentation, les coroutines sont :

  • Des threads allégés
  • Une pure abstraction de langage, pour l’utilisateur
  • Une manière d’exécuter du code non bloquant et asynchrone

Je pense qu’on peut les voir tout simplement comme des « wrappers » de threads qui permettent de gérer de manière assez fine l’endroit et le moment où l’on va exécuter notre code.

Kotlin coroutines, en bref

  • Une coroutine ne dépend pas d’un thread en particulier.
  • Elle peut être suspendue dans un thread et reprise dans un autre.
  • Chaque coroutine peut communiquer entre elles.

Notre première coroutine

fun main() {
    GlobalScope.launch { 
        delay(1000L)
        println("World!")
    }
    print("Hello,") 
    Thread.sleep(2000L)
}

Voici, un hello world classique obtenu avec une coroutine, regardons par ligne ce qui est fait :

  • Une nouvelle coroutine est lancée en arrière-plan grâce au mot-clé launch.
  • On fait une pause non bloquante pour 1 seconde (le temps par défaut est en ms) grâce à la méthode delay.
  • Après 1 seconde d’attente, on affiche le mot « World » .
  • Pendant ce temps, le main thread continue de tourner.
  • Il affiche le mot « Hello »
  • Enfin, on fait attendre le thread principal pendant 2 secondes pour maintenir la JVM en vie et ainsi laisser le temps à la coroutine de se terminer.

Les suspending functions

Un concept assez important introduit avec les coroutines, ce sont les suspending functions. Sur l’exemple du dessus, notre coroutine contenait peu de code mais bien sur lorsque ce dernier devient assez conséquent, il est préférable de le scinder dans une fonction à part. Cette fonction doit alors être marquée avec le modifier suspend. Cela lui permet non seulement d’être utilisée dans une coroutine mais aussi de profiter de ces fonctionnalités, comme la méthode delay qu’on a vu au-dessus.

Chose importante à savoir, une suspending function ne peut être appelée que depuis une autre suspending function ou alors à l’intérieur d’une coroutine !

Coroutines builder

Les builders sont utilisés pour préciser comment une coroutine doit se lancer :

  • Avec la commande launch
    • Cette dernière va alors retourner un objet Job.
    • Mais ne renverra pas de résultat.
  • Grâce à la méthode async
    • Qui va renvoyer un objet Deferred<T>, l’équivalent par exemple de PromiseKit pour Swift.
    • On peut alors utiliser la fonction await(), pour mettre en pause le thread courant et récupérer le résultat quand on en a besoin.
  • Avec runBlocking
    • Dont l’objectif est de lancer une coroutine et bloque le thread courant jusqu’à la fin de son exécution.

CoroutineStart

Il est possible grâce à la classe CoroutineStart de préciser, quand une coroutine doit se lancer :

  • L’argument par défaut est DEFAULT (surprenant, non ?), cela signifie tout simplement que la coroutine démarre immédiatement.
  • LAZY: la coroutine va se lancer uniquement lorsqu’elle sera nécessaire.
  • ATOMIC: similaire à l’argument par défaut, mais la coroutine ne peut pas être annulée tant qu’elle n’a pas été exécutée.

Voici un exemple d’utilisation :

GlobalScope.async(start = CoroutineStart.LAZY) {
    ...
}

Annuler une coroutine

Il est bien sûr possible d’annuler une coroutine précédemment lancée. Par exemple, si l’utilisateur ouvre une page qui nécessite l’appel d’une API mais qu’il ferme la page aussitôt, le résultat de l’appel n’aura donc plus de sens et peut-être annulé. Comme, on l’a vu au-dessus, un coroutine builder retourne un Job ou un Deferred, ces objets peuvent être utilisés afin d’annuler une coroutine. Prenons un exemple :

val job = launch {
    repeat(1000) { i ->
        println("job: I'm sleeping $i ...")
        delay(500L)
    }
}
delay(1300L)
println("main: I'm tired of waiting!")
job.cancel()
job.join()
println("main: Now I can quit.")

Analysons ce petit bout de code :

  • On lance une coroutine grâce au coroutine builder launch, on récupère se faisaint une instance de la classe Job.
  • Cette dernière va itérer toutes les secondes et afficher un message avant de faire une pause de 500 ms.
  • Pour le main thread, on fait une pause de 1,3 s, puis on va appeler la méthode cancel et join de la classe Job.
    • cancel(): on arrêtera la coroutine à la prochaine itération.
    • join(): on attend la fin de la complétion de la coroutine.

À savoir, qu’il existe la méthode cancelAndJoin, qui a exactement le même effet que faire les appels aux deux méthodes cancel et join à la suite.

Rendre sa coroutine cancellable

Il faut savoir que l’annulation d’une coroutine est coopérative ! Qu’est-ce que ça veut dire ? Cela signifie que le code doit être prévu pour être cancellable. Prenons un cas concret, si vous lancez une coroutine et que cette dernière effectue une opération sans vérifier si elle peut être annulée alors elle ne le sera pas. C’est peut-être encore flou, prenons l’exemple de la documentation :

val startTime = System.currentTimeMillis()
val job = launch(Dispatchers.Default) {
    var nextPrintTime = startTime
    var i = 0
    while (i < 5) { // computation loop
        if (System.currentTimeMillis() >= nextPrintTime) {
            println("job: I'm sleeping ${i++} ...")
            nextPrintTime += 500L
        }
    }
}
delay(1300L)
println("main: I'm tired of waiting!")
job.cancelAndJoin()
println("main: Now I can quit.")

Ce qui nous donne :

job: I'm sleeping 0 ...
job: I'm sleeping 1 ...
job: I'm sleeping 2 ...
main: I'm tired of waiting!
job: I'm sleeping 3 ...
job: I'm sleeping 4 ...
main: Now I can quit.

Le problème est simple, à aucun moment, on ne vérifie l’état de la coroutine, comme on effectue un while, l’exécution se continuera tant que i ne sera pas supérieur à 5. Il faut donc rendre son code cancellable, pour faire cela, deux possibilités s’offrent à nous :

  • Une première solution serait d’utiliser une suspending function pour vérifier l’état de l’annulation. En utilisant la function yield par exemple.
  • La seconde approche, c’est tout simplement de vérifier l’état d’annulation de la coroutine, grâce à l’attribut: isActive, qui retourne true, si le job est encore actif (non complété et non annulé).

Si vous voudriez rendre annulable l’exemple précédent, il suffirait de remplacer le while (i < 5) par while (isActive). Enfin, la raison la plus évidente qui existerait pour annuler une coroutine, c'est qu'un certain temps a été atteint. Par exemple dans le cas d'une requête réseau, on pourrait vouloir arrêter la coroutine si un certain timeout a été atteint. Il existe déjà des méthodes pour faire cela :

CoroutineScope

Un coroutine builder est une extension de CoroutineScope et hérite ainsi du coroutineContext pour pouvoir automatiquement propager à la fois les éléments du context et l’annulation.

La meilleure manière de récupérer l’instance d’un scope, c’est avec les factories CoroutineScope et MainScope. On peut rajouter des éléments de contexte dans un scope grâce à l’opérateur plus.

Chaque CoroutineBuilder (comme launch et async) et chaque scoping function (comme coroutineScope, withContext, etc) fournissent leurs propres scopes avec leur propre instance de la classe Job dans leur propre bloc de code. Par convention, ils vont tous attendre que les coroutines à l’intérieur de leur bloc ce soient terminés avant de finir leur propre exécution.

Autrement dit, lorsque l’on crée une coroutine, cette dernière est définie dans un scope donné. Si vous lancez à l’intérieur de cette même coroutine un nouveau scope grâce par exemple à la méthode coroutineScope alors, la coroutine parente va devoir attendre cette dernière avant de pouvoir continuer à s’exécuter. Cela peut-être intéressant dans certains cas, on y reviendra dans la suite de cet article.

CoroutineContext & dispatchers

android coroutine dispatcher

Une coroutine tourne dans un contexte défini, représenté par le type CoroutineContext. Chaque contexte, inclut un objet CoroutineDispatcher, qui détermine le thred ou les threads utilisaient lors de l’exécution de la coroutine.

  • Dispatchers.Default – utilise un pool commun de threads partagés. Utile pour les tâches qui consomment des ressources CPU.
  • Dispatchers.IO – utilise un pool partagé de threads créé à la demande. Utile pour les opérations bloquantes d’I/O.
  • newSingleThreadContext – on crée et dispatch le code dans un thread prévu à cet effet
  • newFixedThreadPoolContext – on fait la même chose qu’au-dessus mais sur un ThreadPool avec une taille définie
  • On peut également préciser son propre Executor, il suffit de le convertir grâce à la méthode asCoroutineDispatcher.

CoroutineDispatcher en action

Comme vous le savez surement sur Android :

  • On ne peut pas faire d’appels réseaux sur le main thread, sinon on se mange une belle exception.
  • De la même manière, nous ne pouvons pas modifier l’UI en dehors du thread principal.

C’est pourquoi JetBrains, nous fournit la librairie coroutines-android, qui introduit un nouveau type de Dispatchers.main, qui va permettre de d’exécuter la suite du code d’une coroutine sur le thread principal. On peut le voir comme un runOnUIThread().

Grâce à la classe CoroutineDispatcher, on va pouvoir avoir un outil efficace afin de contrôler facilement les appels réseaux de notre application. Petit exemple :

GlobalScope.launch(Dispatchers.Default) {
	val bitmap = DownloaderHelper.getBitmapFromURL(myURL)
	bitmap?.let {
		launch(Dispatchers.Main) {
			imageView.setImageBitmap(it)
		}
	}
}

Vous l’aurez surement compris mais dans le doute je vous explique, on lance le téléchargement d’une image depuis un thread en arrière-plan et dès que le téléchargement est terminé, on dispatch le résultat dans le main thread afin de pouvoir changer l’UI et d’afficher l’image.

Petit résumé sur les coroutines

Si vous arrivez à suivre jusque-là, vous devriez pouvoir comprendre la signature de la fonction launch :

android coroutine résumé

Si tout est clair, je vous propose de passer à un exemple plus concret !

Les coroutines en actions !

L’intérêt premier de tout ça, c’est de pouvoir facilement gérer des actions qui peuvent être complexe, dans cet exemple, on télécharge depuis deux APIs différentes une liste de boissons, une API retournera des boissons alcoolisées et l’autre des boissons non alcoolisées. Avec les coroutines, vous allez voir c’est plutôt simple à faire.

GlobalScope.launch(Dispatchers.Default) {
    val drinks = ArrayList<Drink>()

    coroutineScope {
        launch {
            val alcoholDrinks = fetchAlcoholDrinks()
            if (alcoholDrinks.isNotEmpty()) {
            	drinks.addAll(alcoholDrinks)
            }
        }
        launch {
            val nonAlcoholDrinks = fetchNonAlcoholDrinks()
            if (nonAlcoholDrinks.isNotEmpty()) {
            	drinks.addAll(nonAlcoholDrinks)
            }
        }
    }

    drinks.sortBy { 
        it.strDrink 
    }

    launch(Dispatchers.Main) {
        if (drinks.isNotEmpty()) {
           onListingFinishedListener.onSuccess(drinks)
       } else {
           onListingFinishedListener.onFail("...")
       }
    }
}

private suspend fun fetchAlcoholDrinks(): List<Drink> {
    ...
}


private suspend fun fetchNonAlcoholDrinks(): List<Drink> {
    ...
}

Tout d’abord, on lance notre action dans une nouvelle coroutine en utilisant le Dispatcher par défaut, c’est-à-dire un thread. On crée une liste de boissons. Ensuite, on définit un nouveau scope et on lance deux nouvelles coroutines à l’intérieur, ce qui nous permet de faire attendre notre première coroutine, tant que les deux APIs n’auront pas fini de récupérer les données.

Dès que le téléchargement des deux APIs est fini, le thread de la première coroutine récupére la main. Il nous reste plus qu’à trier et retourner le résultat sur le thread principal grâce au CoroutineDispatcher Main.

Aller plus loin

Ici, je n’ai fait qu’effleurer ce qui est possible avec les coroutines, il vous reste encore plein de choses à découvrir comme les channels ! Pour poursuivre, je vous propose un peu de littérature :

J’espère que cette introduction aura pu vous aider, n’hésitez pas à me faire des retours, notamment sur les parties moins clair de l’article !

Laisser un commentaire

Votre adresse e-mail ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.