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.

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.
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.
// 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<UserProfileState> = _uiState.asStateFlow()
// État séparé pour le chargement - MVVM permet plusieurs flux
private val _isLoading = MutableStateFlow(false)
val isLoading: StateFlow<Boolean> = _isLoading.asStateFlow()
// État pour les messages d'erreur one-shot
private val _errorMessage = MutableSharedFlow<String>()
val errorMessage: SharedFlow<String> = _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 :
// 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<List<CartItem>>(emptyList())
private val _selectedAddress = MutableStateFlow<Address?>(null)
private val _selectedPayment = MutableStateFlow<PaymentMethod?>(null)
private val _promoCode = MutableStateFlow<String?>(null)
private val _isLoading = MutableStateFlow(false)
private val _error = MutableStateFlow<String?>(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.
// 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<UserProfileState> = _state.asStateFlow()
// Canal pour les effets secondaires (navigation, snackbar)
private val _sideEffect = Channel<UserProfileSideEffect>()
val sideEffect: Flow<UserProfileSideEffect> = _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.
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 :
// É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.
// MVVM : état potentiellement fragmenté
class MvvmViewModel : ViewModel() {
// Plusieurs sources de vérité - synchronisation manuelle nécessaire
private val _users = MutableStateFlow<List<User>>(emptyList())
private val _selectedUser = MutableStateFlow<User?>(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<User> = 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 }
)
}
}
}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é.
// 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<List<User>>()
val loadingStates = mutableListOf<Boolean>()
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<UsersState>()
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.
// MVVM : démarrage rapide, moins de code
class SimpleViewModel : ViewModel() {
private val _name = MutableStateFlow("")
val name: StateFlow<String> = _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<SimpleState> = _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 lignesPour un écran simple, MVI peut sembler excessif. Mais cette structure paie ses dividendes quand l'écran se complexifie.
Prêt à réussir tes entretiens Android ?
Entraîne-toi avec nos simulateurs interactifs, fiches express et tests techniques.
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.
// 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.
// Écran de checkout avec état complexe : MVI excelle
data class CheckoutState(
val cartItems: List<CartItem> = emptyList(),
val selectedAddress: Address? = null,
val selectedPayment: PaymentMethod? = null,
val promoCode: PromoCode? = null,
val deliveryOptions: List<DeliveryOption> = 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é :
// Base ViewModel avec structure MVI légère
// Réutilisable pour tous les écrans
abstract class MviViewModel<S, I>(initialState: S) : ViewModel() {
private val _state = MutableStateFlow(initialState)
val state: StateFlow<S> = _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, ProfileIntent>(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 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
Passe à la pratique !
Teste tes connaissances avec nos simulateurs d'entretien et tests techniques.
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.
Tags
Partager
Articles similaires

Jetpack Compose : Animations avancées pas à pas
Guide complet des animations avancées en Jetpack Compose : transitions, AnimatedVisibility, Animatable, gestures et performance pour des interfaces fluides.

Top 20 questions d'entretien Jetpack Compose en 2026
Les 20 questions les plus posées en entretien sur Jetpack Compose : recomposition, state, navigation, performance et architecture.

Kotlin 2.3 pour Android : Destructuration par Nom, KMP et Questions d'Entretien 2026
Questions d'entretien Kotlin 2.3 pour développeurs Android en 2026. Destructuration par nom, KMP, paramètres de contexte, Flow et coroutines avec exemples de code.