31 mai 2025·8 min de lecture

Kotlin MVI vs MVVM pour applications Android axées sur les formulaires : états UI

Kotlin MVI vs MVVM pour applications Android axées sur les formulaires, expliqué avec des approches pratiques pour modéliser validation, UI optimiste, états d'erreur et brouillons hors ligne.

Kotlin MVI vs MVVM pour applications Android axées sur les formulaires : états UI

Pourquoi les applications Android axées sur les formulaires deviennent vite chaotiques

Les applications lourdes en formulaires paraissent lentes ou fragiles parce que les utilisateurs attendent constamment de petites décisions que votre code doit prendre : ce champ est-il valide, l'enregistrement a-t-il réussi, doit-on afficher une erreur, que se passe-t-il si le réseau lâche ?

Les formulaires exposent aussi les bogues d'état en premier, car ils mélangent plusieurs types d'état à la fois : état de l'UI (ce qui est visible), état des entrées (ce que l'utilisateur a tapé), état serveur (ce qui est sauvegardé) et état temporaire (ce qui est en cours). Quand ces éléments se désynchronisent, l'application commence à paraître « aléatoire » : des boutons se désactivent au mauvais moment, d'anciennes erreurs persistent ou l'écran se réinitialise après une rotation.

La plupart des problèmes se concentrent dans quatre domaines : la validation (surtout les règles entre champs), l'UI optimiste (retour rapide pendant que le travail est en cours), la gestion des erreurs (échecs clairs et récupérables) et les brouillons hors ligne (ne pas perdre le travail inachevé).

Une bonne UX de formulaire suit quelques règles simples :

  • La validation doit aider et être proche du champ. Ne bloquez pas la saisie. Soyez strict quand c'est important, généralement à la soumission.
  • L'UI optimiste doit refléter l'action de l'utilisateur immédiatement, mais elle doit aussi prévoir un rollback propre si le serveur la rejette.
  • Les erreurs doivent être spécifiques, actionnables et ne jamais effacer la saisie de l'utilisateur.
  • Les brouillons doivent survivre aux redémarrages, interruptions et connexions instables.

C'est pour ça que les débats d'architecture deviennent intenses pour les formulaires. Le pattern que vous choisissez détermine à quel point ces états restent prévisibles sous pression.

Rappel rapide : MVVM et MVI en termes simples

La vraie différence entre MVVM et MVI est la façon dont le changement circule dans un écran.

MVVM (Model View ViewModel) ressemble généralement à ceci : le ViewModel contient les données de l'écran, les expose à l'UI (souvent via StateFlow ou LiveData) et fournit des méthodes comme save, validate ou load. L'UI appelle des fonctions du ViewModel quand l'utilisateur interagit.

MVI (Model View Intent) ressemble généralement à ceci : l'UI envoie des événements (intents), un reducer les traite, et l'écran se rend à partir d'un objet d'état unique qui représente tout ce dont l'UI a besoin maintenant. Les effets secondaires (réseau, base de données) sont déclenchés de manière contrôlée et reportent leurs résultats sous forme d'événements.

Une manière simple de retenir la mentalité :

  • MVVM se demande « Quelles données le ViewModel doit-il exposer et quelles méthodes doit-il offrir ? »
  • MVI se demande « Quels événements peuvent survenir et comment transforment-ils un état en un autre ? »

Les deux patterns fonctionnent très bien pour des écrans simples. Dès que vous ajoutez de la validation entre champs, de l'autosave, des retries et des brouillons hors ligne, vous avez besoin de règles plus strictes sur qui peut changer l'état et quand. MVI impose ces règles par défaut. MVVM peut toujours bien marcher, mais il exige de la discipline : chemins de mise à jour cohérents et gestion soignée des événements ponctuels (toasts, navigation).

Comment modéliser l'état d'un formulaire sans surprises

La manière la plus rapide de perdre le contrôle est de laisser les données du formulaire vivre à trop d'endroits : bindings de vue, multiple flows, et « encore un booléen ». Les écrans lourds en formulaires restent prévisibles quand il y a une seule source de vérité.

Une forme pratique pour FormState

