Перейти к основному содержимому
05МОБИЛКА · ГЛАВА 05Состояние и события

Состояние и события

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

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

Прошлая глава собрала первый экран — но он был неподвижным: Text печатал фиксированную строку, а Button при нажатии не делал ничего, кроме пустой лямбды {}. Реальные экраны так не работают: нажал на кнопку — счётчик увеличился, напечатал текст в поле — он появился на экране. Эта глава разбирает, откуда Compose вообще узнаёт, что нужно перерисовать экран, и как значение, которое меняется (состояние, state), заставляет это происходить.

Здесь появляется новый синтаксис Kotlin, который выглядит непривычно даже для тех, кто прочитал все четыре прошлые главы: слово by. Это не хитрость Compose, а обычный механизм языка Kotlin — делегирование свойств (property delegation), который прекрасно работает и без единой строчки Android-кода. Поэтому главный демонстрационный пример этой главы — не composable-функция, а самодельный класс на чистом Kotlin, который повторяет, буква в букву, то же самое поведение, что и настоящий mutableStateOf внутри Compose. Разобрав его до конца, ты будешь понимать by remember { mutableStateOf(0) } не как заклинание для копирования, а как код, который делает ровно то, что написано.

Как и в прошлой главе: сам Jetpack Compose (Text, Column, Button, TextField, @Composable) в браузере не запускается — для него нужен настоящий Android-проект. Compose-код здесь снова даётся статичными блоками с построчным разбором. А там, где под капотом Compose скрывается обычный Kotlin — делегаты, лямбды, замыкания, — глава впервые в этом курсе даёт исполняемые примеры буквально для каждой идеи, включая типичные ошибки: все они собраны так, что запускаются прямо здесь, в песочнице.

var, который не переживает вызов

Прежде чем говорить о том, как Compose хранит состояние, стоит увидеть проблему, которую это решает. Composable-функция — это обычная функция Kotlin, и Compose вызывает её заново каждый раз, когда экран нужно перерисовать (к этому механизму, рекомпозиции, глава ещё вернётся отдельно). А обычная локальная переменная, объявленная через var внутри тела функции, при каждом новом вызове функции создаётся заново — с тем значением, что стоит в момент объявления, и ничего не помнит про прошлые вызовы.

fun renderScreen(): Int { var count = 0 count++ return count } fun main() { println(renderScreen()) println(renderScreen()) println(renderScreen()) }

Разбор по строкам

  • Строки 1–4: fun renderScreen(): Int { var count = 0; count++; return count } — функция объявляет локальную переменную count, сразу увеличивает её на единицу и возвращает результат. renderScreen условно изображает composable-функцию: Compose вызывает такую функцию заново при каждой перерисовке, точно как здесь main вызывает renderScreen заново три раза подряд.
  • Строки 7–9: println(renderScreen()) × 3 — три отдельных, независимых вызова одной и той же функции.

Прежде чем читать разгадку ниже — предскажи сам, что напечатают три вызова подряд:

fun renderScreen(): Int {
  var count = 0
  count++
  return count
}

fun main() {
  println(renderScreen())
  println(renderScreen())
  println(renderScreen())
}

Что напечатает этот код? Сначала ответь, потом проверяй.

Что выведет: 1, 1, 1 — три одинаковые строки, а не 1, 2, 3, как можно было бы ожидать от «счётчика». Причина в том, что var count = 0 внутри тела функции — это не одна переменная, которая живёт между вызовами, а новая переменная при каждом вызове: она создаётся, увеличивается на единицу и тут же исчезает вместе с завершением функции. Ровно так же вела бы себя composable-функция, если бы попыталась хранить счётчик нажатий в обычном var внутри своего тела: при каждой рекомпозиции счётчик обнулялся бы заново, и нажатия как будто не считались бы.

remember: место, которое переживает пересборку

Чтобы значение действительно накапливалось между вызовами, ему нужно жить не внутри тела функции, а где-то снаружи — в месте, которое функция не создаёт заново при каждом запуске, а лишь находит и переиспользует. В Compose за это отвечает функция remember — она запоминает результат вычисления при первом вызове и на всех следующих вызовах отдаёт то же самое сохранённое значение, не выполняя вычисление заново.

Прежде чем смотреть на настоящий remember, стоит увидеть сам принцип «место снаружи функции переживает повторные вызовы» на чистом Kotlin — через переменную уровня файла, которая существует независимо от того, сколько раз вызвали функцию:

var savedCount = 0 fun renderScreen(): Int { savedCount++ return savedCount } fun main() { println(renderScreen()) println(renderScreen()) println(renderScreen()) }

Разбор по строкам

  • Строка 1: var savedCount = 0 — переменная объявлена СНАРУЖИ функции, на уровне файла. Она создаётся один раз при запуске программы, а не при каждом вызове renderScreen.
  • Строки 3–6: fun renderScreen(): Int { savedCount++; return savedCount } — функция больше не создаёт свою переменную, а изменяет и возвращает существующую, внешнюю.

Что выведет: 1, 2, 3 — теперь значение растёт от вызова к вызову, потому что оно живёт не внутри функции, а вне её.

COMPOSE · var ПРОТИВ remember МЕЖДУ ВЫЗОВАМИvar count = 0 внутри функции111каждый вызов создаёт count заново — 1, 1, 1var count by remember { mutableStateOf(0) }123значение живёт снаружи — 1, 2, 3, растёт от вызова к вызову
var внутри функции создаётся заново при каждом вызове — 1, 1, 1. remember переживает пересборку и растёт от вызова к вызову — 1, 2, 3.
Важно

