Si vous développez sur Android depuis quelques années, vous avez forcément connu la suprématie de MVVM. Pour beaucoup d’entre nous, c’était une véritable révélation après des années de Controllers massifs (les fameux « Massive View Controllers » dans les Activities ou Fragments). MVVM, avec son ViewModel agnostique du cycle de vie et ses LiveData (puis StateFlow), a apporté une structure et une testabilité dont nous avions cruellement besoin. C’était, et ça reste, une architecture solide.
Pourtant, au fil des projets, nos écrans se sont complexifiés. Un simple écran de liste est devenu un tableau de bord dynamique avec des mises à jour en temps réel, des actions utilisateur multiples et des états d’erreur granulaires. C’est dans cette complexité que les fissures de notre belle architecture MVVM ont commencé à apparaître. C’est là que nous avons commencé à regarder MVI (Model-View-Intent), non pas comme une mode, mais comme une réponse pragmatique à des problèmes bien réels.
Cet article n’est pas un réquisitoire contre MVVM. C’est le partage d’un cheminement technique qui nous a conduits à adopter MVI pour gagner en clarté, en robustesse et, finalement, en sérénité.
MVVM : Le pilier fiable, mais parfois dépassé par les événements
Soyons clairs : MVVM fait très bien le travail pour 80% des écrans d’une application standard. Une vue, un formulaire, une liste simple… le ViewModel expose quelques StateFlow, la vue s’y abonne, et tout fonctionne à merveille.
Le problème survient lorsque la complexité augmente. Prenons un exemple concret : un écran de profil utilisateur. Cet écran doit :
- Afficher les informations de l’utilisateur
- Charger la liste de ses dernières publications
- Permettre à l’utilisateur de mettre à jour sa photo de profil
- Afficher une bannière promotionnelle récupérée d’une autre source
Dans une approche MVVM classique, notre ViewModel ressemblerait vite à ceci :
Kotlin
class ProfileViewModel : ViewModel() {
private val _userState = MutableStateFlow<UserState>(UserState.Loading)
val userState: StateFlow<UserState> = _userState
private val _postsState = MutableStateFlow<PostsState>(PostsState.Loading)
val postsState: StateFlow<PostsState> = _postsState
private val _avatarUpdateState = MutableStateFlow<AvatarUpdateState>(AvatarUpdateState.Idle)
val avatarUpdateState: StateFlow<AvatarUpdateState> = _avatarUpdateState
private val _promoBannerState = MutableStateFlow<PromoBannerState>(PromoBannerState.Hidden)
val promoBannerState: StateFlow<PromoBannerState> = _promoBannerState
// ... la logique pour mettre à jour chaque flux indépendamment
}Chaque élément de l’interface a son propre flux d’état. Sur le papier, c’est propre. En pratique, c’est une porte ouverte aux incohérences. Que se passe-t-il si le chargement des publications échoue (PostsState.Error) mais que la mise à jour de l’avatar réussit (AvatarUpdateState.Success) ? Comment combiner ces états dans la vue pour présenter une UI cohérente ? La logique de coordination, au lieu d’être centralisée, se retrouve éclatée, soit dans le ViewModel via des combine, soit pire, directement dans la couche View (Composable/Fragment). C’est à ce moment précis que l’on perd le contrôle.
MVI : Un changement de philosophie avant d’être une architecture
MVI nous force à repenser le problème fondamentalement. Au lieu de se demander « Quelles sont les différentes parties de mon écran qui peuvent changer ? », il nous pose la question : « Quel est l’état complet de mon écran à un instant T ? »
La réponse est un unique objet, une simple data class qui est la source unique de vérité.
Kotlin
// L'unique État pour notre écran de profil
data class ProfileScreenState(
val isMainContentLoading: Boolean = true,
val user: User? = null,
val posts: List<Post>? = null,
val isAvatarUploading: Boolean = false,
val promoBannerUrl: String? = null,
val screenError: String? = null
)Toute l’interface peut être dessinée à partir de cet unique objet. Il n’y a plus d’ambiguïté. Mais comment passe-t-on d’un état à l’autre ? C’est là qu’interviennent les Intents.
Un Intent n’est rien de plus qu’un message, un événement qui décrit une action que l’utilisateur (ou le système) souhaite accomplir. C’est un contrat clair entre la vue et la logique.
Kotlin
sealed interface ProfileIntent {
data object ReloadScreen : ProfileIntent
data class AvatarSelected(val fileUri: Uri) : ProfileIntent
data object DismissError : ProfileIntent
}Le flux devient alors un cycle limpide : la vue émet un Intent, le ViewModel le reçoit, et en fonction de l’état actuel et de cet intent, il produit un nouvel état complet qui est renvoyé à la vue. C’est une boucle vertueuse, prévisible et unidirectionnelle.
Les bénéfices concrets qui ont changé notre quotidien de développeur
Adopter MVI n’est pas une décision purement académique. Voici les gains qui nous ont convaincus sur le terrain.
1. Reprendre le contrôle de son état : la clarté avant tout
Avec un seul objet State, le concept de « race condition » au niveau de l’état de l’UI disparaît presque entièrement. Il est impossible d’avoir un état où isLoading est true en même temps que error est non-nul, à moins que vous ne l’ayez explicitement modélisé dans votre State. Cette approche force une discipline de pensée qui élimine par construction une classe entière de bugs. La complexité n’est plus subie, elle est maîtrisée et centralisée en un seul point : la fonction qui génère le nouvel état.
2. L’Immutabilité : Votre meilleure assurance contre les bugs complexes
L’un des principes fondamentaux de MVI est que l’état est immuable. On ne dit jamais state.isLoading = false. On crée une copie de l’état en ne modifiant que ce qui doit changer : state.copy(isLoading = false).
Croyez-moi, l’une des leçons les plus importantes que j’ai apprises est que les états mutables partagés sont la racine de tous les maux dans les applications complexes, surtout avec les coroutines. J’ai vu des équipes passer des jours à chasser des bugs qui n’étaient que la conséquence d’une liste modifiée dans un thread pendant que l’UI essayait de la lire dans un autre. Avec l’immutabilité de MVI, ce problème n’existe tout simplement pas. Chaque état est une photo atomique et thread-safe du monde.
3. Le débogage qui vous fait gagner des heures, pas des minutes
C’est peut-être l’avantage le plus spectaculaire. Puisque chaque changement d’état est le résultat d’un Intent, il suffit d’ajouter un log à l’entrée et à la sortie de votre logique de traitement pour obtenir une retranscription parfaite de la vie de votre écran.
Imaginez un rapport de bug d’un testeur QA qui inclut simplement ce log. Vous n’avez plus besoin de deviner ou de tenter de reproduire péniblement le scénario. Vous pouvez lire la séquence d’actions et d’états et identifier immédiatement l’anomalie. C’est ce qu’on appelle le « time-travel debugging », et c’est un véritable super-pouvoir pour un développeur.
4. Des tests unitaires qui inspirent confiance
Tester un ViewModel MVVM peut parfois être délicat. Il faut observer les StateFlow, gérer les Turbine ou autres TestCoroutineDispatcher, et s’assurer que chaque flux émet la bonne valeur au bon moment.
Avec MVI, le cœur de votre logique est souvent une fonction pure appelée « Reducer » : (State, Intent) -> State. C’est la chose la plus simple au monde à tester. Il n’y a pas de dépendance Android, pas d’état caché, pas d’asynchronisme à gérer. Vous fournissez un état initial et un intent, et vous affirmez que l’état de sortie est celui que vous attendez. Vos tests deviennent plus simples, plus rapides et couvrent 100% de votre logique de transition d’état avec une confiance absolue.
L’alliance évidente : MVI et Jetpack Compose
Si MVI apporte de la clarté, Compose la sublime. Compose est un framework qui prend un état et le transforme en pixels. Son slogan pourrait être UI = f(State). MVI est le fournisseur parfait de ce State.
La connexion est d’une élégance rare :
Kotlin
val state by viewModel.state.collectAsState() // Un seul abonnement !
// Toute l'UI est une fonction pure de cet état.
MyScreenContent(
isLoading = state.isMainContentLoading,
user = state.user,
// ...
)Avec MVI, on s’assure que Compose ne se recompose que lorsqu’une nouvelle version de l’état, unique et cohérente, est émise. Cela rend les recompositions plus efficaces et plus faciles à analyser avec les outils de debug de Compose.
Conclusion : Le bon outil pour la bonne complexité
Alors, faut-il tout réécrire en MVI ? Probablement pas. MVVM reste un excellent choix pour les projets plus simples ou pour les équipes qui débutent.
Mais si vous construisez une application destinée à vivre, à évoluer et à s’enrichir de nouvelles fonctionnalités, vous ferez inévitablement face à une complexité grandissante. Pour nous, MVI a été l’outil qui nous a permis de dompter cette complexité. La courbe d’apprentissage initiale est réelle, et le « boilerplate » peut sembler un peu plus lourd au début.
Mais c’est un investissement. Un investissement dans un code plus prévisible, plus facile à maintenir et infiniment plus simple à déboguer. Si vous vous êtes déjà retrouvé perdu dans les méandres d’états incohérents d’un ViewModel complexe, je vous invite sincèrement à essayer MVI sur un nouvel écran. L’investissement en clarté et en robustesse vous sera rendu au centuple.
Besoin d’aller plus loin sur l’architecture de vos apps mobiles ?
Choisir la bonne architecture (MVVM, MVI, ou une combinaison des deux) a un impact direct sur la maintenabilité et la robustesse de vos applications sur le long terme. Chez Atecna, notre équipe accompagne les entreprises sur l’ensemble du cycle de vie de leurs projets mobiles : architecture, performance, accessibilité et maintenance.