Publié par

Il y a 2 mois -

Temps de lecture 12 minutes

Créer une application Android en utilisant le pattern MVI et Kotlin Coroutines

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 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.

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.

 

  1. 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.
  2. En plus du texte, nous récupérons également l’url d’une image que l’on va afficher(2).
  3. 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 :

  1. Un State définissant l’état de l’écran.
  2. Une View qui s’occupera d’appliquer le dernier State fourni par le ViewModel.
  3. Un ViewModel responsable d’exposer le State et de manipuler les UserIntents.

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 :


State data class
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.

ViewRenderer interface
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.

Observing and rendering the State in an Activity
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 :

  1. Ajouter une fact en haut de la liste
  2. 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.

User intents
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.

Connecting click events to user intents
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.

 

ViewModel interface
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.

Partial states
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.

A partial state
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.

The Reducer’s reduce function
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.

 

Actions
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.

Declare a Channel for PartialState objects
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.

Iterate through Channel
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 :

Send PartialState through Channel
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.

Unit testing states
@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« .

 

(info) Le code complet est accessible sur notre page github xebia-france.

 

 

Publié par

Commentaire

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Nous recrutons

Être un Sapient, c'est faire partie d'un groupe de passionnés ; C'est l'opportunité de travailler et de partager avec des pairs parmi les plus talentueux.