IdentifiantMot de passe
Loading...
Mot de passe oublié ?Je m'inscris ! (gratuit)

Vous êtes nouveau sur Developpez.com ? Créez votre compte ou connectez-vous afin de pouvoir participer !

Vous devez avoir un compte Developpez.com et être connecté pour pouvoir participer aux discussions.

Vous n'avez pas encore de compte Developpez.com ? Créez-en un en quelques instants, c'est entièrement gratuit !

Si vous disposez déjà d'un compte et qu'il est bien activé, connectez-vous à l'aide du formulaire ci-dessous.

Identifiez-vous
Identifiant
Mot de passe
Mot de passe oublié ?
Créer un compte

L'inscription est gratuite et ne vous prendra que quelques instants !

Je m'inscris !

Kotlin Coroutines 1.5 est disponible :
GlobalScope signalée comme API « sensible », amélioration de l'API de canaux, et plus encore

Le , par Michael Guilloux

52PARTAGES

9  0 
La version 1.5.0 de la bibliothèque de coroutines de JetBrains est disponible. Les nouveautés de cette version couvrent un bon nombre de domaines, notamment : l'API GlobalScope, les extensions pour JUnit, l'API de canaux, la stabilisation des intégrations Reactive, entre autres.

  • GlobalScope est maintenant signalée comme API “sensible”, nécessitant une attention particulière. Elle offre en effet des fonctionnalités avancées qui peuvent facilement donner lieu à une utilisation incorrecte. Dorénavant, le compilateur vous avertit en cas de risque d’utilisation incorrecte et un opt-in sera requis pour l’utilisation de cette classe dans votre programme.
  • Extensions pour JUnit : CoroutinesTimeout est maintenant disponible pour JUnit 5.
  • Amélioration de l’API de canaux : en plus des nouvelles règles de nommage pour les fonctions de la bibliothèque, les fonctions non suspensives trySend et tryReceive ont été introduites comme de meilleures alternatives à offer et poll.
  • Stabilisation des intégrations Reactive : JetBrains a ajouté plus de fonctions pour la conversion des types Reactive Streams en Kotlin Flow et inversement, et stabilisé de nombreuses fonctions existantes et l’API ReactiveContext.

Nous revenons dans la suite sur ces nouveautés avec plus de détails.

GlobalScope signalée comme API à appréhender avec précaution

La classe GlobalScope est désormais marquée avec l’annotation @DelicateCoroutinesApi. Dorénavant, toute utilisation de GlobalScope nécessitera un opt-in explicite avec @OptIn(DelicateCoroutinesApi::class).

Bien que l’utilisation de GlobalScope ne soit pas recommandée dans la plupart des cas, la documentation officielle propose un certain nombre de concepts utilisant cette API.

Un CoroutineScope global n’est lié à aucune tâche. GlobalScope est utilisée pour exécuter des coroutines de niveau supérieur qui fonctionnent pendant toute la durée de vie de l’application et ne sont pas annulées prématurément. Les coroutines actives lancées dans GlobalScope ne permettent pas d’éviter l’arrêt du processus. Elles sont similaires à des threads démons.

L’utilisation de l’API GlobalScope est délicate et requiert de la prudence, car elle peut facilement engendrer des pertes de ressources ou de mémoire. Une coroutine lancée dans GlobalScope n’obéit pas au principe de concurrence structurée, donc en cas de blocage ou de ralentissement (en raison de la lenteur du réseau par exemple), elle continuera de fonctionner et de consommer des ressources. Voici un exemple :

Code : Sélectionner tout
1
2
3
4
5
6
7
fun loadConfiguration() {
    GlobalScope.launch {
        val config = fetchConfigFromServer() // network request
        updateConfiguration(config)
    }
}

L’appel à loadConfiguration crée une coroutine dans GlobalScope qui s’exécute en arrière-plan et aucune condition n’est spécifiée pour l’annuler ou attendre son achèvement. Si le réseau est lent, elle reste en attente en arrière-plan et consomme des ressources. Des appels répétés à loadConfiguration entraîneront la consommation de plus en plus de ressources.

