# MVVM vs MVI : Quelle architecture choisir en 2026 ? > Comparaison détaillée entre MVVM et MVI sur Android : avantages, inconvénients, cas d'usage et guide pour choisir la bonne architecture en 2026. - Published: 2026-01-06 - Updated: 2026-03-28 - Author: SharpSkill - Tags: android, mvvm, mvi, architecture, jetpack compose - Reading time: 11 min --- Choisir la bonne architecture est une décision cruciale qui impacte la maintenabilité, la testabilité et l'évolutivité de votre application Android. En 2026, deux patterns dominent l'écosystème : MVVM, le standard de l'industrie, et MVI, l'approche réactive qui gagne en popularité avec Jetpack Compose. > **L'enjeu du choix** > > Une mauvaise architecture coûte cher : dette technique, bugs difficiles à reproduire, et refactoring douloureux. Comprendre les forces et faiblesses de chaque approche vous évitera bien des problèmes. ## Comprendre MVVM : le standard établi MVVM (Model-View-ViewModel) est l'architecture recommandée par Google depuis l'introduction de Jetpack. Elle sépare clairement les responsabilités en trois couches distinctes, rendant le code plus organisé et testable. ### Les fondamentaux de MVVM Le pattern MVVM repose sur une séparation claire : le Model gère les données et la logique métier, la View affiche l'interface utilisateur, et le ViewModel fait le pont entre les deux en exposant des états observables. Voyons comment implémenter un écran de profil utilisateur avec MVVM. Ce premier exemple montre la structure de base avec un ViewModel qui expose un état observable et des méthodes pour les interactions utilisateur. ```kotlin // UserProfileViewModel.kt // ViewModel MVVM classique pour un écran de profil utilisateur // Il expose un état observable et des méthodes pour les actions class UserProfileViewModel( private val userRepository: UserRepository, private val analyticsTracker: AnalyticsTracker ) : ViewModel() { // État observable avec StateFlow - la View observe ces changements private val _uiState = MutableStateFlow(UserProfileState()) val uiState: StateFlow = _uiState.asStateFlow() // État séparé pour le chargement - MVVM permet plusieurs flux private val _isLoading = MutableStateFlow(false) val isLoading: StateFlow = _isLoading.asStateFlow() // État pour les messages d'erreur one-shot private val _errorMessage = MutableSharedFlow() val errorMessage: SharedFlow = _errorMessage.asSharedFlow() // Chargement initial du profil utilisateur fun loadProfile(userId: String) { viewModelScope.launch { _isLoading.value = true try { // Appel au repository pour récupérer les données val user = userRepository.getUser(userId) // Mise à jour de l'état avec les nouvelles données _uiState.update { currentState -> currentState.copy( user = user, isEditing = false ) } // Tracking analytics analyticsTracker.trackProfileViewed(userId) } catch (e: Exception) { // Émission d'un message d'erreur one-shot _errorMessage.emit("Impossible de charger le profil") } finally { _isLoading.value = false } } } // Activation du mode édition fun enableEditMode() { _uiState.update { it.copy(isEditing = true) } } // Sauvegarde des modifications du profil fun saveProfile(name: String, bio: String) { viewModelScope.launch { _isLoading.value = true try { val updatedUser = userRepository.updateUser( _uiState.value.user?.id ?: return@launch, name = name, bio = bio ) _uiState.update { it.copy(user = updatedUser, isEditing = false) } } catch (e: Exception) { _errorMessage.emit("Échec de la sauvegarde") } finally { _isLoading.value = false } } } } // Data class représentant l'état de l'écran data class UserProfileState( val user: User? = null, val isEditing: Boolean = false ) ``` Ce ViewModel illustre l'approche MVVM typique : plusieurs flux observables (état principal, loading, erreurs) et des méthodes publiques pour chaque action utilisateur. ### Avantages de MVVM MVVM présente plusieurs points forts qui expliquent son adoption massive : - **Familiarité** : la majorité des développeurs Android connaissent ce pattern - **Flexibilité** : vous pouvez structurer l'état comme vous le souhaitez - **Écosystème** : parfaite intégration avec Jetpack (LiveData, StateFlow, Hilt) - **Simplicité** : courbe d'apprentissage douce pour les débutants > MVVM est particulièrement adapté aux équipes mixtes avec des développeurs de niveaux variés. Sa simplicité conceptuelle facilite l'onboarding. ### Les limites de MVVM Cependant, MVVM montre ses limites à mesure que l'application grandit. Le principal problème est la gestion de l'état distribué. Regardons un exemple qui illustre ce problème courant : ```kotlin // ProblematicViewModel.kt // Exemple de ViewModel MVVM avec état fragmenté // Ce pattern devient problématique quand l'écran se complexifie class CheckoutViewModel : ViewModel() { // Problème : état dispersé sur plusieurs flux private val _cart = MutableStateFlow>(emptyList()) private val _selectedAddress = MutableStateFlow(null) private val _selectedPayment = MutableStateFlow(null) private val _promoCode = MutableStateFlow(null) private val _isLoading = MutableStateFlow(false) private val _error = MutableStateFlow(null) // Chaque modification peut créer des états incohérents temporaires fun applyPromoCode(code: String) { viewModelScope.launch { _isLoading.value = true _error.value = null // Reset de l'erreur try { val discount = promoRepository.validate(code) _promoCode.value = code // L'état du panier doit aussi être mis à jour... // mais il y a un délai entre les deux updates recalculateCart() } catch (e: Exception) { _error.value = e.message _promoCode.value = null } finally { _isLoading.value = false } } } // Difficile de garantir la cohérence entre tous ces états private fun recalculateCart() { // Logique complexe qui dépend de plusieurs états... } } ``` Cet exemple montre comment l'état peut se fragmenter en MVVM, rendant difficile le suivi des transitions et la reproduction des bugs. ## Comprendre MVI : l'approche unidirectionnelle MVI (Model-View-Intent) adopte une philosophie différente : un flux de données unidirectionnel et un état unique immutable. Cette approche, inspirée de Redux, élimine les problèmes d'état incohérent. ### Les fondamentaux de MVI En MVI, tout suit un cycle clair : l'utilisateur émet une **Intent** (action), le **Reducer** transforme l'état actuel en nouvel état, et la **View** affiche cet état unique. C'est prévisible, testable et débugable. Voyons comment implémenter le même écran de profil utilisateur, cette fois avec MVI. Notez comment l'état est centralisé et les actions explicitement typées. ```kotlin // UserProfileMviViewModel.kt // ViewModel MVI pour le même écran de profil utilisateur // Notez la structure : Intent → Reducer → State unique class UserProfileMviViewModel( private val userRepository: UserRepository, private val analyticsTracker: AnalyticsTracker ) : ViewModel() { // État unique et immutable - source de vérité absolue private val _state = MutableStateFlow(UserProfileState()) val state: StateFlow = _state.asStateFlow() // Canal pour les effets secondaires (navigation, snackbar) private val _sideEffect = Channel() val sideEffect: Flow = _sideEffect.receiveAsFlow() // Point d'entrée unique pour toutes les actions utilisateur fun onIntent(intent: UserProfileIntent) { when (intent) { is UserProfileIntent.LoadProfile -> loadProfile(intent.userId) is UserProfileIntent.EnableEditMode -> enableEditMode() is UserProfileIntent.SaveProfile -> saveProfile(intent.name, intent.bio) is UserProfileIntent.CancelEdit -> cancelEdit() } } private fun loadProfile(userId: String) { viewModelScope.launch { // Transition vers l'état de chargement _state.update { it.copy(isLoading = true, error = null) } try { val user = userRepository.getUser(userId) // Une seule mise à jour atomique de l'état _state.update { it.copy( user = user, isLoading = false, error = null ) } analyticsTracker.trackProfileViewed(userId) } catch (e: Exception) { // L'état d'erreur est intégré dans l'état principal _state.update { it.copy( isLoading = false, error = "Impossible de charger le profil" ) } } } } private fun enableEditMode() { // Mise à jour simple et prévisible _state.update { it.copy(isEditing = true) } } private fun saveProfile(name: String, bio: String) { viewModelScope.launch { val currentUser = _state.value.user ?: return@launch _state.update { it.copy(isLoading = true) } try { val updatedUser = userRepository.updateUser( currentUser.id, name = name, bio = bio ) _state.update { it.copy( user = updatedUser, isEditing = false, isLoading = false ) } // Effet secondaire pour notifier l'utilisateur _sideEffect.send(UserProfileSideEffect.ShowSuccess("Profil mis à jour")) } catch (e: Exception) { _state.update { it.copy(isLoading = false, error = "Échec de la sauvegarde") } } } } private fun cancelEdit() { _state.update { it.copy(isEditing = false) } } } // Toutes les actions possibles, explicitement typées sealed class UserProfileIntent { data class LoadProfile(val userId: String) : UserProfileIntent() object EnableEditMode : UserProfileIntent() data class SaveProfile(val name: String, val bio: String) : UserProfileIntent() object CancelEdit : UserProfileIntent() } // État unique et complet de l'écran data class UserProfileState( val user: User? = null, val isLoading: Boolean = false, val isEditing: Boolean = false, val error: String? = null ) // Effets secondaires one-shot sealed class UserProfileSideEffect { data class ShowSuccess(val message: String) : UserProfileSideEffect() data class NavigateTo(val destination: String) : UserProfileSideEffect() } ``` La différence est claire : un seul flux d'état, des actions explicites, et une séparation nette entre état persistant et effets one-shot. > **Débogage facilité** > > Avec MVI, vous pouvez logger chaque Intent et chaque transition d'état. Reproduire un bug devient trivial : rejouez la séquence d'Intents. ### MVI avec Jetpack Compose MVI brille particulièrement avec Jetpack Compose, car les deux partagent la même philosophie : état immutable et UI déclarative. Voici comment connecter le ViewModel à un écran Compose : ```kotlin // UserProfileScreen.kt // Écran Compose qui consomme l'état MVI // La connexion entre ViewModel et UI est élégante et réactive @Composable fun UserProfileScreen( viewModel: UserProfileMviViewModel = hiltViewModel(), onNavigateBack: () -> Unit ) { // Collecte de l'état unique val state by viewModel.state.collectAsStateWithLifecycle() // Gestion des effets secondaires LaunchedEffect(Unit) { viewModel.sideEffect.collect { effect -> when (effect) { is UserProfileSideEffect.ShowSuccess -> { // Afficher un snackbar } is UserProfileSideEffect.NavigateTo -> { // Navigation } } } } // UI purement déclarative basée sur l'état UserProfileContent( state = state, onIntent = viewModel::onIntent ) } @Composable private fun UserProfileContent( state: UserProfileState, onIntent: (UserProfileIntent) -> Unit ) { Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { // Affichage conditionnel basé sur l'état unique when { state.isLoading -> { CircularProgressIndicator( modifier = Modifier.align(Alignment.CenterHorizontally) ) } state.error != null -> { ErrorMessage( message = state.error, onRetry = { state.user?.id?.let { onIntent(UserProfileIntent.LoadProfile(it)) } } ) } state.user != null -> { ProfileCard( user = state.user, isEditing = state.isEditing, onEditClick = { onIntent(UserProfileIntent.EnableEditMode) }, onSaveClick = { name, bio -> onIntent(UserProfileIntent.SaveProfile(name, bio)) }, onCancelClick = { onIntent(UserProfileIntent.CancelEdit) } ) } } } } ``` L'UI devient une fonction pure de l'état : prévisible, testable, et sans effet de bord caché. ## Comparaison détaillée Maintenant que nous avons vu les deux patterns en action, comparons-les sur les critères qui comptent vraiment en production. ### Gestion de l'état La différence fondamentale réside dans la gestion de l'état. Cette distinction impacte directement la maintenabilité à long terme. ```kotlin // StateComparison.kt // MVVM : état potentiellement fragmenté class MvvmViewModel : ViewModel() { // Plusieurs sources de vérité - synchronisation manuelle nécessaire private val _users = MutableStateFlow>(emptyList()) private val _selectedUser = MutableStateFlow(null) private val _isLoading = MutableStateFlow(false) private val _searchQuery = MutableStateFlow("") // Que se passe-t-il si _selectedUser pointe vers un user // qui n'est plus dans _users après un refresh ? // → État incohérent difficile à détecter } // MVI : état unique et cohérent par construction class MviViewModel : ViewModel() { // Une seule source de vérité - impossible d'avoir des incohérences private val _state = MutableStateFlow(UsersState()) data class UsersState( val users: List = emptyList(), val selectedUser: User? = null, // Toujours cohérent avec users val isLoading: Boolean = false, val searchQuery: String = "" ) // Chaque update maintient les invariants automatiquement private fun selectUser(userId: String) { _state.update { currentState -> currentState.copy( selectedUser = currentState.users.find { it.id == userId } ) } } } ``` > **Les bugs fantômes** > > En MVVM, les états incohérents sont souvent des bugs intermittents difficiles à reproduire. En MVI, si l'état est invalide, il l'est de manière déterministe. ### Testabilité des architectures Les deux architectures sont testables, mais MVI offre un avantage significatif grâce à sa prévisibilité. ```kotlin // TestComparison.kt // Test MVVM : nécessite de vérifier plusieurs flux @Test fun `loadUsers should update state correctly`() = runTest { val viewModel = MvvmViewModel(fakeRepository) // Observer plusieurs flux simultanément val users = mutableListOf>() val loadingStates = mutableListOf() val job1 = launch { viewModel.users.toList(users) } val job2 = launch { viewModel.isLoading.toList(loadingStates) } viewModel.loadUsers() advanceUntilIdle() // Assertions sur différents flux assertThat(users.last()).isEqualTo(expectedUsers) assertThat(loadingStates).containsExactly(false, true, false) job1.cancel() job2.cancel() } // Test MVI : un seul flux à vérifier, séquence d'états claire @Test fun `LoadUsers intent should produce correct state sequence`() = runTest { val viewModel = MviViewModel(fakeRepository) // Collecter tous les états dans l'ordre val states = mutableListOf() val job = launch { viewModel.state.toList(states) } // Envoyer l'intent viewModel.onIntent(UsersIntent.LoadUsers) advanceUntilIdle() // Vérifier la séquence exacte d'états assertThat(states).containsExactly( UsersState(), // Initial UsersState(isLoading = true), // Loading UsersState(users = expectedUsers, isLoading = false) // Success ) job.cancel() } ``` MVI permet de tester la séquence exacte des transitions d'état, ce qui est particulièrement utile pour les écrans complexes avec de nombreuses interactions. ### Complexité et boilerplate Soyons honnêtes sur les compromis. MVI demande plus de boilerplate et une compréhension plus profonde des concepts. ```kotlin // BoilerplateComparison.kt // MVVM : démarrage rapide, moins de code class SimpleViewModel : ViewModel() { private val _name = MutableStateFlow("") val name: StateFlow = _name.asStateFlow() fun updateName(newName: String) { _name.value = newName } } // Total : ~10 lignes // MVI : plus de structure, plus de code class SimpleMviViewModel : ViewModel() { private val _state = MutableStateFlow(SimpleState()) val state: StateFlow = _state.asStateFlow() fun onIntent(intent: SimpleIntent) { when (intent) { is SimpleIntent.UpdateName -> { _state.update { it.copy(name = intent.name) } } } } } data class SimpleState(val name: String = "") sealed class SimpleIntent { data class UpdateName(val name: String) : SimpleIntent() } // Total : ~20 lignes ``` Pour un écran simple, MVI peut sembler excessif. Mais cette structure paie ses dividendes quand l'écran se complexifie. ## Quand choisir MVVM ? MVVM reste le choix pragmatique dans plusieurs situations : ### Projets existants Si votre application utilise déjà MVVM, migrer vers MVI représente un effort considérable. Améliorer la structure MVVM existante est souvent plus judicieux. ### Équipes juniors ou mixtes MVVM est plus accessible. Une équipe avec des développeurs débutants sera productive plus rapidement avec MVVM qu'avec MVI. ### Écrans simples Pour des écrans avec peu d'états et d'interactions, MVI ajoute de la complexité sans bénéfice proportionnel. ```kotlin // SettingsViewModel.kt // Pour un écran de paramètres simple, MVVM suffit largement class SettingsViewModel( private val preferencesRepository: PreferencesRepository ) : ViewModel() { val darkMode = preferencesRepository.darkModeFlow .stateIn(viewModelScope, SharingStarted.Lazily, false) val notificationsEnabled = preferencesRepository.notificationsFlow .stateIn(viewModelScope, SharingStarted.Lazily, true) fun toggleDarkMode() { viewModelScope.launch { preferencesRepository.setDarkMode(!darkMode.value) } } fun toggleNotifications() { viewModelScope.launch { preferencesRepository.setNotifications(!notificationsEnabled.value) } } } ``` ## Quand choisir MVI ? MVI montre sa valeur dans des contextes spécifiques : ### Applications avec état complexe Quand un écran a de nombreux états interdépendants, MVI garantit la cohérence. ```kotlin // CheckoutState.kt // Écran de checkout avec état complexe : MVI excelle data class CheckoutState( val cartItems: List = emptyList(), val selectedAddress: Address? = null, val selectedPayment: PaymentMethod? = null, val promoCode: PromoCode? = null, val deliveryOptions: List = emptyList(), val selectedDelivery: DeliveryOption? = null, val subtotal: Money = Money.ZERO, val discount: Money = Money.ZERO, val deliveryFee: Money = Money.ZERO, val total: Money = Money.ZERO, val isLoading: Boolean = false, val error: CheckoutError? = null, val step: CheckoutStep = CheckoutStep.CART ) { // Invariants vérifiables init { require(total == subtotal - discount + deliveryFee) { "Total incohérent avec les composants" } } } sealed class CheckoutIntent { data class AddItem(val item: CartItem) : CheckoutIntent() data class RemoveItem(val itemId: String) : CheckoutIntent() data class SelectAddress(val address: Address) : CheckoutIntent() data class SelectPayment(val method: PaymentMethod) : CheckoutIntent() data class ApplyPromo(val code: String) : CheckoutIntent() object RemovePromo : CheckoutIntent() data class SelectDelivery(val option: DeliveryOption) : CheckoutIntent() object ProceedToPayment : CheckoutIntent() object ConfirmOrder : CheckoutIntent() } ``` ### Applications temps réel Pour les apps avec WebSockets, notifications push, ou synchronisation en temps réel, MVI gère élégamment les flux de données multiples. ### Exigences de débogage strictes Dans des domaines réglementés (fintech, santé), la capacité à reproduire exactement une séquence d'événements est précieuse. > MVI permet d'implémenter facilement du "time-travel debugging" : enregistrer tous les états et rejouer la session utilisateur. ## Approche hybride : le meilleur des deux mondes En pratique, beaucoup d'équipes adoptent une approche hybride : MVI pour les écrans complexes, MVVM simplifié pour les écrans simples. Voici un pattern recommandé : ```kotlin // MviViewModel.kt // Base ViewModel avec structure MVI légère // Réutilisable pour tous les écrans abstract class MviViewModel(initialState: S) : ViewModel() { private val _state = MutableStateFlow(initialState) val state: StateFlow = _state.asStateFlow() protected val currentState: S get() = _state.value // Point d'entrée unique pour les intents abstract fun onIntent(intent: I) // Helper pour mettre à jour l'état protected fun updateState(reducer: S.() -> S) { _state.update { it.reducer() } } } // Implémentation concrète reste simple class ProfileViewModel( private val userRepository: UserRepository ) : MviViewModel(ProfileState()) { override fun onIntent(intent: ProfileIntent) { when (intent) { is ProfileIntent.Load -> load(intent.userId) is ProfileIntent.Refresh -> refresh() is ProfileIntent.ToggleFavorite -> toggleFavorite() } } private fun load(userId: String) { viewModelScope.launch { updateState { copy(isLoading = true) } val user = userRepository.getUser(userId) updateState { copy(user = user, isLoading = false) } } } private fun refresh() = load(currentState.user?.id ?: return) private fun toggleFavorite() { updateState { copy(user = user?.copy(isFavorite = !user.isFavorite)) } } } ``` Cette approche offre les bénéfices de MVI (état unique, intents typés) sans le boilerplate excessif. ## Recommandations pour 2026 Voici les recommandations pour choisir entre ces deux architectures selon le contexte : ### Pour les nouveaux projets avec Compose Adoptez MVI dès le départ. Compose et MVI partagent la même philosophie, et l'investissement initial est rapidement rentabilisé. ### Pour les projets existants en Views Restez en MVVM, mais adoptez progressivement les bonnes pratiques MVI : état unique dans le ViewModel, actions typées avec sealed classes. ### Pour les grandes équipes Standardisez sur une approche et documentez-la. La cohérence à travers la codebase est plus importante que le choix du pattern lui-même. > **Le vrai critère** > > Le meilleur pattern est celui que votre équipe comprend et applique correctement. Un MVVM bien implémenté bat un MVI mal compris. ## Conclusion MVVM et MVI sont deux approches valides pour architecturer vos applications Android. MVVM offre simplicité et familiarité, tandis que MVI apporte prévisibilité et débogage facilité. ### Checklist de décision - ✅ **Choisissez MVVM si** : équipe junior, projet simple, migration coûteuse - ✅ **Choisissez MVI si** : Compose natif, état complexe, debugging critique - ✅ **Hybride recommandé** : MVI light avec état unique, sans over-engineering - ✅ **Priorité absolue** : cohérence à travers la codebase Quelle que soit votre choix, l'essentiel est de comprendre les forces et faiblesses de chaque approche pour prendre une décision éclairée. Et n'oubliez pas : le meilleur code est celui que votre équipe peut maintenir sereinement sur le long terme. --- Source: SharpSkill (https://sharpskill.dev), tech interview preparation for your real stack. HTML version of this page: https://sharpskill.dev/fr/blog/android/mvvm-vs-mvi-architecture