31 мая 2025 г.·6 мин

Kotlin: MVI против MVVM для Android‑приложений с большим количеством форм — состояния UI

Kotlin: MVI против MVVM для Android‑приложений с большим количеством форм — практические способы моделировать валидацию, оптимистичный UI, состояния ошибок и оффлайн‑черновики.

Kotlin: MVI против MVVM для Android‑приложений с большим количеством форм — состояния UI

Почему приложения с большим количеством форм быстро становятся сложными

Приложения с множеством форм кажутся медленными или хрупкими, потому что пользователи постоянно ждут ответа на мелкие решения, которые принимает ваш код: поле валидно или нет, удалось ли сохранить, показывать ли ошибку и что делать при потере сети.

Формы также первыми выявляют баги состояния, потому что в них смешиваются несколько типов состояния одновременно: UI‑состояние (что видно), состояние ввода (что набрал пользователь), серверное состояние (что сохранено) и временное состояние (что в процессе). Когда всё это рассинхронизируется, приложение начинает казаться «случайным»: кнопки блокируются не в то время, старые ошибки остаются, или экран сбрасывается при повороте.

Большинство проблем сходятся в четырёх областях: валидация (особенно правила между полями), оптимистичный UI (быстрая обратная связь, пока работа выполняется), обработка ошибок (понятные, восстанавливаемые ошибки) и оффлайн‑черновики (не терять незавершённую работу).

Хороший UX формы следует простым правилам:

  • Валидация должна быть полезной и близкой к полю. Не мешайте вводу. Будьте строги там, где это важно, обычно на отправке.
  • Оптимистичный UI должен немедленно отражать действие пользователя, но при этом иметь чистый откат, если сервер отвергнет изменение.
  • Ошибки должны быть конкретными, предлагающими действие и никогда не стирать ввод пользователя.
  • Черновики должны переживать перезапуски, прерывания и плохое соединение.

Именно поэтому дебаты об архитектуре так оживлённы для форм. Паттерн, который вы выбираете, решает, насколько предсказуемо эти состояния будут себя вести под нагрузкой.

Краткое напоминание: MVVM и MVI простыми словами

Реальная разница между MVVM и MVI в том, как по экрану течёт изменение.

MVVM (Model View ViewModel) обычно выглядит так: ViewModel держит данные экрана, выставляет их в UI (часто через StateFlow или LiveData) и предоставляет методы, такие как save, validate или load. UI вызывает функции ViewModel при взаимодействии пользователя.

MVI (Model View Intent) обычно выглядит так: UI отправляет события (intents), редьюсер их обрабатывает, и экран рендерится из одного объекта состояния, который представляет всё, что UI нужно в данный момент. Сайд‑эффекты (сеть, БД) триггерятся контролируемо и сообщают результаты обратно как события.

Простая мнемоника:

  • MVVM спрашивает: «Какие данные должен открывать ViewModel и какие методы она должна предоставлять?»
  • MVI спрашивает: «Какие события могут происходить и как они превращают одно состояние в другое?»

Оба паттерна хорошо подходят для простых экранов. Как только вы добавляете валидацию между полями, автосохранение, повторные попытки и оффлайн‑черновики, вам нужны более строгие правила о том, кто и когда может менять состояние. MVI по умолчанию эти правила усиливает. MVVM всё ещё может работать хорошо, но требует дисциплины: единые пути обновления и аккуратной обработки одноразовых UI‑событий (snackbars, навигация).

Как моделировать состояние формы без сюрпризов

Самый быстрый способ потерять контроль — позволить данным формы жить в слишком многих местах: view‑binding, нескольких flow и «ещё одном» булевом флаге. Экраны с множеством форм остаются предсказуемыми, если есть один источник истины.

Практичная форма FormState

Стремитесь к единому FormState, который держит сырые вводы плюс несколько выведенных флагов, которым можно доверять. Сделайте его уныло полным, даже если он кажется чуть больше.

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 }

Это держит валидацию на уровне полей отдельно от проблем уровня формы (например, «сумма должна быть > 0»). Выводимые флаги вроде isDirty и isValid должны вычисляться в одном месте, а не по‑новой в UI.

Чистая ментальная модель: поля (что набрал пользователь), валидация (что не так), статус (что делает приложение), «грязность» (что изменилось с момента последнего сохранения) и черновики (существует ли оффлайн‑копия).

Где живут одноразовые эффекты

Формы также порождают одноразовые события: snackbars, навигация, баннер «сохранено». Не кладите их в FormState, иначе они будут срабатывать снова при повороте или при повторной подписке UI.

