Avec LiveData
et ViewModel
, les dĂ©veloppeurs Android ont Ă disposition des outils trĂšs puissants pour les aider Ă concevoir des applications plus fluides et rĂ©actives. Aujourdâhui, le design pattern MVVM (Model View ViewModel) est relativement rĂ©pandu et permet de les exploiter. Cependant, il est possible dâaller plus loin et de les utiliser au mieux de leur potentiel et cela grĂące au pattern MVI (Model View Intent) et Ă la librairie Kotlin Coroutines. Ainsi, il devient possible de crĂ©er des applications plus simples, faciles Ă maintenir et faciles Ă tester.
MVI !? Un autre membre de la famille MVx ?
De mĂȘme que MVC, MVP ou encore MVVM, MVI est un design pattern qui a pour but de nous aider Ă mieux organiser notre code afin de crĂ©er des applications robustes et maintenables. Il est de la mĂȘme famille que Flux ou encore Redux et a Ă©tĂ© introduit pour la premiĂšre fois par AndrĂ© Medeiros. Cet acronyme est formĂ© par la contraction des mots Model, View et Intent.
Intent
ReprĂ©sente lâintention de lâutilisateur lorsquâil interagit avec lâUI. Par exemple, un clique sur un bouton pour rafraĂźchir une liste de donnĂ©es sera modĂ©lisĂ© sous forme dâun Intent. Pour Ă©viter toute confusion avec lâIntent
du framework Android, nous allons lâappeler dans la suite de cet article un UserIntent
.
Model
Il sâagit dâun ViewModel
oĂč lâon va exĂ©cuter diffĂ©rentes tĂąches synchrones ou asynchrones. Il accepte des UserIntent
s en entrĂ©e et produit un ou plusieurs Ă©tats successifs en sortie. Ces Ă©tats sont exposĂ©s via un LiveData pour ĂȘtre utilisĂ©s par la vue.
View
La vue se contente de traiter des états immuables qui lui parviennent du ViewModel
pour mettre Ă jour lâUI. Elle permet Ă©galement de transmettre Ă ce dernier les actions de lâutilisateur afin dâaccomplir des tĂąches dĂ©finies.
Â
Mais ce nâest pas tout ! MVI se compose Ă©galement des Ă©lĂ©ments suivants :
State
Il représente un état immuable de la vue. Un nouvel état est créé par le ViewModel
Ă chaque fois quâune mise Ă jour de la vue est nĂ©cessaire.
Reducer
Lorsque lâon souhaite crĂ©er un nouvel Ă©tat State
, on fait appel au Reducer
. On lui fournit lâĂ©tat actuel ainsi que de nouveaux Ă©lĂ©ments Ă inclure et il se charge de produire un Ă©tat immuable.
Dis mâen plus, pourquoi MVI est intĂ©ressant ?
MVI a Ă©tĂ© conçu autour du paradigme de la programmation rĂ©active et utilise des flux dâobservables pour Ă©changer des messages entre diffĂ©rentes entitĂ©s. Par consĂ©quent, chacune dâentre elles sera indĂ©pendante et donc plus flexible et rĂ©siliente. De plus, les informations vont toujours circuler dans un sens unique : ce concept est connu sous Unidirectional Data Flow ou UDF. Une fois lâarchitecture Ă©tablie, le dĂ©veloppeur aura plus de facilitĂ© Ă raisonner et Ă dĂ©boguer si besoin. Il faudra cependant rigoureusement respecter ce concept tout au long du dĂ©veloppement.
Â
Dans dâautres design patterns, un Presenter ou un ViewModel possĂšdent souvent plusieurs entrĂ©es et plusieurs sorties. Si ces sorties sont indĂ©pendantes, alors il y a un risque de dĂ©synchronisation et dâincohĂ©rence, ce qui est notamment vrai en multi-threading. Selon les cas et lâimportance de la cohĂ©rence des donnĂ©es affichĂ©es, cela peut avoir des consĂ©quences parfois majeures.
Â
Avec MVI, non seulement il y a une source unique pour lâĂ©tat de la vue (single source of truth), mais en plus, les Ă©tats produits seront toujours immuables. GrĂące Ă un flux dâobservables (LiveData), lâUI reflĂštera Ă chaque instant lâĂ©tat du ViewModel. Les Ă©tats sont prĂ©dictibles et facilement testables.
Â
Autre avantage non nĂ©gligeable, MVI va Ă©galement pousser le dĂ©veloppeur Ă se recentrer sur lâutilisateur car tout commence avec un UserIntent
. Le dĂ©veloppeur va dâabord se mettre dans la position dâun utilisateur et va commencer Ă raisonner Ă haut niveau avant de se tourner vers des questions plus techniques telles que les dĂ©tails dâimplĂ©mentation. Cela ne peut ĂȘtre que bĂ©nĂ©fique pour lâexpĂ©rience utilisateur et peut mĂȘme aider le dĂ©veloppeur Ă mieux penser son code et mieux apprĂ©hender le caractĂšre asynchrone inhĂ©rent Ă un grand nombre de tĂąches.
Et en pratique, ça donne quoi ?
Revoyons tout ceci dans le contexte dâune petite application Android composĂ©e dâun seul Ă©cran relativement simple.
Vous connaissez sans doute les célÚbres Chuck Norris facts: des histoires 100% vraies sur la vie de Chuck Norris. En voici deux parmi les plus célÚbres :
Â
âGoogle, c'est le seul endroit oĂč tu peux taper Chuck NorrisâŠâ
âChuck Norris donne frĂ©quemment du sang Ă la Croix-Rouge. Mais jamais le sien.â
Â
Et bien, nous allons nous servir des API proposĂ©es sur api.chucknorris.io afin dâafficher des âfactsâ random en utilisant le pattern MVI. Lâimage ci-dessous montre ce que lâon souhaite accomplir.
Â
- Une liste de catégories est disponible via un endpoint /
jokes/categories
. Elle va ĂȘtre proposĂ©e Ă lâutilisateur via le Spinner (1), puis le choix servira de paramĂštre pour afficher une âfactâ random dans la catĂ©gorie sĂ©lectionnĂ©e. - En plus du texte, nous rĂ©cupĂ©rons Ă©galement lâurl dâune image que lâon va afficher(2).
- Un premier bouton va permettre de rĂ©cupĂ©rer une nouvelle âfactâ et de lâajouter en tĂȘte de liste. Le deuxiĂšme bouton va, quant Ă lui, permettre de repartir sur une liste vide (3).
Â
Comme expliquĂ© dans lâintroduction, lâapplication va se composer des Ă©lĂ©ments suivants :
- Un
State
dĂ©finissant lâĂ©tat de lâĂ©cran. - Une
View
qui sâoccupera dâappliquer le dernierState
fourni par leViewModel
. - Un
ViewModel
responsable dâexposer leState
et de manipuler lesUserIntents
.
State
Chose extrĂȘmement importante avec MVI, on modĂ©lise un Ă©tat complet de la vue avec toutes les donnĂ©es nĂ©cessaires pour afficher notre UI. Pour reproduire lâimage ci-dessus, nous avons besoin dâune liste de catĂ©gories et dâune liste de « facts » avec texte et image. Les boutons quant Ă eux seront toujours visibles et le texte ne changera pas. Cependant, ces derniers vont ĂȘtre actifs ou inactifs selon lâĂ©tat. Par exemple, lors dâun appel rĂ©seau, nous les dĂ©sactiverons et afficherons une ProgressBar
. Ces informations feront donc partie de lâĂ©tat.
Nous utiliserons une Data class pour modéliser un état State
comme suit :
data class State( val isLoadingCategories: Boolean, val isLoadingFact: Boolean, val isSpinnerEnabled: Boolean, val facts: List<Fact>, val categories: List<String>, val isKickButtonEnabled: Boolean, val isClearButtonEnabled: Boolean )
Â
View
La partie View est représentée dans notre application Android par une Activity
. Elle implémentera une interface générique avec une seule fonction render qui prend un état State
en paramĂštre.
interface ViewRenderer<STATE> { fun render(state: STATE) }
Â
Il y a essentiellement deux choses Ă faire : modifier la vue en fonction de lâĂ©tat et envoyer des UserIntents
au ViewModel
.
Ă chaque changement dâĂ©tat, lâActivity
sera notifiĂ©e et recevra un objet immuable. Ce dernier va ĂȘtre simplement passĂ© en argument Ă la fonction render qui va se charger dâeffectivement appliquer les changements Ă la vue. Câest simple, concis et ce sera le seul moyen de mettre Ă jour lâUI.
override fun onCreate(savedInstanceState: Bundle?) { ... viewModel.state.observe(this, Observer { state -> render(state) }) } override fun render(state: State) { with(state) { progressBar.setVisibility(isLoadingFact) categoriesProgressBar.setVisibility(isLoadingCategories) kickButton.isEnabled = isKickButtonEnabled clearButton.isEnabled = isClearButtonEnabled spinner.isEnabled = isSpinnerEnabled spinnerAdapter.apply { clear() addAll(categories) } recyclerViewAdapter.update(state.facts) } }
Â
Pour en finir avec lâimplĂ©mentation de lâActivity
, il reste Ă connecter les actions de lâutilisateur au ViewModel
, autrement dit : générer des UserIntents
. Nous allons donc lister les actions que lâon souhaite proposer.
Lâutilisateur doit pouvoir :
- Ajouter une fact en haut de la liste
- Effacer le contenu de la liste
Â
CrĂ©ons une Sealed class qui modĂ©lise ces âintentionsâ et qui permet de les traiter de maniĂšre exhaustive.
sealed class UserIntent { data class ShowNewFact(val category: String?) : UserIntent() object ClearFact : UserIntent() }
Â
Et enfin, il faut les lier aux événements déclencheurs adéquats, à savoir les onClick des boutons.
kickButton.setOnClickListener { viewModel.dispatchIntent( UserIntent.ShowNewFact(spinner.selectedItem?.let { it as String }) ) } clearButton.setOnClickListener { viewModel.dispatchIntent(UserIntent.ClearFact) }
ViewModel
Attaquons-nous maintenant Ă la partie la plus intĂ©ressante de notre logique. On sâefforcera de garder en tĂȘte les deux concepts citĂ©s prĂ©cĂ©demment : UDF et Reactive Programming. Nous nous servirons uniquement de ce quâoffre Kotlin, LiveData et la librairie Coroutines.
Le ViewModel
va implĂ©menter une interface gĂ©nĂ©rique qui expose lâĂ©tat via un LiveData
et qui offre un point dâentrĂ©e pour les UserIntents
.
Â
interface Model<STATE, INTENT> { val state: LiveData<STATE> fun dispatchIntent(intent: INTENT) }
Â
Dans ce ViewModel
, nous allons lancer une coroutine sur le UI thread afin quâelle puisse mettre Ă jour directement la valeur de notre LiveData
. Sa tĂąche sera de crĂ©er un nouvel Ă©tat en fonction de lâĂ©tat actuel et dâun Ă©tat partiel reçu en paramĂštre. Câest notre reducer !
La donnĂ©e circulera dâun module Ă lâautre via des flux et ne pourra aller que dans un sens unique dĂ©fini, comme le montre le schĂ©ma ci-dessous.
Â
Â
Un Ă©tat partiel est en quelque sorte un sous-Ă©tat de notre vue. Câest simplement une data class avec uniquement la partie de lâĂ©tat Ă mettre Ă jour.
sealed class PartialState { data class FactRetrievedSuccessfully : PartialState() data class FetchFactFailed: PartialState() }
Â
Ainsi, lorsquâune fact est rĂ©cupĂ©rĂ©e via le repository, elle devra faire partie du nouvel Ă©tat créé. Mais il y a aussi dâautres changements que lâĂ©tat devra faire apparaĂźtre. Une fois la tĂąche exĂ©cutĂ©e, la ProgressBar doit disparaĂźtre Ă lâĂ©cran et les boutons doivent redevenir actifs.
data class FactRetrievedSuccessfully(val fact: Fact) : PartialState() { val isKickButtonEnabled = true val isClearButtonEnabled = true val isLoadingFact = false }
Â
Il ne reste plus maintenant quâĂ implĂ©menter le Reducer
. La fonction copy
des data class va nous ĂȘtre ici trĂšs utile pour crĂ©er les nouveaux Ă©tats.
fun reduce(currentState: State, partialState: PartialState): State { return when (partialState) { is PartialState.FactRetrievedSuccessfully -> state.copy( isClearButtonEnabled = partialState.isClearButtonEnabled, isKickButtonEnabled = partialState.isKickButtonEnabled, isSpinnerEnabled = partialState.isSpinnerEnabled, isLoadingFact = partialState.isLoadingFact, facts = state.facts.toMutableList().apply { add(0, partialState.fact) } ) is PartialState.CategoriesRetrievedSuccessfully -> state.copy( categories = partialState.categories.map { it.title }, isClearButtonEnabled = partialState.isClearButtonEnabled, isKickButtonEnabled = partialState.isJokeButtonEnabled, isSpinnerEnabled = partialState.isSpinnerEnabled, isLoadingCategories = partialState.isLoadingCategories ) is PartialState.Loading -> state.copy( ... ) is PartialState.FetchFactFailed -> state.copy( ... ) is PartialState.FetchCategoriesFailed -> state.copy( ... ) is PartialState.FactsCleared -> state.copy( ... ) } }
Â
Â
Ensuite, on propose de traiter les UserIntent
en les convertissant dâabord en objets Action
avec un simple mapping. Cela permet de nâexposer Ă la vue quâune partie des actions possibles. De plus, on pourra exĂ©cuter des side effects sous forme dâAction
dans le ViewModel sans casser le concept UDF, car ça suivra le mĂȘme circuit. Câest le cas de FetchCategories
qui, dans le cadre de cette dĂ©mo, nâest lancĂ©e quâĂ lâinstanciation du ViewModel et sans aucune action de la part de lâutilisateur.
Â
private sealed class Action { data class FetchRandomFact(val category: String?) : Action() object ClearFact : Action() object FetchCategories : Action() }
Â
Les Action
s vont ĂȘtre exĂ©cutĂ©es dans des C
oroutine
et on y fera potentiellement appel au repository. Une fois le résultat obtenu, nous créons un PartialState
adĂ©quat et nous le transfĂ©rons Ă la coroutine chargĂ©e de mettre Ă jour lâĂ©tat (reducer).
La communication entre coroutines se fait via un Channel
. Câest une queue non bloquante qui utilise des suspend functions telles que send ou receive. Cela nous permettra dâintercepter les PartialState gĂ©nĂ©rĂ©s par diffĂ©rentes tĂąches indĂ©pendantes.
private val stateChannel = Channel<PartialState>()
Ainsi, au sein du CoroutineScope
du ViewModel
, nous lançons une coroutine qui va itĂ©rer sur les Ă©lements du channel au fur et Ă mesure quâils arrivent. Lorsquâils ont tous Ă©tĂ© traitĂ©s, la coroutine est suspendue en attente dâun nouveau PartialState
.
launch { for (partialState in stateChannel) { //Do something } }
Puis, lorsquâon exĂ©cute une tĂąche dans une autre coroutine et que lâon souhaite mettre Ă jour lâĂ©tat en consĂ©quence, nous utiliserons le Channel
pour transmettre un état partiel :
stateChannel.send(PartialState.Loading(...))
Â
Il devient maintenant possible dâĂ©crire facilement des tests unitaires pour vĂ©rifier les Ă©tats de la vue. On peut avoir un reducer totalement testĂ© et ĂȘtre ainsi trĂšs confiant quant aux transitions de la vue dâun Ă©tat Ă lâautre et donc de la cohĂ©rence de ce qui est affichĂ© Ă chaque instant.
@Test fun `reduce FactRetrievedSuccessfully should add a fact to the top of the list`() { //Given val someFact = Fact("some fact title", "https://fake.com/some-fact-url.png") val newFact = Fact("new fact title","https://fake.com/new-fact-url.png") val currentState = State( isLoadingCategories = false, isLoadingFact = true, isSpinnerEnabled = false, facts = listOf(someFact), categories = emptyList(), isKickButtonEnabled = false, isClearButtonEnabled = false ) val partialState = PartialState.JokeRetrievedSuccessfully( newFact ) val expectedNewState = currentState.copy( facts = listOf(newFact) + currentState.facts, isSpinnerEnabled = true, isLoadingFact = false, isKickButtonEnabled = true, isClearButtonEnabled = true ) val reducer = Reducer() //When val newState = reducer.reduce(currentState, partialState) //Then assertThat(newState, `is`(expectedNewState)) }
Â
Conclusion
Voici, en quelques points, ce quâil faut retenir concernant le pattern MVI :
- MVI est un design pattern qui se base sur la programmation réactive.
- Lâobjectif est dâavoir du code moins complexe, testable et facile Ă maintenir.
- Un Intent (ou UserIntent dans cet article) dĂ©crit lâaction dâun utilisateur.
- Les actions sâexĂ©cutent en suivant toujours le mĂȘme circuit Ă sens unique (UDF).
- Nous manipulons des états immuables qui modélisent la vue.
- Un Reducer est un composant qui permet de produire de nouveaux états.
Bonus
On souhaite à présent afficher un Toast
, par exemple, lorsquâune erreur se produit. La solution risque de ne pas ĂȘtre banale. Nous expliquerons cela dans un prochain Ă©pisode.
Pour les plus impatients, voici un indice : « lifecycle« .
Â
Le code complet est accessible sur notre page github xebia-france.
Â
Â