Visez un seul FormState qui contient les entrées brutes plus quelques flags dérivés sur lesquels on peut compter. Gardez-le ennuyeux et complet, même si ça paraît un peu volumineux.

data class FormState(
  val fields: Fields,
  val fieldErrors: Map<FieldId, String> = 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 }

Cela sépare la validation au niveau du champ (par entrée) des problèmes de niveau formulaire (comme « total doit être > 0 »). Les flags dérivés tels que isDirty et isValid doivent être calculés en un seul endroit, pas réimplémentés dans l'UI.

Un modèle mental propre : fields (ce que l'utilisateur a tapé), validation (ce qui ne va pas), status (ce que l'app fait), dirtiness (ce qui a changé depuis le dernier enregistrement) et drafts (s'il existe une copie hors ligne).

Où placer les effets ponctuels

Les formulaires déclenchent aussi des événements ponctuels : snackbars, navigation, bannières « enregistré ». Ne mettez pas ces éléments dans FormState, sinon ils se déclencheront à nouveau lors d'une rotation ou quand l'UI se réabonne.

En MVVM, émettez les effets via un canal séparé (par exemple un SharedFlow). En MVI, modélisez-les comme des Effects (ou Events) que l'UI consomme une seule fois. Cette séparation évite les « erreurs fantômes » et les messages de succès dupliqués.

Flux de validation en MVVM vs MVI

La validation est l'endroit où les écrans de formulaire deviennent fragiles. Le choix clé est : où résident les règles et comment leurs résultats reviennent-ils à l'UI.

Les règles simples et synchrones (champs obligatoires, longueur min, plages de nombres) doivent s'exécuter dans le ViewModel ou la couche domaine, pas dans l'UI. Cela rend les règles testables et cohérentes.

Les règles asynchrones (comme « cet email est-il déjà pris ? ») sont plus délicates. Il faut gérer le chargement, les résultats obsolètes et le cas « l'utilisateur a tapé à nouveau ».

En MVVM, la validation devient souvent un mélange d'état et de méthodes d'aide : l'UI envoie des changements (mises à jour de texte, pertes de focus, clics de soumission) au ViewModel ; le ViewModel met à jour un StateFlow/LiveData et expose des erreurs par champ et un canSubmit dérivé. Les vérifications asynchrones démarrent généralement un job, mettent à jour un flag de chargement puis une erreur à la fin.

En MVI, la validation tend à être plus explicite. Une division pratique des responsabilités :

  • Le reducer exécute la validation synchrone et met à jour les erreurs de champ immédiatement.
  • Un effect exécute la validation asynchrone et dispatch un intent de résultat.
  • Le reducer applique ce résultat seulement s'il correspond toujours à l'entrée la plus récente.

Cette dernière étape est importante. Si l'utilisateur retape un nouvel email pendant que la vérification « email unique » est en cours, les anciens résultats ne doivent pas écraser l'entrée actuelle. MVI facilite souvent ce comportement parce que vous pouvez stocker la dernière valeur vérifiée dans l'état et ignorer les réponses obsolètes.

UI optimiste et sauvegardes asynchrones

Expédier le backend avec
Créez des endpoints API et de la logique métier sans écrire manuellement le boilerplate pour chaque formulaire.
Générer le backend

L'UI optimiste signifie que l'écran se comporte comme si la sauvegarde avait réussi avant la réponse réseau. Dans un formulaire, cela signifie souvent que le bouton Enregistrer passe en « Saving... », un petit indicateur « Enregistré » apparaît quand c'est fait, et les champs restent utilisables (ou sont volontairement verrouillés) tant que la requête est en cours.

En MVVM, on implémente cela en basculant des flags comme isSaving, lastSavedAt et saveError. Le risque est la dérive : des sauvegardes qui se chevauchent peuvent laisser ces flags incohérents. En MVI, un reducer met à jour un seul objet d'état, donc « Saving » et « Disabled » sont moins susceptibles de se contredire.

Pour éviter les doubles soumissions et les conditions de course, traitez chaque sauvegarde comme un événement identifié. Si l'utilisateur appuie deux fois sur Save ou édite pendant une sauvegarde, vous avez besoin d'une règle sur quelle réponse prime. Quelques garde-fous fonctionnent pour les deux patterns : désactiver Save pendant l'enregistrement (ou débouncer les taps), attacher un requestId (ou version) à chaque sauvegarde et ignorer les réponses obsolètes, annuler le travail en cours quand l'utilisateur quitte, et définir ce que signifient les modifications pendant l'enregistrement (mettre en file une autre sauvegarde, ou marquer le formulaire comme dirty à nouveau).

Le succès partiel est aussi courant : le serveur accepte certains champs et en rejette d'autres. Modélisez cela explicitement. Conservez des erreurs par champ (et, si nécessaire, un statut de sync par champ) pour pouvoir afficher « Enregistré » globalement tout en mettant en évidence un champ qui nécessite une attention.

États d'erreur dont l'utilisateur peut se remettre

Informer automatiquement les utilisateurs
Envoyez confirmations et alertes par email, SMS ou Telegram depuis la logique de votre processus.
Ajouter la messagerie

Les écrans de formulaire échouent de plus de façons que « quelque chose s'est mal passé ». Si chaque échec devient un toast générique, les utilisateurs retapent des données, perdent confiance et abandonnent. L'objectif est toujours le même : garder la saisie en sécurité, indiquer une correction claire et rendre le retry naturel.

Il est utile de séparer les erreurs selon leur lieu d'appartenance. Un format d'email incorrect n'est pas la même chose qu'une panne serveur.

Les erreurs par champ doivent être inline et liées à une entrée. Les erreurs au niveau du formulaire doivent être proches de l'action de soumission et expliquer ce qui bloque l'envoi. Les erreurs réseau doivent proposer un retry et garder le formulaire éditable. Les erreurs d'autorisation doivent guider l'utilisateur pour se réauthentifier tout en préservant un brouillon.

Règle de récupération centrale : ne jamais effacer la saisie utilisateur en cas d'échec. Si la sauvegarde échoue, conservez les valeurs courantes en mémoire et sur disque. Le retry doit renvoyer la même payload à moins que l'utilisateur ne modifie quelque chose.

Là où les patterns diffèrent est la façon dont les erreurs serveur sont mappées vers l'état UI. En MVVM, il est facile de mettre à jour plusieurs flows ou champs et de créer accidentellement des incohérences. En MVI, vous appliquez généralement la réponse serveur en une seule étape de reducer qui met à jour fieldErrors et formError ensemble.

Décidez aussi ce qui est état vs un effet ponctuel. Les erreurs inline et le « échec de soumission » appartiennent à l'état (elles doivent survivre à une rotation). Les actions ponctuelles comme un snackbar, une vibration ou une navigation doivent être des effets.

Brouillons hors ligne et restauration des formulaires en cours

Une application lourde en formulaires se sent « hors ligne » même quand le réseau va bien. Les utilisateurs changent d'app, l'OS peut tuer votre processus, ou ils perdent le signal en cours de route. Les brouillons les empêchent de tout recommencer.

D'abord, définissez ce qu'un brouillon signifie. Sauvegarder uniquement le modèle « propre » n'est souvent pas suffisant. Vous voulez généralement restaurer l'écran exactement comme il était, y compris les champs à moitié tapés.

Ce qu'il faut persister, c'est surtout la saisie brute de l'utilisateur (chaînes telles qu'elles sont tapées, IDs sélectionnés, URI de pièces jointes), plus assez de métadonnées pour merger en toute sécurité plus tard : un snapshot serveur connu et un marqueur de version (updatedAt, ETag, ou un simple incrément). La validation peut être recomputée à la restauration.