Possibilités de remplacement

Dans de nombreux cas, l’utilisation de GlobalScope doit être évitée et l’opération contenante doit être marquée avec suspend, par exemple :

Code : Sélectionner tout
1
2
3
4
5
suspend fun loadConfiguration() {
    val config = fetchConfigFromServer() // network request
    updateConfiguration(config)
}

Dans les cas où GlobalScope.launch est utilisé pour lancer plusieurs opérations simultanées, les opérations correspondantes doivent plutôt être regroupées avec coroutineScope :

Code : Sélectionner tout
1
2
3
4
5
6
7
8
// concurrently load configuration and data
suspend fun loadConfigurationAndData() {
    coroutineScope {
        launch { loadConfiguration() }
        launch { loadData() }
    }
}

Dans le code de niveau supérieur, lors du lancement d’une opération concurrente simultanée à partir d’un contexte non suspensif, utilisez une instance CoroutineScope correctement délimitée au lieu de GlobalScope.

Cas d’utilisation appropriés

L’utilisation de GlobalScope est sûre et justifiée dans quelques cas, parmi lesquels les processus d’arrière-plan de niveau supérieur qui doivent s’exécuter pendant toute la durée de vie d’une application. C’est pourquoi toute utilisation de GlobalScope requiert un opt-in explicite avec @OptIn(DelicateCoroutinesApi::class) :

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
// A global coroutine to log statistics every second, 
// must be always active
@OptIn(DelicateCoroutinesApi::class)
val globalScopeReporter = GlobalScope.launch {
    while (true) {
        delay(1000)
        logStatistics()
    }
}

Il est recommandé de passer en revue toutes vos utilisations de GlobalScope et d’annoter uniquement celles qui rentrent dans la catégorie des « cas d’utilisation appropriés ». Pour tous les autres cas, pour éviter les bugs dans votre code, il faut remplacer l’utilisation de GlobalScope comme décrit ci-dessus.

Extensions pour JUnit 5

JetBrains a ajouté une annotation CoroutinesTimeout qui vous permet d’exécuter des tests dans un thread séparé, de les désactiver une fois le temps imparti expiré et d’interrompre le thread. Auparavant, CoroutinesTimeout était seulement disponible pour JUnit 4. Avec cette version, l’intégration pour JUnit 5 a été ajoutée.

Pour utiliser la nouvelle annotation, ajoutez la dépendance suivante à votre projet :

Code : Sélectionner tout
1
2
3
4
5
dependencies {
  …
  testImplementation("org.jetbrains.kotlinx:kotlinx-coroutines-debug:$coroutinesVersion")
}

Voici un exemple simple de l’utilisation de CoroutinesTimeout dans vos tests :

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import kotlinx.coroutines.debug.junit5.CoroutinesTimeout
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.junit.Test
​
@CoroutinesTimeout(100)
class CoroutinesTimeoutSimpleTest {
​
     @CoroutinesTimeout(300)
     @Test
     fun firstTest() {
         runBlocking {
             delay(200)  // succeeds
         }
     }
​
     @Test
     fun secondTest() {
         runBlocking {
             delay(200)  // fails
         }
     }
 }

Dans cet exemple, le délai d’expiration des coroutines est défini au niveau de la classe et spécifiquement pour firstTest. Il n’y a pas de délai imparti pour le test annoté, car l’annotation de la fonction prévaut sur celle de la classe. En revanche il y en a un pour secondTest, qui utilise l’annotation au niveau de la classe.

L’annotation est déclarée de la façon suivante :

Code : Sélectionner tout
1
2
3
4
5
6
7
package kotlinx.coroutines.debug.junit5

public annotation class CoroutinesTimeout(
    val testTimeoutMs: Long,
    val cancelOnTimeout: Boolean = false
)

