Tutoriel pour apprendre à utiliser les coroutines en Kotlin

Vous êtes-vous déjà retrouvé à lire un code asynchrone :

  • avec de nombreux Threads, HandlerThreads, IntentServices… ;
  • utilisant des bibliothèques externes, comme EventBus, rendant la navigation dans le code très pénible, car vous ne saviez pas où les évènements étaient envoyés ;
  • présentant de nombreux callbacks pour notifier les listeners / observers ;
  • qui sont un casse-tête lors du débogage des potentiels deadlocks ;
  • et dont les crashs sont dus au fait que le code continue de s’exécuter alors que l’activité / fragment n’est plus visible pour afficher le résultat.

Si vous avez été chanceux de ne pas connaître cela, moi en tant que développeur Android Sénior, j’y ai souvent été confronté. (◞‸◟)

Toutefois, depuis 2017, une nouvelle façon d’écrire du code asynchrone a vu le jour : les coroutines. ヽ(ヅ)ノ

Alors, comment les utiliser ?

J’ai créé une application récupérant des informations stockées préalablement sur Firebase.

Elle me servira d’exemple afin de vous montrer comment les appréhender.

Prérequis pour la lecture de cet article :

  • Android ;
  • Kotlin ;
  • Mockk.

Pour réagir au contenu de cet article, un espace de dialogue vous est proposé sur le forum : Commentez Donner une note  l'article (5)

Note  : à la fin de cet article, dans la section : pour aller plus loin , vous pourrez télécharger le modèle d’application dont cet exemple est issu.

Article lu   fois.

L'auteur

Site personnel

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

I. Qu’est-ce qu’une coroutine

Le schéma ci-dessous illustre l’un des cas d’utilisation de mon application :

Image non disponible

L’idée est assez simple : récupérer des informations stockées sur Firebase. Pour cela, il faut :

  1. Exécuter une requête réseau
  2. Attendre le résultat car il n’est pas obtenu immédiatement
  3. Ne pas bloquer le thread UI pour garantir une bonne expérience utilisateur

Pour respecter ces conditions, le code en question doit être exécuté dans un bloc non bloquant et asynchrone. C’est ce que permettent de faire les coroutines.

Il faut les voir comme des threads allégés. On peut donc en créer des milliers sans risquer des problèmes mémoires tels que OutOfMemoryError.

Leur avantage est qu’elles peuvent être suspendues et reprises plus tard et qu’elles ne dépendent pas d’un thread en particulier. Cela signifie qu’un thread A peut démarrer une coroutine ; un thread B peut l’arrêter ; et un thread C peut la redémarrer.

Ces fonctionnalités des coroutines permettent donc de résoudre les problèmes de deadlocks.

II. Supprimer un listener et le remplacer par une coroutine

Reprenons le schéma de mon application : pour récupérer les informations stockées sur Firebase, un exemple de code pourrait ressembler à ça :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
class CloudFirestoreInformation(private val database: FirebaseFirestore): InformationDatabaseRepository
{
  override fun fetchInformationsWithListener(listener: (List<Information>) -> Unit)
  {
    val data: MutableList<Information> = mutableListOf()
    database.document("infos").collection(INFORMATIONS).get().addOnSuccessListener { result ->
      result.forEach{
        val model = it.toObject(FirebaseInformation::class.java)
        val userRef = (it["userRef"] as DocumentReference).path
        data.add(model.copy(path = it.id, user_ref = userRef).toInformation())
      }
      listener(data)
    }
    .addOnFailureListener { exception ->
      Log.w(this.javaClass.name, "Error getting documents.", exception)
      listener(data)
    }
  }
}

Ce bout de code réalise les choses suivantes :

  1. Requêter les données de Firebase. C’est la ligne : database.document()…;
  2. Si la requête est réussie, récupérer toutes les informations et les stocker dans la liste data ;
  3. Notifier le listener des informations récupérées.

C’est un exemple classique de code dont l’exécution est asynchrone.

Toutefois, on peut améliorer la lecture de ce code en supprimant ce listener grâce à une coroutine. L’exemple ci-dessous illustre cela :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
class CloudFirestoreInformation(private val database: FirebaseFirestore): InformationDatabaseRepository
{
  override suspend fun fetchInformationsWithCoroutine(): List<Information> {
    val data: MutableList<Information> = mutableListOf()
    return suspendCancellableCoroutine {
      database.document("infos").collection(INFORMATIONS).get().addOnSuccessListener { result ->
        result.forEach {
          val model = it.toObject(FirebaseInformation::class.java)
          val userRef = (it["userRef"] as DocumentReference).path
          data.add(model.copy(path = it.id, user_ref = userRef).toInformation())
        }
        it.resume(data)
      }
      .addOnFailureListener { exception ->
        Log.w(this.javaClass.name, "Error getting documents.", exception)
        it.resumeWithException(exception)
      }
    }
  }
}