Le choix du stockage dépend de la sensibilité et de la taille. Les petits brouillons peuvent vivre dans les préférences, mais les formulaires multi-étapes et les pièces jointes sont plus sûrs dans une base locale. Si le brouillon contient des données personnelles, utilisez un stockage chiffré.

La grande question d'architecture est : où se trouve la source de vérité. En MVVM, les équipes persistèrent souvent depuis le ViewModel à chaque changement de champ. En MVI, persister après chaque mise à jour du reducer peut être plus simple car vous sauvez un état cohérent (ou un objet Draft dérivé).

Le timing de l'autosave compte. Sauver à chaque frappe est bruyant ; un petit debounce (par exemple 300 à 800 ms) plus une sauvegarde lors du changement d'étape fonctionne bien.

Quand l'utilisateur revient en ligne, vous avez besoin de règles de fusion. Une approche pratique : si la version serveur est inchangée, appliquez le brouillon et soumettez. Si elle a changé, affichez un choix clair : conserver mon brouillon ou recharger les données serveur.

Étapes pas à pas : implémenter un formulaire fiable avec l'un ou l'autre pattern

Soutenir les formulaires avec des outils admin
Créez des outils internes pour revoir les soumissions, corriger les erreurs et relancer les actions.
Construire un panneau admin