Le premier paramètre, testTimeoutMs, spécifie la durée du délai imparti en millisecondes. Le deuxième paramètre, cancelOnTimeout, détermine si toutes les coroutines en cours d’exécution doivent être annulées à la fin du délai imparti. S’il est défini comme true, toutes les coroutines seront automatiquement annulées.

Lorsque vous utilisez l’annotation CoroutinesTimeout, elle active automatiquement le débogueur de coroutines et crée un dump de toutes les coroutines lorsque le délai imparti a expiré. Le dump contient les traces de pile de la création des coroutines. Si vous devez désactiver les traces de pile afin d’accélérer les tests vous pouvez utiliser CoroutinesTimeoutExtension directement pour définir les paramètres appropriés.

Amélioration de l’API de canaux

Les canaux constituent des primitives de communication importantes, qui permettent l’échange de données entre coroutines et callbacks. Pour cette version, JetBrains a retravaillé l’API de canaux, en remplaçant les fonctions offer et poll, qui prêtaient à confusion, par de meilleures alternatives. Au passage, l'entreprise a développé un nouveau système de nommage cohérent pour les méthodes suspensives et non suspensives.

Nouveau schéma de nommage

JetBrains a voulu créer des règles de nommage cohérentes qui pourraient ensuite être utilisées dans d’autres bibliothèques ou API de coroutine. Il fallait s'assurer que le nom de la fonction transmettrait les informations sur son comportement. Voici ce à quoi JetBrains est parvenu :

  • Les méthodes suspensives régulières sont laissées telles quelles, par exemple send et receive.
  • Tous les noms des méthodes non suspensives avec encapsulation d’erreur sont systématiquement préfixés par « try » : trySend et tryReceive au lieu de offer et poll.
  • Les nouvelles méthodes suspensives d’encapsulation d’erreur auront le suffixe « Catching ».

Voyons ces nouvelles méthodes plus en détail.

Fonctions Try : équivalents non suspensifs de send et receive

Une coroutine peut envoyer des informations à un canal, tandis que l’autre peut recevoir ces informations de ce canal. Les fonctions send et receive sont toutes les deux suspensives. send suspend sa coroutine si le canal est plein et ne peut pas prendre en compte de nouvel élément et receive suspend sa coroutine si le canal n’a aucun élément à retourner :

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking<Unit> {
    val channel = Channel<String>()
    launch {
        // Suspends until the element can be sent
        println("Sending...")
        channel.send("Element")
     }
     // Suspends until the element can be received
     println("Receiving...")
     println(channel.receive())
}

Ces fonctions ont des équivalents non suspensifs pour une utilisation dans du code synchrone : offer et poll, qui sont remplacés par trySend et tryReceive , et la prise en charge de l’ancienne fonctionnalité est interrompue. Voyons quelles sont les raisons de ce changement.

Les fonctions offer et poll sont censées avoir le même comportement que send et receive, mais sans suspension. Cela semble simple et c’est le cas tant que l’élément peut être envoyé ou reçu. Mais qu’arriverait-il en cas d’erreur ? send et receive seraient alors suspendues jusqu’à ce qu’elles puissent de nouveau fonctionner correctement. offer et poll retournaient simplement false et null respectivement si l’élément n’avait pas pu être ajouté parce que le canal était plein ou si aucun élément n’avait pu être récupéré, car le canal était vide. Elles ont toutes les deux lancé une exception pour tenter de travailler avec un canal fermé, ce qui a causé des problèmes avec leur utilisation.

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking<Unit> {
    val channel = Channel<String>()
    launch {
        println("Sending...")
        // Doesn't suspend
        // Returns 'false' if the channel is full
        // Or throws an exception if it's closed
        channel.offer("Element")
    }
    println("Receiving...")
    // Doesn't suspend
    // Returns 'null' if the channel is empty
    println(channel.poll())
//  channel.close()
}

