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.

Comparaison des architectures MVVM et MVI pour Android

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.

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.

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.

UserProfileViewModel.ktkotlin
// 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 :

ProblematicViewModel.ktkotlin
// 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 :

UserProfileMviViewModel.ktkotlin
// 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.

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 :

UserProfileScreen.ktkotlin
// 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.

StateComparison.ktkotlin
// 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 }
            )
        }
    }
}
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é.

TestComparison.ktkotlin
// 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.

BoilerplateComparison.ktkotlin
// 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 lines

Pour 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.

SettingsViewModel.ktkotlin
// 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.

CheckoutState.ktkotlin
// 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é :

MviViewModel.ktkotlin
// 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 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.

Sources

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.

Défi du jour

Tu saurais repérer le bug en Android ?

Un vrai bout de code, un bug caché, une tentative par jour. Sans compte pour essayer.

Anthony Fillion-Maillet

Écrit par

Anthony Fillion-Maillet

Fondateur 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

#android
#mvvm
#mvi
#architecture
#jetpack compose

Partager

Articles similaires