MVVM vs MVI : Quelle architecture choisir en 2026 ?
Comparaison approfondie de MVVM et MVI sur Android : avantages, inconvénients, cas d'usage et guide pratique. Mis à jour avec les explicit backing fields de Kotlin 2.4.

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 devenue le choix naturel pour 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.
Ce premier exemple montre la structure de base avec un ViewModel qui expose un état observable et des méthodes pour les interactions utilisateur. La syntaxe des explicit backing fields de Kotlin 2.4 élimine le boilerplate traditionnel du pattern _uiState / uiState.
// MVVM ViewModel with Kotlin 2.4 explicit backing fields
// The backing field syntax eliminates the _state / state dance
class UserProfileViewModel(
private val userRepository: UserRepository,
private val analyticsTracker: AnalyticsTracker
) : ViewModel() {
// Kotlin 2.4: explicit backing field - no more _uiState / uiState pair
val uiState: StateFlow<UserProfileState>
field = MutableStateFlow(UserProfileState())
// Separate loading state - MVVM allows multiple flows
val isLoading: StateFlow<Boolean>
field = MutableStateFlow(false)
// One-shot error messages via SharedFlow
private val _errorMessage = MutableSharedFlow<String>()
val errorMessage: SharedFlow<String> = _errorMessage.asSharedFlow()
// Initial profile loading
fun loadProfile(userId: String) {
viewModelScope.launch {
isLoading.value = true
try {
// Repository call to fetch data
val user = userRepository.getUser(userId)
// Update state with new data
uiState.update { currentState ->
currentState.copy(
user = user,
isEditing = false
)
}
// Analytics tracking
analyticsTracker.trackProfileViewed(userId)
} catch (e: Exception) {
// Emit one-shot error message
_errorMessage.emit("Unable to load profile")
} finally {
isLoading.value = false
}
}
}
// Enable edit mode
fun enableEditMode() {
uiState.update { it.copy(isEditing = true) }
}
// Save profile changes
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("Failed to save profile")
} finally {
isLoading.value = false
}
}
}
}
// Data class representing the screen state
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. La syntaxe des explicit backing fields de Kotlin 2.4 rend le code plus propre en déclarant le type de propriété comme StateFlow tandis que le backing field est MutableStateFlow.
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é. Cet exemple illustre ce problème courant de fragmentation de l'état :
// Example MVVM ViewModel with fragmented state
// This pattern becomes problematic as the screen grows in complexity
class CheckoutViewModel : ViewModel() {
// Problem: state scattered across multiple flows
val cart: StateFlow<List<CartItem>>
field = MutableStateFlow(emptyList())
val selectedAddress: StateFlow<Address?>
field = MutableStateFlow(null)
val selectedPayment: StateFlow<PaymentMethod?>
field = MutableStateFlow(null)
val promoCode: StateFlow<String?>
field = MutableStateFlow(null)
val isLoading: StateFlow<Boolean>
field = MutableStateFlow(false)
val error: StateFlow<String?>
field = MutableStateFlow(null)
// Each modification can create temporary inconsistent states
fun applyPromoCode(code: String) {
viewModelScope.launch {
isLoading.value = true
error.value = null
try {
val discount = promoRepository.validate(code)
promoCode.value = code
// Cart state also needs updating...
// but there's a delay between the two updates
recalculateCart()
} catch (e: Exception) {
error.value = e.message
promoCode.value = null
} finally {
isLoading.value = false
}
}
}
// Hard to guarantee consistency across all these states
private fun recalculateCart() {
// Complex logic depending on multiple states...
}
}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.
Cette implémentation du même écran de profil utilisateur montre comment l'état est centralisé et les actions explicitement typées :
// MVI ViewModel for the same user profile screen
// Note the structure: Intent -> Reducer -> Single State
class UserProfileMviViewModel(
private val userRepository: UserRepository,
private val analyticsTracker: AnalyticsTracker
) : ViewModel() {
// Single, immutable state - the absolute source of truth
val state: StateFlow<UserProfileState>
field = MutableStateFlow(UserProfileState())
// Channel for side effects (navigation, snackbar)
private val _sideEffect = Channel<UserProfileSideEffect>()
val sideEffect: Flow<UserProfileSideEffect> = _sideEffect.receiveAsFlow()
// Single entry point for all user actions
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 to loading state
state.update { it.copy(isLoading = true, error = null) }
try {
val user = userRepository.getUser(userId)
// Single atomic state update
state.update {
it.copy(
user = user,
isLoading = false,
error = null
)
}
analyticsTracker.trackProfileViewed(userId)
} catch (e: Exception) {
// Error state is part of the main state
state.update {
it.copy(
isLoading = false,
error = "Unable to load profile"
)
}
}
}
}
private fun enableEditMode() {
// Simple, predictable update
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
)
}
// Side effect to notify the user
_sideEffect.send(UserProfileSideEffect.ShowSuccess("Profile updated"))
} catch (e: Exception) {
state.update {
it.copy(isLoading = false, error = "Failed to save profile")
}
}
}
}
private fun cancelEdit() {
state.update { it.copy(isEditing = false) }
}
}
// All possible actions, explicitly typed
sealed class UserProfileIntent {
data class LoadProfile(val userId: String) : UserProfileIntent()
data object EnableEditMode : UserProfileIntent()
data class SaveProfile(val name: String, val bio: String) : UserProfileIntent()
data object CancelEdit : UserProfileIntent()
}
// Single, complete screen state
data class UserProfileState(
val user: User? = null,
val isLoading: Boolean = false,
val isEditing: Boolean = false,
val error: String? = null
)
// One-shot side effects
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 :
// Compose screen consuming MVI state
// The connection between ViewModel and UI is elegant and reactive
@Composable
fun UserProfileScreen(
viewModel: UserProfileMviViewModel = hiltViewModel(),
onNavigateBack: () -> Unit
) {
// Collect the single state
val state by viewModel.state.collectAsStateWithLifecycle()
// Handle side effects
LaunchedEffect(Unit) {
viewModel.sideEffect.collect { effect ->
when (effect) {
is UserProfileSideEffect.ShowSuccess -> {
// Show snackbar
}
is UserProfileSideEffect.NavigateTo -> {
// Navigate
}
}
}
}
// Purely declarative UI based on state
UserProfileContent(
state = state,
onIntent = viewModel::onIntent
)
}
@Composable
private fun UserProfileContent(
state: UserProfileState,
onIntent: (UserProfileIntent) -> Unit
) {
Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
// Conditional rendering based on the single state
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 les deux patterns sont clairs, comparons-les sur les critères qui comptent 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: potentially fragmented state
class MvvmViewModel : ViewModel() {
// Multiple sources of truth - manual synchronization needed
val users: StateFlow<List<User>>
field = MutableStateFlow(emptyList())
val selectedUser: StateFlow<User?>
field = MutableStateFlow(null)
val isLoading: StateFlow<Boolean>
field = MutableStateFlow(false)
val searchQuery: StateFlow<String>
field = MutableStateFlow("")
// What happens if selectedUser points to a user
// that's no longer in users after a refresh?
// -> Inconsistent state that's hard to detect
}
// MVI: consistent state by construction
class MviViewModel : ViewModel() {
// Single source of truth - inconsistencies are impossible
val state: StateFlow<UsersState>
field = MutableStateFlow(UsersState())
data class UsersState(
val users: List<User> = emptyList(),
val selectedUser: User? = null, // Always consistent with users
val isLoading: Boolean = false,
val searchQuery: String = ""
)
// Each update automatically maintains invariants
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é.
// MVVM test: requires verifying multiple flows
@Test
fun `loadUsers should update state correctly`() = runTest {
val viewModel = MvvmViewModel(fakeRepository)
// Observe multiple flows simultaneously
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 on different flows
assertThat(users.last()).isEqualTo(expectedUsers)
assertThat(loadingStates).containsExactly(false, true, false)
job1.cancel()
job2.cancel()
}
// MVI test: single flow to verify, clear state sequence
@Test
fun `LoadUsers intent should produce correct state sequence`() = runTest {
val viewModel = MviViewModel(fakeRepository)
// Collect all states in order
val states = mutableListOf<UsersState>()
val job = launch { viewModel.state.toList(states) }
// Send the intent
viewModel.onIntent(UsersIntent.LoadUsers)
advanceUntilIdle()
// Verify the exact state sequence
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
Depuis Kotlin 2.4, l'écart de boilerplate entre MVVM et MVI s'est réduit significativement. Les explicit backing fields éliminent le pattern _state / state qui ajoutait des lignes à chaque ViewModel.
// MVVM: quick start, less code
class SimpleViewModel : ViewModel() {
val name: StateFlow<String>
field = MutableStateFlow("")
fun updateName(newName: String) {
name.value = newName
}
}
// Total: ~8 lines
// MVI: more structure, more code
class SimpleMviViewModel : ViewModel() {
val state: StateFlow<SimpleState>
field = MutableStateFlow(SimpleState())
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: ~18 linesPour 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.
// For a simple settings screen, MVVM is plenty
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.
// Checkout screen with complex state: MVI excels
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
) {
// Verifiable invariants
init {
require(total == subtotal - discount + deliveryFee) {
"Total inconsistent with components"
}
}
}
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()
data object RemovePromo : CheckoutIntent()
data class SelectDelivery(val option: DeliveryOption) : CheckoutIntent()
data object ProceedToPayment : CheckoutIntent()
data 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.
Alternative émergente : Circuit
Circuit, développé par Slack, propose une implémentation MVI native Compose qui mérite considération pour les nouveaux projets. Il combine des presenters avec Molecule sous le capot, éliminant une grande partie du boilerplate MVI tout en conservant le flux de données unidirectionnel.
Circuit est particulièrement intéressant pour les projets Kotlin Multiplatform, car il permet de partager la logique de présentation entre plateformes. Cependant, son adoption en dehors de Slack reste limitée comparée à l'approche standard avec Jetpack ViewModel.
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 with lightweight MVI structure
// Reusable for all screens
abstract class MviViewModel<S, I>(initialState: S) : ViewModel() {
val state: StateFlow<S>
field = MutableStateFlow(initialState)
protected val currentState: S get() = state.value
// Single entry point for intents
abstract fun onIntent(intent: I)
// Helper to update state
protected fun updateState(reducer: S.() -> S) {
state.update { it.reducer() }
}
}
// Concrete implementation stays 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
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é. Avec les explicit backing fields de Kotlin 2.4, l'écart de boilerplate s'est réduit.
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.
Sources
- Kotlin 2.4.0 What's New - Les explicit backing fields sont maintenant stables, éliminant le boilerplate
_state / state - Jetpack Compose August 2026 Release - Compose 1.12 avec BOM 2026.08.00
- Circuit by Slack - Architecture MVI native Compose avec intégration Molecule
- Android Architecture Guide - Recommandations officielles de Google sur l'architecture en couches
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.
Quel que soit le choix, l'essentiel est de comprendre les forces et faiblesses de chaque approche pour prendre une décision éclairée. Le meilleur code est celui que l'équipe peut maintenir sereinement sur le long terme.
Tu saurais repérer le bug en Android ?
Un vrai bout de code, un bug caché, une tentative par jour. Sans compte pour essayer.

Écrit par
Anthony Fillion-MailletFondateur de SharpSkill
Développeur fullstack depuis plus de 10 ans. Il dirige SharpSkill et répond de tout ce qui y est publié.
Mis à jour le 19 août 2026
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.

Android WorkManager en 2026 : Tâches en Arrière-plan, Contraintes et Questions d'Entretien
Guide complet sur WorkManager pour Android en 2026 : création de Workers, contraintes d'exécution, chaînage de tâches et questions techniques d'entretien.