I. Qu’est-ce qu’une coroutine▲
Le schéma ci-dessous illustre l’un des cas d’utilisation de mon application :
L’idée est assez simple : récupérer des informations stockées sur Firebase. Pour cela, il faut :
- Exécuter une requête réseau
- Attendre le résultat car il n’est pas obtenu immédiatement
- 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 :
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 :
- Requêter les données de Firebase. C’est la ligne : database.document()…;
- Si la requête est réussie, récupérer toutes les informations et les stocker dans la liste data ;
- 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 :
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 :
- La fonction ne prend plus de listener en paramètre, mais renvoie directement la liste des informations récupérées ;
-
Le mot-clef
suspend
est apparu devant le nom de la fonction. Il a deux impacts sur la fonction :- elle peut n’être appelée que dans une coroutine,
- elle peut être suspendue puis relancée plus tard ;
- suspendCoroutineest une coroutine bloquante par défaut et qui doit être relancée plus tard en fonction d’évènements externes ;
- 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 :
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 :
- Un thread est coûteux en consommation mémoire ;
- 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 :
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 :
-
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 :
- Main : cela correspond au thread UI,
- IO : optimisé pour les instructions sur le disque ou des appels réseau,
- Default : optimisé pour les instructions nécessitant beaucoup de CPU ;
-
L’apparition de viewModelScope.launch{…}. C’est un coroutineScope qui permet les deux choses suivantes :
- si une coroutine de ce scope échoue, le scope échoue et toutes les coroutines au sein de ce même scope sont annulées,
- 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 :
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 :
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 :
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 :
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)