В MVVM эмитьте эффекты через отдельный канал (например, SharedFlow). В MVI моделируйте их как Effects (или Events), которые UI потребляет один раз. Это разделение предотвращает «фантомные» ошибки и дублирование сообщений об успехе.

Поток валидации в MVVM vs MVI

Валидация — это место, где экраны форм начинают казаться хрупкими. Ключевой выбор — где живут правила и как результаты попадают в UI.

Простые синхронные правила (обязательное поле, мин. длина, числовой диапазон) должны выполняться во ViewModel или в доменном слое, а не в UI. Так правила остаются тестируемыми и консистентными.

Асинхронные проверки (например, «этот email уже занят?») сложнее. Нужно обрабатывать загрузку, устаревшие результаты и ситуацию «пользователь снова набрал текст».

В MVVM валидация часто превращается в смесь состояния и вспомогательных методов: UI отправляет изменения (обновления текста, потеря фокуса, клики отправки) в ViewModel; ViewModel обновляет StateFlow/LiveData и выставляет ошибки по полям и выведенный canSubmit. Асинхронные проверки обычно стартуют джобу, затем обновляют флаг загрузки и ошибку по завершении.

В MVI валидация становится более явной. Практичное разделение обязанностей:

  • Редьюсер выполняет синхронную валидацию и сразу обновляет ошибки полей.
  • Эффект выполняет асинхронную валидацию и диспатчит результат как intent.
  • Редьюсер применяет этот результат только если он по‑прежнему соответствует последнему вводу.

Последний шаг важен. Если пользователь набрал новый email, пока проверка «уникального email» выполняется, старый результат не должен перезаписывать текущий ввод. MVI часто делает это проще, потому что можно хранить последнее проверенное значение в состоянии и игнорировать устаревшие ответы.

Оптимистичный UI и асинхронные сохранения

Запускайтесь в своём темпе
Разверните в AppMaster Cloud или на вашей инфраструктуре AWS, Azure или Google Cloud.
Развернуть сейчас

Оптимистичный UI означает, что экран ведёт себя так, как будто сохранение прошло успешно до получения ответа сети. В форме это часто значит: кнопка Сохранить меняется на «Сохранение…», появляется небольшой индикатор «Сохранено», а поля остаются доступными (или намеренно блокируются) пока запрос в пути.

В MVVM это обычно реализуют переключением флагов вроде isSaving, lastSavedAt и saveError. Риск — рассинхронизация: перекрывающиеся сохранения могут привести к несогласованности флагов. В MVI редьюсер обновляет один объект состояния, поэтому вероятность противоречий между «Saving» и «Disabled» ниже.

Чтобы избежать двойной отправки и гонок, рассматривайте каждое сохранение как идентифицированное событие. Если пользователь нажал Сохранить дважды или редактирует во время сохранения — нужен правил для того, какой ответ побеждает. Несколько страховок работают в обоих паттернах: отключать Save во время сохранения (или дебаунсить нажатия), прикреплять requestId (или версию) к каждому сохранению и игнорировать устаревшие ответы, отменять выполняющуюся работу при уходе пользователя со сцены и чётко определять, что правка во время сохранения значит (поставить в очередь ещё одно сохранение или пометить форму снова как грязную).

Частичный успех тоже распространён: сервер принимает некоторые поля и отклоняет другие. Моделируйте это явно. Держите ошибки по полям (и, если нужно, статус синхронизации по полю), чтобы можно было показать «Всё сохранено» в целом и одновременно подсветить поле, требующее внимания.

Состояния ошибок, от которых пользователь может восстановиться

Отправляйте бэкенд вместе с формой
Создавайте API и бизнес‑логику без ручной записи болванки для каждой формы.
Сгенерировать бэкенд

Формы падают не только как «что‑то пошло не так». Если каждая неудача превращается в общий тост, пользователи перезаполняют данные, теряют доверие и бросают поток. Цель всегда одна: сохранить ввод, показать понятное решение и сделать повторную попытку естественной.

Полезно разделять ошибки по месту их происхождения. Неправильный формат email — не то же самое, что падение сервера.

Ошибки полей должны быть inline и привязаны к конкретному вводу. Ошибки уровня формы должны сидеть рядом с действием отправки и объяснять, что мешает отправке. Сетевые ошибки должны предлагать повтор и оставлять форму редактируемой. Ошибки авторизации или прав должны направлять пользователя на вход, при этом сохраняя черновик.

