31 mag 2025·7 min di lettura

Kotlin MVI vs MVVM per app Android con molti form: stati UI

Kotlin MVI vs MVVM per app Android con molti form: spiegato con modi pratici per modellare validazione, UI ottimistica, stati di errore e bozze offline.

Kotlin MVI vs MVVM per app Android con molti form: stati UI

Perché le app Android con molti form diventano caotiche in fretta

Le app ricche di form danno spesso la sensazione di essere lente o fragili perché gli utenti aspettano continuamente piccole decisioni che il tuo codice deve prendere: questo campo è valido, il salvataggio è andato a buon fine, dobbiamo mostrare un errore, e cosa succede se la rete cade.

I form inoltre espongono prima i bug di stato perché mescolano più tipi di stato insieme: stato UI (ciò che è visibile), stato di input (ciò che l'utente ha digitato), stato server (ciò che è salvato) e stato temporaneo (ciò che è in progresso). Quando questi si scollano, l'app comincia a sembrare “casuale”: pulsanti disabilitati al momento sbagliato, errori vecchi che restano, o lo schermo che si resetta dopo la rotazione.

La maggior parte dei problemi si concentra in quattro aree: validazione (specialmente regole tra campi), UI ottimistica (feedback veloce mentre il lavoro è in corso), gestione degli errori (fallimenti chiari e recuperabili) e bozze offline (non perdere lavoro incompleto).

Una buona UX per i form segue poche regole semplici:

  • La validazione deve essere utile e vicino al campo. Non bloccare la digitazione. Sii severo quando conta, di solito al submit.
  • La UI ottimistica deve riflettere immediatamente l'azione dell'utente, ma deve avere anche un rollback pulito se il server la rifiuta.
  • Gli errori devono essere specifici, azionabili e non cancellare mai l'input dell'utente.
  • Le bozze devono sopravvivere a riavvii, interruzioni e connessioni scadenti.

Per questo i dibattiti architetturali diventano intensi per i form. Il pattern che scegli decide quanto prevedibili appaiono quegli stati sotto pressione.

Rapido ripasso: MVVM e MVI in parole semplici

La vera differenza tra MVVM e MVI è come il cambiamento scorre attraverso una schermata.

MVVM (Model View ViewModel) di solito funziona così: il ViewModel tiene i dati della schermata, li espone all'UI (spesso via StateFlow o LiveData) e fornisce metodi come save, validate o load. L'UI chiama funzioni del ViewModel quando l'utente interagisce.

MVI (Model View Intent) di solito funziona così: l'UI invia eventi (intent), un reducer li elabora, e la schermata viene renderizzata da un unico oggetto di stato che rappresenta tutto ciò di cui l'UI ha bisogno in quel momento. Gli effetti collaterali (rete, DB) vengono scatenati in modo controllato e riportano i risultati come eventi.

Un modo semplice per ricordare la mentalità:

  • MVVM chiede: “Quali dati dovrebbe esporre il ViewModel e quali metodi dovrebbe offrire?”
  • MVI chiede: “Quali eventi possono accadere e come trasformano uno stato in un altro?”

Entrambi i pattern vanno bene per schermate semplici. Quando aggiungi validazione tra campi, salvataggi automatici, retry e bozze offline, hai bisogno di regole più severe su chi può cambiare lo stato e quando. MVI applica queste regole per default. MVVM può funzionare bene, ma richiede disciplina: percorsi di aggiornamento coerenti e gestione accurata degli eventi one-off (toast, navigazione).

Come modellare lo stato di un form senza sorprese

Il modo più rapido per perdere il controllo è lasciare i dati del form sparsi in troppi posti: binding della view, più flow e “solo un booleano in più”. Le schermate con molti form restano prevedibili quando c'è una sola fonte di verità.

Una forma pratica di FormState

Punta a un singolo FormState che contenga gli input grezzi più qualche flag derivato di cui ti puoi fidare. Mantienilo noioso e completo, anche se sembra un po' più grande.

data class FormState(
  val fields: Fields,
  val fieldErrors: Map\u003cFieldId, String\u003e = emptyMap(),
  val formError: String? = null,
  val isDirty: Boolean = false,
  val isValid: Boolean = false,
  val submitStatus: SubmitStatus = SubmitStatus.Idle,
  val draftStatus: DraftStatus = DraftStatus.NotSaved
)

sealed class SubmitStatus { object Idle; object Saving; object Saved; data class Failed(val msg: String) }
sealed class DraftStatus { object NotSaved; object Saving; object Saved }

Questo mantiene la validazione a livello di campo (per input) separata dai problemi a livello di form (come “il totale deve essere > 0”). Flag derivati come isDirty e isValid dovrebbero essere calcolati in un solo punto, non riimplementati nella UI.

Un modello mentale pulito è: campi (ciò che l'utente ha digitato), validazione (cosa non va), status (cosa sta facendo l'app), dirtiness (cosa è cambiato dall'ultimo salvataggio) e bozze (se esiste una copia offline).

Dove vanno gli effetti one-off

I form scatenano anche eventi una tantum: snackbars, navigazione, banner “salvato”. Non mettere questi dentro FormState, altrimenti si attiverebbero di nuovo su rotazione o quando l'UI si ri-sottoscrive.

In MVVM, emetti effetti tramite un canale separato (per esempio un SharedFlow). In MVI, modellali come Effects (o Events) che l'UI consuma una sola volta. Questa separazione previene errori “fantasma” e messaggi di successo duplicati.

Flusso di validazione in MVVM vs MVI

La validazione è dove le schermate form iniziano a sembrare fragili. La scelta chiave è dove risiedono le regole e come i risultati tornano all'UI.

Regole semplici e sincrone (campi obbligatori, lunghezza minima, intervalli numerici) dovrebbero girare nel ViewModel o nel livello di dominio, non nell'UI. Questo mantiene le regole testabili e coerenti.

Le regole asincrone (tipo “questa email è già registrata?”) sono più complicate. Devi gestire loading, risultati obsoleti e il caso in cui “l'utente ha digitato di nuovo”.

In MVVM, la validazione spesso diventa un mix di stato e metodi helper: l'UI invia cambiamenti (aggiornamenti di testo, perdita di focus, click di submit) al ViewModel; il ViewModel aggiorna un StateFlow/LiveData ed espone errori per campo e un canSubmit derivato. I controlli asincroni solitamente avviano un job, aggiornano un flag di caricamento e impostano un errore alla fine.

In MVI, la validazione tende a essere più esplicita. Una divisione pratica di responsabilità è:

  • Il reducer esegue validazioni sincrone e aggiorna immediatamente gli errori dei campi.
  • Un effect esegue la validazione asincrona e dispatcha un intent di risultato.
  • Il reducer applica quel risultato solo se corrisponde ancora all'input più recente.

Quest'ultimo passo è cruciale. Se l'utente digita una nuova email mentre il controllo “email unica” è in corso, i risultati vecchi non dovrebbero sovrascrivere l'input attuale. MVI spesso rende più semplice codificare questo comportamento perché puoi memorizzare l'ultimo valore controllato nello stato e ignorare risposte obsolete.

UI ottimistica e salvataggi asincroni

Supporta i form con strumenti admin
Crea strumenti interni per revisionare sottomissioni, correggere errori e supportare i retry.
Crea pannello admin

La UI ottimistica significa che lo schermo si comporta come se il salvataggio fosse già riuscito prima che arrivi la risposta di rete. In un form ciò spesso significa che il pulsante Salva diventa “Saving...”, appare un piccolo indicatore “Saved” al termine, e gli input restano utilizzabili (o bloccati intenzionalmente) mentre la richiesta è in volo.

In MVVM, questo si implementa comunemente alternando flag come isSaving, lastSavedAt e saveError. Il rischio è lo sfasamento: salvataggi sovrapposti possono lasciare questi flag incoerenti. In MVI, un reducer aggiorna un unico oggetto di stato, quindi “Saving” e “Disabled” hanno meno probabilità di contraddirsi.

Per evitare double submit e condizioni di race, tratta ogni salvataggio come un evento identificato. Se l'utente tocca Salva due volte o modifica durante un salvataggio, hai bisogno di una regola su quale risposta prevale. Alcune precauzioni funzionano in entrambi i pattern: disabilitare Salva mentre si salva (o debounce dei tap), allegare un requestId (o versione) a ogni salvataggio e ignorare risposte obsolete, cancellare il lavoro in volo quando l'utente esce e definire cosa significano le modifiche durante il salvataggio (accodare un altro salvataggio, o segnare il form come dirty di nuovo).

Il successo parziale è comune: il server accetta alcuni campi ma ne rifiuta altri. Modellalo esplicitamente. Mantieni errori per campo (e, se serve, uno stato di sync per campo) così puoi mostrare “Salvato” complessivamente pur evidenziando un campo che richiede attenzione.

Stati di errore che l'utente può risolvere

Rendi prevedibili i cambi di stato
Trasforma le tue regole di validazione e salvataggio in Business Processes testabili in anticipo.
Inizia a costruire

Le schermate form possono fallire in modi diversi oltre al semplice “qualcosa è andato storto”. Se ogni fallimento diventa un toast generico, gli utenti riscrivono dati, perdono fiducia e abbandonano il flusso. L'obiettivo è sempre lo stesso: mantenere l'input al sicuro, mostrare una correzione chiara e rendere il retry una cosa normale.

Aiuta separare gli errori per dove appartengono. Un formato email sbagliato non è la stessa cosa di un'uscita del server.

Gli errori di campo dovrebbero essere inline e legati a un singolo input. Gli errori a livello di form dovrebbero stare vicino all'azione di submit e spiegare cosa blocca l'invio. Gli errori di rete dovrebbero offrire retry e mantenere il form modificabile. Errori di permessi o autenticazione dovrebbero guidare l'utente al re-auth preservando la bozza.

Una regola di recupero fondamentale: non cancellare mai l'input dell'utente al fallimento. Se il salvataggio fallisce, conserva i valori correnti in memoria e su disco. Il retry dovrebbe reinviare lo stesso payload a meno che l'utente non modifichi qualcosa.

Dove i pattern divergono è nel modo in cui gli errori server vengono mappati di nuovo nello stato UI. In MVVM è facile aggiornare più flow o campi e creare inconsistenze. In MVI, di solito applichi la risposta server in un unico passo del reducer che aggiorna insieme fieldErrors e formError.

Decidi anche cosa è stato e cosa è un effetto one-time. Errori inline e “submit fallito” appartengono allo stato (devono sopravvivere alla rotazione). Azioni one-off come uno snackbar, una vibrazione o una navigazione dovrebbero essere effetti.

Bozze offline e ripristino di form in corso

Un'app con molti form sembra “offline” anche quando la rete è OK. Gli utenti cambiano app, il sistema uccide il processo o perdono segnale a metà. Le bozze evitano che ricomincino da capo.

Prima, definisci cosa significa bozza. Salvare solo il modello “pulito” spesso non basta. Di solito vuoi ripristinare lo schermo esattamente com'era, inclusi i campi a metà digitazione.

Ciò che vale la pena persistere è per lo più l'input grezzo (stringhe come digitate, ID selezionati, URI degli allegati), più metadati sufficienti per fondere in modo sicuro dopo: uno snapshot server noto e un marcatore di versione (updatedAt, ETag o un semplice incremento). La validazione può essere ricalcolata al ripristino.

La scelta dello storage dipende da sensibilità e dimensione. Bozze piccole possono stare nelle preferences, ma form multi-step e allegati sono più sicuri in un database locale. Se la bozza contiene dati personali, usa storage crittografato.

La domanda architetturale più grande è dove vive la fonte di verità. In MVVM, i team spesso persistono dal ViewModel ogni volta che i campi cambiano. In MVI, persistere dopo ogni aggiornamento del reducer può essere più semplice perché salvi uno stato coerente (o un oggetto Draft derivato).

Il timing dell'autosave conta. Salvare ad ogni battuta è rumoroso; un breve debounce (per esempio 300–800 ms) più un salvataggio al cambio di step funziona bene.

Quando l'utente torna online, servono regole di merge. Un approccio pratico: se la versione server non è cambiata, applica la bozza e submit. Se è cambiata, mostra una scelta chiara: mantenere la bozza o ricaricare i dati server.

Passo dopo passo: implementare un form affidabile con entrambi i pattern

Tieni gli utenti informati automaticamente
Invia conferme e avvisi via email, SMS o Telegram dalla logica del processo.
Aggiungi messaggistica

I form affidabili partono da regole chiare, non dal codice UI. Ogni azione dell'utente dovrebbe portare a uno stato prevedibile, e ogni risultato asincrono dovrebbe avere un posto ovvio dove atterrare.

Annota le azioni a cui la tua schermata deve reagire: digitazione, perdita di focus, submit, retry e navigazione tra step. In MVVM queste diventano metodi del ViewModel e aggiornamenti di stato. In MVI diventano intent espliciti.

Poi costruisci a passi piccoli:

  1. Definisci gli eventi per l'intero ciclo di vita: edit, blur, submit, save success/failure, retry, restore draft.
  2. Progetta un oggetto di stato unico: valori dei campi, errori per campo, stato generale del form e “has unsaved changes”.
  3. Aggiungi la validazione: controlli leggeri durante l'editing, controlli più accurati al submit.
  4. Aggiungi regole per il salvataggio ottimistico: cosa cambia immediatamente e cosa può scatenare rollback.
  5. Aggiungi le bozze: autosave con debounce, ripristino all'apertura e mostra un piccolo indicatore “bozza ripristinata” così gli utenti si fidano di ciò che vedono.

Tratta gli errori come parte dell'esperienza. Mantieni l'input, evidenzia solo ciò che deve essere corretto e offri una singola azione successiva chiara (modificare, ritentare o mantenere la bozza).

Se vuoi prototipare stati di form complessi prima di scrivere l'UI su Android, una piattaforma no-code come AppMaster può essere utile per validare prima il workflow. Poi puoi implementare le stesse regole in MVVM o MVI con meno sorprese.

Scenario esemplare: form spese multi-step

Immagina un report spese in 4 step: dettagli (data, categoria, importo), upload ricevuta, note, poi revisione e invio. Dopo l'invio mostra uno stato di approvazione come Draft, Submitted, Rejected, Approved. Le parti più delicate sono la validazione, i salvataggi che possono fallire e mantenere una bozza quando il telefono è offline.

In MVVM normalmente tieni un FormUiState nel ViewModel (spesso un StateFlow). Ogni cambio di campo chiama una funzione del ViewModel come onAmountChanged() o onReceiptSelected(). La validazione gira al change, al cambio di step o al submit. Una struttura comune è input grezzi più errori per campo, con flag derivati che controllano se Next/Submit sono abilitati.

In MVI lo stesso flusso diventa esplicito: l'UI invia intent come AmountChanged, NextClicked, SubmitClicked e RetrySave. Un reducer restituisce un nuovo stato. Gli effetti collaterali (upload ricevuta, chiamata API, mostrare uno snackbar) girano fuori dal reducer e riportano i risultati come eventi.

In pratica, MVVM rende facile aggiungere funzioni e aggiornare rapidamente un flow. MVI rende più difficile saltare una transizione di stato per errore perché ogni cambiamento viene instradato attraverso il reducer.

Errori comuni e trappole

Passa da form a pagamento
Se il form finisce con un pagamento, aggiungi la logica Stripe nello stesso flusso.
Collega Stripe

La maggior parte dei bug nei form nasce da regole poco chiare su chi possiede la verità, quando gira la validazione e cosa succede quando arrivano risultati asincroni in ordine sbagliato.

L'errore più comune è mescolare fonti di verità. Se un campo di testo a volte legge da un widget, a volte dallo stato del ViewModel e a volte da una bozza ripristinata, otterrai reset casuali e segnalazioni “il mio input è scomparso”. Scegli una fonte canonica per la schermata e deriva tutto da essa (modello di dominio, righe di cache, payload API).

Un'altra trappola facile è confondere stato ed eventi. Un toast, una navigazione o un banner “Salvato!” è one-off. Un messaggio di errore che deve rimanere visibile finché l'utente non modifica qualcosa è stato. Mischiarli causa effetti duplicati alla rotazione o feedback mancanti.

Due problemi di correttezza emergono spesso:

  • Validare troppo ad ogni battuta, specialmente per controlli costosi. Debounce, valida al blur o valida solo i campi toccati.
  • Ignorare risultati asincroni fuori ordine. Se l'utente salva due volte o modifica dopo il salvataggio, risposte più vecchie possono sovrascrivere input più recenti a meno che tu non usi request ID (o logica “solo l'ultimo”) o versioning.

Infine, le bozze non sono “solo salva JSON”. Senza versioning, gli aggiornamenti dell'app possono rompere i ripristini. Aggiungi una semplice versione dello schema e una storia di migrazione, anche se è “drop e ricomincia” per bozze molto vecchie.

Checklist rapida prima della pubblicazione

Spedisci anche il backend
Crea endpoint API e logica di business senza scrivere boilerplate per ogni form.
Genera backend

Prima di discutere MVVM vs MVI, assicurati che il tuo form abbia una fonte di verità chiara. Se un valore può cambiare in schermata, appartiene allo stato, non a un widget della view o a un flag nascosto.

Un controllo pratico pre-release:

  • Lo stato include input, errori per campo, stato di salvataggio (idle/saving/saved/failed) e stato bozza/coda così l'UI non deve indovinare.
  • Le regole di validazione sono pure e testabili senza UI.
  • La UI ottimistica ha un percorso di rollback per i rifiuti server.
  • Gli errori non cancellano mai l'input.
  • Il ripristino delle bozze è prevedibile: o banner di auto-restore chiaro o un'azione esplicita “Ripristina bozza”.

Un test che cattura bug reali: attiva la modalità aereo a metà salvataggio, disattivala e poi fai retry due volte. Il secondo retry non dovrebbe creare un duplicato. Usa un request ID, una chiave di idempotenza o un marcatore locale “pending save” così i retry sono sicuri.

Se le risposte sono imprecise, stringi prima il modello di stato, poi scegli il pattern che rende più semplice applicare quelle regole.

Prossimi passi: scegliere la strada e sviluppare più velocemente

Inizia con una domanda: quanto è costoso se il tuo form finisce in uno stato mezzo aggiornato? Se il costo è basso, mantieni la semplicità.

MVVM è una buona scelta quando la schermata è semplice, lo stato è per lo più “campi + errori” e il team già rilascia con fiducia usando ViewModel + LiveData/StateFlow.

MVI è più adatto quando hai bisogno di transizioni di stato rigorose e prevedibili, molti eventi asincroni (autosave, retry, sync) o quando i bug sono costosi (pagamenti, compliance, workflow critici).

Qualunque strada tu scelga, i test con maggiore ritorno non toccano quasi mai l'UI: casi limite di validazione, transizioni di stato (edit, submit, success, failure, retry), rollback ottimistico del salvataggio e ripristino bozze più comportamento di conflitto.

Se hai anche bisogno del backend, pannelli admin e API insieme alla mobile app, AppMaster (appmaster.io) può generare backend, web e app native di produzione da un unico modello, il che aiuta a mantenere regole di validazione e workflow coerenti su tutte le superfici.

FAQ

Quando dovrei scegliere MVVM vs MVI per una schermata Android con molti form?

Scegli MVVM quando il flusso del form è per lo più lineare e il team ha già convenzioni consolidate per StateFlow/LiveData, eventi one-off e cancellazione. Scegli MVI quando prevedi molti lavori asincroni sovrapposti (salvataggi automatici, retry, upload) e vuoi regole più rigorose per evitare che lo stato venga modificato da più punti in modo incontrollato.

Qual è il modo più semplice per evitare che lo stato di un form si disallinei?

Inizia con un singolo oggetto di stato per la schermata (ad esempio, FormState) che contenga i valori grezzi dei campi, gli errori per campo, un errore a livello di form e stati chiari come Saving o Failed. Mantieni flag derivati come isValid e canSubmit calcolati in un unico punto così che l'interfaccia si limiti a renderizzare e non a rideterminare la logica.

Con quale frequenza dovrebbe essere eseguita la validazione in un form: ad ogni battuta o solo al submit?

Esegui controlli leggeri e veloci mentre l'utente scrive (campi obbligatori, intervalli, formato base) e lancia controlli più rigorosi al submit. Tieni la logica di validazione fuori dall'interfaccia in modo che sia testabile e memorizza gli errori nello stato così sopravvivano a rotazioni e riavvii del processo.

Come gestisco la validazione asincrona tipo “email già usata” senza risultati obsoleti?

Tratta la validazione asincrona come “vince l'input più recente”. Memorizza il valore che hai controllato (o un id/versione della richiesta) e ignora i risultati che non corrispondono allo stato corrente. Questo evita che risposte vecchie sovrascrivano digitazioni più recenti, che è una causa comune di messaggi di errore “random”.

Qual è l'approccio predefinito sicuro per una UI ottimistica quando salvo un form?

Aggiorna immediatamente l'interfaccia per riflettere l'azione (ad esempio mostra Saving… e mantieni l'input visibile), ma prevedi sempre una strada di rollback se il server rifiuta il salvataggio. Usa un request id/versione, disabilita o debounce il pulsante Salva e definisci cosa significano le modifiche durante il salvataggio (bloccare i campi, mettere in coda un altro salvataggio o segnare il form come dirty).

Come strutturo gli stati di errore in modo che l'utente possa recuperare senza riscrivere tutto?

Non cancellare mai l'input dell'utente in caso di errore. Metti i problemi specifici del campo inline sul campo interessato, conserva gli errori a livello di form vicino al pulsante di submit e rendi i guasti di rete recuperabili con un retry che reinvia lo stesso payload a meno che l'utente non abbia cambiato qualcosa.

Dove dovrebbero vivere eventi one-time come snackbars e navigazione?

Tieni gli effetti one-shot fuori dallo stato persistente. In MVVM, inviali tramite uno stream separato (per esempio un SharedFlow), e in MVI modellali come Effects che l'interfaccia consuma una volta. Questo evita snackbars duplicate o navigazioni ripetute dopo una rotazione o una ri-sottoscrizione.

Cosa dovrei salvare esattamente per le bozze offline di un form?

Persisti principalmente l'input grezzo dell'utente (come digitato), più metadati minimi per ripristinare e fondere in sicurezza in seguito, come un marcatore della versione server. Ricalcola la validazione al ripristino invece di persisterla, e aggiungi una semplice versione dello schema così da poter gestire aggiornamenti dell'app senza rompere i ripristini.

Come deve essere temporizzato l'autosave per sembrare affidabile ma non rumoroso?

Usa un breve debounce (qualche centinaio di millisecondi) più salvataggi sui cambi di step o quando l'utente manda l'app in background. Salvare ad ogni battuta è rumoroso e può creare contesa; salvare solo all'uscita rischia di perdere lavoro in caso di kill del processo o interruzioni.

Come gestisco i conflitti di bozza quando i dati server sono cambiati mentre l'utente era offline?

Conserva un marcatore di versione (per esempio updatedAt, un ETag o un contatore locale) sia per lo snapshot server sia per la bozza. Se la versione server non è cambiata, applica la bozza e submit; se è cambiata, mostra una scelta chiara: mantiene la mia bozza o ricarica i dati server, invece di sovrascrivere silenziosamente una delle due parti.

Facile da avviare
Creare qualcosa di straordinario

Sperimenta con AppMaster con un piano gratuito.
Quando sarai pronto potrai scegliere l'abbonamento appropriato.

Iniziare