Publié par

Il y a 4 mois -

Temps de lecture 6 minutes

Android – MVI et le problème du Toast

Nous avions vu ce qu’est le design pattern MVI et comment l’appliquer au sein d’une une application Android simple dans notre précédent article « Créer une application Android en utilisant le pattern MVI et Kotlin Coroutines« . Reprenons donc là où nous nous étions arrêtés et attaquons nous au “problème du Toast” !

Si vous n’avez pas lu la première partie, il est fortement conseillé de le parcourir d’abord. Le temps de lecture est estimé à 12mn. Allez y, on vous attendra. 😉

Le problème :

Maintenant que tout le monde est là, reprenons l’énoncé du problème :

On souhaite afficher un Toast lorsqu’une erreur se produit. Pour cela, nous allons simplement simuler une absence d’accès à internet en désactivant le wifi.

Rappelons nous, nous avons une data class State qui définit l’état de notre vue. Il suffirait donc, a priori, de rajouter un nouveau champ pour inclure un événement susceptible d’être exécuté par par la vue.

Ajoutons alors le champ uiEvent sous forme d’une lambda qui prend en entrée un Context. Ainsi, le Context ne sert qu’au moment de l’exécution et on évite de potentiels memory leaks qui risquent d’arriver en gardant une référence.

Add uiEvent to State
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,

   val uiEvent: ((Context) -> Unit)? = null
)

Ensuite, il faut modifier l’état partiel FetchJokeFailed pour y ajouter le code permettant d’afficher un Toast. Pour aujourd’hui, on affiche simplement le nom de l’exception qui s’est produite.

Define the lambda in FetchJokeFailed
sealed class PartialState {
	…
	data class FetchJokeFailed(val exception: GenericNetworkException) : PartialState() {
	   val isJokeButtonEnabled = true
	   val isLoadingFact = false
	   val isLoadingCategories = false
	   val isSpinnerEnabled = true

	   val uiEvent = { context: Context ->
   	   		Toast.makeText(context, exception.javaClass.simpleName, Toast.LENGTH_LONG).show()
   	   }
	}
}

La vue n’a plus qu’à invoquer l’événement pour exécuter la lambda et afficher le Toast.

Show a Toast in the Activity
override fun render(state: State) {
   with(state) {
      ...
      uiEvent?.invoke(this@MainActivity)
   }
}

And voilà ! Mais… que se passe-t-il lorsque l’écran est pivoté ? 🤔

Explications :

Nous savons que l’Activity va être recrée et donc onCreate va être appelée à chaque rotation de l’écran. Le ViewModel, quant à lui, va survivre à ce “changement de configuration” et va pouvoir garder son état et ses données. Ainsi, lorsque la vu va observer de nouveau l’état State, celui-ci aura déjà le dernier état connu. Ce qui permet à la vue de pouvoir se se mettre instantanément à jour. C’est top !… sauf dans le cas où l’on veut afficher des événements une seule fois… tel que des Toasts. 😬

Il nous faut donc trouver une solution qui permet de garder l’état mais sans répéter l’affichage de notre Toast.

Tentative 1 :

Une solution naïve consiste à remplacer val par var pour pouvoir le mettre à null dès qu’on affiche le Toast. Mais, rappelons la définition de State dans notre précédent article:

State: Il représente un état immuable de la vue.

L’immutabilité est importante car elle évite toute modification non contrôlée de la valeur. Et, l’un des avantages du pattern MVI est qu’il nous oblige à conserver cette immutabilité. Nous écartons donc cette solution.

Tentative 2 :

Et si l’on créait une classe OneTimeEvent avec la possibilité de consommer l’événement ?

Create a consumable OneTimeEvent class
class OneTimeEvent(private val event: (Context) -> Unit) {
   
   @Volatile
   private var consumed: Boolean = false
   
   @UiThread
   fun consume(context: Context) {
       synchronized(this) {
           if (!consumed) {
               event(context)
               consumed = true
           }
       }
   }
}
Add uiEvent to State
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,

   val uiEvent: OneTimeEvent? = null
)

Show a Toast in the Activity

override fun render(state: State) {
   with(state) {
      ...
      uiEvent?.consume(this@MainActivity)
   }
}

La solution résout bien le problème ! Cette fois-ci, State peut sembler immuable avec val oneTimeEvent, mais l’est-il vraiment ?

En fait, même si l’on oublie cette “tricherie”, on enfreint une autre règle importante en faisant cela. Car comme nous le disions précédemment:

les informations vont toujours circuler dans un sens unique : ce concept est connu sous Unidirectional Data Flow ou UDF.

Tentative 3 : cette fois-ci, ce sera la bonne !

En pratique, la vue se contente d’appliquer les changements pour se mettre à jour vis à vis du dernier State connu. Si l’on veut modifier ou mettre à jour l’état, le seul moyen est d’envoyer des UserIntent au ViewModel.

Faisons donc cela en commençant par créer l’intention :

Add NotifyEventExecuted intent
sealed class UserIntent {
   data class ShowNewFact(val category: String?) : UserIntent()
   object ClearFact : UserIntent()
   object NotifyEventExecuted : UserIntent()
}

Du côté de la vue, une fois l’événement consommé, le ViewModel devra en être notifié comme ceci :

Consume execute event and notify the ViewModel
oneTimeEvent?.invoke(this@MainActivity)
   ?.also {
       viewModel.dispatchIntent(UserIntent.NotifyEventExecuted)
   }

Il reste à faire les changements nécessaires dans le ViewModel pour gérer ce nouveau cas. Puis, le Reducer va reprendre l’état tel quel et l’événement va être écrasé. De cette manière, la vue est notifiée avec un nouvel état identique au précédent, excepté oneTimeEvent à null cette fois-ci. Du point de vue de l’utilisateur, rien n’a changé.

Set event to null when consumed
is PartialState.EventConsumed -> {
   state.copy(oneTimeEvent = null)
}

Finalement, nous avons opté pour une solution efficace et qui respecte tous les concepts prônés par MVI. Par conséquent, on profite de tous les bénéfices que peut apporter le pattern et on évite d’introduire des exceptions ou des cas spéciaux car moins il y en a, plus le code sera facile à maintenir.

Le problème du Toast est maintenant résolu. Le code complet se trouve sur notre page GitHub xebia-france.

Sources :

Reactive MVC and the Virtual DOM – André Medeioros (anglais)

MVI Series: A Pragmatic Reactive Architecture for Android – Ragunath Jawahar (anglais)

Reactive Apps with Model-View-Intent – Hannes Dorfmann (anglais)

Advanced Model View Intent The Missing Guide – Hannes Dorfmann, Kostiantyn Tarasenko (anglais)

MVI : une architecture robuste et moderne – Simone Civetta, Arnaud Piroelle (français)

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.