Основное правило восстановления: никогда не очищайте ввод пользователя при ошибке. Если сохранение не удалось, держите текущие значения в памяти и на диске. Повторная отправка должна пересылать тот же payload, если только пользователь не внес изменения.

Где паттерны различаются — это то, как серверные ошибки маппятся обратно в UI. В MVVM легко обновить несколько потоков или полей и случайно получить рассинхронизацию. В MVI обычно применяют серверный ответ одним шагом редьюсера, который обновляет fieldErrors и formError одновременно.

Также решите, что является состоянием, а что — одноразовым эффектом. Inline‑ошибки и «отправка не удалась» — это состояние (они должны пережить поворот экрана). Одноразовые действия вроде snackbar или вибрации — эффекты.

Оффлайн‑черновики и восстановление незавершённых форм

Формы дают ощущение «оффлайна» даже при нормальной сети. Пользователи переключают приложения, ОС убивает процесс или сигнал пропадает. Черновики не дают им начинать заново.

Сначала определите, что значит черновик. Сохранять только «чистую» модель часто недостаточно. Обычно хочется восстановить экран точно таким же, включая наполовину введённые поля.

Что стоит персистить: в основном сырые вводы пользователя (строки как введены, выбранные ID, URI вложений) и достаточно метаданных для безопасного слияния позже: последний известный серверный снимок и маркер версии (updatedAt, ETag или простой инкремент). Валидацию можно пересчитать при восстановлении.

Выбор хранилища зависит от чувствительности и размера. Маленькие черновики можно держать в preferences, но многошаговые формы и вложения надёжнее хранить в локальной базе. Если черновик содержит персональные данные — используйте шифрованное хранилище.

Крупный архитектурный вопрос — где живёт источник истины. В MVVM команды часто персистят из ViewModel при каждом изменении поля. В MVI персистить после каждого обновления редьюсера проще, потому что вы сохраняете один согласованный state (или выводимый объект Draft).

Тайминг автосохранения важен. Сохранение на каждый ввод создаст лишний шум; короткий дебаунс (300–800 мс) плюс сохранение при смене шага работают хорошо.

Когда пользователь возвращается онлайн, нужны правила слияния. Практичный подход: если серверная версия не изменилась — применяйте черновик и отправляйте. Если изменилась — покажите понятный выбор: сохранить мой черновик или перечитать серверные данные.

Пошагово: реализовать надёжную форму в любом паттерне

Проверьте UX прежде чем писать Kotlin
Прототипируйте мобильный UI и поток, затем генерируйте нативные приложения, когда UX готов.
Создать мобильное приложение

Надёжные формы начинаются с чётких правил, а не с UI‑кода. Каждое действие пользователя должно приводить к предсказуемому состоянию, и каждый асинхронный результат должен иметь одно очевидное место, куда попадать.

Запишите действия, на которые экран должен реагировать: набор текста, потеря фокуса, отправка, повтор, навигация по шагам. В MVVM это становятся методами ViewModel и обновлениями состояния. В MVI это явные intents.

Затем стройте итеративно:

  1. Определите события для полного жизненного цикла: edit, blur, submit, save success/failure, retry, restore draft.
  2. Спроектируйте один объект состояния: значения полей, ошибки по полям, общий статус формы и флаг «есть несохранённые изменения».
  3. Добавьте валидацию: лёгкие проверки при редактировании, тяжёлые при отправке.
  4. Добавьте правила оптимистичного сохранения: что меняется сразу и что триггерит откат.
  5. Добавьте черновики: автосохранение с дебаунсом, восстановление при открытии и небольшой индикатор «черновик восстановлен», чтобы пользователи доверяли тому, что видят.

Рассматривайте ошибки как часть опыта. Сохраняйте ввод, подсвечивайте только то, что нужно исправить, и предлагайте одно понятное следующее действие (править, повторить, оставить черновик).

Если хотите прототипировать сложные состояния формы до написания Android UI, безкодовая платформа вроде AppMaster может помочь сначала проверить workflow. Потом те же правила можно реализовать в MVVM или MVI с меньшим количеством сюрпризов.

Пример: многошаговая форма отчёта о расходах

Представьте 4‑шаговую форму: детали (дата, категория, сумма), загрузка чека, заметки, затем просмотр и отправка. После отправки показывается статус одобрения: Draft, Submitted, Rejected, Approved. Сложные места — валидация, сохранения, которые могут упасть, и сохранение черновика при оффлайне.

