Améliorer les performances des applications avec les coroutines Kotlin

Les coroutines Kotlin vous permettent d'écrire du code asynchrone clair et simplifié qui maintient la réactivité de votre application tout en gérant les tâches de longue durée, telles que les appels réseau ou les opérations sur disque.

Cet article fournit une présentation détaillée des coroutines sur Android. Si vous ne maîtrisez pas les coroutines, assurez-vous de lire l'article Coroutines Kotlin sur Android avant de lire celui-ci.

Gérer les tâches de longue durée

Les coroutines s'appuient sur des fonctions standards en ajoutant deux opérations pour gérer les tâches de longue durée. En plus de invoke (ou call) et return, les coroutines ajoutent suspend et resume :

  • suspend interrompt l'exécution de la coroutine actuelle, ce qui enregistre toutes les variables locales.
  • resume reprend l'exécution d'une coroutine suspendue de là où elle a été interrompue.

Vous ne pouvez appeler des fonctions suspend qu'à partir d'autres fonctions suspend ou en utilisant un constructeur de coroutines tel que launch pour démarrer une nouvelle coroutine.

L'exemple suivant illustre une implémentation simple de coroutine pour une tâche de longue durée hypothétique :

suspend fun fetchDocs() {                             // Dispatchers.Main
    val result = get("https://developer.android.com") // Dispatchers.IO for `get`
    show(result)                                      // Dispatchers.Main
}

suspend fun get(url: String) = withContext(Dispatchers.IO) { /* ... */ }

Dans cet exemple, get() s'exécute toujours sur le thread principal, mais il suspend la coroutine avant de lancer la requête réseau. Une fois la requête réseau terminée, get reprend la coroutine suspendue au lieu d'utiliser un rappel pour avertir le thread principal.

Kotlin utilise un bloc de pile pour gérer la fonction qui s'exécute avec toutes les variables locales. Lorsque vous suspendez une coroutine, le bloc de pile actuel est copié et enregistré pour plus tard. Lors de la reprise, le bloc de pile est recopié à partir de l'endroit où il a été enregistré, et la fonction reprend son exécution. Même si le code peut ressembler à une requête de blocage séquentiel ordinaire, la coroutine garantit que la requête réseau évite de bloquer le thread principal.

Utiliser des coroutines pour la sécurité principale

Les coroutines Kotlin utilisent des coordinateurs afin de déterminer les threads utilisés pour leur exécution. Pour exécuter du code en dehors du thread principal, vous pouvez demander aux coroutines Kotlin d'effectuer des tâches sur le coordinateur par défaut ou IO. Dans Kotlin, toutes les coroutines doivent s'exécuter dans un coordinateur, même lorsqu'elles s'exécutent sur le thread principal. Les coroutines peuvent se suspendre elles-mêmes, et le coordinateur se charge de les réactiver.

Pour spécifier où les coroutines doivent s'exécuter, Kotlin fournit trois coordinateurs que vous pouvez utiliser :

  • Dispatchers.Main : permet d'exécuter une coroutine sur le thread Android principal. Ce coordinateur ne doit être utilisé que pour interagir avec l'interface utilisateur et pour effectuer des opérations rapides. Par exemple, vous pouvez appeler des fonctions suspend, exécuter des opérations de la structure d'IU Android et mettre à jour des objets LiveData.
  • Dispatchers.IO : ce coordinateur est optimisé pour effectuer des opérations d'E/S sur le disque ou le réseau en dehors du thread principal. Exemples : utilisation du composant Room, lecture ou écriture dans des fichiers et exécution d'opérations réseau.
  • Dispatchers.Default : ce coordinateur est optimisé pour effectuer des tâches nécessitant une utilisation intensive des processeurs en dehors du thread principal. Parmi les cas d'utilisation typiques, on peut citer des tâches comme trier une liste ou effectuer une analyse JSON.

Pour reprendre l'exemple précédent, vous pouvez utiliser les coordinateurs pour redéfinir la fonction get. Dans le corps de get, appelez withContext(Dispatchers.IO) pour créer un bloc qui s'exécute sur le pool de threads d'E/S. Le code que vous placez dans ce bloc s'exécute toujours via le coordinateur IO. Comme withContext est une fonction de suspension, la fonction get l'est également.