Важная оговорка на честность: настоящий remember устроен не через глобальную переменную файла — он хранит значение в специальной внутренней структуре Compose (композиции, composition), привязанной к конкретному месту вызова composable-функции в дереве экрана, а не ко всему файлу целиком. Если бы это была обычная глобальная переменная, два разных счётчика на экране делили бы одно и то же значение — а remember в двух разных местах хранит для каждого своё. Но сама суть — «место снаружи тела функции, которое не создаётся заново при повторном вызове» — у обоих одинаковая, и для первого знакомства с идеей этого достаточно; более точный механизм — в разделе «Углубиться» в конце главы.

Настоящий remember в Compose выглядит так — обёрнутый вокруг ещё одной новой функции, mutableStateOf, к которой глава переходит в следующем разделе:

val countState = remember { mutableStateOf(0) }
Нажми на часть выражения

А теперь потрогай разницу руками: один и тот же счётчик, объявленный с remember и без, и кнопка, которая форсирует рекомпозицию — то есть вызывает функцию заново.

var count = 0 // внутри тела функции
Счёт: 0
Рекомпозиций: 0
Накрути счёт и форсируй рекомпозицию в обоих режимах — сравни, что случится со значением.

mutableStateOf и делегат by

mutableStateOf: наблюдаемый контейнер

remember сам по себе решает только половину задачи — «не пересчитывать заново». Вторая половина — «сообщить Compose, что нужно перерисовать экран, когда значение изменилось» — задача функции mutableStateOf. Она оборачивает обычное значение в объект специального типа MutableState (изменяемое состояние): не просто хранилище, а хранилище, за изменением которого Compose умеет следить. У MutableState<T> есть одно свойство — value — и именно запись в это свойство запускает всю цепочку «Compose узнал об изменении → запланировал рекомпозицию».

Делегат: что означает слово by

Здесь и начинается самая важная новая конструкция этой главы. Собранные вместе, remember и mutableStateOf почти всегда пишут не так:

val countState = remember { mutableStateOf(0) }
// дальше приходится всегда писать countState.value
countState.value++

а вот так:

var count by remember { mutableStateOf(0) }
// дальше count можно читать и писать как обычную переменную
count++

Слово by (по-английски дословно «посредством», «через») — это делегирование свойства (property delegation): вместо того чтобы count был обычной переменной со своим местом в памяти, компилятор перенаправляет КАЖДОЕ чтение и КАЖДУЮ запись count объекту справа от by — в данном случае тому самому MutableState, что вернул remember { mutableStateOf(0) }. Разберём объявление по частям:

Нажми на часть выражения

Как работает delegate на самом деле: свой класс без единой строчки Compose

Слово by — не специальная магия именно для MutableState, а общий механизм Kotlin: делегатом может быть любой класс, у которого есть две специальные функции с ключевым словом operatorgetValue (вызывается при чтении) и setValue (вызывается при записи). Ниже — самодельный класс Loud («громкий»), который делегирует Int-свойство и печатает в консоль каждое обращение к нему, чтобы стало видно, когда именно Kotlin вызывает эти функции сам, без единой явной команды в main.

import kotlin.reflect.KProperty class Loud(private var current: Int) { operator fun getValue(thisRef: Any?, property: KProperty<*>): Int { println("Читаю ${property.name}: $current") return current } operator fun setValue(thisRef: Any?, property: KProperty<*>, value: Int) { println("Пишу в ${property.name}: $current -> $value") current = value } } fun main() { var count by Loud(0) println("Старт") count++ println("Итог: $count") }