В MVVM обычно держат FormUiState во ViewModel (часто в StateFlow). Каждое изменение поля вызывает функцию ViewModel, например onAmountChanged() или onReceiptSelected(). Валидация выполняется при изменениях, при навигации по шагам или при отправке. Обычная структура — сырые вводы плюс ошибки по полям и выведенные флаги, контролирующие доступность Next/Submit.

В MVI тот же поток становится явным: UI посылает intents вроде AmountChanged, NextClicked, SubmitClicked, RetrySave. Редьюсер возвращает новое состояние. Сайд‑эффекты (загрузка чека, вызов API, показ snackbar) выполняются вне редьюсера и возвращают результаты как события.

На практике MVVM упрощает быстрое добавление функций и обновление потока. MVI затрудняет пропуск шагов состояния, потому что каждое изменение проходит через редьюсер.

Распространённые ошибки и ловушки

Прототипируйте сложные формы быстрее
Смоделируйте поток формы один раз и сгенерируйте бэкенд, веб и мобильные приложения.
Попробовать AppMaster

Большинство багов форм происходит из неясных правил о том, кто владеет истиной, когда запускается валидация и что происходит при опоздавших асинхронных результатах.

Самая частая ошибка — смешивание источников истины. Если текстовое поле иногда читает из виджета, иногда из ViewModel и иногда из восстановленного черновика, получите случайные сбросы и жалобы «мой ввод пропал». Выберите каноническое состояние для экрана и выводите всё остальное из него (доменная модель, кэши, API‑пэйлоады).

Ещё одна ловушка — путаница состояния и событий. Тост, навигация или баннер «Сохранено!» — одноразовое событие. Сообщение об ошибке, которое должно оставаться до правки, — это состояние. Смешение приводит к дублированным эффектам при повороте или к пропущенной обратной связи.

Две корректности, которые часто встречаются:

  • Слишком жёсткая валидация на каждый ввод, особенно для тяжёлых проверок. Используйте дебаунс, валидацию на blur или только для touched‑полей.
  • Игнорирование несортированных асинхронных результатов. Если пользователь сохраняет дважды или редактирует после сохранения, старые ответы могут перезаписать новые, если не использовать request IDs или логику «только последний».

Наконец, черновики — это не просто «сохранить JSON». Без версионирования обновления приложения могут ломать восстановление. Добавьте простую версию схемы и историю миграций, даже если это «скинуть и начать заново» для очень старых черновиков.

Быстрый чек‑лист перед релизом

Обрабатывайте вход без лишних шагов
Используйте встроенные модули аутентификации, чтобы формы фокусировались на задаче пользователя.
Добавить аутентификацию

Прежде чем спорить MVVM vs MVI, убедитесь, что у формы есть один явный источник истины. Если значение может меняться на экране, оно должно быть в состоянии, а не в виджете представления или скрытом флаге.

Практическая предпусковая проверка:

  • Состояние включает вводы, ошибки по полям, статус сохранения (idle/saving/saved/failed) и статус черновика/очереди, чтобы UI никогда не догадывался.
  • Правила валидации чисты и тестируемы без UI.
  • Оптимистичный UI имеет путь отката при отказе сервера.
  • Ошибки никогда не стирают ввод пользователя.
  • Восстановление черновика предсказуемо: либо явный баннер «черновик восстановлен», либо действие «Восстановить черновик».

Ещё один тест, который ловит реальные баги: включите авиарежим во время сохранения, выключите, затем попробуйте повторить дважды. Вторая попытка не должна создавать дубликат. Используйте request ID, idempotency key или локальную метку «pending save», чтобы повторы были безопасны.

Если ответы неочевидны — сначала ужесточите модель состояния, затем выберите паттерн, который легче гарантирует эти правила.

Следующие шаги: выбрать путь и ускориться

Задайте себе один вопрос: насколько дорого, если форма окажется в странном полузаконченно‑обновлённом состоянии? Если цена низкая — оставляйте всё проще.

MVVM хорошо подходит, когда экран прямолинейный, состояние — в основном «поля + ошибки», и команда уже уверенно шлёт с ViewModel + LiveData/StateFlow.

MVI лучше подходит, когда нужны строгие предсказуемые переходы состояния, много асинхронных событий (автосохранение, повторы, синхронизация) или когда баги дорого обходятся (платежи, соответствие требованиям, критичные рабочие процессы).

Вне зависимости от выбранного пути, тесты с наибольшим возвратом обычно не трогают UI: крайние случаи валидации, переходы состояний (редактирование, отправка, успех, ошибка, повтор), откат оптимистичного сохранения и восстановление черновика плюс поведение при конфликтах.

