ViewModel и StateFlow

ViewModel хранит состояние и бизнес-логику экрана, переживает поворот, а StateFlow реактивно отдаёт это состояние в Compose через collectAsStateWithLifecycle.
Суть: ViewModel — это владелец состояния экрана, который не зависит от жизненного цикла Activity, а StateFlow обеспечивает однонаправленный поток данных из логики в интерфейс.

В уроке про жизненный цикл мы выяснили: при повороте Activity пересоздаётся и теряет данные. Решение — ViewModel. Это объект, который привязан к экрану, но переживает пересоздание Activity. В нём держат состояние и логику, а UI лишь отображает состояние и шлёт события. Это естественное продолжение паттерна state hoisting: владельцем состояния становится ViewModel.

Состояние ViewModel отдаёт через StateFlow — реактивный держатель значения, у которого всегда есть текущее состояние и который уведомляет подписчиков об изменениях. Изнутри его обновляют через приватный MutableStateFlow, а наружу отдают только для чтения.

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch

data class CounterUiState(val count: Int = 0)

class CounterViewModel : ViewModel() {
    private val _uiState = MutableStateFlow(CounterUiState())
    val uiState: StateFlow<CounterUiState> = _uiState.asStateFlow()

    fun increment() {
        // обновляем состояние иммутабельно через copy
        _uiState.update { it.copy(count = it.count + 1) }
    }
}

Подключение к Compose

В composable ViewModel получают функцией viewModel(), а его StateFlow собирают через collectAsStateWithLifecycle. Этот сборщик lifecycle-aware: он собирает данные, только пока экран на переднем плане, и приостанавливается, когда экран не виден, экономя ресурсы.

import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel

@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
    val state by viewModel.uiState.collectAsStateWithLifecycle()
    Button(onClick = { viewModel.increment() }) {
        Text("Нажато: ${state.count} раз")
    }
}

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

ViewModel создаётся через специальную фабрику и хранится в ViewModelStore, привязанном к области видимости экрана, а не к конкретному экземпляру Activity. При повороте Activity пересоздаётся, но получает ту же ViewModel — поэтому состояние сохраняется. viewModelScope — это CoroutineScope, живущий ровно столько, сколько ViewModel: когда экран окончательно уничтожается, все его корутины отменяются автоматически. collectAsStateWithLifecycle подписывается на StateFlow с учётом состояния жизненного цикла: в фоне сбор приостанавливается, а при возврате на экран возобновляется.

  Поток состояния: логика --> UI

  ViewModel
    MutableStateFlow(count=0)
        |  update { copy(count+1) }
        v
    StateFlow (только чтение)
        |  collectAsStateWithLifecycle()
        v
    Composable рисует count
        |  onClick --> viewModel.increment()
        ^___________________________________|

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

Хранить состояние в Activity вместо ViewModel. Тогда оно теряется при повороте — для этого ViewModel и нужна.

Выставлять наружу MutableStateFlow. Наружу отдавайте только StateFlow (через asStateFlow), чтобы UI не менял состояние напрямую.

Использовать collectAsState вместо lifecycle-версии. Обычный collectAsState не приостанавливается в фоне и может зря тратить ресурсы; предпочитайте collectAsStateWithLifecycle.

Best practices

  • Состояние и логику экрана держите в ViewModel, а UI делайте тонким.
  • Описывайте состояние экрана одним иммутабельным data-классом и обновляйте через copy.
  • Наружу отдавайте StateFlow, изменяйте через приватный MutableStateFlow.
  • В Compose собирайте состояние через collectAsStateWithLifecycle.

Поток «событие меняет состояние во ViewModel, UI перерисовывается» удобно смоделировать редьюсером на Python. Запустите врезку.

# Аналог ViewModel + StateFlow: состояние и редьюсер
state = {'count': 0}

def increment(s):
    # иммутабельное обновление, как copy() в data-классе
    return {**s, 'count': s['count'] + 1}

def render(s):
    return 'Нажато: ' + str(s['count']) + ' раз'

print(render(state))
for _ in range(3):
    state = increment(state)   # ViewModel меняет состояние
    print(render(state))       # UI перерисовывается

Попробуй сам ▶ — добавьте редьюсер reset, возвращающий count=0. Заметьте: состояние всегда обновляется иммутабельно — новый объект, а не правка старого, ровно как через copy() в Kotlin.

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

Проверьте себя
1. Почему состояние экрана хранят в ViewModel, а не в Activity?
AViewModel работает быстрее
BViewModel переживает пересоздание Activity (например, при повороте), поэтому состояние не теряется
CВ Activity вообще нельзя хранить переменные
DViewModel автоматически сохраняет данные на диск
2. Чем collectAsStateWithLifecycle лучше обычного collectAsState?
AОн короче по названию
BОн lifecycle-aware: приостанавливает сбор, когда экран не виден, экономя ресурсы
CОн работает только с LiveData
DМежду ними нет разницы