Les formulaires fiables commencent par des règles claires, pas par du code UI. Chaque action utilisateur doit mener à un état prévisible, et chaque résultat asynchrone doit avoir un lieu évident où atterrir.

Écrivez les actions auxquelles votre écran doit réagir : saisie, perte de focus, soumission, retry et navigation d'étape. En MVVM, cela devient des méthodes ViewModel et des mises à jour d'état. En MVI, ce sont des intents explicites.

Ensuite, construisez par petites itérations :

  1. Définissez les événements pour le cycle de vie complet : edit, blur, submit, save success/failure, retry, restore draft.
  2. Concevez un objet d'état unique : valeurs des champs, erreurs par champ, statut global du formulaire et « has unsaved changes ».
  3. Ajoutez la validation : contrôles légers pendant la saisie, contrôles plus lourds à la soumission.
  4. Ajoutez les règles d'optimistic save : ce qui change immédiatement et ce qui déclenche un rollback.
  5. Ajoutez les brouillons : autosave avec debounce, restauration à l'ouverture, et une petite indication « brouillon restauré » pour que l'utilisateur fasse confiance à ce qu'il voit.

Considérez les erreurs comme faisant partie de l'expérience. Conservez la saisie, mettez en évidence uniquement ce qui doit être corrigé, et offrez une action suivante claire (éditer, retry ou conserver le brouillon).

Si vous voulez prototyper des états de formulaire complexes avant d'écrire une UI Android, une plateforme no-code comme AppMaster peut être utile pour valider d'abord le workflow. Ensuite, vous pouvez implémenter les mêmes règles en MVVM ou MVI avec moins de surprises.

Scénario d'exemple : formulaire de note de frais en plusieurs étapes

Imaginez un rapport de dépenses en 4 étapes : détails (date, catégorie, montant), upload de reçu, notes, puis revue et soumission. Après la soumission, on affiche un statut d'approbation comme Draft, Submitted, Rejected, Approved. Les points délicats sont la validation, les sauvegardes qui peuvent échouer et la conservation d'un brouillon lorsque le téléphone est hors ligne.

En MVVM, on garde typiquement un FormUiState dans le ViewModel (souvent un StateFlow). Chaque changement de champ appelle une fonction ViewModel comme onAmountChanged() ou onReceiptSelected(). La validation s'exécute au changement, lors de la navigation d'étape ou à la soumission. Structure courante : entrées brutes plus erreurs par champ, avec des flags dérivés contrôlant si Next/Submit est activé.

En MVI, le même flux devient explicite : l'UI envoie des intents tels que AmountChanged, NextClicked, SubmitClicked, et RetrySave. Un reducer renvoie un nouvel état. Les side effects (upload du reçu, appel API, affichage d'un snackbar) s'exécutent hors du reducer et renvoient des résultats sous forme d'événements.

En pratique, MVVM rend facile l'ajout rapide de fonctions et la mise à jour d'un flow. MVI rend plus difficile de rater une transition d'état accidentelle parce que chaque changement passe par le reducer.

Erreurs et pièges courants

Gérer la connexion sans détours
Utilisez des modules d'authentification intégrés pour que les écrans de formulaire restent focalisés sur la tâche utilisateur.
Ajouter l'authentification

La plupart des bogues de formulaire viennent d'un manque de règles claires sur qui possède la vérité, quand la validation s'exécute et ce qui arrive lorsque les résultats asynchrones arrivent en retard.