Dans cet exemple, poll est appelé avant qu’un élément ne soit ajouté et renvoie donc null immédiatement. Notez que cette fonction n’est pas censée être utilisée de cette façon : il est préférable de continuer à interroger les éléments régulièrement. Elle est appelée directement pour simplifier cette explication. L’appel de offer est également infructueux, car il s’agit d’un canal rendez-vous ayant une capacité de mémoire tampon nulle. Par conséquent, offer renvoie false et poll renvoie null, simplement parce qu’elles n’ont pas été appelées dans bon ordre.

Dans l’exemple ci-dessus, essayez de décommenter l’instruction channel.close() pour vous assurer que l’exception est levée. Dans ce cas, poll renvoie false, comme précédemment. Mais ensuite offer essaie d’ajouter un élément à un canal déjà fermé, échoue et lance une exception. JetBrains a reçu de nombreuses remarques selon lesquelles ce comportement est source d’erreurs. Il est facile d’oublier de gérer cette exception, or l’ignorer ou la traiter différemment aura pour conséquence de planter votre programme.

Les nouvelles fonctions trySend et tryReceive corrigent ce problème et renvoient un résultat plus détaillé. Chacune renvoie l’instance ChannelResult, qui peut indiquer trois choses : un résultat positif, un échec ou la fermeture du canal.

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking<Unit> {
    val channel = Channel<String>()
    launch {
        println("Sending...")
        // Doesn't suspend
        // Returns 'Failed' if the channel is full
        // Or 'Channel was closed' result if it's closed
        val result = channel.trySend("Element")
        println(result)

        //We can verify the result
        if(result.isClosed){
            println("Sending failed. The channel is closed.")
        }
    }
    println("Receiving...")
    println(channel.tryReceive())
//  channel.close()
}

Cet exemple fonctionne de la même manière que le précédent. La seule différence est que tryReceive et trySend renvoient un résultat plus détaillé. Vous pouvez voir le résultat Value(Failed) au lieu de false et null. Décommentez la ligne fermant à nouveau le canal et assurez-vous que trySend renvoie maintenant un résultat Closed capturant une exception.

Grâce aux classes de valeurs inline, l’utilisation de ChannelResult ne crée pas de wrappers supplémentaires en dessous et si la valeur réussie est renvoyée, elle l’est telle quelle, sans surcharge.

Fonctions Catching : suspendre les fonctions qui encapsulent des erreurs

À partir de cette version, les méthodes suspensives d’encapsulation des erreurs auront le suffixe « Catching ». Par exemple, la nouvelle fonction receiveCatching gère l’exception dans le cas d’un canal fermé. Prenons cet exemple simple :

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking<Unit> {
    val channel = Channel<String>()
    channel.close()
    println(channel.receiveCatching())
}

Le canal est fermé avant que nous essayions de récupérer une valeur. Cependant, le programme aboutit avec succès, indiquant que le canal a été fermé. Si vous remplacez receiveCatching par la fonction receive ordinaire, elle lancera ClosedReceiveChannelException :

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking

fun main() = runBlocking<Unit> {
    val channel = Channel<String>()
    channel.close()
    println(channel.receive())
}

Actuellement, JetBrains fournit seulement receiveCatching et onReceiveCatching (au lieu de la fonction interne receiveOrClosed auparavant), mais il est prévu d’ajouter d’autres fonctions.

Migration de votre code vers de nouvelles fonctions

