Coroutines Kotlin sur Android

Une coroutine est un modèle de conception de simultanéité que vous pouvez utiliser sur Android pour simplifier le code qui s'exécute de manière asynchrone. Les coroutines ont été ajoutées à Kotlin dans la version 1.3 et sont basées sur des concepts établis dans d'autres langages.

Sur Android, les coroutines permettent de gérer les tâches de longue durée qui pourraient autrement bloquer le thread principal et entraîner le blocage de votre application. Plus de 50 % des développeurs professionnels qui utilisent des coroutines ont constaté une augmentation de la productivité. Cet article décrit comment vous pouvez utiliser les coroutines Kotlin pour résoudre ces problèmes et écrire du code plus clair et plus concis pour vos applications.

Fonctionnalités

Les coroutines sont la solution recommandée pour la programmation asynchrone sur Android. Les fonctionnalités les plus importantes sont les suivantes :

  • Légèreté : vous pouvez exécuter de nombreuses coroutines sur un seul thread grâce à la prise en charge de la suspension, qui ne bloque pas le thread dans lequel la coroutine est exécutée. La suspension permet de libérer de la mémoire contrairement au blocage, tout en prenant en charge plusieurs opérations simultanées.
  • Moins de fuites de mémoire: utilisez la simultanéité structurée pour exécuter des opérations dans un champ d'application.
  • Prise en charge intégrée de l'annulation : l'annulation est automatiquement propagée dans la hiérarchie des coroutines en cours d'exécution.
  • Intégration Jetpack : de nombreuses bibliothèques Jetpack incluent des extensions compatibles avec toutes les coroutines. Certaines bibliothèques fournissent également leur propre champ d'application de coroutine que vous pouvez utiliser pour la simultanéité structurée.

Présentation des exemples

Sur la base du guide de l'architecture des applications, les exemples de cet article effectuent une requête réseau et renvoient le résultat au thread principal, où l'application peut ensuite afficher le résultat à l'utilisateur.

Plus précisément, le composant d'architecture ViewModel appelle la couche du dépôt sur le thread principal pour déclencher la requête réseau. Ce guide présente diverses solutions qui utilisent des coroutines pour éviter de bloquer le thread principal.

ViewModel inclut un ensemble d'extensions KTX qui fonctionnent directement avec les coroutines. Ce sont les extensions de la bibliothèque lifecycle-viewmodel-ktx que nous utilisons dans ce guide.

Informations sur les dépendances

Pour utiliser des coroutines dans votre projet Android, ajoutez la dépendance suivante au fichier build.gradle de votre application :

Groovy

dependencies {
    implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9'
}

Kotlin

dependencies {
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.3.9")
}

Exécuter sur un thread d'arrière-plan

Effectuer une requête réseau sur le thread principal le met en attente ou le bloque jusqu'à ce qu'il reçoive une réponse. Comme le thread est bloqué, l'OS ne peut pas appeler onDraw(), ce qui provoque le blocage de votre application et peut entraîner l'affichage d'une boîte de dialogue "L'application ne répond pas" (ANR). Pour améliorer l'expérience utilisateur, exécutons cette opération sur un thread d'arrière-plan.

Tout d'abord, examinons la classe Repository et voyons comment elle envoie la requête réseau :

sealed class Result<out R> {
    data class Success<out T>(val data: T) : Result<T>()
    data class Error(val exception: Exception) : Result<Nothing>()
}

class LoginRepository(private val responseParser: LoginResponseParser) {
    private const val loginUrl = "https://example.com/login"

    // Function that makes the network request, blocking the current thread
    fun makeLoginRequest(
        jsonBody: String
    ): Result<LoginResponse> {
        val url = URL(loginUrl)
        (url.openConnection() as? HttpURLConnection)?.run {
            requestMethod = "POST"
            setRequestProperty("Content-Type", "application/json; utf-8")
            setRequestProperty("Accept", "application/json")
            doOutput = true
            outputStream.write(jsonBody.toByteArray())
            return Result.Success(responseParser.parse(inputStream))
        }
        return Result.Error(Exception("Cannot open HttpURLConnection"))
    }
}

makeLoginRequest est synchrone et bloque le thread d'appel. Pour modéliser la réponse à la requête réseau, nous disposons de notre propre classe Result.

ViewModel déclenche la requête réseau lorsque l'utilisateur clique, par exemple, sur un bouton :

class LoginViewModel(
    private val loginRepository: LoginRepository
): ViewModel() {

    fun login(username: String, token: String) {
        val jsonBody = "{ username: \"$username\", token: \"$token\"}"
        loginRepository.makeLoginRequest(jsonBody)
    }
}

Avec le code précédent, LoginViewModel bloque le thread UI lors de l'envoi de la requête réseau. La solution la plus simple pour déplacer l'exécution en dehors du thread principal consiste à créer une coroutine et à exécuter la requête réseau sur un thread d'E/S :

class LoginViewModel(
    private val loginRepository: LoginRepository
): ViewModel() {

    fun login(username: String, token: String) {
        // Create a new coroutine to move the execution off the UI thread
        viewModelScope.launch(Dispatchers.IO) {
            val jsonBody = "{ username: \"$username\", token: \"$token\"}"
            loginRepository.makeLoginRequest(jsonBody)
        }
    }
}