L'erreur la plus courante est de mélanger des sources de vérité. Si un champ texte lit parfois depuis un widget, parfois depuis le ViewModel, et parfois depuis un brouillon restauré, vous obtiendrez des réinitialisations aléatoires et des rapports « ma saisie a disparu ». Choisissez un état canonique pour l'écran et dérivez tout le reste à partir de celui-ci (modèle domaine, lignes de cache, payload API).

Un autre piège facile est de confondre état et événements. Un toast, une navigation ou une bannière « Enregistré ! » est ponctuel. Un message d'erreur qui doit rester visible jusqu'à ce que l'utilisateur édite est de l'état. Les mélanger cause des effets dupliqués après rotation ou un retour manquant d'information.

Deux problèmes de correction apparaissent souvent :

  • Sur-valider à chaque frappe, surtout pour des contrôles coûteux. Débouncez, validez au blur, ou validez uniquement les champs touchés.
  • Ignorer les résultats asynchrones hors ordre. Si l'utilisateur sauvegarde deux fois ou édite après une sauvegarde, d'anciennes réponses peuvent écraser une entrée plus récente à moins d'utiliser des request IDs (ou une logique « latest only »).

Enfin, les brouillons ne sont pas « juste sauvegarder du JSON ». Sans versionnage, les mises à jour de l'app peuvent casser les restaurations. Ajoutez une version de schéma simple et une stratégie de migration, même si c'est « supprimer et recommencer » pour des brouillons très anciens.

Liste de vérification rapide avant de publier

Construire autour d'un seul modèle
Concevez une source unique de vérité avec Data Designer et gardez l'état de l'application cohérent.
Créer un projet

Avant de discuter MVVM vs MVI, assurez-vous que votre formulaire a une source de vérité claire. Si une valeur peut changer à l'écran, elle appartient à l'état, pas au widget de vue ni à un flag caché.

Un contrôle pratique avant publication :

  • L'état inclut les entrées, les erreurs par champ, le statut d'enregistrement (idle/saving/saved/failed) et le statut draft/queue pour que l'UI n'ait jamais à deviner.
  • Les règles de validation sont pures et testables sans UI.
  • L'UI optimiste a un chemin de rollback en cas de rejet serveur.
  • Les erreurs n'effacent jamais la saisie utilisateur.
  • La restauration des brouillons est prévisible : soit une restauration automatique claire, soit une action explicite « Restaurer le brouillon ».

Un test qui attrape de vrais bogues : activez le mode avion au milieu d'une sauvegarde, désactivez-le, puis réessayez deux fois. Le second retry ne doit pas créer un doublon. Utilisez un request ID, une clé d'idempotence ou un marqueur local « save en attente » pour rendre les retries sûrs.

Si vos réponses sont floues, resserrez d'abord le modèle d'état, puis choisissez le pattern qui rend ces règles les plus faciles à appliquer.

Étapes suivantes : choisir une voie et accélérer le développement

Commencez par une question : combien coûte un état « moitié mis à jour » ? Si le coût est faible, gardez la solution simple.

MVVM convient bien quand l'écran est simple, l'état est principalement « champs + erreurs » et votre équipe maîtrise déjà ViewModel + LiveData/StateFlow.

MVI est un meilleur choix quand vous avez besoin de transitions d'état strictes et prévisibles, beaucoup d'événements asynchrones (autosave, retries, sync), ou quand les bogues coûtent cher (paiements, conformité, workflows critiques).

Quel que soit le choix, les tests à fort retour sur investissement pour les formulaires ne touchent souvent pas l'UI : cas limites de validation, transitions d'état (edit, submit, success, failure, retry), rollback d'optimistic save et restauration de brouillon + comportement de conflit.

Si vous avez aussi besoin du backend, d'écrans admin et d'APIs avec votre mobile, AppMaster (appmaster.io) peut générer un backend, des sites web et des apps mobiles natives prêts pour la production à partir d'un même modèle, ce qui aide à garder les règles de validation et de workflow cohérentes entre les surfaces.

FAQ

When should I choose MVVM vs MVI for a form-heavy Android screen?

Choisissez MVVM lorsque votre flux de formulaires est majoritairement linéaire et que votre équipe possède déjà des conventions solides pour StateFlow/LiveData, la gestion des effets ponctuels et l'annulation. Choisissez MVI lorsque vous attendez de nombreux travaux asynchrones qui se chevauchent (autosave, retries, uploads) et que vous souhaitez des règles plus strictes pour empêcher les changements d'état provenant de multiples endroits.