suspend fun fetchDocs() {                      // Dispatchers.Main
    val result = get("developer.android.com")  // Dispatchers.Main
    show(result)                               // Dispatchers.Main
}

suspend fun get(url: String) =                 // Dispatchers.Main
    withContext(Dispatchers.IO) {              // Dispatchers.IO (main-safety block)
        /* perform network IO here */          // Dispatchers.IO (main-safety block)
    }                                          // Dispatchers.Main
}

Les coroutines vous permettent de coordonner les threads avec précision. Étant donné que withContext() vous permet de contrôler le pool de threads de n'importe quelle ligne de code sans introduire de rappels, vous pouvez l'appliquer à de très petites fonctions, telles que la lecture d'une base de données ou l'exécution d'une requête réseau. Une bonne pratique consiste à utiliser withContext() pour vous assurer que chaque fonction est sécurisée, ce qui signifie que vous pouvez l'appeler à partir du thread principal. De cette façon, l'appelant n'a jamais besoin de déterminer quel thread doit être utilisé pour exécuter la fonction.

Dans l'exemple précédent, fetchDocs() s'exécute sur le thread principal. Cependant, il peut appeler get de façon sécurisée, qui effectue une requête réseau en arrière-plan. Étant donné que les coroutines prennent en charge les fonctions suspend et resume, la coroutine sur le thread principal est reprise avec le résultat get dès que le bloc withContext est terminé.

Performances de withContext()

withContext() n'entraîne pas de surcharge supplémentaire par rapport à une implémentation équivalente basée sur le rappel. De plus, dans certaines situations, il est possible d'optimiser les appels withContext() au-delà d'une implémentation équivalente basée sur le rappel. Par exemple, si une fonction effectue dix appels vers un réseau, vous pouvez demander à Kotlin de changer de thread une seule fois en utilisant un withContext() externe. Ainsi, même si la bibliothèque réseau utilise withContext() plusieurs fois, elle reste sur le même coordinateur sans avoir à changer de thread. En outre, Kotlin optimise le passage entre Dispatchers.Default et Dispatchers.IO afin d'éviter les commutations de threads dans la mesure du possible.

Démarrer une coroutine

Deux options s'offrent à vous pour démarrer des coroutines :

  • launch démarre une nouvelle coroutine et ne renvoie pas le résultat à l'appelant. Toutes les opérations de type "fire and forget" peuvent être lancées avec launch.
  • async démarre une nouvelle coroutine et vous permet de renvoyer un résultat avec une fonction de suspension appelée await.

En règle générale, vous devez exécuter l'action launch pour une nouvelle coroutine à partir d'une fonction standard, car une telle fonction ne peut pas appeler await. Utilisez async uniquement à l'intérieur d'une autre coroutine ou à l'intérieur d'une fonction de suspension et en effectuant une décomposition en parallèle.

Décomposition en parallèle

Toutes les coroutines démarrées dans une fonction suspend doivent être arrêtées lorsqu'elle est renvoyée. Vous devez donc sans doute vous assurer que ces coroutines se terminent avant d'être renvoyée. Avec la simultanéité structurée en Kotlin, vous pouvez définir une coroutineScope qui démarre une ou plusieurs coroutines. Ensuite, en utilisant await() (pour une seule coroutine) ou awaitAll() (pour plusieurs), vous pouvez garantir que celles-ci se terminent avant d'être renvoyées à partir de la fonction.

Définissons, par exemple, une valeur coroutineScope qui récupère deux documents de manière asynchrone. En appelant await() sur chaque référence différée, nous garantissons que les deux opérations async se terminent avant de renvoyer une valeur :

suspend fun fetchTwoDocs() =
    coroutineScope {
        val deferredOne = async { fetchDoc(1) }
        val deferredTwo = async { fetchDoc(2) }
        deferredOne.await()
        deferredTwo.await()
    }

Vous pouvez également utiliser awaitAll() sur des collections, comme illustré dans l'exemple suivant :

suspend fun fetchTwoDocs() =        // called on any Dispatcher (any thread, possibly Main)
    coroutineScope {
        val deferreds = listOf(     // fetch two docs at the same time
            async { fetchDoc(1) },  // async returns a result for the first doc
            async { fetchDoc(2) }   // async returns a result for the second doc
        )
        deferreds.awaitAll()        // use awaitAll to wait for both network requests
    }

