ViewModel: состояние, которое переживает поворот
- Зачем это нужно
- Что переживает пересоздание
- Первая ViewModel
UiState: одно состояние вместо россыпи флагов- Обновление состояния через
copy viewModelScope: корутины, живущие с экраном- Однонаправленный поток данных
- Чего в ViewModel быть не должно
- Типичные ошибки
- Слова главы
- Тренажёр: набрать вслепую
- На чемпионате
- Что дальше
- Углубиться
- Куда дальше: проверенные ресурсы
Зачем это нужно
Есть простой опыт, который стоит проделать самому. Соберите экран со счётчиком на remember, нажмите кнопку десять раз и поверните телефон. Счётчик обнулится.
Причина не в Compose и не в ошибке. При повороте Android пересоздаёт Activity целиком: старая уничтожается, новая строится заново, и всё, что жило внутри неё, исчезает вместе с ней. То же самое происходит при смене языка системы, при переключении на тёмную тему, при изменении размера окна.
Пользователь при этом уверен, что ничего не делал — он просто повернул телефон. Потерянный текст в форме, сброшенный фильтр, пропавший список — всё это выглядит как поломка приложения.
Нужен объект, который живёт дольше экрана. В Android он называется ViewModel, и на чемпионате именно с него начинается модуль архитектуры — самый первый и всегда обязательный.
Что переживает пересоздание
Проследим границу на модели:
class Screen(val name: String) { var counter = 0 }
class ViewModelStore { val kept = mutableMapOf<String, Int>() }
fun main() {
val store = ViewModelStore()
var screen = Screen("Каталог")
screen.counter = 7
store.kept["counter"] = screen.counter
println("пользователь досчитал до ${screen.counter}")
println("поворот экрана — Activity пересоздаётся...")
screen = Screen("Каталог")
println(" переменная внутри экрана: ${screen.counter} ← обнулилась")
println(" значение во ViewModel: ${store.kept["counter"]} ← пережило")
}
Живой вывод:
пользователь досчитал до 7
поворот экрана — Activity пересоздаётся...
переменная внутри экрана: 0 ← обнулилась
значение во ViewModel: 7 ← пережило
Важно понимать границы. ViewModel живёт дольше экрана, но не вечно:
| Событие | remember | rememberSaveable | ViewModel | Хранилище на диске |
|---|---|---|---|---|
| Рекомпозиция | переживает | переживает | переживает | переживает |
| Поворот экрана | теряется | переживает | переживает | переживает |
| Уход в фон и возврат | переживает | переживает | переживает | переживает |
| Система убила процесс | теряется | переживает | теряется | переживает |
| Перезапуск приложения | теряется | теряется | теряется | переживает |
Отсюда правило: ViewModel — для состояния экрана в течение сеанса. Для того, что обязано пережить перезапуск — например, выбранной вкладки из прошлой главы — нужно хранилище, и до него мы дойдём в блоке про данные.
Первая ViewModel
class CatalogViewModel : ViewModel() {
private val _uiState = MutableStateFlow(CatalogUiState())
val uiState: StateFlow<CatalogUiState> = _uiState.asStateFlow()
fun onRetryClick() {
_uiState.value = _uiState.value.copy(isLoading = true, error = null)
}
}
Экран подписывается на состояние так:
@Composable
fun CatalogScreen(viewModel: CatalogViewModel = viewModel()) {
val state by viewModel.uiState.collectAsStateWithLifecycle()
when {
state.isLoading -> LoadingIndicator()
state.error != null -> ErrorMessage(state.error, onRetry = viewModel::onRetryClick)
else -> ProductList(state.items)
}
}
collectAsStateWithLifecycle — та самая подписка из главы про Flow. Она останавливает сбор, когда экран уходит с переднего плана, и возобновляет при возврате. Обычный collectAsState продолжал бы слушать невидимый экран, тратя батарею и трафик.
UiState: одно состояние вместо россыпи флагов
Соблазн велик: завести отдельные переменные под загрузку, под список, под ошибку. Посмотрим, к чему это приводит:
fun main() {
println("── пять независимых переменных ──")
var isLoading = true; var items = listOf<String>(); var error: String? = null
fun show() {
println(" loading=$isLoading, items=${items.size}, error=$error")
if (isLoading && error != null) println(" ⚠ спиннер и ошибка одновременно — так быть не должно")
}
isLoading = true; error = "нет сети"; show()
println()
println("── одно состояние с закрытым набором вариантов ──")
println(" Loading → крутим спиннер")
println(" Content(items=3) → показываем список")
println(" Error(«нет сети») → показываем ошибку и кнопку")
println(" третьего не дано: невозможных сочетаний просто не существует")
}
── пять независимых переменных ──
loading=true, items=0, error=нет сети
⚠ спиннер и ошибка одновременно — так быть не должно
── одно состояние с закрытым набором вариантов ──
Loading → крутим спиннер
Content(items=3) → показываем список
Error(«нет сети») → показываем ошибку и кнопку
третьего не дано: невозможных сочетаний просто не существует
Пять булевых переменных дают тридцать два сочетания, из которых осмысленны три. Остальные двадцать девять — это баги, ждущие своего часа, и каждый придётся отлавливать вручную.
Два способа записать состояние одним объектом:
// вариант 1: data class — когда части состояния сосуществуют
data class CatalogUiState(
val isLoading: Boolean = false,
val items: List<Product> = emptyList(),
val error: String? = null,
val query: String = "",
)
// вариант 2: sealed interface — когда состояния взаимоисключающие
sealed interface CatalogUiState {
data object Loading : CatalogUiState
data class Content(val items: List<Product>) : CatalogUiState
data class Error(val message: String) : CatalogUiState
}
data class удобен, когда на экране есть постоянные части: строка поиска остаётся видимой и во время загрузки, и при ошибке. sealed interface строже: он делает невозможные сочетания невыразимыми, и when по нему компилятор проверяет на полноту — забыли вариант, получите ошибку компиляции, а не пустой экран.
Обновление состояния через copy
Состояние в StateFlow меняют не по частям, а целиком — новым объектом:
fun onQueryChange(text: String) {
_uiState.value = _uiState.value.copy(query = text)
}
fun onLoadStart() {
_uiState.update { it.copy(isLoading = true, error = null) }
}
copy создаёт новый объект, где изменены названные поля, а остальные скопированы. Это не прихоть: StateFlow сравнивает новое значение с текущим через equals и молчит, если они равны. Если поменять поле внутри существующего объекта, в value окажется тот же самый объект — сравнение с самим собой всегда даёт «равны», и экран не перерисуется. У этого есть и обратная сторона: copy(), который не изменил ни одного поля, тоже не вызовет обновления — новый объект равен старому по значению. Ошибка без сообщения, ровно та, о которой предупреждала глава про Flow.
Функция update { } делает то же самое, но безопаснее: она читает и записывает одной атомарной операцией. Когда состояние меняют из нескольких корутин сразу, _uiState.value = _uiState.value.copy(...) может потерять одно из изменений — между чтением и записью успевает вклиниться другая.
Правило простое: если состояние меняется только из главного потока — годится присваивание. Если хоть где-то из фона или из нескольких корутин — только update { }. Второе не дороже и работает всегда, поэтому проще привыкнуть сразу к нему.
viewModelScope: корутины, живущие с экраном
В главе про корутины мы запускали их через runBlocking и rememberCoroutineScope. У ViewModel есть свой готовый scope:
fun loadProducts() {
viewModelScope.launch {
_uiState.update { it.copy(isLoading = true) }
val result = repository.getProducts()
_uiState.update { it.copy(isLoading = false, items = result) }
}
}
viewModelScope отменяется автоматически, когда ViewModel уничтожается. Пользователь ушёл с экрана — запрос прервался сам, и результат не придёт в объект, которого уже нет. Это избавляет от целого класса утечек, которые в старом Android приходилось вычищать руками.
Однонаправленный поток данных
Сложив всё вместе, получаем схему, которую на чемпионате называют по имени: состояние идёт вниз, события идут вверх.
Экран не решает ничего. Он получает состояние и рисует его. Когда пользователь нажимает кнопку, экран не меняет данные — он сообщает о событии:
@Composable
fun ProductList(
state: CatalogUiState,
onRetryClick: () -> Unit,
onQueryChange: (String) -> Unit,
) { /* только отрисовка */ }
Событие — обычный параметр-функция, ровно как onClick из главы про состояние и события. Компонент не знает, что произойдёт при нажатии, и это правильно: его дело — сообщить.
Практическая польза видна при отладке. Если состояние меняется только в одном месте, вопрос «кто это сломал» имеет ровно один адрес. В коде, где экран правит данные напрямую, тот же вопрос требует обхода всех файлов.
Чего в ViewModel быть не должно
ViewModel живёт дольше экрана — отсюда главный запрет: в ней не должно быть ничего, что связано с конкретным экраном.
Нельзя хранить Context активности, View, NavController или что-либо, ссылающееся на Activity. Экран пересоздастся, а ссылка останется указывать на уничтоженный объект — это утечка памяти в чистом виде, и приложение будет держать в памяти всё старое дерево разметки.
Нельзя обращаться к ресурсам через контекст: context.getString(R.string.error). Строку с текстом ошибки формирует экран, а ViewModel отдаёт ему код или тип ошибки. Так и локализация работает правильно: при смене языка экран пересоберётся и возьмёт строку заново.
Нельзя делать сетевые запросы напрямую из ViewModel — но об этом отдельно в главе про слои. Пока достаточно помнить, что ViewModel решает, что показать, а не где это взять.
Типичные ошибки
Состояние в remember вместо ViewModel. Поворот экрана обнуляет всё. Самая заметная ошибка и самая простая в починке.
Изменение поля вместо copy. Экран не перерисовывается, ошибок нет. Ищется долго.
Context внутри ViewModel. Утечка памяти. Если контекст действительно нужен — например, для DataStore — берут AndroidViewModel с контекстом приложения, который живёт столько же, сколько процесс.
collectAsState вместо collectAsStateWithLifecycle. Подписка продолжает работать на невидимом экране.
Логика в Composable. Фильтрация списка или разбор даты прямо в разметке выполняются при каждой перерисовке. Место такого кода — ViewModel.
Одна ViewModel на всё приложение. Растёт до тысячи строк и превращается в свалку. У каждого экрана своя.
Слова главы
ViewModel— объект, переживающий пересоздание Activity.viewModel()— функция, которая достаёт существующую или создаёт новую.UiState— состояние экрана одним объектом.sealed interface— закрытый набор состояний;whenпо нему проверяется на полноту.copy()— новый объект с изменёнными полями; без негоStateFlowне заметит изменения.update { }— атомарное обновление состояния.asStateFlow()— сужение до версии только для чтения.viewModelScope— область корутин, отменяемая вместе сViewModel.- Однонаправленный поток — состояние вниз, события вверх.
Тренажёр: набрать вслепую
Цель: скорость ≥ 100 зн/мин, точность ≥ 88%
private val _uiState = MutableStateFlow(CatalogUiState())
На чемпионате
Модуль архитектуры — первый и всегда обязательный, а в критериях он разложен на слои: отдельно проверяют, что слой представления не содержит чужого кода. ViewModel — сердце этого слоя.
Что смотрят по факту:
- Состояние переживает поворот. Проверяется прямо на защите: эксперт поворачивает устройство.
- Индикатор загрузки и обработка ошибок. В заданиях это отдельное требование — «отображение статусов выполнения операций». Ради него и нужен
UiStateс вариантами. - Логика не живёт в разметке. Composable-функция, которая фильтрует список или ходит в сеть, — прямой минус по критерию слоёв.
- Комментарии к классам. По требованиям 2026 года у каждого класса верхнего уровня — включая
ViewModel— должен быть комментарий с описанием, датой создания и номером участника. Это множитель на итоговый балл, а не отдельный аспект.
Что дальше
ViewModel держит состояние и решает, что показать. Но откуда берутся сами данные, пока непонятно: в примерах они появлялись из ниоткуда. Следующая глава — про слои: где живут данные, почему между ViewModel и сетью ставят посредника и как устроить всё так, чтобы подключение настоящего сервера не потребовало переписывать экран.
Углубиться
SavedStateHandle— состояние, переживающее убийство процесса системой: то, что междуViewModelи хранилищем.onCleared()— момент уничтоженияViewModel; место для освобождения ресурсов, которые не закрываются сами.stateIn— превращение холодного потока из репозитория вStateFlowдля экрана, с политикой подписки.- Тестирование
ViewModel:runTest, подмена диспетчера и почему для этогоviewModelScopeв новых версиях стал переопределяемым. AndroidViewModelи когда контекст приложения действительно нужен, а когда это признак того, что логика лежит не там.
Сводная проверка по всей главе: 7 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.
Куда дальше: проверенные ресурсы
- ViewModel — developer.android.com — первоисточник по жизненному циклу и области видимости.
- Состояние в Compose — подъём состояния и связь с
ViewModel. - Слой представления в архитектуре —
UiState, однонаправленный поток и события. - collectAsStateWithLifecycle — почему обычный
collectAsStateнебезопасен.
Комментарии
Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.