Если вам нужен также бэкенд, админ‑панели и API вместе с мобильным приложением, AppMaster (appmaster.io) может сгенерировать production‑ready бэкенд, веб и нативные мобильные приложения из одной модели, что помогает держать правила валидации и workflow консистентными на всех поверхностях.

Вопросы и ответы

Когда мне выбирать MVVM или MVI для экрана Android с большим количеством форм?

Выбирайте MVVM, когда поток форм в основном линейный и команда уже имеет устоявшиеся соглашения по StateFlow/LiveData, одноразовым событиям и отменам задач. Выбирайте MVI, когда ожидается много перекрывающихся асинхронных операций (автосохранение, повторы, загрузки) и нужны строгие правила, чтобы изменения состояния не «просачивались» из разных мест.

Какой самый простой способ не дать состоянию формы рассинхронизироваться?

Начните с единого состояния экрана (например, FormState), которое содержит сырые значения полей, ошибки по полям, ошибку формы и явные статусы вроде Saving или Failed. Вычисляемые флаги вроде isValid и canSubmit держите в одном месте, чтобы UI только отображал результат, а не принимал логику.

Как часто должна запускаться валидация: на каждый ввод или только на отправку?

Делайте лёгкие, дешёвые проверки во время набора (обязательное поле, диапазон, базовый формат), а строгую валидацию — на отправке. Вынесите логику валидации из UI, чтобы её можно было тестировать, и храните ошибки в состоянии, чтобы они переживали поворот экрана и восстановление процесса.

Как обрабатывать асинхронную валидацию типа «email уже занят» без устаревших результатов?

Реализуйте принцип «побеждает последнее введённое». Сохраняйте значение, для которого выполнялась асинхронная проверка (или id запроса/версии) и игнорируйте результаты, которые не соответствуют текущему состоянию. Это предотвращает перезапись новых вводов устаревшими ответами.

Какой безопасный подход по умолчанию для оптимистичного UI при сохранении формы?

Обновляйте UI сразу (например, показывайте Saving…, оставляйте ввод видимым), но всегда имейте путь отката, если сервер отклонит запрос. Используйте id запроса/версию, отключайте или дебаунсьте кнопку Сохранить и заранее определяйте, что значит правка во время сохранения (блокировка полей, очередь на сохранение или пометка как «грязное»).

Как структурировать состояния ошибок, чтобы пользователь мог восстановиться без перезаполнения формы?

Никогда не очищайте ввод пользователя при ошибке. Показывайте проблемы по полям прямо рядом с ними, поместите ошибки уровня формы около кнопки отправки и делайте сетевые ошибки восстанавливаемыми с возможностью повторной отправки того же полезного тела, если пользователь не менял данные.

Где должны жить одноразовые события вроде snackbars и навигации?

Выносите одноразовые эффекты из персистентного состояния. В MVVM отправляйте их через отдельный поток (например, SharedFlow), в MVI моделируйте их как Effects, которые UI потребляет один раз. Это предотвращает дубль‑сообщений или навигацию после поворота экрана.

Что именно стоит сохранять для оффлайн‑черновиков формы?

Сохраняйте в основном сырые вводы пользователя (строки как введены, выбранные ID, URI вложений) и минимальную метаинформацию для безопасного слияния позже: последний известный снимок сервера и маркер версии. Валидацию пересчитывайте при восстановлении, а не храните её. Добавьте простую версию схемы, чтобы обновления приложения не ломали восстановление.

Как тайминг автосохранения сделать надёжным, но не навязчивым?

Используйте короткий дебаунс (несколько сотен миллисекунд) плюс сохранение при переходе шага или при сворачивании приложения. Сохранение на каждый символ создаёт шум и лишнюю конкуренцию, а сохранение только при выходе рискует потерять работу при убийстве процесса.

Как обрабатывать конфликты черновиков, когда сервер изменился пока пользователь был оффлайн?

Держите маркер версии (например, updatedAt, ETag или локальный инкремент) и для серверной версии, и для черновика. Если сервер не менялся — применяйте черновик и отправляйте. Если изменился — покажите понятный выбор: сохранить мой черновик или загрузить серверные данные, вместо молчаливого перезаписывания.

Легко начать
Создай что-то невероятное

Экспериментируйте с AppMaster с бесплатной подпиской.
Как только вы будете готовы, вы сможете выбрать подходящий платный план.

Попробовать AppMaster