What’s the simplest way to keep form state from drifting out of sync?

Commencez par un objet d'état unique pour l'écran (par exemple FormState) qui contient les valeurs brutes des champs, les erreurs par champ, une erreur globale de formulaire et des statuts clairs comme Saving ou Failed. Gardez des indicateurs dérivés comme isValid et canSubmit calculés en un seul endroit pour que l'UI se contente d'afficher l'état et ne réimplémente pas la logique.

How often should validation run in a form: on every keystroke or only on submit?

Effectuez des vérifications légères et peu coûteuses pendant la saisie (champs obligatoires, formats de base), et lancez des contrôles stricts au moment de la soumission. Placez la logique de validation hors de l'UI afin qu'elle soit testable, et stockez les erreurs dans l'état pour qu'elles survivent à la rotation ou à la restauration après suppression du processus.

How do I handle async validation like “email already taken” without stale results?

Considérez la validation asynchrone comme « la saisie la plus récente gagne ». Conservez la valeur qui a été vérifiée (ou un identifiant/version de requête) et ignorez les résultats qui ne correspondent pas à l'état actuel. Cela empêche des réponses obsolètes d'écraser une saisie plus récente, source courante de messages d'erreur “aléatoires”.

What’s a safe default approach for optimistic UI when saving a form?

Mettez à jour l'interface immédiatement pour refléter l'action (par exemple afficher Saving… tout en laissant les champs visibles), mais prévoyez toujours un chemin de rollback si le serveur rejette la sauvegarde. Utilisez un identifiant de requête/version, désactivez ou débouncez le bouton Enregistrer, et définissez clairement ce que signifient les modifications pendant l'enregistrement (verrouiller les champs, mettre en file un nouvel enregistrement, ou marquer comme dirty).

How should I structure error states so users can recover without retyping?

Ne supprimez jamais la saisie utilisateur en cas d'échec. Affichez les problèmes spécifiques aux champs inline à côté des champs concernés, placez les erreurs globales près du bouton de soumission et rendez les pannes réseau récupérables via un retry qui renvoie la même payload, sauf si l'utilisateur modifie quelque chose entre-temps.

Where should one-time events like snackbars and navigation live?

Ne mettez pas les événements ponctuels dans votre état persistant. En MVVM, envoyez-les sur un flux séparé (par exemple un SharedFlow) ; en MVI, modélisez-les comme des Effects que l'UI consomme une seule fois. Cela évite les snackbars dupliquées ou les navigations répétées après une rotation.

What exactly should I save for offline drafts of a form?

Persistez surtout les saisies brutes de l'utilisateur (telles qu'elles sont tapées), plus les métadonnées minimales nécessaires pour restaurer et fusionner en toute sécurité plus tard, comme un marqueur de version serveur connu. Recalculez la validation au moment de la restauration plutôt que de la persister, et ajoutez une version de schéma simple pour gérer les mises à jour de l'application sans casser les restaurations.

How should autosave be timed so it feels reliable but not noisy?

Utilisez un court délai de debounce (quelques centaines de millisecondes) et enregistrez aussi lors des changements d'étape ou lorsque l'utilisateur met l'app en arrière-plan. Sauvegarder à chaque frappe génère trop d'activité et peut créer des conflits, tandis que ne sauvegarder qu'à la sortie risque de perdre du travail si le processus est tué.

How do I handle draft conflicts when the server data changed while the user was offline?

Conservez un marqueur de version (comme updatedAt, un ETag ou un incrément local) pour l'instantané serveur et pour le brouillon. Si la version serveur n'a pas changé, appliquez le brouillon et soumettez ; si elle a changé, affichez un choix clair : conserver le brouillon ou recharger les données serveur, au lieu d'écraser silencieusement l'un ou l'autre.

Facile à démarrer
Créer quelque chose d'incroyable

Expérimentez avec AppMaster avec un plan gratuit.
Lorsque vous serez prêt, vous pourrez choisir l'abonnement approprié.

Démarrer