MVVM vs MVI: Quale Architettura Scegliere nel 2026?
Confronto approfondito tra MVVM e MVI su Android: pro, contro, casi d'uso e una guida pratica per scegliere l'architettura giusta. Aggiornato con Kotlin 2.4 explicit backing fields.

Scegliere l'architettura giusta è una decisione cruciale che influenza la manutenibilità, la testabilità e la scalabilità di un'applicazione Android. Nel 2026, due pattern dominano l'ecosistema: MVVM, lo standard del settore, e MVI, l'approccio reattivo che si è affermato come complemento naturale per Jetpack Compose.
Una scelta architettonica errata è costosa: debito tecnico, bug difficili da riprodurre e refactoring dolorosi. Comprendere i punti di forza e di debolezza di ciascun approccio risparmia notevoli problemi nel lungo periodo.
Comprendere MVVM: Lo Standard Consolidato
MVVM (Model-View-ViewModel) è l'architettura consigliata da Google fin dall'introduzione di Jetpack. Separa le responsabilità in tre livelli distinti, rendendo il codice più organizzato e testabile.
Principi Fondamentali di MVVM
Il pattern MVVM si basa su una separazione netta: il Model gestisce dati e logica di business, la View visualizza l'interfaccia, e il ViewModel fa da ponte esponendo stati osservabili.
Questo primo esempio mostra la struttura base con un ViewModel che espone stato osservabile e metodi per le interazioni utente. Da notare come gli explicit backing fields di Kotlin 2.4 eliminino il tradizionale pattern boilerplate _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
)Questo ViewModel illustra l'approccio MVVM tipico: più flow osservabili (stato principale, caricamento, errori) e metodi pubblici per ogni azione utente. La sintassi degli explicit backing fields di Kotlin 2.4 rende il codice più pulito dichiarando il tipo della proprietà come StateFlow mentre il backing field è MutableStateFlow.
Vantaggi di MVVM
MVVM presenta diversi punti di forza che ne spiegano l'adozione massiccia:
- Familiarità: La maggior parte degli sviluppatori Android conosce questo pattern
- Flessibilità: Lo stato può essere strutturato secondo le esigenze del caso d'uso
- Ecosistema: Integrazione perfetta con Jetpack (LiveData, StateFlow, Hilt)
- Semplicità: Curva di apprendimento graduale per i principianti
MVVM è particolarmente adatto per team misti con sviluppatori di diversi livelli. La sua semplicità concettuale facilita l'onboarding.
Limiti di MVVM
Tuttavia, MVVM mostra i suoi limiti man mano che l'applicazione cresce. Il problema principale è la gestione distribuita dello stato. Questo esempio illustra il problema comune dello stato frammentato:
// 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...
}
}Questo esempio mostra come lo stato possa frammentarsi in MVVM, rendendo difficile tracciare le transizioni e riprodurre i bug.
Comprendere MVI: L'Approccio Unidirezionale
MVI (Model-View-Intent) adotta una filosofia diversa: flusso di dati unidirezionale e un singolo stato immutabile. Questo approccio, ispirato a Redux, elimina i problemi di stato inconsistente.
Principi Fondamentali di MVI
In MVI, tutto segue un ciclo chiaro: l'utente emette un Intent (azione), il Reducer trasforma lo stato corrente in uno nuovo, e la View visualizza questo singolo stato. È prevedibile, testabile e debuggabile.
Questa implementazione della stessa schermata del profilo utente dimostra come lo stato sia centralizzato e le azioni siano tipizzate esplicitamente:
// 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 differenza è evidente: un singolo flow di stato, azioni esplicite e una separazione netta tra stato persistente ed effetti monouso.
Con MVI è possibile registrare ogni Intent e ogni transizione di stato. Riprodurre un bug diventa banale: basta riprodurre la sequenza di Intent.
MVI con Jetpack Compose
MVI brilla particolarmente con Jetpack Compose, poiché entrambi condividono la stessa filosofia: stato immutabile e UI dichiarativa. Ecco come connettere il ViewModel a una schermata 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'interfaccia diventa una funzione pura dello stato: prevedibile, testabile e senza effetti collaterali nascosti.
Confronto Dettagliato
Ora che entrambi i pattern sono chiari, segue un confronto sui criteri che contano davvero in produzione.
Gestione dello Stato
La differenza fondamentale risiede nella gestione dello stato. Questa distinzione impatta direttamente la manutenibilità a lungo termine.
// 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 }
)
}
}
}In MVVM, gli stati inconsistenti si manifestano spesso come bug intermittenti difficili da riprodurre. In MVI, se lo stato è invalido, lo è in modo deterministico.
Testabilità dell'Architettura
Entrambe le architetture sono testabili, ma MVI offre un vantaggio significativo grazie alla sua prevedibilità.
// 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 consente di verificare la sequenza esatta delle transizioni di stato, particolarmente utile per schermate complesse con molte interazioni.
Complessità e Boilerplate
Da Kotlin 2.4, il divario di boilerplate tra MVVM e MVI si è ridotto significativamente. La funzionalità degli explicit backing fields elimina il pattern _state / state che prima aggiungeva righe a ogni 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 linesPer una schermata semplice, MVI può sembrare eccessivo. Ma questa struttura ripaga man mano che la schermata cresce in complessità.
Pronto a superare i tuoi colloqui su Android?
Pratica con i nostri simulatori interattivi, flashcards e test tecnici.
Quando Scegliere MVVM?
MVVM rimane la scelta pragmatica in diverse situazioni:
Progetti Esistenti
Se l'applicazione usa già MVVM, migrare a MVI rappresenta un effort considerevole. Migliorare la struttura MVVM esistente è spesso la decisione più sensata.
Team Junior o Misti
MVVM è più accessibile. Un team con sviluppatori principianti sarà produttivo più rapidamente con MVVM che con MVI.
Schermate Semplici
Per schermate con pochi stati e interazioni, MVI aggiunge complessità senza beneficio proporzionale.
// 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)
}
}
}Quando Scegliere MVI?
MVI dimostra il suo valore in contesti specifici:
Applicazioni con Stato Complesso
Quando una schermata ha molti stati interdipendenti, MVI garantisce la coerenza.
// 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()
}Applicazioni in Tempo Reale
Per app con WebSocket, notifiche push o sincronizzazione in tempo reale, MVI gestisce elegantemente più flussi di dati.
Requisiti Rigidi di Debugging
In settori regolamentati (fintech, sanità), la capacità di riprodurre esattamente una sequenza di eventi è inestimabile.
MVI facilita l'implementazione del "time-travel debugging": registrare tutti gli stati e riprodurre la sessione utente.
Alternativa Emergente: Circuit
Circuit, sviluppato da Slack, offre un'implementazione MVI nativa per Compose che vale la pena considerare per nuovi progetti. Combina presenter con Molecule sotto il cofano, eliminando gran parte del boilerplate MVI mantenendo il flusso di dati unidirezionale.
Circuit è particolarmente interessante per progetti Kotlin Multiplatform, poiché supporta la condivisione della logica di presentazione tra piattaforme. Tuttavia, l'adozione al di fuori di Slack rimane limitata rispetto all'approccio standard con Jetpack ViewModel.
Approccio Ibrido: Il Meglio dei Due Mondi
Nella pratica, molti team adottano un approccio ibrido: MVI per schermate complesse, MVVM semplificato per schermate semplici. Ecco un pattern consigliato:
// 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))
}
}
}Questo approccio offre i vantaggi di MVI (stato unico, intent tipizzati) senza boilerplate eccessivo.
Raccomandazioni
Ecco le raccomandazioni per scegliere tra le due architetture in base al contesto:
Per Nuovi Progetti con Compose
Adottare MVI dall'inizio. Compose e MVI condividono la stessa filosofia, e l'investimento iniziale si ripaga rapidamente. Con gli explicit backing fields di Kotlin 2.4, il divario di boilerplate si è ridotto.
Per Progetti Esistenti Basati su View
Mantenere MVVM, ma adottare gradualmente le best practice di MVI: stato unico nel ViewModel, azioni tipizzate con sealed class.
Per Team Grandi
Standardizzarsi su un unico approccio e documentarlo. La coerenza nel codice è più importante della scelta del pattern stesso.
Il miglior pattern è quello che il team comprende e applica correttamente. Un MVVM ben implementato supera un MVI mal compreso.
Fonti
- Kotlin 2.4.0 What's New - Gli explicit backing fields sono ora stabili, eliminando il boilerplate
_state / state - Jetpack Compose August 2026 Release - Compose 1.12 con BOM 2026.08.00
- Circuit by Slack - Architettura MVI basata su Compose con integrazione Molecule
- Android Architecture Guide - Raccomandazioni ufficiali di Google per l'architettura a livelli
Conclusione
MVVM e MVI sono entrambi approcci validi per architettare applicazioni Android. MVVM offre semplicità e familiarità, mentre MVI porta prevedibilità e debugging più agevole.
Checklist per la Decisione
- Scegliere MVVM se: team junior, progetto semplice, migrazione costosa
- Scegliere MVI se: Compose nativo, stato complesso, debugging critico
- Approccio ibrido consigliato: MVI leggero con stato unico, senza over-engineering
- Priorità massima: coerenza in tutto il codice
Inizia a praticare!
Metti alla prova le tue conoscenze con i nostri simulatori di colloquio e test tecnici.
Indipendentemente dalla scelta, la chiave è comprendere i punti di forza e di debolezza di ciascun approccio per prendere una decisione informata. Il codice migliore è quello che il team riesce a mantenere serenamente nel lungo periodo.
Sapresti trovare il bug in Android?
Uno snippet reale, un bug nascosto, un tentativo al giorno. Senza account per provare.

Scritto da
Anthony Fillion-MailletFondatore di SharpSkill
Sviluppatore fullstack da oltre 10 anni. Guida SharpSkill e risponde di tutto ciò che vi viene pubblicato.
Aggiornato il 19 agosto 2026
Tag
Condividi
Articoli correlati

Jetpack Compose: Animazioni Avanzate Passo dopo Passo
Guida completa alle animazioni avanzate in Compose: transizioni, AnimatedVisibility, Animatable, gesture e prestazioni per interfacce Android fluide.

Le 20 domande più frequenti su Jetpack Compose nei colloqui 2026
Le 20 domande più frequenti su Jetpack Compose nei colloqui tecnici: recomposition, gestione dello stato, navigazione, performance e pattern architetturali.

Jetpack Navigation Compose nel 2026: Navigazione Type-Safe e Domande da Colloquio
Una guida completa alla navigazione type-safe con Jetpack Compose. Esempi pratici, best practice e domande frequenti nei colloqui tecnici per sviluppatori Android.