StateFlow против SharedFlow в Jetpack Compose: моделируем UI-состояние без бага повторного воспроизведения
Когда использовать StateFlow, а когда SharedFlow в Compose ViewModel — и баг одноразового события, который возникает, если их перепутать.
Каждый выпущенный мной экран на Compose рано или поздно упирается в один и тот же вопрос: описывает ли эта информация как экран выглядит прямо сейчас, или она описывает что-то, что произошло один раз? Ошибиться с ответом — значит получить конкретный, раздражающий баг: тост, который снова появляется после поворота экрана, событие навигации, срабатывающее дважды, празднование «сессия завершена», которое проигрывается заново каждый раз, когда пользователь сворачивает и снова открывает приложение. Решение — не обходной путь. Решение — с самого начала выбрать правильный тип Flow.
Kotlin предлагает два hot flow, которые с точки зрения вызывающего кода выглядят взаимозаменяемыми — оба собираются через collectAsStateWithLifecycle() или LaunchedEffect, оба живут внутри ViewModel, оба переживают изменение конфигурации. Но они не взаимозаменяемы, и разница — это ровно то различие, что описано выше.
StateFlow: всегда есть значение, всегда его воспроизводит
StateFlow создан для состояния — данных, у которых в любой момент времени есть текущее значение, независимо от того, смотрит ли кто-то на них. Оставшиеся секунды таймера, ошибки валидации формы, статус loading/error/success списка. Новые коллекторы сразу получают последнее значение, и каждый коллектор всегда видит одно и то же.
private val _uiState = MutableStateFlow(TimerUiState.Idle)
val uiState: StateFlow<TimerUiState> = _uiState.asStateFlow()
fun start(durationMinutes: Int) {
_uiState.value = TimerUiState.Running(
remainingSeconds = durationMinutes * 60,
)
}
Это поведение — «всегда воспроизводить последнее значение новым коллекторам» — и есть весь смысл StateFlow: именно оно делает экран Compose корректным после изменения конфигурации. Поворачиваете устройство, Composable перекомпоновывается, подключается новый коллектор — и он сразу отрисовывает текущее состояние вместо пустого экрана, ожидающего следующей эмиссии.
SharedFlow: описывает то, что уже произошло
То же самое поведение воспроизведения оказывается ровно неправильным для одноразового события. Допустим, сессия Pomodoro завершилась, и вы хотите проиграть звук «отлично сделано» и лёгкую анимацию празднования — то, что должно произойти один раз, в момент завершения, а не при каждой перекомпоновке экрана.
Если смоделировать это событие как StateFlow<Boolean>, устанавливаемый в true при завершении, то каждый новый коллектор — включая тот, что создаётся после поворота экрана, или после того как пользователь вышел из приложения и вернулся, — сразу увидит true и повторно проиграет празднование. Первое решение, к которому обычно тянется рука, — это ручной сброс (_sessionComplete.value = false сразу после потребления значения), которое работает до тех пор, пока два коллектора не начнут гонку за чтением, или пока сброс не выполнится не на том диспетчере и флаг вовремя не вернётся обратно.
Правильный примитив здесь — SharedFlow без replay, потому что он вообще не хранит «текущего значения» — он только эмитит тем коллекторам, что активно подписаны в момент эмиссии:
private val _events = MutableSharedFlow<TimerEvent>()
val events: SharedFlow<TimerEvent> = _events.asSharedFlow()
private fun onSessionComplete() {
viewModelScope.launch {
_events.emit(TimerEvent.SessionComplete)
}
}
Коллектор, который не слушал в момент события, просто никогда его не увидит — что верно для анимации празднования и было бы неверно для состояния таймера. Эта асимметрия и есть всё решение целиком: если поздний подписчик должен увидеть текущее значение — это состояние; если он должен был его пропустить — это событие.
Практическое правило, которое действительно работает
Я использую один вопрос для принятия решения, и он подтверждался на каждом экране, построенном таким образом: будет ли багом, если совершенно новый коллектор немедленно получит это значение?
- Состояние таймера, содержимое формы, флаги загрузки, содержимое списка → нет, новый коллектор должен увидеть текущее значение →
StateFlow. - «Покажи этот snackbar», «перейди на этот экран», «проиграй этот звук» → да, воспроизвести это позднему подписчику и есть баг →
SharedFlow(replay = 0).
Единственное исключение, о котором стоит знать: SharedFlow(replay = 1) существует и выглядит как золотая середина, но на практике это просто StateFlow без гарантии «всегда есть значение» и без .value для синхронного чтения — редко есть причина использовать его вместо настоящего StateFlow при моделировании состояния или SharedFlow(replay = 0) при моделировании события.
Подключение к экрану Compose
Эти два типа потребляются по-разному, и перепутать это — вторая по частоте версия этого бага:
@Composable
fun TimerScreen(viewModel: TimerViewModel = viewModel()) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
LaunchedEffect(Unit) {
viewModel.events.collect { event ->
when (event) {
TimerEvent.SessionComplete -> playCompletionSound()
}
}
}
TimerContent(uiState)
}
collectAsStateWithLifecycle() предназначен для StateFlow — ему нужно текущее значение, которое он выставит как состояние Compose. events вместо этого собирается внутри LaunchedEffect, именно потому что у него нет текущего значения, которое стоило бы хранить; это поток, на который нужно реагировать, а не значение, которое нужно читать.
Вывод
Баг, который это предотвращает, достаточно тонкий, чтобы обычно уходить в релиз незамеченным — дублирующийся тост после поворота экрана, повторно срабатывающая анимация после возврата из фона — потому что в обычном случае с единственным коллектором, который никогда не отключается, всё выглядит нормально. Он проявляется только тогда, когда экран перезапускает свой коллектор, а именно это Compose и делает при каждом изменении конфигурации. Моделируйте состояние через StateFlow, потому что новые коллекторы должны догонять текущее значение. Моделируйте события через SharedFlow без replay, потому что не должны. Я использую это разделение в таймере Mintly: идущий обратный отсчёт — это состояние, которое должна корректно отражать каждая перекомпоновка, а празднование конца сессии — это событие, которое должно сработать ровно один раз — никогда дважды, и никогда на только что заново открытом экране.
// По теме
Ещё из журнала
In-App Review API в Android: как просить оценку, не раздражая пользователя
Практическое руководство по In-App Review API от Google — как он на самом деле работает, когда его запускать и почему обычный попап «оцените нас» незаметно портит вашу оценку в Play Store.
Ярлыки приложений Android и плитка быстрых настроек: логирование воды без открытия приложения
Практическое руководство по динамическому ShortcutManager API и TileService в Android — как позволить действию в один тап полностью обойти приложение, и о подводных камнях, на которых спотыкается большинство реализаций.
Типы foreground-сервисов в Android в 2026 году: как выбрать тот, что реально подходит вашей функции
Практическое руководство по ограничениям типов foreground-сервисов Android — dataSync, mediaPlayback, specialUse, shortService — и как выбрать правильный, не получив ни принудительной остановки, ни отказа в проверке.