ViewModel, StateFlow и collectAsState

Выносим состояние экрана в ViewModel и подписываем UI на него через StateFlow и collectAsState.

ViewModel хранит состояние и логику экрана и переживает пересоздание UI (например, при повороте), а StateFlow отдаёт это состояние наблюдаемым потоком.

Зачем выносить состояние из composable

Состояние через remember живёт в композиции и теряется, когда Activity пересоздаётся (поворот экрана, нехватка памяти). Для важных данных это плохо. ViewModel существует дольше: она переживает такие пересоздания и держит состояние и бизнес-логику отдельно от UI.

class CounterViewModel : ViewModel() {
    private val _count = MutableStateFlow(0)
    val count: StateFlow<Int> = _count

    fun increment() {
        _count.value += 1
    }
}

Здесь MutableStateFlow — изменяемый поток с текущим значением. Наружу отдаётся только read-only StateFlow, чтобы UI не менял состояние напрямую — он лишь зовёт методы вроде increment().

Подписка из UI

Чтобы UI реагировал на поток, его превращают в Compose-состояние через collectAsState:

@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
    val count by viewModel.count.collectAsState()
    Button(onClick = { viewModel.increment() }) {
        Text(text = "Счёт: $count")
    }
}

collectAsState() подписывается на StateFlow и возвращает обычное Compose-состояние. Когда поток испускает новое значение, count обновляется, и Compose перерисовывает кнопку. Функция viewModel() даёт экземпляр ViewModel, привязанный к экрану. На реальных экранах удобно собирать всё состояние в один data class (так называемый UiState) и отдавать его единым StateFlow — тогда UI подписывается на один объект, а не на десяток отдельных потоков. Для безопасного сбора с учётом жизненного цикла в продакшене берут collectAsStateWithLifecycle(), чтобы подписка приостанавливалась, когда экран не виден.

Схема потока данных

UI --increment()--> ViewModel --обновляет--> StateFlow
 ^                                              |
 +------ collectAsState() (новое значение) -----+

Как работает под капотом

ViewModel хранится в специальном контейнере, привязанном к Activity/экрану, и не уничтожается при простом пересоздании UI. StateFlow всегда имеет «текущее значение» и при подписке сразу отдаёт его. collectAsState внутри использует эффект, который запускает сбор потока и записывает каждое новое значение в Compose-состояние, вызывая recomposition. Подписка автоматически прекращается, когда composable покидает экран.

Частые ошибки

  • Отдавать наружу MutableStateFlow — тогда UI сможет менять состояние в обход логики ViewModel.
  • Создавать ViewModel вручную через конструктор вместо viewModel() — теряется привязка к жизненному циклу и переживание поворота.
  • Забыть collectAsState и читать flow.value напрямую — UI не будет перерисовываться при изменениях.

Итог

  • ViewModel хранит состояние и логику и переживает пересоздание UI.
  • StateFlow отдаёт состояние потоком; наружу — только read-only версия.
  • collectAsState превращает поток в Compose-состояние и запускает перерисовку.
Проверьте себя
1. Зачем выносить состояние в ViewModel вместо remember?
AViewModel рендерится быстрее
BViewModel переживает пересоздание UI (например, поворот) и отделяет логику от интерфейса
Cremember не существует в Compose
DЭто требование Material 3
2. Что делает collectAsState?
AСоздаёт новый поток
BПодписывается на StateFlow и превращает его значения в Compose-состояние, вызывая перерисовку
CОстанавливает корутины
DСохраняет данные в базу
3. Почему наружу из ViewModel отдают StateFlow, а не MutableStateFlow?
AStateFlow быстрее
BЧтобы UI не менял состояние напрямую в обход логики ViewModel
CMutableStateFlow нельзя собирать
DЭто случайное соглашение без причины