I. 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.
I-A. Intent▲
Représente l'intention de l'utilisateur lorsqu'il interagit avec l'UI. Par exemple, un clic 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.
I-B. Model▲
Il s'agit d'un ViewModel où l'on va exécuter différentes tâches synchrones ou asynchrones. Il accepte des UserIntents 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.
I-C. 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…
I-D. 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.
I-E. 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.
II. 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 multithreading. 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.
III. 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 dernier State fourni par le ViewModel ;
- Un ViewModel responsable d'exposer le State et de manipuler les UserIntents.
III-A. 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 :
2.
3.
4.
5.
6.
7.
8.
9.
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
)
III-B. 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.
2.
3.
4.
5.
6.
7.
8.
9.
10.
11.
12.
13.
14.
15.
16.
17.
18.
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.
2.
3.
4.
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.
2.
3.
4.
5.
6.
7.
8.
kickButton.setOnClickListener {
viewModel.dispatchIntent(
UserIntent.ShowNewFact(spinner.selectedItem?.let { it
as
String
})
)
}
clearButton.setOnClickListener {
viewModel.dispatchIntent(UserIntent.ClearFact)
}
III-C. 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 bibliothèque 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.
2.
3.
4.
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.
2.
3.
4.
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.
2.
3.
4.
5.
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.
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.
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.
2.
3.
4.
5.
private
sealed
class
Action {
data
class
FetchRandomFact(val
category: String
?) : Action()
object
ClearFact : Action()
object
FetchCategories : Action()
}
Les Actions vont être exécutées dans des Coroutine 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 éléments 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.
2.
3.
4.
5.
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.
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.
31.
32.
@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))
}
IV. 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.
IV-A. 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.
V. Remerciements▲
Cet article a été publié avec l'aimable autorisation de la société Publicis Sapient Engineering (anciennement Xebia) qui est la communauté Tech de Publicis Sapient, la branche de transformation numérique du groupe Publicis.
Nous tenons à remercier Claude Leloup pour sa correction orthographique et Winjerome pour la mise au gabarit.