Il y a 3 ans -
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
[java]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
)[/java]
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
[java]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()
}
}
}[/java]
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
[java]override fun render(state: State) {
with(state) {
…
uiEvent?.invoke(this@MainActivity)
}
}[/java]
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 Toast
s. 😬
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
[java]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
}
}
}
}[/java]
Add uiEvent to State
[java]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
)[/java]
Show a Toast in the Activity
[java]override fun render(state: State) {
with(state) {
…
uiEvent?.consume(this@MainActivity)
}
}[/java]
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
[java]sealed class UserIntent {
data class ShowNewFact(val category: String?) : UserIntent()
object ClearFact : UserIntent()
object NotifyEventExecuted : UserIntent()
}[/java]
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
[java]oneTimeEvent?.invoke(this@MainActivity)
?.also {
viewModel.dispatchIntent(UserIntent.NotifyEventExecuted)
}[/java]
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
[java]is PartialState.EventConsumed -> {
state.copy(oneTimeEvent = null)
}[/java]
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)
Commentaire