Перейти к основному содержимому
Мобилка28ГЛАВА 28ViewModel: состояние, которое переживает поворот07пережило поворот

ViewModel: состояние, которое переживает поворот

Прочитано0%
Квизы0/5
Тренажёрыне пройден0/1
Уровень 1 · Новичок

Зачем это нужно​

Есть простой опыт, который стоит проделать самому. Соберите экран со счётчиком на 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 ← пережило
ПОВОРОТ ЭКРАНА: ЧТО УМИРАЕТ, ЧТО ОСТАЁТСЯActivity и вся разметкаvar counter = 7пересоздаётся целикомпосле поворота counter = 0поворотViewModeluiState.value = 7живёт дольше экранапосле поворота counter = 7ViewModel умирает не при повороте, а когда экран закрыт по-настоящемуперезапуск приложения она тоже не переживает — это уже задача хранилища
Activity пересоздаётся при повороте, ViewModel остаётся; но перезапуск приложения не переживает и она

Важно понимать границы. ViewModel живёт дольше экрана, но не вечно:

СобытиеrememberrememberSaveableViewModelХранилище на диске
Рекомпозицияпереживаетпереживаетпереживаетпереживает
Поворот экранатеряетсяпереживаетпереживаетпереживает
Уход в фон и возвратпереживаетпереживаетпереживаетпереживает
Система убила процесстеряетсяпереживаеттеряетсяпереживает
Перезапуск приложениятеряетсятеряетсятеряетсяпереживает

Отсюда правило: 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(«нет сети») → показываем ошибку и кнопку
третьего не дано: невозможных сочетаний просто не существует

Пять булевых переменных дают тридцать два сочетания, из которых осмысленны три. Остальные двадцать девять — это баги, ждущие своего часа, и каждый придётся отлавливать вручную.

ПЯТЬ ПЕРЕМЕННЫХ ИЛИ ОДНО СОСТОЯНИЕisLoading = trueitems = []error = "нет сети"isEmpty = falseспиннер и ошибка одновременносочетание, которого быть не должноUiStateLoadingспиннерContent(items)списокError(msg)ошибка и «Повторить»третьего не дано — невозможных сочетаний нетэкран рисуется одним when по состоянию, а не пятью вложенными if
Независимые флаги допускают невозможные сочетания; закрытый набор состояний их не допускает вовсе

Два способа записать состояние одним объектом:

// вариант 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 по нему компилятор проверяет на полноту — забыли вариант, получите ошибку компиляции, а не пустой экран.

Отвечено вопросов: 0 из 2
Почему состояние экрана держат в одном объекте, а не в отдельных переменных?
Зачем наружу отдают StateFlow, а внутри держат MutableStateFlow?
Отвечено верно: 0 из 2

Обновление состояния через 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 приходилось вычищать руками.

Однонаправленный поток данных​

Сложив всё вместе, получаем схему, которую на чемпионате называют по имени: состояние идёт вниз, события идут вверх.

ОДНОНАПРАВЛЕННЫЙ КРУГViewModelдержит состояние, решает, что с ним делатьЭкранрисует то, что дали; сам ничего не решаетсостояние внизсобытия вверхonRetryClick: () -> Unitсобытие — это параметр-функция: экран сообщает о нажатии, но не знает, что произойдётданные текут в одну сторону, поэтому всегда понятно, кто изменил состояние
ViewModel отдаёт состояние экрану, экран сообщает о действиях пользователя обратно

Экран не решает ничего. Он получает состояние и рисует его. Когда пользователь нажимает кнопку, экран не меняет данные — он сообщает о событии:

@Composable
fun ProductList(
state: CatalogUiState,
onRetryClick: () -> Unit,
onQueryChange: (String) -> Unit,
) { /* только отрисовка */ }

Событие — обычный параметр-функция, ровно как onClick из главы про состояние и события. Компонент не знает, что произойдёт при нажатии, и это правильно: его дело — сообщить.

Практическая польза видна при отладке. Если состояние меняется только в одном месте, вопрос «кто это сломал» имеет ровно один адрес. В коде, где экран правит данные напрямую, тот же вопрос требует обхода всех файлов.

Отвечено вопросов: 0 из 2
Изменили поле внутри объекта состояния, не создавая новый. Что произойдёт?
Что происходит с корутиной из viewModelScope, когда пользователь уходит с экрана?
Отвечено верно: 0 из 2

Чего в 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 — должен быть комментарий с описанием, датой создания и номером участника. Это множитель на итоговый балл, а не отдельный аспект.
Отвечено вопросов: 0 из 2
Эксперт поворачивает устройство, и введённый в форму текст пропадает. В чём причина?
Можно ли хранить Context активности во ViewModel?
Отвечено верно: 0 из 2

Что дальше​

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

Отвечено вопросов: 0 из 1
Выбранная вкладка нижней панели должна пережить перезапуск приложения. Хватит ViewModel?
Отвечено верно: 0 из 1

Углубиться​

  • SavedStateHandle — состояние, переживающее убийство процесса системой: то, что между ViewModel и хранилищем.
  • onCleared() — момент уничтожения ViewModel; место для освобождения ресурсов, которые не закрываются сами.
  • stateIn — превращение холодного потока из репозитория в StateFlow для экрана, с политикой подписки.
  • Тестирование ViewModel: runTest, подмена диспетчера и почему для этого viewModelScope в новых версиях стал переопределяемым.
  • AndroidViewModel и когда контекст приложения действительно нужен, а когда это признак того, что логика лежит не там.

Сводная проверка по всей главе: 7 вопросов, 5:00 на всё, без подсказок по ходу. Вопросы показываются по одному, ответ изменить нельзя. Если время выйдет — экзамен завершится с тем, что успел ответить. Пересдавать можно сколько угодно раз.

Куда дальше: проверенные ресурсы​

Видео по теме

ВидеоViewModels & Configuration Changes - Android BasicsPhilipp Lackner
ВидеоState in Jetpack ComposeAndroid Developers
ВидеоShould You Use Compose State or StateFlow in Your ViewModel?Philipp Lackner
ВидеоMastering UI Events and States in Jetpack ComposeStevdza-San
ВидеоAndroid Jetpack: ViewModelAndroid Developers

Комментарии

Обсуждение живёт в GitHub Discussions и грузится с чужого домена — поэтому только по нажатию.