Перейти к содержимому
Все записи

StateFlow против SharedFlow в Jetpack Compose: моделируем UI-состояние без бага повторного воспроизведения

Когда использовать StateFlow, а когда SharedFlow в Compose ViewModel — и баг одноразового события, который возникает, если их перепутать.

MFKAPPS 4 мин чтения

Каждый выпущенный мной экран на 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: идущий обратный отсчёт — это состояние, которое должна корректно отражать каждая перекомпоновка, а празднование конца сессии — это событие, которое должно сработать ровно один раз — никогда дважды, и никогда на только что заново открытом экране.

// По теме

Ещё из журнала

MFKAPPS 4 мин чтения

In-App Review API в Android: как просить оценку, не раздражая пользователя

Практическое руководство по In-App Review API от Google — как он на самом деле работает, когда его запускать и почему обычный попап «оцените нас» незаметно портит вашу оценку в Play Store.

#android #engineering #kotlin
MFKAPPS 5 мин чтения

Ярлыки приложений Android и плитка быстрых настроек: логирование воды без открытия приложения

Практическое руководство по динамическому ShortcutManager API и TileService в Android — как позволить действию в один тап полностью обойти приложение, и о подводных камнях, на которых спотыкается большинство реализаций.

#android #engineering #kotlin
MFKAPPS 4 мин чтения

Типы foreground-сервисов в Android в 2026 году: как выбрать тот, что реально подходит вашей функции

Практическое руководство по ограничениям типов foreground-сервисов Android — dataSync, mediaPlayback, specialUse, shortService — и как выбрать правильный, не получив ни принудительной остановки, ни отказа в проверке.

#android #engineering #kotlin