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.

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

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

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

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

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
// É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.

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

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

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

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

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

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

#android
#mvvm
#mvi
#architecture
#jetpack compose

Partager

Articles similaires