Vous pouvez voir que toute la partie concernant Firebase est identique. Toutefois les différences majeures sont :

  1. La fonction ne prend plus de listener en paramètre, mais renvoie directement la liste des informations récupérées ;
  2. Le mot-clef suspend est apparu devant le nom de la fonction. Il a deux impacts sur la fonction :

    1. elle peut n’être appelée que dans une coroutine,
    2. elle peut être suspendue puis relancée plus tard ;
  3. suspendCoroutineest une coroutine bloquante par défaut et qui doit être relancée plus tard en fonction d’évènements externes ;
  4. Lorsque le code bloquant la coroutine est terminé, on la relance en appelant soit resume si tout s’est bien passé, soit resumeWithException s’il y a eu un problème.

Cet exemple vous montre comment on peut simplifier la lecture et l’écriture d’un code asynchrone grâce aux coroutines. Dit autrement, les coroutines permettent d’écrire du code asynchrone de manière synchrone.

III. Exécuter du code asynchrone hors de l’UI thread avec une coroutine

Ce que je ne vous ai pas dit dans l’exemple précédent, c’est que oui, le code est asynchrone, mais non, il n’est pas exécuté dans son propre thread.

Cela signifie que si j’installe l’application telle qu’elle sur un téléphone, l’interface va se figer car le thread UI sera bloqué tant que Firebase ne m’aura pas renvoyé les informations… Et cela peut-être long !

Alors que faire ?

Lancer le code bloquant hors de du thread UI !

Pour illustrer cela, nous allons examiner le viewModel de ma page d’accueil : InformationViewModel.

Commençons par un exemple de code sans coroutines :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
class InformationViewModel(private val repository: InformationDatabaseService): ViewModel()
{
  var informations: MutableLiveData<List<Information>> = MutableLiveData()
  private set

  fun requestInformationsWithoutCoroutine()
  {
    thread {
      repository.fetchInformationsWithListener{infos-> informations.value = infos}
    }
  }
}

Vous pouvez maintenant voir à la ligne 8 qu’un thread est créé et démarré automatiquement. Le thread UI n’est donc plus bloqué par l’attente de la réponse de Firebase.

Toutefois, un tel code n’est franchement pas idéal, car :

  1. Un thread est coûteux en consommation mémoire ;
  2. Il y a des risques de deadlocks.

Remarque : pour ceux qui ne connaissent pas le Livedata, c’est un composant Android permettant de notifier un listener. La grande différence avec un observer pattern que vous connaissez déjà, c’est que le Livedata est attaché à un composant ayant un cycle de vie (activity, fragment…). Cela signifie que le listener ne sera notifié que si le composant auquel il est attaché est visible.

Alors, améliorons les choses grâce à une coroutine :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
class InformationViewModel(private val repository: InformationDatabaseRepository): ViewModel()
{
  var informations: MutableLiveData<List<Information>> = MutableLiveData()
  private set

  fun requestInformationsWithCoroutine()
  {
    viewModelScope.launch {
      withContext(Dispatchers.IO) {
        informations.value = requestAllInformation.execute()
      }
    }
  }
}

Contrairement au code contenant le thread, vous pouvez voir maintenant :

  1. L’apparition de withContext(). Cela permet de lancer une coroutine dans un contexte particulier et d’attendre le résultat. Les différents contextes (dont le terme correct est Dispatcher) que peut prendre withContext() sont :

    1. Main : cela correspond au thread UI,
    2. IO : optimisé pour les instructions sur le disque ou des appels réseau,
    3. Default : optimisé pour les instructions nécessitant beaucoup de CPU ;
  2. L’apparition de viewModelScope.launch{…}. C’est un coroutineScope qui permet les deux choses suivantes :

    1. si une coroutine de ce scope échoue, le scope échoue et toutes les coroutines au sein de ce même scope sont annulées,
    2. si le InformationViewModel est détruit, le scope est annulé et toutes les coroutines au sein de ce scope sont également annulées.

Ce scope permet donc d’éviter de potentielles fuites mémoires.

III-A. BONUS

Parmi les composants Android lié aux coroutines, il existe également un scope spécifique aux liveData. Il permet d’exécuter une coroutine lorsque le lifecycle attaché à ce liveData est actif et l’annule lorsqu’il devient inactif.