Разбор по строкам

  • Строка 1: import kotlin.reflect.KPropertyKProperty живёт в библиотеке рефлексии Kotlin (reflection — способность программы «разглядывать» собственный код во время выполнения); её нужно подключить отдельно, чтобы использовать в сигнатуре делегата.
  • Строка 3: class Loud(private var current: Int) { — обычный класс с одним приватным изменяемым свойством current, в котором и хранится настоящее значение (примерно как настоящий MutableState внутри себя хранит value).
  • Строки 4–7: operator fun getValue(thisRef: Any?, property: KProperty<*>): Int { ... } — функция, которую Kotlin вызывает сам при КАЖДОМ чтении делегированного свойства.
  • Строки 8–11: operator fun setValue(thisRef: Any?, property: KProperty<*>, value: Int) { ... } — функция, которую Kotlin вызывает сам при КАЖДОЙ записи в делегированное свойство; третий параметр, value, — это и есть то новое значение, которое пытаются записать.
  • Строка 15: var count by Loud(0) — объявляет локальную переменную count, делегированную объекту Loud(0). С этой строки count для остального кода выглядит как обычная переменная типа Int.
  • Строка 17: count++ — выглядит как одна операция, но компилятор превращает её в чтение (getValue) и следом запись увеличенного значения (setValue) — то же самое, что count = count + 1.
  • Строка 18: println("Итог: $count") — снова чтение, снова getValue.
KOTLIN · ДЕЛЕГАТ by: count++ ПОД КАПОТОМcount++1 читатьgetValue()вернёт 02 писатьsetValue(1)делегат хранит 1count нигде не хранится сам — каждое чтение и запись уходит через делегат
count++ незаметно превращается в вызов getValue, потом setValue у делегата — сам count нигде не хранится отдельно.

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

Нажми на часть выражения

Прокрути main в голове: какие строки и в каком порядке напечатает делегат? Ответь сам, прежде чем читать разбор ниже:

import kotlin.reflect.KProperty

class Loud(private var current: Int) {
  operator fun getValue(thisRef: Any?, property: KProperty<*>): Int {
      println("Читаю ${property.name}: $current")
      return current
  }
  operator fun setValue(thisRef: Any?, property: KProperty<*>, value: Int) {
      println("Пишу в ${property.name}: $current -> $value")
      current = value
  }
}

fun main() {
  var count by Loud(0)
  println("Старт")
  count++
  println("Итог: $count")
}

Что напечатает этот код? Сначала ответь, потом проверяй.

Что выведет: Старт, затем Читаю count: 0, Пишу в count: 0 -> 1, Читаю count: 1, и наконец Итог: 1. Обрати внимание: слово count нигде явно не вызывает getValue или setValue — Kotlin сам подставляет эти вызовы на месте каждого чтения и каждой записи, потому что count объявлен через by.

Именно так устроен и настоящий by remember { mutableStateOf(0) } — только вместо самодельного Loud делегатом выступает MutableState, а функции getValue/setValue для него определены не как методы самого класса, а как отдельные функции-расширения (extension functions — механизм Kotlin, который «пристраивает» новые функции к уже существующему типу) в библиотеке androidx.compose.runtime. Чтобы by заработал с mutableStateOf, эти функции-расширения должны быть в области видимости — обычно за это отвечают import androidx.compose.runtime.getValue и import androidx.compose.runtime.setValue, которые Android Studio почти всегда добавляет сама через автодополнение. Что происходит, если их всё-таки нет, — отдельная типичная ошибка этой главы, разобранная дальше.

Отвечено вопросов: 0 из 4
В демо с renderScreen() и локальным var count — почему println выводит 1, 1, 1, а не 1, 2, 3?
Что конкретно решает remember, а что — mutableStateOf, если использовать их по отдельности?
В демо с классом Loud — что именно вызывает Kotlin, когда в коде написано count++?
Зачем у getValue второй параметр имеет тип KProperty<*>, а не просто отсутствует?
Отвечено верно: 0 из 4

Рекомпозиция: что перерисуется и почему

Прошлая глава уже определила рекомпозицию (recomposition) как повторный вызов composable-функции с новыми данными, когда что-то изменилось. Теперь понятно, что именно запускает этот повторный вызов: запись в .value объекта MutableState (то есть setValue делегата) не просто меняет число — она сообщает системе Compose «то место экрана, что читало это значение, устарело, перерисуй его заново».

COMPOSE · ЦИКЛ СОСТОЯНИЯEventonClickStatecount++Recompositionтолько читающиенажатие → состояние изменилось → перерисовалось только то, что его читало
Цикл состояния в Compose: событие меняет state, state запускает рекомпозицию — и перерисовывается только то, что его читало.

Важная деталь, которую легко упустить: перерисовывается не весь экран целиком, а только те composable-функции, которые ДЕЙСТВИТЕЛЬНО читали именно то состояние, которое изменилось. Если на экране есть текст с именем пользователя и отдельно — счётчик, а меняется только счётчик, то текст с именем не перерисовывается: он же не читал переменную счётчика, значит для него ничего не устарело.

Проверить эту идею вживую в браузере с настоящим Compose нельзя — но можно смоделировать на чистом Kotlin: два «куска экрана» в виде обычных функций, и вручную решить, какой из них перерисовать после изменения одного состояния.

fun main() { val name = "Олег" var count = 0 fun renderName() { println("[renderName] Имя: $name") } fun renderCounter() { println("[renderCounter] Счётчик: $count") } println("--- Первая отрисовка ---") renderName() renderCounter() println("--- count изменился, name — нет ---") count++ renderCounter() }

Разбор по строкам

  • Строки 2–3: val name = "Олег" и var count = 0 — два независимых значения, как будто два разных состояния на экране.
  • Строки 5–7 и 9–11: renderName() и renderCounter() — вложенные функции (Kotlin разрешает объявлять функцию внутри функции); каждая печатает своё значение и условно изображает отдельный кусок composable-дерева, который читает только одно из состояний.
  • Строки 13–15: первая отрисовка — вызывают обе функции: это аналог первой композиции (initial composition) экрана, когда Compose строит его с нуля и вызывает вообще всё.
  • Строки 17–19: count++ и повторный renderCounter() — имитирует: состояние count изменилось, и перерисовывается ТОЛЬКО тот кусок, что его читал.

Что выведет:

--- Первая отрисовка ---
[renderName] Имя: Олег
[renderCounter] Счётчик: 0
--- count изменился, name — нет ---
[renderCounter] Счётчик: 1

renderName не вызывается второй раз — и это специально сделано в примере руками, чтобы показать сам принцип. В настоящем Compose это происходит автоматически: тот же плагин компилятора, что распознаёт @Composable (из прошлой главы), встраивает в каждую composable-функцию скрытый код, который отслеживает, к каким именно объектам State она обращалась во время выполнения, — и при изменении конкретного State перерисовывает только те функции, что реально его читали.

Совет

Отсюда и требование из прошлой главы, что composable-функция должна быть быстрой и идемпотентной (при одинаковых входных данных вести себя одинаково, сколько бы раз её ни вызвали): раз Compose решает сам, когда и сколько раз перевызвать функцию, внутри неё нельзя полагаться на побочные эффекты, которые ломаются от повторного выполнения, — печать в лог для отладки безопасна, а, например, отправка сетевого запроса «на каждый вызов функции» — нет.

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

Родитель TrainingScreen — владеет countперерисован 1 раз
Text("Счёт: $count") — читает count
Счёт: 0
перерисован 1 раз
Text("Имя: $name") — НЕ читает count
Имя: Олег
перерисован 1 раз
Нажми «Плюс» и следи за бейджами: кто из трёх кусков экрана перерисовался, а кто — нет.
Это модель на React, в Compose логика та же: у «Имени» перерисовку пропускает React.memo, а в Compose то же самое делает отслеживание чтений State.
Отвечено вопросов: 0 из 4
Что в реальном Compose играет роль setValue из прошлого раздела — что именно запускает рекомпозицию?
В демо с renderName/renderCounter — почему renderName ни разу не печатается заново после count++?
Почему composable-функция должна быть идемпотентной, если разговор зашёл именно про рекомпозицию?
Если на экране Text читает и count, и name, а изменился только count — что произойдёт с этим Text?
Отвечено верно: 0 из 4

onClick и обработчики: событие как лямбда, ещё раз

Прошлая глава уже показала: onClick — это параметр-лямбда типа () -> Unit (функция без аргументов, ничего не возвращающая), которую Compose вызывает не сразу при отрисовке, а в момент настоящего нажатия. Теперь, когда состояние (state) введено, у onClick появляется конкретная работа — менять его:

Button(onClick = { count++ }) {
Text(text = "Плюс")
}
Нажми на часть выражения

TextField и onValueChange: обработчик с параметром

Кнопка — не единственный источник событий. Поле ввода, TextField, реагирует не на клик, а на каждое изменение текста внутри — и вместо onClick у него параметр onValueChange. Разница принципиальная: onClick ничего не сообщает о клике, кроме самого факта «нажали», а onValueChange обязан сообщить, ЧТО именно теперь написано в поле, — поэтому его тип другой.

TextField(value = text, onValueChange = { text = it })
Нажми на часть выражения

Тип onValueChange, если написать его явно, — (String) -> Unit: функция, принимающая один аргумент типа String и ничего не возвращающая. Сравним его с уже знакомым () -> Unit у onClick:

Нажми на часть выражения
Отвечено вопросов: 0 из 4
Чем принципиально отличается тип onClick от типа onValueChange?
В { text = it } внутри onValueChange — что представляет собой it?
Почему TextField(value = text, ...) не запоминает введённый текст сам по себе?
Что произойдёт, если в Button(onClick = { count++ }) написать вместо этого пустую лямбду Button(onClick = {})?
Отвечено верно: 0 из 4

Состояние вверх: подъём состояния на счётчике

До сих пор состояние объявлялось прямо внутри той же composable-функции, что его показывает. Это работает, но у такого подхода есть цена: состояние, спрятанное внутри функции, недоступно снаружи — ни для сброса, ни для отображения где-то ещё, ни для сохранения.

@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Column {
Text(text = "Счёт: $count")
Button(onClick = { count++ }) {
Text(text = "Плюс")
}
}
}