Même si fetchTwoDocs() lance de nouvelles coroutines avec async, la fonction utilise awaitAll() pour attendre que les coroutines lancées se terminent avant de renvoyer une valeur. Notez cependant que même si nous n'avions pas appelé awaitAll(), le constructeur coroutineScope ne reprend la coroutine qui a appelé fetchTwoDocs qu'une fois toutes les nouvelles coroutines terminées.

De plus, coroutineScope intercepte toutes les exceptions que les coroutines génèrent et les renvoie vers l'appelant.

Pour en savoir plus sur la décomposition en parallèle, consultez la section Composer des fonctions de suspension.

Concepts de coroutines

CoroutineScope

Un CoroutineScope effectue le suivi de toute coroutine qu'il crée à l'aide de launch ou async. Vous pouvez annuler les opérations en cours (c'est-à-dire les coroutines en cours d'exécution) à tout moment en appelant scope.cancel(). Sous Android, certaines bibliothèques KTX fournissent leur propre CoroutineScope pour certaines classes du cycle de vie. Par exemple, ViewModel a un viewModelScope, et Lifecycle a un lifecycleScope. Cependant, contrairement à un coordinateur, un CoroutineScope n'exécute pas les coroutines.

viewModelScope est également utilisé dans les exemples de la section Exécuter des threads d'arrière-plan sur Android avec les coroutines. Cependant, si vous avez besoin de créer votre propre CoroutineScope pour contrôler le cycle de vie des coroutines dans une couche particulière de votre application, vous pouvez en créer un comme suit :

class ExampleClass {

    // Job and Dispatcher are combined into a CoroutineContext which
    // will be discussed shortly
    val scope = CoroutineScope(Job() + Dispatchers.Main)

    fun exampleMethod() {
        // Starts a new coroutine within the scope
        scope.launch {
            // New coroutine that can call suspend functions
            fetchDocs()
        }
    }

    fun cleanUp() {
        // Cancel the scope to cancel ongoing coroutines work
        scope.cancel()
    }
}

Un champ d'application annulé ne peut pas créer plus de coroutines. Par conséquent, vous ne devez appeler scope.cancel() que lorsque la classe qui contrôle son cycle de vie est détruite. Lorsque vous utilisez viewModelScope, la classe ViewModel annule automatiquement le champ d'application dans la méthode onCleared() du ViewModel.

Job

Un Job est un handle vers une coroutine. Chaque coroutine que vous créez avec launch ou async renvoie une instance de Job qui identifie de manière unique la coroutine et gère son cycle de vie. Vous pouvez également transmettre un Job à un CoroutineScope pour mieux gérer son cycle de vie, comme illustré dans l'exemple suivant :

class ExampleClass {
    ...
    fun exampleMethod() {
        // Handle to the coroutine, you can control its lifecycle
        val job = scope.launch {
            // New coroutine
        }

        if (...) {
            // Cancel the coroutine started above, this doesn't affect the scope
            // this coroutine was launched in
            job.cancel()
        }
    }
}

CoroutineContext

Un CoroutineContext définit le comportement d'une coroutine à l'aide de l'ensemble d'éléments suivant :

Pour les nouvelles coroutines créées dans un champ d'application, une nouvelle instance de Job est attribuée à la nouvelle coroutine, et les autres éléments du CoroutineContext sont hérités du champ d'application qui les contient. Vous pouvez remplacer les éléments hérités en transmettant un nouveau CoroutineContext à la fonction launch ou async. Notez que transmettre Job à launch ou async n'a aucun effet, car une nouvelle instance de Job est toujours attribuée à une nouvelle coroutine.

class ExampleClass {
    val scope = CoroutineScope(Job() + Dispatchers.Main)

    fun exampleMethod() {
        // Starts a new coroutine on Dispatchers.Main as it's the scope's default
        val job1 = scope.launch {
            // New coroutine with CoroutineName = "coroutine" (default)
        }

        // Starts a new coroutine on Dispatchers.Default
        val job2 = scope.launch(Dispatchers.Default + CoroutineName("BackgroundCoroutine")) {
            // New coroutine with CoroutineName = "BackgroundCoroutine" (overridden)
        }
    }
}

Ressources supplémentaires sur les coroutines

Pour plus de ressources sur les coroutines, consultez les liens suivants :