Kotlin MVI vs MVVM dla aplikacji Android z wieloma formularzami: stany UI
Kotlin MVI kontra MVVM dla aplikacji Android z wieloma formularzami — praktyczne sposoby modelowania walidacji, optymistycznego UI, obsługi błędów i draftów offline.

Dlaczego aplikacje z dużą liczbą formularzy szybko się komplikują
Aplikacje z wieloma formularzami wydają się wolne albo kruche, ponieważ użytkownicy ciągle czekają na drobne decyzje, które musi podjąć twój kod: czy pole jest poprawne, czy zapis się powiódł, czy pokazać błąd i co się stanie, gdy sieć padnie.
Formularze też szybko ujawniają błędy stanu, bo mieszają kilka rodzajów stanu naraz: stan UI (co jest widoczne), stan wejścia (co użytkownik wpisał), stan serwera (co jest zapisane) i stan tymczasowy (co jest w toku). Gdy te elementy się rozjadą, aplikacja zaczyna wydawać się „losowa”: przyciski wyłączają się w złym momencie, stare błędy zostają, albo ekran resetuje się po rotacji.
Większość problemów skupia się w czterech obszarach: walidacja (zwłaszcza reguły między polami), optymistyczne UI (szybki feedback podczas działania), obsługa błędów (czytelne, możliwe do naprawienia) oraz drafty offline (nie zgubić niedokończonej pracy).
Dobra ergonomia formularzy przestrzega kilku prostych zasad:
- Walidacja powinna pomagać i być blisko pola. Nie blokuj wpisywania. Bądź surowy tam, gdzie to ważne — zwykle przy submit.
- Optymistyczne UI powinno odzwierciedlać akcję użytkownika natychmiast, ale musi też mieć czysty rollback, gdy serwer odrzuci operację.
- Błędy powinny być specyficzne, dawać wskazówkę co robić i nigdy nie usuwać danych użytkownika.
- Drafty powinny przetrwać restarty, przerwy i słabe połączenia.
Dlatego debaty architektoniczne przy formularzach bywają ostre. Wzorzec, który wybierzesz, decyduje o tym, jak przewidywalne będą te stany pod presją.
Krótkie przypomnienie: MVVM i MVI prostym językiem
Rzeczywista różnica między MVVM a MVI to sposób, w jaki zmiana przepływa przez ekran.
MVVM (Model View ViewModel) zwykle wygląda tak: ViewModel trzyma dane ekranu, udostępnia je UI (często przez StateFlow lub LiveData) i oferuje metody takie jak save, validate czy load. UI wywołuje funkcje ViewModelu, gdy użytkownik wchodzi w interakcję.
MVI (Model View Intent) zwykle wygląda tak: UI wysyła zdarzenia (intenty), reducer je przetwarza, a ekran renderuje się z jednego obiektu stanu, który reprezentuje wszystko, czego UI potrzebuje w danym momencie. Efekty uboczne (sieć, baza) są wyzwalane w kontrolowany sposób i raportują wyniki z powrotem jako zdarzenia.
Prosty sposób zapamiętania mentalności:
- MVVM pyta: „Jakie dane powinien udostępniać ViewModel i jakie metody ma oferować?”
- MVI pyta: „Jakie zdarzenia mogą zajść i jak przekształcają one jeden stan w kolejny?”
Oba wzorce działają dobrze przy prostych ekranach. Gdy dodasz walidację między polami, autosave, ponowienia i drafty offline, potrzebujesz surowszych reguł dotyczących tego, kto i kiedy może zmieniać stan. MVI wymusza te reguły domyślnie. MVVM nadal może działać dobrze, ale wymaga dyscypliny: spójnych ścieżek aktualizacji i ostrożnej obsługi jednorazowych zdarzeń (snackbary, nawigacja).
Jak modelować stan formularza bez niespodzianek
Najszybszy sposób, by stracić kontrolę, to pozwolić, aby dane formularza żyły w zbyt wielu miejscach: powiązania widoku, wiele flow, i „jeszcze jeden” boolean. Ekrany z dużą liczbą formularzy są przewidywalne, gdy mają jedno źródło prawdy.
Praktyczny kształt FormState
Dąż do jednego FormState, który trzyma surowe inputy plus kilka pochodnych flag, którym można zaufać. Trzymaj go nudnym i pełnym, nawet jeśli będzie trochę większy.
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 }
To oddziela walidację per-pole od problemów na poziomie formularza (np. „suma musi być > 0”). Flagi pochodne takie jak isDirty i isValid powinny być liczone w jednym miejscu, a nie ponownie implementowane w UI.
Czysty model mentalny: pola (co użytkownik wpisał), walidacja (co jest nie tak), status (co aplikacja robi), dirty (co zmieniło się od ostatniego zapisu) i drafty (czy istnieje kopia offline).
Gdzie trzymać jednorazowe efekty
Formularze też wyzwalają jednorazowe zdarzenia: snackbary, nawigację, banery „zapisano”. Nie wkładaj ich do FormState, bo będą się ponownie pojawiać po rotacji lub ponownym subskrybowaniu.
W MVVM emituj efekty przez oddzielny kanał (np. SharedFlow). W MVI modeluj je jako Effects (lub Events), które UI konsumuje raz. To rozdzielenie zapobiega „phantomowym” błędom i duplikowanym komunikatom o sukcesie.
Przepływ walidacji w MVVM kontra MVI
Walidacja to miejsce, gdzie ekrany formularzy zaczynają być kruche. Kluczowy wybór to: gdzie reguły żyją i jak wyniki wracają do UI.
Proste, synchroniczne reguły (pola wymagane, min długość, zakresy liczb) powinny działać w ViewModelu lub warstwie domenowej, nie w UI. Dzięki temu reguły są testowalne i spójne.
Asynchroniczne reguły (np. „czy ten email już jest zajęty?”) są trudniejsze. Musisz obsłużyć ładowanie, przestarzałe wyniki i przypadek „użytkownik znów pisał”.
W MVVM walidacja często staje się mieszanką stanu i helperów: UI wysyła zmiany (aktualizacje tekstu, utratę focusu, kliknięcia submit) do ViewModelu; ViewModel aktualizuje StateFlow/LiveData i udostępnia błędy per-pole oraz pochodny canSubmit. Sprawdzania asynchroniczne zwykle startują zadanie, potem aktualizują flagę ładowania i błąd po zakończeniu.
W MVI walidacja jest zwykle bardziej eksplicytna. Praktyczny podział obowiązków:
- Reducer wykonuje synchronizacyjne walidacje i od razu aktualizuje błędy pól.
- Efekt uruchamia asynchroniczną walidację i wysyła wynik jako intent.
- Reducer stosuje ten wynik tylko wtedy, gdy nadal pasuje do najnowszego inputu.
Ten ostatni krok ma znaczenie. Jeśli użytkownik wpisze nowy email podczas gdy „sprawdzanie unikalności” trwa, stare wyniki nie powinny nadpisać aktualnego inputu. MVI ułatwia zakodowanie tego, bo możesz przechować ostatnio sprawdzaną wartość w stanie i ignorować przestarzałe odpowiedzi.
Optymistyczne UI i zapisy asynchroniczne
Optymistyczne UI oznacza, że ekran zachowuje się tak, jakby zapis się powiódł, zanim przyjdzie odpowiedź z sieci. W formularzu często znaczy to, że przycisk Zapis zmienia się na „Saving...”, pojawia się mały wskaźnik „Saved”, a pola pozostają używalne (albo celowo zablokowane) podczas trwania żądania.
W MVVM zwykle implementuje się to przez przełączanie flag typu isSaving, lastSavedAt i saveError. Ryzyko to drift: nakładające się zapisy mogą pozostawić te flagi niespójne. W MVI reducer aktualizuje jeden obiekt stanu, więc „Saving” i „Disabled” rzadziej będą ze sobą kolidować.
Aby uniknąć podwójnego submitu i warunków wyścigu, traktuj każdy zapis jako zidentyfikowane zdarzenie. Jeśli użytkownik kliknie Zapis dwa razy lub edytuje w trakcie zapisu, potrzebujesz reguły, która odpowiedź ma pierwszeństwo. Kilka zabezpieczeń działa w obu wzorcach: dezaktywuj Zapis podczas zapisu (lub debounce'uj kliknięcia), dołącz requestId (lub wersję) do każdego zapisu i ignoruj przestarzałe odpowiedzi, anuluj trwającą pracę przy opuszczeniu ekranu i zdefiniuj, co oznaczają edycje podczas zapisu (odkładać kolejny zapis, czy oznaczać formularz jako ponownie zmieniony).
Często zdarza się częściowy sukces: serwer zaakceptuje niektóre pola, a odrzuci inne. Modeluj to wprost. Trzymaj błędy per-pole (i opcjonalnie status synchronizacji per-pole), by móc pokazać „Zapisano” ogólnie, a jednocześnie wyróżnić pole, które wymaga uwagi.
Stany błędów, z których użytkownik może się odzyskać
Ekrany formularzy zawodzą na wiele sposobów, nie tylko „coś poszło nie tak”. Jeśli każdy błąd stanie się ogólnym toastem, użytkownicy będą przepisywać dane, stracą zaufanie i porzucą proces. Cel jest zawsze taki sam: zabezpieczyć dane wejściowe, pokazać jasne rozwiązanie i uczynić retry normalnym.
Pomaga rozdzielić błędy według miejsca ich występowania. Błędny format e-maila to nie to samo co awaria serwera.
Błędy pól powinny być inline i związane z konkretnym inputem. Błędy na poziomie formularza powinny leżeć blisko akcji submit i wyjaśniać, co blokuje wysłanie. Błędy sieciowe powinny oferować retry i zostawić formularz edytowalny. Błędy uprawnień czy auth mają poprowadzić użytkownika do ponownego logowania, zachowując draft.
Podstawowa zasada odzyskiwania: nigdy nie usuwaj danych użytkownika przy błędzie. Jeśli zapis się nie powiedzie, zachowaj bieżące wartości w pamięci i na dysku. Retry powinien wysłać ten sam payload, chyba że użytkownik coś zmieni.
Gdzie wzorce się różnią, to mapowanie błędów serwera z powrotem na UI. W MVVM łatwo jest zaktualizować wiele flow lub pól i przypadkowo stworzyć niespójności. W MVI zwykle stosujesz odpowiedź serwera w jednym kroku reducera, który aktualizuje fieldErrors i formError razem.
Zdecyduj też, co jest stanem, a co jednorazowym efektem. Błędy inline i „submission failed” powinny być w stanie (muszą przetrwać rotację). Jednorazowe akcje jak snackbar, wibracja czy nawigacja powinny być efektami.
Drafty offline i przywracanie niedokończonych formularzy
Aplikacja z wieloma formularzami wydaje się „offline” nawet kiedy sieć działa. Użytkownicy przełączają aplikacje, OS może zabić proces, albo tracą połączenie w połowie. Drafty zapobiegają zaczynaniu od nowa.
Najpierw zdefiniuj, co znaczy draft. Zapis tylko „czystego” modelu często nie wystarcza. Zwykle chcesz przywrócić ekran dokładnie takim, jakim był, łącznie z półwypełnionymi polami.
Warto przechowywać głównie surowe dane użytkownika (stringi tak, jak wpisane, wybrane ID, URI załączników), plus tyle metadanych, by bezpiecznie scalić później: ostatnia znana migawka serwera i znacznik wersji (updatedAt, ETag lub prosty inkrement). Walidację można przeliczyć przy przywróceniu.
Wybór magazynu zależy od wrażliwości i rozmiaru. Małe drafty mogą żyć w preferences, ale formularze wieloetapowe i załączniki lepiej trzymać w lokalnej bazie. Jeśli draft zawiera dane osobowe, użyj szyfrowanego storage.
Największe pytanie architektoniczne to: gdzie leży źródło prawdy. W MVVM zespoły często persistują z ViewModelu przy każdej zmianie pól. W MVI zapisywanie po każdej aktualizacji reducera może być prostsze, bo zapisujesz jeden spójny stan (lub pochodny obiekt Draft).
Timing autosave ma znaczenie. Zapisywanie przy każdym znaku jest hałaśliwe; krótki debounce (np. 300–800 ms) plus zapis przy zmianie kroku działa dobrze.
Gdy użytkownik wróci online, potrzebujesz reguł scalania. Praktyczne podejście: jeśli wersja serwera nie zmieniła się, zastosuj draft i wyślij. Jeśli się zmieniła, pokaż jasny wybór: zachowaj mój draft lub przeładuj dane serwera.
Krok po kroku: zaimplementuj niezawodny formularz w obu wzorcach
Niezawodne formularze zaczynają się od jasnych reguł, nie od kodu UI. Każda akcja użytkownika powinna prowadzić do przewidywalnego stanu, a każdy asynchroniczny rezultat powinien mieć jedno oczywiste miejsce, by wylądować.
Wypisz akcje, na które ekran musi reagować: wpisywanie, utrata focusu, submit, retry i nawigacja między krokami. W MVVM stają się to metody ViewModelu i aktualizacje stanu. W MVI to eksplicytne intenty.
Następnie buduj małymi krokami:
- Zdefiniuj zdarzenia dla całego cyklu życia: edit, blur, submit, save success/failure, retry, restore draft.
- Zaprojektuj jeden obiekt stanu: wartości pól, błędy per-pole, ogólny status formularza i „ma nie zapisane zmiany”.
- Dodaj walidację: lekkie sprawdzenia podczas edycji, cięższe przy submit.
- Dodaj reguły optymistycznego zapisu: co zmienia się od razu, a co uruchamia rollback.
- Dodaj drafty: autosave z debounce, przywracanie przy otwarciu i mały wskaźnik „draft przywrócony”, by użytkownicy ufali temu, co widzą.
Traktuj błędy jako część doświadczenia. Zachowaj input, wyróżniaj tylko to, co trzeba poprawić, i oferuj jedną jasną kolejną akcję (edytuj, retry lub zachowaj draft).
Jeśli chcesz prototypować złożone stany formularzy przed pisaniem UI na Androida, platforma no-code jak AppMaster może pomóc zweryfikować przepływ najpierw. Potem możesz wdrożyć te same reguły w MVVM lub MVI z mniejszą liczbą niespodzianek.
Przykład: wieloetapowy formularz raportu kosztów
Wyobraź sobie 4-etapowy raport kosztów: szczegóły (data, kategoria, kwota), upload paragonu, notatki, potem przegląd i submit. Po submit pokazuje status zatwierdzenia: Draft, Submitted, Rejected, Approved. Trudne części to walidacja, zapisy, które mogą nie zadziałać, i trzymanie draftu, gdy telefon jest offline.
W MVVM zwykle trzymasz FormUiState w ViewModelu (często jako StateFlow). Każda zmiana pola wywołuje funkcję ViewModelu jak onAmountChanged() czy onReceiptSelected(). Walidacja działa przy zmianie, przy nawigacji między krokami lub przy submit. Typowa struktura to surowe inputy plus błędy per-pole i flagi pochodne sterujące aktywnością Next/Submit.
W MVI ten sam przepływ staje się eksplicytny: UI wysyła intenty typu AmountChanged, NextClicked, SubmitClicked i RetrySave. Reducer zwraca nowy stan. Efekty uboczne (upload paragonu, wywołanie API, pokazanie snackbara) działają poza reducerem i zwracają wyniki jako zdarzenia.
W praktyce MVVM ułatwia szybkie dodawanie funkcji i aktualizowanie flow. MVI utrudnia przypadkowe pominięcie przejścia stanu, bo każda zmiana jest skierowana przez reducer.
Częste błędy i pułapki
Większość bugów w formularzach wynika z niejasnych reguł: kto jest właścicielem prawdy, kiedy działa walidacja i co się dzieje, gdy przyjdą opóźnione rezultaty asynchroniczne.
Najczęstszy błąd to mieszanie źródeł prawdy. Jeśli pole tekstowe czasem czyta z widgetu, czasem z ViewModelu, a czasem z przywróconego draftu, dostaniesz losowe resetowania i zgłoszenia „moje dane zniknęły”. Wybierz jedno kanoniczne źródło stanu dla ekranu i wszystko od niego wyprowadzaj (model domeny, wiersze cache, payloady API).
Inna pułapka to mieszanie stanu z wydarzeniami. Toast, nawigacja czy baner „Zapisano!” to jednorazowe zdarzenia. Komunikat o błędzie, który ma zostać widoczny do momentu edycji, to stan. Mieszanie ich powoduje duplikaty po rotacji lub brak feedbacku.
Dwa problemy poprawności pojawiają się często:
- Nadmierna walidacja przy każdym znaku, zwłaszcza dla kosztownych sprawdzeń. Użyj debounce, waliduj przy blur lub tylko dotknięte pola.
- Ignorowanie nieuporządkowanych wyników asynchronicznych. Jeśli użytkownik zapisuje dwa razy lub edytuje po zapisie, starsze odpowiedzi mogą nadpisać nowsze dane, chyba że użyjesz request ID (lub logiki „tylko najnowsze”).
Na końcu: drafty to nie „tylko zapisz JSON”. Bez wersjonowania aktualizacje aplikacji mogą zepsuć przywracanie. Dodaj prostą wersję schematu i historię migracji, nawet jeśli to „odrzuć i zacznij od nowa” dla bardzo starych draftów.
Krótka lista kontrolna przed wydaniem
Zanim zaczniesz kłócić się o MVVM vs MVI, upewnij się, że twój formularz ma jedno źródło prawdy. Jeśli wartość może się zmieniać na ekranie, należy ją trzymać w stanie, nie w widżecie lub ukrytym flagu.
Praktyczny pre-ship check:
- Stan zawiera inputy, błędy pól, status zapisu (idle/saving/saved/failed) oraz status draft/queue, by UI nigdy nie musiało zgadywać.
- Reguły walidacji są czyste i testowalne bez UI.
- Optymistyczne UI ma ścieżkę rollback przy odrzuceniu przez serwer.
- Błędy nigdy nie kasują danych użytkownika.
- Przywracanie draftu jest przewidywalne: albo wyraźny banner auto-restore, albo jawne „Przywróć draft”.
Jeden dodatkowy test, który łapie prawdziwe bugi: włącz tryb samolotowy w trakcie zapisu, wyłącz, potem spróbuj ponownie dwa razy. Drugi retry nie powinien stworzyć duplikatu. Użyj request ID, klucza idempotencyjnego lub lokalnego znacznika „pending save”, by retry był bezpieczny.
Jeśli odpowiedzi są niejasne, najpierw wzmocnij model stanu, potem wybierz wzorzec, który najłatwiej wymusi te reguły.
Kolejne kroki: wybór ścieżki i szybsze budowanie
Zacznij od pytania: jak kosztowne jest, jeśli formularz znajdzie się w dziwnym, półzaktualizowanym stanie? Jeśli koszt jest niski, trzymaj to proste.
MVVM dobrze pasuje, gdy ekran jest prosty, stan to głównie „pola + błędy”, a zespół już pewnie publikuje z ViewModel + LiveData/StateFlow.
MVI lepiej pasuje, gdy potrzebujesz ścisłych, przewidywalnych przejść stanu, dużo asynchronicznych zdarzeń (autosave, retry, sync) lub gdy błędy są kosztowne (płatności, zgodność, krytyczne przepływy).
Wybierając cokolwiek, testy o największym zwrocie zwykle nie ruszają UI: przypadki brzegowe walidacji, przejścia stanu (edit, submit, success, failure, retry), rollback optymistycznego zapisu i przywracanie draftu plus zachowanie przy konflikcie.
Jeśli potrzebujesz też backendu, paneli admina i API razem z mobilną aplikacją, AppMaster (appmaster.io) może wygenerować produkcyjny backend, web i natywne aplikacje z jednego modelu, co pomaga utrzymać reguły walidacji i przepływy spójne na wszystkich powierzchniach.
FAQ
Wybierz MVVM, gdy przepływ formularza jest głównie liniowy, a zespół ma już sprawdzone konwencje dla StateFlow/LiveData, obsługi jednorazowych zdarzeń i anulowania zadań. Wybierz MVI, gdy spodziewasz się wielu nakładających się asynchronicznych operacji (autosave, ponowienia, uploady) i chcesz surowszych reguł, aby zmiany stanu nie mogły „wślizgnąć się” z różnych miejsc.
Zacznij od pojedynczego stanu ekranu (np. FormState), który zawiera surowe wartości pól, błędy per pole, błąd na poziomie formularza oraz jasne statusy, takie jak Saving lub Failed. Pola pochodne jak isValid i canSubmit obliczaj w jednym miejscu, aby UI tylko renderował, a nie ponownie podejmował decyzje.
Uruchamiaj lekkie, tanie sprawdzenia podczas edycji (wymagane pola, zakresy, podstawowy format), a surowsze walidacje wykonuj przy submit. Trzymaj kod walidacji poza UI, żeby dało się go testować, a błędy przechowuj w stanie, by przetrwały rotacje i przywrócenia procesu.
Traktuj asynchroniczną walidację jak zasadę „wygrywa najnowszy stan”. Przechowaj wartość, którą sprawdzano (lub identyfikator zapytania/wersję) i ignoruj wyniki, które nie pasują do aktualnego stanu. To zapobiega nadpisywaniu nowego wpisu starymi odpowiedziami, co często powoduje „losowe” komunikaty o błędach.
Zaktualizuj UI natychmiast, by odzwierciedlić akcję (np. pokaż Saving… i pozostaw pola widoczne), ale zawsze miej ścieżkę rollback, gdy serwer odrzuci zapis. Użyj identyfikatora żądania/wersji, dezaktywuj lub debounce'uj przycisk Zapisz oraz zdefiniuj, co oznaczają edycje w czasie zapisu (zablokować pola, złożyć kolejne zapisanie czy oznaczyć formę jako dirty).
Nigdy nie kasuj danych użytkownika przy błędzie. Umieszczaj problemy przypisane do pola bezpośrednio przy nim, trzymaj błędy na poziomie formularza w pobliżu akcji submit i spraw, by błąd sieciowy można było naprawić przez retry wysyłający ten sam ładunek, chyba że użytkownik coś zmienił.
Trzymaj jednorazowe efekty poza trwałym stanem. W MVVM wyślij je osobnym strumieniem (np. SharedFlow), a w MVI modeluj je jako Effects, które UI konsumuje raz. To unika duplikatów snackbarów czy powtarzanej nawigacji po rotacji lub ponownym subskrybowaniu.
Zapisuj głównie surowe dane wpisane przez użytkownika (dokładnie tak, jak pisane), plus minimalne metadane potrzebne do bezpiecznego przywrócenia i scalania, np. znacznik wersji serwera. Przywróć walidację po odczycie, zamiast ją przechowywać, i dodaj prostą wersję schematu, by móc obsłużyć aktualizacje aplikacji bez łamania przywracania.
Użyj krótkiego debounce (kilkaset milisekund) plus zapisywania przy zmianie kroku lub gdy użytkownik wychodzi z aplikacji. Zapis przy każdym naciśnięciu klawisza jest hałaśliwy i może powodować konflikt, a zapis tylko przy wyjściu ryzykuje utratę pracy przy zabiciu procesu.
Trzymaj znacznik wersji (np. updatedAt, ETag lub lokalny inkrement) dla migawki serwera i draftu. Jeśli wersja serwera się nie zmieniła, zastosuj draft i wyślij; jeśli się zmieniła, pokaż jasny wybór: zachowaj mój draft lub załaduj dane serwera, zamiast cicho nadpisywać którąkolwiek stronę.