Analysons le code des coroutines dans la fonction login :

  • viewModelScope est un CoroutineScope prédéfini qui est inclus dans les extensions KTX du ViewModel. Notez que toutes les coroutines doivent s'exécuter dans un champ d'application. Un CoroutineScope gère une ou plusieurs coroutines associées.
  • launch est une fonction qui crée une coroutine et envoie l'exécution de son corps de fonction au coordinateur correspondant.
  • Dispatchers.IO indique que cette coroutine doit être exécutée sur un thread réservé aux opérations d'E/S.

La fonction login est exécutée comme suit :

  • L'application appelle la fonction login à partir de la couche View sur le thread principal.
  • launch crée une coroutine, et la requête réseau est effectuée indépendamment sur un thread réservé aux opérations d'E/S.
  • Pendant que la coroutine est en cours d'exécution, la fonction login continue de s'exécuter et renvoie un résultat, éventuellement avant la fin de la requête réseau. Notez que pour plus de simplicité, la réponse réseau est ignorée pour le moment.

Comme cette coroutine est démarrée avec viewModelScope, elle est exécutée dans le champ d'application du ViewModel. Si ViewModel est détruit, car l'utilisateur quitte l'écran, viewModelScope est automatiquement annulé, et toutes les coroutines en cours d'exécution sont également annulées.

Un problème avec l'exemple précédent est que tout appel à makeLoginRequest doit déplacer explicitement l'exécution en dehors du thread principal. Voyons comment modifier Repository pour résoudre ce problème.

Utiliser des coroutines pour la sécurité principale

Nous considérons qu'une fonction est sécurisée lorsqu'elle ne bloque pas les mises à jour de l'interface utilisateur sur le thread principal. La fonction makeLoginRequest n'est pas sécurisée, car si makeLoginRequest appelle à partir du thread principal, cela bloque l'interface utilisateur. Utilisez la fonction withContext() de la bibliothèque de coroutines pour déplacer l'exécution d'une coroutine vers un autre thread :

class LoginRepository(...) {
    ...
    suspend fun makeLoginRequest(
        jsonBody: String
    ): Result<LoginResponse> {

        // Move the execution of the coroutine to the I/O dispatcher
        return withContext(Dispatchers.IO) {
            // Blocking network request code
        }
    }
}

withContext(Dispatchers.IO) déplace l'exécution de la coroutine vers un thread d'E/S, ce qui renforce la sécurité de notre fonction d'appel et permet à l'interface utilisateur de se mettre à jour si nécessaire.

makeLoginRequest est également marqué avec le mot clé suspend. Ce mot clé permet à Kotlin d'appliquer une fonction à partir d'une coroutine.

Dans l'exemple suivant, la coroutine est créée dans le LoginViewModel. Lorsque makeLoginRequest déplace l'exécution en dehors du thread principal, la coroutine de la fonction login peut désormais être exécutée dans le thread principal :

class LoginViewModel(
    private val loginRepository: LoginRepository
): ViewModel() {

    fun login(username: String, token: String) {

        // Create a new coroutine on the UI thread
        viewModelScope.launch {
            val jsonBody = "{ username: \"$username\", token: \"$token\"}"

            // Make the network call and suspend execution until it finishes
            val result = loginRepository.makeLoginRequest(jsonBody)

            // Display result of the network request to the user
            when (result) {
                is Result.Success<LoginResponse> -> // Happy path
                else -> // Show error in UI
            }
        }
    }
}

Notez que la coroutine est toujours nécessaire ici, car makeLoginRequest est une fonction suspend, et toutes les fonctions suspend doivent être exécutées dans une coroutine.

Ce code diffère de l'exemple précédent de login de deux manières :

  • launch n'accepte pas le paramètre Dispatchers.IO. Lorsque vous ne transmettez pas de Dispatcher à launch, toutes les coroutines lancées à partir du viewModelScope s'exécutent dans le thread principal.
  • Le résultat de la requête réseau est maintenant géré pour afficher l'interface utilisateur de réussite ou d'échec.

La fonction de connexion s'exécute désormais comme suit :

  • L'application appelle la fonction login() à partir de la couche View sur le thread principal.
  • launch crée une coroutine sur le thread principal, qui commence à s'exécuter.
  • Dans la coroutine, l'appel de loginRepository.makeLoginRequest() suspend l'exécution de la coroutine jusqu'à la fin de l'exécution du bloc withContext dans makeLoginRequest().
  • Une fois l'exécution du bloc withContext terminée, la coroutine de login() reprend l'exécution sur le thread principal avec le résultat de la requête réseau.

Gérer les exceptions

Pour gérer les exceptions que la couche Repository peut générer, utilisez la compatibilité intégrée avec les exceptions de Kotlin. Dans l'exemple suivant, nous utilisons un bloc try-catch :

class LoginViewModel(
    private val loginRepository: LoginRepository
): ViewModel() {

    fun login(username: String, token: String) {
        viewModelScope.launch {
            val jsonBody = "{ username: \"$username\", token: \"$token\"}"
            val result = try {
                loginRepository.makeLoginRequest(jsonBody)
            } catch(e: Exception) {
                Result.Error(Exception("Network request failed"))
            }
            when (result) {
                is Result.Success<LoginResponse> -> // Happy path
                else -> // Show error in UI
            }
        }
    }
}

Dans cet exemple, toute exception inattendue générée par l'appel makeLoginRequest() est traitée comme une erreur dans l'interface utilisateur.

Ressources supplémentaires sur les coroutines

Pour en savoir plus sur les coroutines sur Android, consultez la page Améliorer les performances des applications avec les coroutines Kotlin.

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