Урок 5 из 8 · 25 мин · обновлено

Состояние и ViewModel: экран реагирует на действия

Что такое состояние в Compose, как remember и mutableStateOf перерисовывают экран, почему данные пропадают при повороте телефона и как ViewModel хранит состояние экрана.

Состояние

Состояние — это данные, от которых зависит экран: введённый текст, список товаров, отмечен ли пункт. В Compose правило простое: изменилось состояние — Compose сам перерисовал те части экрана, которые от него зависят.

Kotlin
@Composable
fun Counter() {
    var count by remember { mutableIntStateOf(0) }

    Button(onClick = { count++ }) {
        Text("Нажато: $count")
    }
}
  • mutableIntStateOf(0) создаёт состояние с начальным значением 0. Для любых других типов есть mutableStateOf(…);
  • remember запоминает его между перерисовками. Без него при каждой перерисовке счётчик создавался бы заново с нулём;
  • by позволяет работать с состоянием как с обычной переменной: count++.

Если сделать count обычной переменной, число на кнопке меняться не будет: Compose не узнает, что что-то изменилось.

Поле ввода

Поле ввода в Compose не хранит текст само. Значение хранит состояние, а поле сообщает о каждом изменении:

Kotlin
var text by remember { mutableStateOf("") }

TextField(
    value = text,
    onValueChange = { text = it },
    label = { Text("Товар") },
)

value — что показывать, onValueChange — что делать, когда пользователь печатает. Здесь — сохранить новый текст. Такой подход называют однонаправленным потоком данных: данные идут сверху вниз, а события — снизу вверх.

Поворот экрана и ViewModel

Добавьте в список несколько товаров, храня их в remember, и поверните телефон — список исчезнет. При повороте Android пересоздаёт экран, и всё, что хранилось в remember, теряется. Для простых значений есть rememberSaveable, а данные экрана и действия с ними выносят во ViewModel:

Kotlin
class ShoppingViewModel : ViewModel() {

    private val _products = MutableStateFlow(listOf(Product("Молоко", 2)))
    val products: StateFlow<List<Product>> = _products.asStateFlow()

    fun add(name: String) {
        _products.update { it + Product(name, 1) }
    }

    fun delete(product: Product) {
        _products.update { it - product }
    }
}
  • ViewModel переживает поворот экрана и живёт, пока пользователь не ушёл с экрана;
  • MutableStateFlow — поток данных, который хранит текущее значение и сообщает подписчикам об изменениях. Наружу отдаём версию только для чтения, StateFlow, чтобы менять список можно было лишь через методы ViewModel;
  • update { it + … } создаёт новый список с добавленным товаром. Списки здесь неизменяемые: вместо правки старого создаётся новый.

Экран подписывается на данные и передаёт во ViewModel действия пользователя:

Kotlin
@Composable
fun ShoppingScreen(viewModel: ShoppingViewModel = viewModel()) {
    val products by viewModel.products.collectAsStateWithLifecycle()

    LazyColumn {
        items(products) { product ->
            ProductRow(
                name = product.name,
                count = product.count,
                onDelete = { viewModel.delete(product) },
            )
        }
    }
}

viewModel() создаёт ViewModel или возвращает уже существующую, а collectAsStateWithLifecycle() превращает поток в состояние Compose. Теперь кнопка «Удалить» из прошлого урока работает.

Это и есть базовая архитектура Android-приложения: интерфейс только показывает данные и сообщает о нажатиях, а ViewModel хранит данные и решает, что с ними делать.

Попробуйте сами. Добавьте над списком поле ввода и кнопку «Добавить». По нажатию товар попадает в список через ViewModel, а поле очищается. Пустую строку добавлять нельзя.

Решение
Kotlin
@Composable
fun AddProductRow(onAdd: (String) -> Unit) {
    var text by remember { mutableStateOf("") }

    Row(verticalAlignment = Alignment.CenterVertically) {
        TextField(
            value = text,
            onValueChange = { text = it },
            label = { Text("Товар") },
            modifier = Modifier.weight(1f),
        )
        Button(
            onClick = {
                onAdd(text.trim())
                text = ""
            },
            enabled = text.isNotBlank(),
        ) {
            Text("Добавить")
        }
    }
}

На экране: AddProductRow(onAdd = { name -> viewModel.add(name) }) над LazyColumn, обоих поместите в Column.

Текст поля — временное состояние, поэтому живёт в remember внутри строки. Список товаров — данные экрана, они во ViewModel. enabled = text.isNotBlank() выключает кнопку, пока поле пустое или в нём одни пробелы.

Мини-тест: проверьте себя

Закрепите урок: несколько вопросов, разбор ответов — в конце.

  1. Пользователь нажал кнопку, переменная count увеличилась, но число на экране не поменялось. В чём вероятная причина?

  2. Что произойдёт с текстом в поле, если в TextField передать value = text, но не обновлять text в onValueChange?

  3. Почему после поворота телефона список, который хранился в remember, пропал?

  4. Где по правилам хорошей архитектуры должна жить логика «добавить товар в список»?