Ainsi, le code précédent peut être simplifié en utilisant ce liveData scope :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
class InformationViewModel(private val repository: InformationDatabaseRepository): ViewModel()
{
  val informations: LiveData<List<Information>> = liveData {
    withContext(Dispatchers.IO) {
      val publication = repository.fetchInformationsWithCoroutine()
      emit(publication)
    }
  }
}

Vous voyez à la ligne 3 l’utilisation du scope dont je vous ai parlé : liveData{…}.

Dans cet exemple, ce scope est attaché à une activity.

IV. Valider sa coroutine avec un test unitaire

Un proverbe dans le monde de l’informatique dit : « tester, c’est douter ». Toutefois, je préfère tester et ne pas douter(^v^). Personne ne sait qui modifiera votre code à l’avenir ¯\_(ツ)_/¯.

Dans la partie précédente, je vous ai expliqué l’utilisation de withContext(Dispatcher.IO) avec un exemple pédagogique.

Toutefois, pour faciliter l’écriture de tests unitaires, je vous conseille de l’injecter grâce au constructeur de InformationViewModel. Celui-ci devient donc :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
class InformationViewModel(private val repository: InformationDatabaseRepository, private val dispatcher: CoroutineDispatcher): ViewModel()
{
  var informations: MutableLiveData<List<Information>> = MutableLiveData()
  private set

  fun requestInformationsWithCoroutine()
  {
    viewModelScope.launch {
      withContext(dispatcher) {
        informations.value = repository.fetchInformationsWithCoroutine()
      }
    }
  }
}

Intéressons-nous à la création du test unitaire de cette classe. Bien évidemment, je l’ai déjà créé et il ressemble à ça :

 
Sélectionnez
1.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
19.
20.
21.
22.
23.
24.
25.
26.
27.
28.
29.
30.
class InformationViewModelTest
{
  private val testDispatcher = TestCoroutineDispatcher()

  @get:Rule
  val rule = InstantTaskExecutorRule()

  @MockK
  private lateinit var repository: InformationDatabaseRepository

  @InjectMockKs
  private lateinit var viewModel: InformationViewModel

  @Before
  fun setUp() {
    MockKAnnotations.init(this)
    //Fix : java.lang.IllegalStateException: Module with the Main      dispatcher had failed to initialize.
    Dispatchers.setMain(testDispatcher)
  }

  @Test
  fun `informations must be requested from repository`() = runBlockingTest {
    val info = listOf(Information(path = "path1"), Information(path = "path2"))
    coEvery { repository.fetchInformationsWithCoroutine() } returns info

    viewModel.requestInformationsWithCoroutine()

    assertThat (viewModel.informations.value, equalTo(info))
  }
}

L’écriture de ce test a été faite avec MockK. D’ailleurs, j’ai rédigé un article sur ce sujet.

Je vous invite à lire cet article si vous n’êtes pas familier avec son utilisation.

Je ne vais pas revenir sur les spécificités de cette bibliothèque et je vais me concentrer sur ce qui concerne les coroutines :

  • TestCoroutineDispatcher() : dispatcher spécial (comme IO, Main, Default) permettant de ne pas bloquer une coroutine et de l’exécuter immédiatement ;
  • InstantTaskExecutorRule() : règle JUnit permettant d’exécuter du code asynchrone de manière synchrone. Elle est nécessaire, car j’utilise les livedata dans mon viewModel ;
  • RunBlockingTest: bloc permettant de tester des coroutines. Il permet de contrôler l’exécution de ses coroutines comme par exemple : pause(), start()… ;
  • CoEvery: permet de mocker une suspend fonction. Toutes les fonctions mock de MockK existent pour les suspend fonctions.

IV-A. Problème connu

Au moment où j’écris cet article, il semble y avoir un bug dans le framework permettant de tester les coroutines. Cela concerne spécifiquement le test des suspendCancellableCoroutines présentées au début de cet article.

Écrire le test unitaire d’un code contenant ce type de coroutine provoque l’erreur ci-dessous :

Image non disponible

J’ai averti l’équipe responsable sur github mais je ne sais pas quand la correction sera disponible.

V. Conclusion et remerciement

Au travers de cet article, je vous ai montré comment vous pouvez commencer à utiliser les coroutines dans vos applications.

Vous avez vu comment elles améliorent l’écriture et la lecture d’un code asynchrone.

Étant des threads allégés, elles corrigent également les problèmes liés au code multi threadé.

Je tiens à remercier Mickael Baron pour sa relecture technique et la mise au gabarit ainsi que Bruno Barthel pour sa relecture orthographique attentive de cet article.

VI. Pour aller plus loin

Télécharger mon modèle d’application Android dont est issu cet exemple.

La documentation des Coroutines (en anglais)

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2020 Sanders. Aucune reproduction, même partielle, ne peut être faite de ce site ni de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.