Состояние экрана на Kotlin Flow без запутанных наблюдателей
Узнайте, как построить понятную модель состояния экрана на Kotlin Flow для загрузки, повторной попытки и устаревших данных, чтобы UI оставался предсказуемым, а баг-репорты указывали на один источник истины.

Почему состояние экрана быстро превращается в кашу
Большинство Android-экранов ломаются не потому, что Flow сложный. Они ломаются потому, что UI собирает слишком много мелких сигналов, и они начинают расходиться. Один StateFlow говорит, что загрузка идёт, другой флаг ошибки приходит из callback, а список всё ещё показывает старые элементы с прошлого успешного запроса. Экран рендерит всё это одновременно, хотя такая смесь не имеет смысла.
Часто всё начинается просто: isLoading, isRefreshing, errorMessage, items, возможно ещё isEmpty. Через пару запросов от команды у каждого флага появляется свой путь обновления. Один сетевой вызов выключает загрузку. Кнопка retry сбрасывает ошибку. Локальный кэш оставляет старые данные на экране. И никто не останавливается, чтобы спросить, какие полные состояния вообще может увидеть пользователь.
Именно так Kotlin Flow screen state часто превращается в запутанных наблюдателей. Код по-прежнему компилируется. UI по-прежнему обновляется. Но комбинации начинают врать.
Пользователи потом сообщают о таких состояниях:
- Спиннер показывается, пока старый контент остаётся видимым, но нигде не сказано, что данные устарели
- Кнопка повторной попытки отображается, хотя обновление уже идёт
- Баннер ошибки исчезает, а неудачное состояние всё ещё блокирует нажатия
- Экран пустого состояния мелькает до того, как приходит кэшированный контент
Логика повторной попытки делает это ещё хуже, когда прячется в обработчиках кликов, ветках callback или одноразовых корутинах. Вы нажимаете retry, но это действие не проводит экран через понятную модель состояния. Оно просто переключает несколько полей и надеется, что UI сам придёт в норму.
Устаревшие данные тоже создают тихую путаницу. Оставить старый контент на экране часто правильно. Но показывать его без пояснения — нет. Если пользователь видит вчерашний список после неудачного обновления, приложение должно это обозначить. Иначе в баг-репорты прилетают странные смеси: «Я видел старые элементы, индикатор загрузки и тост с ошибкой». Это не одна ошибка. Это несколько путей состояния, слитых в один запутанный экран.
Когда баг-репорты выглядят как невозможные комбинации, проблема обычно в модели, а не в количестве наблюдателей.
Начните с одного контракта экрана
Экран становится запутанным, когда три разных наблюдателя считают, что каждый из них владеет истиной. Один следит за загрузкой, другой — за данными, а третий — за ошибками. Через неделю кто-то сообщает о баге: спиннер висит поверх старого контента после retry. Обычно исправление начинается с удаления лишних хранилищ состояния, а не с добавления ещё одного.
Для состояния экрана на Kotlin Flow выберите одно хранилище состояния для всего экрана. На практике это часто означает один StateFlow<ScreenState> во ViewModel. Если UI нужно что-то отрисовать, этот факт должен быть в состоянии. Если UI не может построить это из состояния, контракт неполный.
Простой контракт часто выглядит так:
sealed interface ScreenState {
data object Loading : ScreenState
data class Content(
val items: List<Item>,
val isRefreshing: Boolean,
val isStale: Boolean
) : ScreenState
data class Error(
val message: String,
val canRetry: Boolean
) : ScreenState
}
Это даёт две полезные вещи. Во-первых, UI точно понимает, что он может показать. Во-вторых, это отсекает странные комбинации. Нельзя случайно показать Loading и Error как два независимых верхнеуровневых состояния одновременно, потому что модель этого не позволяет.
Сначала выпишите переходы, а уже потом пишите логику flow. Держите всё просто. Loading может перейти в Content или Error. Content может перейти в обновляемый Content. Refreshing Content может остаться Content с isStale = true, если запрос не удался, но старые данные всё ещё есть. Такая короткая схема заранее поймает многие баги.
Считайте невозможные комбинации ошибками дизайна, а не крайними случаями. Если ваша модель допускает isRefreshing = true, когда нет ни данных, ни состояния загрузки, остановитесь и переименуйте состояния. Когда названия кажутся неловкими, UI обычно тоже ощущается неловко.
Хороший контракт делает баг-репорты точнее. Вместо «экран после retry выглядит неправильно» вы получаете то, с чем можно работать: «после неудачного retry состояние меняется с Content(isRefreshing=true) на Error, но старые элементы должны остаться видимыми». Это указывает на одну модель, один переход и одно исправление.
Назовите состояния так, как их видит пользователь
Чистое состояние экрана на Kotlin Flow начинается с того, что человек действительно может заметить на экране. Если два внутренних условия выглядят для пользователя одинаково, одного состояния достаточно.
Большинству экранов нужно меньше состояний, чем ожидают команды. Полезное разделение обычно строится вокруг наличия данных, поведения загрузки и того, насколько данные ещё свежие.
sealed interface ScreenState {
data object InitialLoading : ScreenState
data class Content(val items: List<Item>) : ScreenState
data class Refreshing(val items: List<Item>) : ScreenState
data class ErrorEmpty(val message: String) : ScreenState
data class ErrorWithData(val items: List<Item>, val message: String) : ScreenState
data class Stale(val items: List<Item>) : ScreenState
}
InitialLoading означает, что на экране ещё нечего показывать. Используйте его для первой загрузки, а не для каждого сетевого запроса. Полноэкранный спиннер здесь уместен, потому что у пользователя пока нет никаких данных.
Content — это обычное состояние. На экране есть данные, кнопки работают, и человек может читать или нажимать, не ожидая.
Refreshing — это другое состояние. Старый контент остаётся видимым, пока новый запрос идёт в фоне. Такое небольшое разделение убирает частую ошибку: команды слишком рано возвращают пользователя на пустой экран загрузки во время pull-to-refresh, и это ощущается сломанным, даже если запрос потом проходит успешно.
Ошибкам тоже нужны две версии. Если запрос не удался до появления данных, покажите пустое состояние с ошибкой. Если кэшированные данные уже есть, оставьте их на экране и покажите сбой как сообщение или встроенное предупреждение. Обычно пользователи предпочитают старые результаты пустой странице.
Stale помогает, когда данные ещё рабочие, но могут быть устаревшими. Экран погоды, список заказов или дашборд может продолжать показывать последние известные данные с подсказкой об обновлении. Это честнее, чем делать вид, что всё актуально, и менее раздражает, чем блокировать весь экран.
Простой пример это хорошо показывает. Представьте список счетов. Первое открытие: InitialLoading. Данные пришли: Content. Пользователь тянет для обновления: Refreshing. API не отвечает, но вчерашние счета всё ещё существуют: ErrorWithData или Stale, в зависимости от того, хотите ли вы показать сообщение об ошибке, предупреждение о свежести или и то и другое.
Когда приходят баг-репорты, команда может указать на одну модель состояния вместо пяти наблюдателей и трёх флагов, которые борются друг с другом.
Не смешивайте события и побочные эффекты со state
Когда состояние экрана хранит snackbar, цели навигации и историю нажатий, UI начинает повторять старые действия. Поворот экрана, новый collector или восстановление процесса могут повторно вызвать то, что должно было произойти один раз. Так мелкие баги превращаются в странные отчёты.
Состояние должно отвечать на один вопрос: что пользователь видит прямо сейчас? Если это можно увидеть на экране, это место для StateFlow. Если это происходит один раз и потом исчезает, держите это в другом месте.
Отдельный поток событий хорошо подходит для одноразовых сообщений. Snackbar «Сохранено», триггер диалога «Сессия истекла» или вибрация не должны жить внутри состояния экрана. В Kotlin Flow screen state такие вещи лучше отправлять через SharedFlow или похожий канал событий, чтобы новый collector не воспроизвёл их по ошибке.
С навигацией та же проблема. Поле вроде openDetailsId = 42 выглядит просто, но оно остаётся там, пока кто-то его не очистит. Это значит, что после пересоздания экран может перейти ещё раз. Лучше держать навигацию вне модели состояния и отправлять её как событие, когда действие пользователя прошло проверку.
retry легко смоделировать неправильно. Булево shouldRetry = true липкое и расплывчатое. Оно не говорит, кто запросил повтор, когда это произошло и обработало ли приложение это уже. Вместо этого относитесь к retry как к действию: onRetryClick(). Это действие запускает работу, а состояние меняется на что-то реальное, например загрузку, контент с устаревшими данными или ошибку.
Обычно чистое разделение выглядит так:
- State: индикатор загрузки, текущие данные, значок устаревших данных, пустой экран, тело ошибки
- Events: snackbar, навигация, закрытие экрана, открытие диалога разрешений
- Actions: обновить, повторить, нажать на элемент, закрыть сообщение
Так состояние остаётся честным. Оно описывает экран, а не последний тап. Когда кто-то говорит: «Я нажал retry и всё равно видел старые данные», вы можете посмотреть на одну модель состояния вместо того, чтобы гоняться за наблюдателями по всему экрану.
Собирайте поток по шагам
Начните только с двух входов: данные из репозитория и действие пользователя, которое просит свежие данные. Обновление и retry можно объединить в один поток триггеров, но сохраняйте причину у каждого триггера. Эта маленькая деталь позже сильно помогает, потому что retry после ошибки не должен выглядеть так же, как pull-to-refresh поверх уже видимого контента.
Оставьте один тип состояния для всего экрана. Хорошо подойдёт sealed interface или sealed class. Каждое новое значение должно описывать, что пользователь видит прямо сейчас: загрузку, контент, пустое состояние или ошибку. Если старые данные остаются на экране во время неудачного обновления, выдавайте content с флагами вроде isRefreshing = false и isStale = true, а не прыгайте сразу на полный экран ошибки.
Именно здесь состояние экрана на Kotlin Flow становится гораздо проще отлаживать. Вы перестаёте спрашивать: «Какой наблюдатель поменял спиннер?» — и начинаете спрашивать: «Какое состояние выдал ViewModel?» Баг-репорты указывают на одну модель, а не на пять движущихся частей.
Хороший подход — дать репозиторию отдавать кэшированные данные, а затем реагировать на действия загрузки во ViewModel. Первичная загрузка и retry могут показывать полноэкранное состояние загрузки, если данных ещё нет. Обновление обычно должно оставлять текущий список видимым и менять только флаг refresh. Если сетевой вызов не удался во время обновления, оставьте последние корректные данные и пометьте их как устаревшие.
Делитесь результатом как StateFlow во ViewModel с помощью stateIn. Это даёт экрану один источник истины. Новые collector'ы сразу получают последнее состояние, и вам не приходится заново собирать логику загрузки в каждом Fragment или composable.
Проверяйте переходы по одному. Самый быстрый способ — подменить репозиторий и самому запускать поток триггеров. Проверьте такие случаи:
- initial load -> content
- initial load -> error
- content -> refresh -> updated content
- content -> refresh fails -> stale content
- error -> retry -> content
Если для одного из этих путей нужны два или три наблюдателя, чтобы всё стало понятно, модель всё ещё слишком разделена. Верните логику обратно в flow, пока каждый путь не станет выглядеть как простой переход состояния.
Показывайте устаревшие данные без путаницы
Людей меньше раздражают старые данные, чем пустой экран, который то появляется, то исчезает. Если у вас уже есть хороший контент, оставьте его на экране во время обновления и дайте понять, что происходит.
Обычно для этого в модели состояния нужен один понятный флаг устаревания. В состоянии экрана на Kotlin Flow устаревшие данные — это не отдельный экран. Это тот же контент плюс пометка, которая по смыслу говорит: «возможно, это уже неактуально».
data class ScreenState(
val items: List<Item> = emptyList(),
val isRefreshing: Boolean = false,
val isStale: Boolean = false,
val canRetry: Boolean = false,
val errorMessage: String? = null
)
Такое небольшое разделение очень помогает. isRefreshing показывает UI прогресс, не скрывая контент. isStale подсказывает UI показать небольшое предупреждение или бейдж. canRetry появляется только после неудачного запроса. Это связывает retry с реальной проблемой, а не показывает его постоянно.
Частый сценарий выглядит так: список загрузился в 9:00, пользователь вернулся в 9:10, и приложение обновляется в фоне. Оставьте список на 9:00 видимым. Покажите лёгкую подсказку о загрузке. Если запрос не удался, оставьте старый список, установите isStale = true и покажите действие retry с коротким сообщением вроде «Не удалось обновить. Показываем сохранённые результаты.»
Чего делать не стоит — так это переключать экран с контента на полный экран ошибки только потому, что обновление не прошло. У пользователя всё ещё есть что-то полезное на экране. Относитесь к этому как к контенту с проблемой обновления, а не как к пустой неудаче.
После успешного запроса сразу снимайте пометку устаревания. Сбрасывайте isStale, скрывайте retry и убирайте сообщение об ошибке в одном обновлении состояния. Тогда баг-репорты будут указывать на одно и то же место: экран либо показывал свежий контент, либо устаревший контент, либо ещё не имел данных. Без загадочных наблюдателей, без смешанных сигналов и без угадывания, какой callback изменил UI последним.
Пример: список с обновлением и повторной попыткой
Список хорошо показывает, почему одна модель состояния лучше кучи наблюдателей. Представьте, что пользователь открывает приложение с пустым кэшем. Сохранённых элементов нет, так что у экрана есть только одно честное состояние: загрузка без контента.
data class ListScreenState(
val items: List<Item> = emptyList(),
val initialLoading: Boolean = false,
val refreshing: Boolean = false,
val error: UiError? = null,
val stale: Boolean = false
)
При первом открытии initialLoading = true, а items пуст. Если запрос не удался, переключите initialLoading = false, оставьте items = emptyList(), и установите error = LoadFailed. UI может показать полноэкранную ошибку, потому что пользователю больше не на что смотреть.
Когда пользователь нажимает Retry, не нужен второй наблюдатель или отдельный экран retry. Вы обновляете тот же ListScreenState. Снова выставляете initialLoading = true, а затем заполняете items, когда запрос успешно завершается. Теперь экран показывает список, а error = null.
Позже приложение обновляется в фоне или пользователь тянет для обновления. В этот момент данные уже есть, так что неудачный запрос не должен возвращать пользователя на пустую страницу ошибки. Оставьте старые элементы на экране, установите refreshing = false, сохраните ошибку обновления и пометьте stale = true.
Это даёт состояние, которое люди могут понять: список всё ещё полезен, но, возможно, уже неактуален. Небольшое сообщение вроде «Не удалось обновить. Показываем сохранённые результаты.» подходит здесь гораздо лучше, чем полноэкранный сбой.
Именно здесь баг-репорты становятся чище. Вместо «спиннер не исчезал, показался тост, и список выглядел старым» отчёт может указать на одно состояние Android-экрана: элементы есть, ошибка есть, stale true. В состоянии экрана на Kotlin Flow именно в этом и смысл. Одна модель показывает, что видел пользователь, и почему.
Ошибки, которые возвращают запутанных наблюдателей
Путаница с наблюдателями обычно возвращается через небольшие сокращения. Код всё ещё использует Flow, но у экрана уже нет одной понятной истории.
Одна частая ошибка — смешивать булевы флаги, которые могут конфликтовать. Если оставить isLoading, hasError и isEmpty как отдельные флаги, экран может попасть в невозможные комбинации. Спиннер и сообщение об ошибке могут появиться одновременно. Кнопка retry может отображаться, пока данные ещё загружаются. Одна модель состояния избегает этого, потому что каждый случай имеет одно значение.
Другая проблема начинается, когда два collector'а трогают один и тот же виджет. Один обновляет список. Другой показывает и скрывает спиннер. Третий обрабатывает ошибки. Неделю это работает, а потом кто-то добавляет обновление, и UI начинает мигать. Одна часть экрана говорит «загружено», а другая всё ещё говорит «загрузка». В чистой настройке состояния экрана на Kotlin Flow один renderer читает один объект состояния и обновляет всю область экрана.
Состояние также становится грязным, когда зависит от callback'ов view. Если ViewModel нужно, чтобы Fragment сообщал что-то вроде «показан пустой экран» или «в адаптере нет элементов», источник истины теряется. Поверните устройство или восстановите процесс, и вы уже не сможете уверенно восстановить то же состояние.
null — ещё один тихий источник багов. null-список может означать «ещё не загружено», «загрузка не удалась», «пользователь сбросил фильтры» или «идёт обновление». Для одного значения это слишком много смысла. Назовите случай явно.
Первая загрузка и обновление не должны делить один и тот же спиннер. На первой загрузке полноэкранный лоадер часто уместен, потому что контента ещё нет. Во время обновления скрывать старый контент за тем же спиннером обычно ощущается сломанным. Оставьте устаревшие данные видимыми, покажите, что идёт обновление, и дайте людям возможность повторить попытку, не теряя контекст.
Быстрая проверка помогает:
- Могут ли два флага противоречить друг другу?
- Может ли больше одного collector'а менять один и тот же вид?
- Несёт ли
nullбольше одного значения? - Может ли приложение восстановить состояние экрана, не спрашивая view, что произошло?
- Скрывает ли refresh полезные данные без веской причины?
Если ответ «да», значит, наблюдатели снова начинают множиться.
Быстрая проверка перед релизом
Модель состояния экрана готова, когда люди за пределами команды могут её прочитать и пользоваться ей. Если QA пишет баг, они должны ссылаться на одно названное состояние, а не на расплывчатую смесь флагов вроде «загрузка плюс, возможно, ошибка плюс кэшированные данные».
Быстрый тест помогает сразу: возьмите любой скриншот экрана и спросите: «Какое это состояние?» Если два разработчика дают разные ответы, модель всё ещё размыта. В чистой настройке состояния экрана на Kotlin Flow один видимый экран соответствует одному классу состояния или одной понятной ветке модели.
Перед релизом проверьте такие случаи на реальных примерах:
- Откройте экран на медленной сети. У состояния загрузки есть одно чёткое значение, или оно может конфликтовать с пустым состоянием?
- Вызовите ошибку после того, как данные уже загрузились. Может ли приложение повторить попытку и оставить старый контент на экране, или оно без причины выбрасывает хорошие данные?
- Специально состарьте кэшированные данные. Могут ли люди продолжать пользоваться экраном, пока вы показываете, что контент может быть устаревшим?
- Передайте названия состояний QA. Могут ли они написать «устаревший контент с обновлением в процессе» вместо описания пикселей и надежды, что разработчик угадает?
- Попросите нового участника команды проследить переходы. Может ли он пройти путь от первой загрузки к контенту, обновлению и ошибке за несколько минут?
Тесты должны повторять состояния, которые люди видят. Напишите один тест для свежей загрузки, один для устаревших данных с фоновым обновлением, один для успешной пустой загрузки и один для retry после ошибки. Если для достижения состояния вам нужны десятки моков, модель, скорее всего, слишком перегружена.
Одно простое правило сильно экономит силы: retry должен менять только то, что ему действительно нужно изменить. Если у пользователей уже есть хороший список, оставьте его на месте и покажите вокруг него состояние повторной попытки или обновления. Так баг-репорты становятся точнее. «Повторная попытка из устаревших данных не удалась» говорит гораздо больше, чем «экран сломался после нажатия».
Когда всё это проходит, модель состояния становится общим языком. Разработчики отлаживают быстрее, QA пишет более точные баги, а новые участники команды тратят меньше времени на распутывание наблюдателей.
Следующие шаги для вашей команды
Возьмите один экран и проверьте его карандашом, а не планом полного переписывания. Откройте UI, запустите медленную загрузку, вызовите ошибку, потяните для обновления, отключитесь от сети, а потом снова подключитесь. Запишите все состояния, которые пользователь действительно может увидеть. Если два человека в команде описывают один и тот же момент разными словами, модель всё ещё размыта.
Обычно такой разбор быстро находит одну и ту же кашу: isLoading, isRefreshing, errorMessage, кэшированные элементы и разовые флаги retry, разбросанные между ViewModel и UI. Замените эту кучу одной моделью экрана. Смысл не в более красивом коде. Смысл в том, чтобы баг-репорты можно было сопоставить с одним состоянием вместо пяти булевых флагов, которые расходятся друг с другом.
Небольшой чек-лист помогает:
- Перечислите видимые состояния только для одного экрана
- Сведите разрозненные флаги к одной sealed-модели или одному понятному объекту состояния
- Держите временные эффекты, такие как тосты или навигация, вне этого состояния
- Напишите тесты для retry, устаревших данных и восстановления до широкой чистки кода
Начинайте тесты с тех путей, которые команды обычно пропускают. Логика retry часто работает на счастливом пути и ломается после второй неудачи. Устаревшие данные — ещё одно распространённое слепое пятно. Экран, который показывает старый контент с небольшой подсказкой «последнее обновление», часто лучше пустого лоадера, но только если модель состояния говорит об этом прямо. Если вы хорошо используете Kotlin Flow screen state, эти случаи перестают казаться особенными. Они становятся обычными ветками одного потока.
Первый рефакторинг держите узким. Одного списка экранов достаточно, чтобы доказать подход. Если разработчикам нужны три встречи, чтобы объяснить дерево состояний, оно слишком сложное.
Если это изменение начинает затрагивать репозитории, правила кэширования, навигацию и общие для приложения соглашения, сначала проведите короткий архитектурный разбор, а уже потом переносите подход везде. Опытный Fractional CTO, такой как Oleg, может увидеть, где локальная чистка превращается в более широкое решение для приложения. Такая проверка скучная, быстрая и часто дешевле, чем потом откатывать полуготовую модель состояния по всей кодовой базе.