У этой версии Counter есть одна проблема: снаружи о значении count вообще ничего не известно. Если экрану выше по иерархии нужно узнать текущий счёт — например, чтобы показать надпись «Осталось 3 попытки» рядом, — доступа нет: count целиком заперт внутри Counter.

Решение называется подъёмом состояния (state hoisting): переменную remember { mutableStateOf(...) } переносят из дочерней функции в родительскую, а дочерняя вместо этого получает готовое значение и функцию-обработчик как обычные параметры. Дочерняя функция становится stateless (бессостоятельной, не хранящей состояния сама), а состояние живёт в ровно одном месте — в родителе.

@Composable
fun CounterDisplay(count: Int, onIncrement: () -> Unit) {
Column {
Text(text = "Счёт: $count")
Button(onClick = onIncrement) {
Text(text = "Плюс")
}
}
}

@Composable
fun TrainingScreen() {
var count by remember { mutableStateOf(0) }
CounterDisplay(count = count, onIncrement = { count++ })
}

Разбор по строкам

  • Строка 2: fun CounterDisplay(count: Int, onIncrement: () -> Unit) { — вместо remember { mutableStateOf(0) } внутри тела функция получает count уже готовым числом и лямбду onIncrement, которую сама не определяет, а только вызывает по нажатию.
  • Строка 6: Button(onClick = onIncrement) { — обработчик клика передан дальше без изменений: CounterDisplay не знает и не должна знать, ЧТО именно произойдёт при нажатии, только то, что нужно вызвать onIncrement.
  • Строка 12: var count by remember { mutableStateOf(0) } — теперь состояние живёт здесь, в TrainingScreen, а не в CounterDisplay.
  • Строка 13: CounterDisplay(count = count, onIncrement = { count++ }) — родитель передаёт вниз ТЕКУЩЕЕ значение (count = count) и лямбду, которая знает, как его изменить (onIncrement = { count++ }). Сам count++ по-прежнему выполняется здесь, в TrainingScreen, — просто по сигналу, пришедшему снизу через вызов onIncrement.
Интересный факт

Это правило часто формулируют короче: состояние течёт вниз (state flows down) — как обычный параметр, а события текут вверх (events flow up) — как вызов лямбды-обработчика. Официальная документация Compose называет этот принцип однонаправленным потоком данных (unidirectional data flow, UDF): данные всегда движутся в одну сторону, вниз по дереву параметров, а изменения запрашиваются в обратную сторону, вызовом обработчика, а не прямой записью откуда-то снизу.

COMPOSE · СОСТОЯНИЕ ВНИЗ, СОБЫТИЯ ВВЕРХTrainingScreenvar count by remember { mutableStateOf(0) }count: IntonIncrement()CounterDisplayсостояние течёт вниз параметром, событие — вверх вызовом лямбды
Состояние течёт вниз обычным параметром, событие — вверх вызовом лямбды-обработчика: родитель остаётся единственным источником истины.

Главный практический выигрыш: TrainingScreen теперь — и только он — единственный источник истины (single source of truth) для count. CounterDisplay можно переиспользовать где угодно, дать ему любое число и любой обработчик — он не привязан к тому, откуда именно берётся состояние.

Состояние вверх: подъём состояния на поле ввода

Тот же приём работает и для TextField — причём здесь цена «спрятанного» состояния ощущается острее, потому что текст из поля почти всегда нужен где-то ещё: отправить на сервер, проверить на пустоту, показать в другом месте экрана.

@Composable
fun NicknameField() {
var nickname by remember { mutableStateOf("") }
TextField(value = nickname, onValueChange = { nickname = it })
}

У этой версии NicknameField та же проблема, что и у первой версии Counter: nickname заперт внутри и недоступен снаружи. Даже простая надпись «Привет, ...» рядом с полем ввода, зависящая от того, что там написано, здесь невозможна — экран выше не может прочитать nickname.

@Composable
fun NicknameField(nickname: String, onNicknameChange: (String) -> Unit) {
TextField(value = nickname, onValueChange = onNicknameChange)
}

@Composable
fun RegistrationScreen() {
var nickname by remember { mutableStateOf("") }
Column {
NicknameField(nickname = nickname, onNicknameChange = { nickname = it })
Text(text = if (nickname.isBlank()) "Введи никнейм" else "Привет, $nickname!")
}
}

Разбор по строкам

  • Строка 2: fun NicknameField(nickname: String, onNicknameChange: (String) -> Unit) { — те же два параметра по смыслу, что и у CounterDisplay: готовое текущее значение и обработчик изменения, только тип обработчика другой — (String) -> Unit, разобранный в прошлом разделе.
  • Строка 3: TextField(value = nickname, onValueChange = onNicknameChange)onValueChange получает лямбду напрямую из параметра, не создавая свою: NicknameField просто передаёт полученное дальше, ничего не решая сама.
  • Строка 8: var nickname by remember { mutableStateOf("") } — состояние теперь в RegistrationScreen, начальное значение — пустая строка "", а не 0: тип состояния выводится по начальному значению, здесь это String.
  • Строка 10: NicknameField(nickname = nickname, onNicknameChange = { nickname = it }) — та же схема, что и у счётчика: передаём текущее значение и лямбду, которая умеет его менять.
  • Строка 11: Text(text = if (nickname.isBlank()) "Введи никнейм" else "Привет, $nickname!") — а вот это стало возможным ТОЛЬКО благодаря подъёму состояния: RegistrationScreen читает nickname не только для передачи в NicknameField, но и здесь, во второй, совершенно независимой строке текста. Если бы nickname остался внутри NicknameField, эта строка не смогла бы вообще ничего узнать о введённом тексте.
Отвечено вопросов: 0 из 4
Что именно означает фраза "состояние течёт вниз, события текут вверх"?
В NicknameField(nickname: String, onNicknameChange: (String) -> Unit) — почему onNicknameChange, а не просто изменение nickname внутри самой функции?
Что стало возможным в RegistrationScreen благодаря подъёму состояния, чего не было бы с самодостаточной NicknameField() без параметров?
Почему CounterDisplay(count: Int, onIncrement: () -> Unit) называют stateless (бессостоятельной), хотя она всё равно показывает число и реагирует на клик?
Отвечено верно: 0 из 4

Собираем экран целиком

Соберём счётчик и поле ввода на одном экране — том же TrainingScreen, что и в разделе про счётчик, только теперь дополненном никнеймом. Оба состояния хранятся в одном месте, а обе дочерние функции остаются полностью stateless.

@Composable
fun CounterDisplay(count: Int, onIncrement: () -> Unit) {
Column {
Text(text = "Счёт: $count")
Button(onClick = onIncrement) {
Text(text = "Плюс")
}
}
}

@Composable
fun NicknameField(nickname: String, onNicknameChange: (String) -> Unit) {
TextField(value = nickname, onValueChange = onNicknameChange)
}

@Composable
fun TrainingScreen() {
var count by remember { mutableStateOf(0) }
var nickname by remember { mutableStateOf("") }

Column {
NicknameField(nickname = nickname, onNicknameChange = { nickname = it })
Text(text = if (nickname.isBlank()) "Введи никнейм" else "Привет, $nickname!")
CounterDisplay(count = count, onIncrement = { count++ })
}
}

Разбор по строкам

  • Строки 18–19: var count by ... и var nickname by ... — оба состояния объявлены рядом, в одном-единственном месте на весь экран — TrainingScreen и есть единственный источник истины сразу для обоих значений.
  • Строка 22: NicknameField(nickname = nickname, onNicknameChange = { nickname = it }) — та же схема подъёма состояния, что и в отдельном разборе выше.
  • Строка 23: Text(text = if (nickname.isBlank()) ...) — читает nickname напрямую из TrainingScreen, минуя NicknameField.
  • Строка 24: CounterDisplay(count = count, onIncrement = { count++ }) — та же схема, что и для счётчика по отдельности.

Ни CounterDisplay, ни NicknameField не знают о существовании друг друга и не хранят вообще никакого состояния — обе функции целиком переиспользуемы: их можно было бы поставить на любой другой экран с любым другим родителем, и они продолжили бы работать без единой правки внутри себя.

Типичные ошибки

Все пять ошибок этой главы — впервые в курсе — можно вживую запустить прямо здесь, в песочнице: даже ошибка про делегат MutableState воспроизведена через класс той же формы, что и настоящий androidx.compose.runtime.MutableState<T>, — тем же компилятором Kotlin, что стоит за встроенным KotlinPlay.

1. Дочерний параметр — всегда val: count++ внутри чужой функции

После подъёма состояния легко забыть, что параметр count: Int в дочерней функции — это val, и его нельзя менять напрямую; менять разрешено только там, где объявлено настоящее remember-состояние.

fun counterDisplay(count: Int) { count++ println("Счёт: $count") } fun main() { counterDisplay(0) }
'val' cannot be reassigned.

Перевод: «val нельзя переприсвоить». Параметры функций в Kotlin — всегда val, даже без явного слова val перед именем; это ровно то же правило, что действует для любой обычной val-переменной.

Как починить: не менять count внутри дочерней функции напрямую — вместо этого принять лямбду-обработчик (onIncrement: () -> Unit, как в разделе про подъём состояния) и вызвать её, а настоящее изменение состояния оставить родителю, которому принадлежит remember { mutableStateOf(...) }.

2. У самодельного делегата нет setValue — var не может им пользоваться

import kotlin.reflect.KProperty class ReadOnlyBox(private val current: Int) { operator fun getValue(thisRef: Any?, property: KProperty<*>): Int { return current } } fun main() { var count by ReadOnlyBox(0) count = 5 println(count) }
Type 'ReadOnlyBox' has no method 'setValue(Nothing?, KMutableProperty0<*>, Int)', so it cannot serve as a delegate for var (read-write property).

Перевод: «тип ReadOnlyBox не имеет метода setValue(...), поэтому не может служить делегатом для var (свойства для чтения и записи)». ReadOnlyBox определяет только getValue — делегат, пригодный для чтения, но не для записи; а var count требует обоих.

Как починить: либо добавить в класс operator fun setValue(...), либо, если запись действительно не нужна, объявить свойство через val, а не varval-делегату достаточно одного getValue.

3. Забытый импорт: у MutableState нет getValue/setValue без него

Класс MutableState ниже специально повторяет форму настоящего androidx.compose.runtime.MutableState<T> — единственное свойство value, и больше ничего. В реальном Compose функции getValue/setValue для него существуют не как методы самого класса, а как отдельные функции-расширения в библиотеке androidx.compose.runtime; чтобы by заработал, их нужно подключить через import androidx.compose.runtime.getValue и import androidx.compose.runtime.setValue. Здесь эти расширения не определены вовсе — и ошибка получается той же природы, что и в предыдущем пункте, только полностью «пустая»: делегату не хватает не только setValue, но и getValue.

// Повторяет форму настоящего androidx.compose.runtime.MutableState<T>: // единственное свойство value, без getValue/setValue у самого класса. // В реальном Compose эти функции существуют как РАСШИРЕНИЯ в // androidx.compose.runtime и видны только после // import androidx.compose.runtime.getValue / setValue. class MutableState<T>(var value: T) fun <T> mutableStateOf(value: T): MutableState<T> = MutableState(value) fun main() { var count by mutableStateOf(0) count++ println(count) }
Type 'MutableState<Int>' has no method 'getValue(Nothing?, KMutableProperty0<*>)', so it cannot serve as a delegate.
Type 'MutableState<Int>' has no method 'setValue(Nothing?, KMutableProperty0<*>, ??? (Unresolved name: getValue))', so it cannot serve as a delegate for var (read-write property).
Unresolved reference 'getValue'.

Перевод: «тип MutableState<Int> не имеет метода getValue(...), поэтому не может служить делегатом» — и следом ещё две ошибки той же природы, которые компилятор выводит цепочкой, потому что без getValue он не может проверить и setValue, а дальше — саму строку println(count).

Как починить: добавить оба импорта — import androidx.compose.runtime.getValue и import androidx.compose.runtime.setValue (обычно за это отвечает автодополнение Android Studio, если писать by mutableStateOf(...) через подсказки, а не руками).

4. Обработчик вызван сразу, а не передан как ссылка

fun runOnClick(onClick: () -> Unit) { println("Кнопка нажата") onClick() } fun sayHi() { println("Привет!") } fun main() { runOnClick(sayHi()) }
Argument type mismatch: actual type is 'Unit', but '() -> Unit' was expected.

Перевод: «несовпадение типа аргумента: фактический тип Unit, а ожидался тип () -> Unit». sayHi() со скобками — это ВЫЗОВ функции: он выполняется немедленно, здесь же, при вычислении аргумента, а его результатом становится Unit (то, что возвращает sayHi). runOnClick же ждёт не результат вызова, а саму функцию как значение — ссылку на неё, которую сможет вызвать сам, когда потребуется.

Как починить: убрать скобки — передать ссылку на функцию, а не её вызов: runOnClick(::sayHi) или обернуть в лямбду runOnClick { sayHi() }. Та же ошибка на практике чаще всего вылезает при перепутанных onClick = someHandler() и onClick = someHandler — второе верно, первое вызывает обработчик немедленно и передаёт его результат.

5. Не тот тип присвоен делегированной переменной

import kotlin.reflect.KProperty class Box(private var current: Int) { operator fun getValue(thisRef: Any?, property: KProperty<*>): Int = current operator fun setValue(thisRef: Any?, property: KProperty<*>, value: Int) { current = value } } fun main() { var count by Box(0) count = "текст" println(count) }
Assignment type mismatch: actual type is 'String', but 'Int' was expected.

Перевод: «несовпадение типа при присваивании: фактический тип String, а ожидался Int». Тип count зафиксирован по начальному значению Box(0) ещё на строке объявления — setValue принимает только Int, и попытка записать туда String (например, спутав переменную счётчика с переменной для текста из TextField) не пройдёт компиляцию.

Как починить: присваивать значение того же типа, каким count был объявлен изначально — count = 5, а не строку; если действительно нужен текст, использовать отдельную переменную с собственным mutableStateOf(""), как nickname в разделе про поле ввода.

Отвечено вопросов: 0 из 4
Какая из пяти ошибок этой главы возникает из-за того, что параметры функций в Kotlin — всегда val?
Чем ошибка 2 (ReadOnlyBox) отличается по сути от ошибки 3 (MutableState без импортов)?
Что нужно исправить в runOnClick(sayHi()), чтобы код заработал?
Почему count = "текст" в демо с Box — ошибка компиляции, а не просто неверный результат во время выполнения?
Отвечено верно: 0 из 4

Слова главы

state
1 / 8

Потренируйся печатать

Объявление делегированного состояния — то, что придётся набирать перед каждым изменяемым значением на экране:

Цель: скорость ≥ 110 зн/мин, точность ≥ 90%

var count by remember { mutableStateOf(0) }
клавиша ``клавиша 11клавиша 22клавиша 33клавиша 44клавиша 55клавиша 66клавиша 77клавиша 88клавиша 99клавиша 00клавиша --клавиша ==клавиша ⌫клавиша TabTabклавиша QQклавиша WWклавиша EEклавиша RRклавиша TTклавиша YYклавиша UUклавиша IIклавиша OOклавиша PPклавиша [[клавиша ]]клавиша \\клавиша Caps LockCaps Lockклавиша AAклавиша SSклавиша DDклавиша FFклавиша GGклавиша HHклавиша JJклавиша KKклавиша LLклавиша ;;клавиша ''клавиша EnterEnterклавиша ShiftShiftклавиша ZZклавиша XXклавиша CCклавиша VVклавиша BBклавиша NNклавиша MMклавиша ,,клавиша ..клавиша //клавиша ShiftShiftклавиша CtrlCtrlклавиша WinWinклавиша AltAltклавиша ПробелПробелклавиша AltAltклавиша WinWinклавиша MenuMenuклавиша CtrlCtrl
следующая: V — указательный левой руки

Поле ввода с обработчиком изменения текста — вторая самая частая связка символов этой главы:

Цель: скорость ≥ 120 зн/мин, точность ≥ 90%

TextField(value = text, onValueChange = { text = it })

Символы и приёмы главы

Символ / приёмКак называетсяЧто делаетПример
byключевое слово-делегатперенаправляет чтение и запись свойства объекту справаvar count by remember { ... }
remember { }функция-хранилищезапоминает результат лямбды между рекомпозициями, не пересчитывая его зановоremember { mutableStateOf(0) }
mutableStateOf(x)функция-конструктор состояниясоздаёт наблюдаемый MutableState с начальным значением xmutableStateOf(0)
.valueсвойство состоянияпрямой доступ к значению MutableState без делегата bycountState.value++
count++ через byинкремент делегированной переменнойпод капотом — getValue (прочитать), затем setValue с увеличенным значениемButton(onClick = { count++ })
onValueChange = { it }лямбда-обработчик вводавызывается при каждом изменении текста; it — новое значениеTextField(value = t, onValueChange = { t = it })
(String) -> Unitтип функции с параметромлямбда, принимающая один аргумент и не возвращающая результатаonNicknameChange: (String) -> Unit
operator fun getValue/setValueфункции делегатаих вызывает компилятор при чтении/записи делегированного свойствакласс Loud в примере выше
Углубиться

Как remember на самом деле находит "своё" место. Внутри Compose каждый вызов remember привязан не к переменной и не к файлу, а к конкретной позиции вызова в дереве композиции — механизм называется позиционным запоминанием (positional memoization). Если один и тот же composable вызван в цикле несколько раз, у каждого вызова — своя, отдельная ячейка памяти remember, и они не путаются друг с другом, хотя код remember { mutableStateOf(0) } написан один раз. Это и есть главное отличие от упрощённой демонстрации через глобальную переменную файла из этой главы: там ячейка ровно одна на всю программу, а в Compose их может быть сколько угодно — по одной на каждое реальное место использования на экране.

Snapshot-система: как Compose узнаёт, что именно читалось. Когда composable-функция выполняется, Compose оборачивает это выполнение в snapshot (снимок) — специальный механизм, который записывает каждое обращение к State.value в процессе. Именно эта запись и позволяет потом, при изменении какого-то конкретного State, точно понять, какие функции его читали в прошлый раз, и перевызвать только их — не по совпадению имён переменных, а по факту реального обращения во время выполнения.

Дальше по теме:

  • State and Jetpack Compose — официальное объяснение remember, mutableStateOf и однонаправленного потока данных.
  • Delegated properties — официальная документация Kotlin про механизм by, getValue и setValue вне контекста Compose.

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

Русские материалы

Официальная документация (на английском)

  • State and Jetpack Compose — открой за первоисточником примеров этой главы и более полным объяснением remember и State.

  • Where to hoist state — открой за подробным разбором, КУДА именно поднимать состояние в более сложных экранах — до composable-родителя, отдельного state holder-класса или ViewModel.

  • Delegated properties — открой за полным описанием механизма by в самом языке Kotlin, включая lazy и другие встроенные делегаты помимо mutableStateOf.

  • State in Jetpack Compose (codelab) — открой для пошагового практикума с заданиями на реальном приложении-опроснике, включая rememberSaveable — состояние, переживающее не только рекомпозицию, но и поворот экрана.

  • Видео и материалы сообщества — ниже — ролики по теме и ссылки от студентов и преподавателей смотри в самом низу страницы.

Челлендж ⭐

TrainingScreen из раздела «Собираем экран целиком» уже готов. Дальше — без эмулятора, только чтением и написанием кода: реши обе ступени сам, а потом сверься с решением.

Ступень 1. Добавь вторую кнопку «Сбросить» рядом с «Плюс» внутри CounterDisplay, которая возвращает счёт к нулю. Перепиши CounterDisplay так, чтобы у неё появился новый параметр-обработчик onReset: () -> Unit, и обнови вызов CounterDisplay(...) внутри TrainingScreen так, чтобы сброс действительно обнулял count.

Ступень 2 ⭐. Представь, что вместо правильного решения ступени 1 кто-то, торопясь, перенёс var count by remember { mutableStateOf(0) } ОБРАТНО внутрь CounterDisplay — то есть отменил подъём состояния именно для счётчика, оставив кнопку «Сбросить» из ступени 1 внутри TrainingScreen, которая по-прежнему пытается сделать onReset = { count = 0 }. Объясни своими словами, почему это перестанет работать и какую именно ошибку (или логическую проблему) это вызовет.

Решение (сначала попробуй сам)

Ступень 1:

@Composable
fun CounterDisplay(count: Int, onIncrement: () -> Unit, onReset: () -> Unit) {
Column {
Text(text = "Счёт: $count")
Button(onClick = onIncrement) {
Text(text = "Плюс")
}
Button(onClick = onReset) {
Text(text = "Сбросить")
}
}
}

@Composable
fun TrainingScreen() {
var count by remember { mutableStateOf(0) }
var nickname by remember { mutableStateOf("") }

Column {
NicknameField(nickname = nickname, onNicknameChange = { nickname = it })
Text(text = if (nickname.isBlank()) "Введи никнейм" else "Привет, $nickname!")
CounterDisplay(
count = count,
onIncrement = { count++ },
onReset = { count = 0 }
)
}
}

Третий параметр onReset: () -> Unit работает по той же схеме, что и onIncrement: CounterDisplay только вызывает переданную лямбду, а настоящее изменение — count = 0 — происходит в TrainingScreen, единственном владельце состояния.

Ступень 2 ⭐:

Если remember { mutableStateOf(0) } вернуть внутрь CounterDisplay, то count там снова станет ЛОКАЛЬНЫМ состоянием этой функции — а TrainingScreen при этом всё ещё считает, что владеет каким-то своим count (тем самым, что осталось от объявления var count by remember { mutableStateOf(0) } выше в TrainingScreen, если его не убрать) и пытается сбросить именно ЕГО через onReset = { count = 0 }. Получаются два РАЗНЫХ, никак не связанных состояния с одним и тем же именем count: внутреннее, которое реально показывает Text и меняет кнопка «Плюс» внутри CounterDisplay, и внешнее в TrainingScreen, которое меняет кнопка «Сбросить» — но экран не перерисуется, потому что CounterDisplay не читает то, внешнее, состояние вообще. Кнопка «Сбросить» будет нажиматься без видимой ошибки компиляции, но результата на экране не будет: это классический симптом потерянного подъёма состояния — не сбой компилятора, а разрыв единственного источника истины.

Что должен уметь

  • Объяснять, почему обычный var внутри тела composable-функции не переживает рекомпозицию, и какую роль в этом решении играют remember и mutableStateOf по отдельности.
  • Простыми словами объяснять, что такое делегат (delegate) и слово by: какие функции — getValue и setValue — вызывает компилятор при чтении и записи делегированного свойства.
  • Писать и читать var count by remember { mutableStateOf(0) }, понимая роль каждого слова в этой строке.
  • Объяснять, что такое рекомпозиция (recomposition): что именно её запускает и почему перерисовывается не весь экран, а только те функции, что читали изменившееся состояние.
  • Различать onClick: () -> Unit и onValueChange: (String) -> Unit — по числу и типу параметров лямбды, которую они ожидают.
  • Объяснять подъём состояния (state hoisting): зачем переносить remember { mutableStateOf(...) } из дочерней функции в родительскую и как при этом дочерняя функция получает данные и события через параметры.
  • Собирать hoisted-экран из stateless-компонентов на примере счётчика и поля ввода, читая и записывая по строкам, кто чем владеет.
  • Узнавать по сообщению компилятора пять типичных ошибок этой темы — изменение val-параметра, делегат без setValue, забытый импорт getValue/setValue, вызванный вместо переданный обработчик, несовпадение типа при присваивании делегированной переменной — и чинить каждую.

Комментарии

Комментарии появятся после настройки. Нужен аккаунт GitHub — вход прямо в виджете выше.