StateFlow как состояние экрана: паттерн, который стоит выучить наизусть

В Jetpack Compose всё крутится вокруг состояния. Текст в поле ввода — состояние. Индикатор загрузки — состояние. Список товаров в корзине — тоже состояние. Каждый раз, когда состояние меняется, Compose автоматически перерисовывает те части интерфейса, которые от него зависят.

Внутри Composable-функций состояние хранится через remember { mutableStateOf(...) }. Но если данные приходят из ViewModel — а в архитектуре MVVM так происходит почти всегда, — нужен другой механизм. И лучше всего на эту роль подходит StateFlow.

Что такое StateFlow

StateFlow Внутри Composable-функций состояние хранится через remember { mutableStateOf(...) }. Но если данные приходят из ViewModel — а в архитектуре MVVM так происходит почти всегда, — нужен другой механизм. И лучше всего на эту роль подходит StateFlow — горячий поток, который всегда хранит ровно одно значение: текущее. Можно думать о нём как о табло в аэропорту: оно показывает актуальную информацию в любой момент, не заставляя пассажира ждать следующего обновления. Подписался — сразу получил текущее значение. Значение обновилось — все подписчики узнали об этом мгновенно.

В отличие от холодного Flow, здесь нет функции emit(). Вместо неё — свойство value. Присвоил новое значение — подписчики получили обновление.

Каноничный паттерн: приватный Mutable, публичный Read-only

В ViewModel принято хранить StateFlow в двух экземплярах — изменяемом и неизменяемом:

class ProfileViewModel : ViewModel() {
    private val _name = MutableStateFlow("Аноним")
    val name: StateFlow<String> = _name.asStateFlow()

    fun updateName(newName: String) {
        _name.value = newName
    }
}

Зачем два свойства для одного объекта? Всё дело в инкапсуляции. Приватное свойство _name — это MutableStateFlow, в который ViewModel может записывать новые значения. Публичное свойство name — это StateFlow, доступный только для чтения. UI видит name, может подписаться на него, но не может изменить его напрямую. Только ViewModel решает, когда и какие данные попадут в поток.

На самом деле в памяти существует один объект MutableStateFlow. Два свойства — это две ссылки разного типа на один и тот же объект. Поэтому и val, а не var: меняется содержимое объекта, а не ссылка на него.

Метод asStateFlow() создаёт обёртку, которая скрывает метод записи. Можно обойтись и без него, просто указав тип:

val name: StateFlow<String> = _name

Результат тот же — UI не сможет присвоить name.value напрямую. Но asStateFlow() надёжнее: даже если кто-то попробует сделать приведение типа к MutableStateFlow, у него ничего не выйдет.

Пример: экран с рейтингом

Допустим, мы пишем экран, на котором пользователь ставит оценку блюду: от одной до пяти звёзд. ViewModel хранит выбранную оценку и среднее значение по всем оценкам:

class RatingViewModel : ViewModel() {
    private val _selectedRating = MutableStateFlow(0)
    val selectedRating: StateFlow<Int> = _selectedRating.asStateFlow()

    private val _averageRating = MutableStateFlow(4.2f)
    val averageRating: StateFlow<Float> = _averageRating.asStateFlow()

    fun selectRating(stars: Int) {
        _selectedRating.value = stars
    }

    fun submitRating() {
        val rating = _selectedRating.value
        if (rating > 0) {
            // Здесь был бы сетевой запрос
            _averageRating.value = (averageRating.value + rating) / 2
            _selectedRating.value = 0
        }
    }
}

В Composable подписываемся через collectAsStateWithLifecycle():

@Composable
fun RatingScreen(viewModel: RatingViewModel = viewModel()) {
    val selected by viewModel.selectedRating.collectAsStateWithLifecycle()
    val average by viewModel.averageRating.collectAsStateWithLifecycle()

    Column(
        modifier = Modifier.fillMaxSize().padding(24.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text("Средняя оценка: ${"%.1f".format(average)}", fontSize = 20.sp)
        Spacer(Modifier.height(24.dp))

        Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
            for (star in 1..5) {
                IconButton(onClick = { viewModel.selectRating(star) }) {
                    Icon(
                        imageVector = if (star <= selected)
                            Icons.Filled.Star else Icons.Outlined.Star,
                        contentDescription = "Звезда $star"
                    )
                }
            }
        }

        Spacer(Modifier.height(16.dp))
        Button(onClick = { viewModel.submitRating() }) {
            Text("Отправить")
        }
    }
}

Обратите внимание на collectAsStateWithLifecycle(). Эта функция делает то же, что collectAsState(), но дополнительно привязывает сбор потока к жизненному циклу экрана. Когда пользователь сворачивает приложение или переходит на другой экран, сбор приостанавливается — и возобновляется при возвращении. Для реальных проектов это предпочтительный вариант.

StateFlow и distinctUntilChanged

У StateFlow есть встроенная защита от дубликатов. Если присвоить value то же значение, что уже хранится, — подписчики не получат обновления. Это важно для производительности: Compose не будет перерисовывать экран, если ничего не изменилось.

_selectedRating.value = 3  // подписчики получат обновление
_selectedRating.value = 3  // ничего не произойдёт
_selectedRating.value = 4  // подписчики получат обновление

Фактически StateFlow ведёт себя так, будто к нему постоянно применён оператор distinctUntilChanged().

Когда StateFlow недостаточно

StateFlow хранит состояние — нечто, что в каждый момент времени имеет актуальное значение. Но не все данные в приложении — состояние. Есть ещё события: показать уведомление, перейти на другой экран, воспроизвести звук. Событие — это разовый сигнал, который не нужно хранить. Если его никто не услышал — он пропал.

Для событий существует SharedFlow. Он не хранит последнее значение по умолчанию и рассылает данные всем активным подписчикам одновременно. Но это уже другая история.


Паттерн private MutableStateFlow + public StateFlow — рабочая лошадка MVVM-архитектуры в Android. Он прост, надёжен и покрывает большинство сценариев. Запомните его — он будет встречаться практически в каждом ViewModel, который вы напишете.