Quantcast
Channel: Publicis Sapient Engineering – Engineering Done Right
Viewing all articles
Browse latest Browse all 1865
↧

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

$
0
0

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.

 

 

↧

Viewing all articles
Browse latest Browse all 1865

Trending Articles