Состояние и события
Зачем это нужно
Прошлая глава собрала первый экран — но он был неподвижным: 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, который не переживает вызов
- remember: место, которое переживает пересборку
- mutableStateOf и делегат by
- Рекомпозиция: что перерисуется и почему
- onClick и обработчики: событие как лямбда, ещё раз
- Состояние вверх: подъём состояния на счётчике
- Состояние вверх: подъём состояния на поле ввода
- Собираем экран целиком
- Типичные ошибки
- Слова главы
- Потренируйся печатать
- Символы и приёмы главы
- Куда дальше: проверенные ресурсы
- Челлендж ⭐
- Что должен уметь
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 — теперь значение растёт от вызова к вызову, потому что оно живёт не внутри функции, а вне её.
Важная оговорка на честность: настоящий remember устроен не через глобальную переменную файла — он хранит значение в специальной внутренней структуре Compose (композиции, composition), привязанной к конкретному месту вызова composable-функции в дереве экрана, а не ко всему файлу целиком. Если бы это была обычная глобальная переменная, два разных счётчика на экране делили бы одно и то же значение — а remember в двух разных местах хранит для каждого своё. Но сама суть — «место снаружи тела функции, которое не создаётся заново при повторном вызове» — у обоих одинаковая, и для первого знакомства с идеей этого достаточно; более точный механизм — в разделе «Углубиться» в конце главы.
Настоящий remember в Compose выглядит так — обёрнутый вокруг ещё одной новой функции, mutableStateOf, к которой глава переходит в следующем разделе:
val countState = remember { mutableStateOf(0) }
А теперь потрогай разницу руками: один и тот же счётчик, объявленный с remember и без, и кнопка, которая форсирует рекомпозицию — то есть вызывает функцию заново.
var count = 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: делегатом может быть любой класс, у которого есть две специальные функции с ключевым словом operator — getValue (вызывается при чтении) и 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.KProperty—KPropertyживёт в библиотеке рефлексии 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.
Разберём сигнатуру 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 почти всегда добавляет сама через автодополнение. Что происходит, если их всё-таки нет, — отдельная типичная ошибка этой главы, разобранная дальше.
Рекомпозиция: что перерисуется и почему
Прошлая глава уже определила рекомпозицию (recomposition) как повторный вызов composable-функции с новыми данными, когда что-то изменилось. Теперь понятно, что именно запускает этот повторный вызов: запись в .value объекта MutableState (то есть setValue делегата) не просто меняет число — она сообщает системе Compose «то место экрана, что читало это значение, устарело, перерисуй его заново».
Важная деталь, которую легко упустить: перерисовывается не весь экран целиком, а только те 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") — читает countText("Имя: $name") — НЕ читает countonClick и обработчики: событие как лямбда, ещё раз
Прошлая глава уже показала: 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:
Состояние вверх: подъём состояния на счётчике
До сих пор состояние объявлялось прямо внутри той же 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): данные всегда движутся в одну сторону, вниз по дереву параметров, а изменения запрашиваются в обратную сторону, вызовом обработчика, а не прямой записью откуда-то снизу.
Главный практический выигрыш: 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, эта строка не смогла бы вообще ничего узнать о введённом тексте.
Собираем экран целиком
Соберём счётчик и поле ввода на одном экране — том же 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, а не var — val-делегату достаточно одного 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 в разделе про поле ввода.
Слова главы
Потренируйся печатать
Объявление делегированного состояния — то, что придётся набирать перед каждым изменяемым значением на экране:
Цель: скорость ≥ 110 зн/мин, точность ≥ 90%
var count by remember { mutableStateOf(0) }
Поле ввода с обработчиком изменения текста — вторая самая частая связка символов этой главы:
Цель: скорость ≥ 120 зн/мин, точность ≥ 90%
TextField(value = text, onValueChange = { text = it })
Символы и приёмы главы
| Символ / приём | Как называется | Что делает | Пример |
|---|---|---|---|
by | ключевое слово-делегат | перенаправляет чтение и запись свойства объекту справа | var count by remember { ... } |
remember { } | функция-хранилище | запоминает результат лямбды между рекомпозициями, не пересчитывая его заново | remember { mutableStateOf(0) } |
mutableStateOf(x) | функция-конструктор состояния | создаёт наблюдаемый MutableState с начальным значением x | mutableStateOf(0) |
.value | свойство состояния | прямой доступ к значению MutableState без делегата by | countState.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.
Куда дальше: проверенные ресурсы
Русские материалы
- Погружаемся в Compose-Verse — управление состоянием — открой за тем же материалом другими словами: от базового
mutableStateOf/rememberдо подъёма состояния и вынесения его в отдельные state-holder классы. - Урок 8: MutableState, remember. Состояние и Рекомпозиция — открой для того же материала в формате видео-курса с текстовым конспектом рядом.
- Осознанная оптимизация 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 — вход прямо в виджете выше.