Vous pouvez remplacer automatiquement toutes les utilisations des fonctions offer et poll dans votre projet avec de nouveaux appels. Puisque offer a renvoyé Boolean, son équivalent de remplacement est canal.trySend("Element".isSuccess.


De même, la fonction poll renvoie un élément nullable, son remplacement devient donc canal.tryReceive().getOrNull().


Si le résultat de l’appel n’a pas été utilisé, vous pouvez les remplacer directement par de nouveaux appels.

Le comportement de traitement des exceptions est désormais différent, vous devrez donc effectuer les mises à jour nécessaires manuellement. Si votre code repose sur les méthodes « offer » et « poll » qui lancent des exceptions sur un canal fermé, vous devrez utiliser les remplacements suivants.

Le remplacement équivalent pour canal.offer("Element" devrait lever une exception lorsque le canal est fermé, même s’il a été fermé normalement :

Code : Sélectionner tout
1
2
3
4
5
channel
  .trySend("Element")
  .onClosed { throw it ?: ClosedSendChannelException("Channel was closed") }
  .isSuccess

Le remplacement équivalent pour channel.poll() lance une exception si le canal a été fermé avec une erreur et renvoie null s’il a été fermé normalement :

Code : Sélectionner tout
1
2
3
4
channel.tryReceive()
  .onClosed { if (it != null) throw it }
  .getOrNull()

Ces changements correspondent à l’ancien comportement des fonctions offer et poll.

JetBrains est parti du postulat que, dans la plupart des cas, votre code ne reposait pas sur ces subtilités de comportement sur un canal fermé, mais que cela était plutôt une source de bugs. C’est pourquoi les remplacements automatiques fournis par l’EDI simplifient la sémantique. Si cela ne correspond pas à votre cas, veuillez passer en revue vos utilisations et les mettre à jour manuellement et envisager de les réécrire complètement pour traiter les cas de canaux fermés différemment, sans lever d’exceptions.

Les intégrations Reactive sur la voie de la stabilité

Avec la version 1.5 de Kotlin Coroutines, la plupart des fonctions responsables des intégrations avec les frameworks Reactive sont maintenant stables.

Dans l’écosystème de la JVM, quelques frameworks traitent la gestion des threads asynchrones selon la norme des Reactive Streams. Les frameworks Java Project Reactor et RxJava sont de ceux-là.

Bien que les Kotlin Flows soient différents et que les types ne soient pas compatibles avec ceux spécifiés par la norme, ce sont néanmoins des flux. Il est possible de convertir Flow en Reactive (en conformité avec les spécifications et TCK) Publisher et vice versa. Ces convertisseurs sont directement fournis par kotlinx.coroutines et peuvent être trouvés dans les modules Reactive correspondants.

Par exemple, si vous avez besoin d’interopérabilité avec les types de Project Reactor, vous devez ajouter les dépendances suivantes à votre projet :

Code : Sélectionner tout
1
2
3
4
5
dependencies {          
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:${coroutinesVersion}")
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:${coroutinesVersion}")
}

Vous pourrez alors utiliser Flow<T>.asPublisher() si vous voulez utiliser les types Reactive Streams ou Flow<T>.asFlux() si vous devez utiliser directement les types Project Reactor.

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
12
// acquire a Flow instance
val flow: Flow<event> = flow { … }

// Convert Flow to Publisher
val publisher = flow.asPublisher()

// Convert Flow to Reactor's Flux
val flux = flow.asFlux()

//Convert back to Flow 
val anotherFlow = flux.asFlow()

Bien que les intégrations avec les bibliothèques Reactive contribuent à la stabilisation de l’API, d’un point de vue technique, l’objectif est de se débarrasser des @ExperimentalCoroutinesApi et de corriger les bugs.

Meilleure intégration avec Reactive Streams

La compatibilité avec les spécifications de Reactive Streams est importante afin d’assurer l’interopérabilité entre les frameworks tiers et les coroutines Kotlin. Cela permet d’adopter les coroutines Kotlin dans les projets hérités sans avoir à réécrire tout le code.

JetBrains est parvenu à faire évoluer et à stabiliser de nombreuses fonctions. Il est maintenant possible de convertir un type de n’importe quelle implémentation Reactive Streams en Flow et inversement. Par exemple, le nouveau code peut être écrit avec des coroutines, mais intégré à l’ancienne base de code Reactive via les convertisseurs opposés :

Code : Sélectionner tout
1
2
3
4
5
6
7
8
fun legacyFunThatHaveToReturnObservable(): Observable<int> {
  return flow<int> {
    // Use the power of flow!
  }
  // various flow operations
  .asObservable()
}

JetBrains a également apporté de nombreuses améliorations à ReactorContext, qui encapsule le Context de Reactor dans CoroutineContext, pour une intégration fluide et complète entre Project Reactor et Kotlin Coroutines. Grâce à cette intégration, il est possible de propager les informations du Context de Reactor à travers des coroutines.

Le contexte est implicitement propagé via le contexte des subscribers par toutes les intégrations Reactive, telles que Mono, Flux, Publisher.asFlow, Flow.asPublisher et Flow.asFlux. Voici un exemple simple de distribution du context du subscriber dans ReactorContext :

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.reactor.ReactorContext
import kotlinx.coroutines.reactor.asFlux

fun main() {
    val flow = flow<int> {
       println("Reactor context in Flow: " +
          currentCoroutineContext()[ReactorContext]?.context)
    }

    // No context
    // prints "Reactor context in Flow: null"
    flow.asFlux().subscribe() 

    // Add subscriber's context
    // prints "Reactor context in Flow: Context1{answer=42}"
    flow.asFlux()
        .contextWrite { ctx -> ctx.put("answer", 42) }
        .subscribe() 
}

Dans l’exemple ci-dessus, nous construisons une instance Flow qui est ensuite convertie en instance Flux de Reactor, sans contexte. Appeler la méthode subscribe() sans argument a pour conséquence de demander au publisher d’envoyer toutes les données. En conséquence, le programme affiche la phrase « Reactor context in Flow: null ».

De même, la chaîne d’appels suivante convertit Flow en Flux, mais ajoute ensuite une paire clé-valeur réponse=42 au contexte Reactor pour cette chaîne. L’appel à subscribe() déclenche la chaîne. Dans ce cas, puisque le contexte est renseigné, le programme affiche « Reactor context in Flow: Context1{answer=42} ».

Nouvelles fonctions d’assistance

Lorsque vous utilisez des types réactifs comme Mono dans le contexte des coroutines, des fonctions d’aide vous permettent de récupérer les données sans bloquer le thread. Avec cette version, JetBrains arrête la prise en charge des fonctions awaitSingleOr* dans les Publisher arbitraires et a spécialisé certaines fonctions await* pour Mono et Maybe.

Mono produit au plus une valeur, le dernier élément est donc le même que le premier. Dans ce cas, la sémantique de suppression des éléments restants est également inutile. Par conséquent, la prise en charge de Mono.awaitFirst() et Mono.awaitLast() est interrompue et remplacée par celle de Mono.awaitSingle().


Commencer à utiliser kotlinx.coroutines 1.5.0

Cette nouvelle version apporte un nombre impressionnant de nouveautés. Le nouveau schéma de nommage mis au point lors du perfectionnement de l’API de canaux est l’une des réalisations les plus remarquables de l’équipe. Parallèlement, JetBrains s'efforce de rendre l’API des coroutines aussi simple et intuitive que possible.

Pour commencer à utiliser la nouvelle version de Kotlin Coroutines, il suffit de mettre à jour le contenu de votre fichier build.gradle.kts. Assurez-vous d’abord de disposer de la dernière version du plugin Kotlin Gradle :

Code : Sélectionner tout
1
2
3
4
plugins {
   kotlin("jvm") version "1.5.0"
}

Puis, mettez à jour les versions des dépendances, y compris les bibliothèques avec des intégrations spécifiques pour les Reactive Streams.

Code : Sélectionner tout
1
2
3
4
5
6
7
8
9
10
val coroutinesVersion = "1.5.0"
&#8203;
dependencies { 
  implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core-jvm:$coroutinesVersion")
  implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactive:$coroutinesVersion")
  implementation("org.jetbrains.kotlinx:kotlinx-coroutines-reactor:$coroutinesVersion")
  implementation("org.jetbrains.kotlinx:kotlinx-coroutines-rx3:$coroutinesVersion")
  ...
}

Explorez le guide des coroutines (kotlinx.coroutines)

Une erreur dans cette actualité ? Signalez-nous-la !