Урок 8: MutableState, remember. Состояние и Рекомпозиция

Урок 8: MutableState, remember. Состояние и Рекомпозиция

Введение

Мы уже научились отображать и оформлять элементы. Но часто данные являются динамическими – то есть заполняются строками, цифрами и другими значениями в зависимости от состояния. В Jetpack Compose за это отвечает state. В этом уроке мы подробно рассмотрим важные концепции состояния и рекомпозиции.

Сравнение с классической системой представления

В классическом подходе на XML, когда мы хотели изменить значение элемента, например, текстового поля, мы вызывали метод, который отвечает за установку нового значения. Этот метод обновлял внутреннее состояние элемента, чтобы он отображал новое значение.

Когда мы хотим изменить внутреннее состояние composable элемента, мы просто вызываем эту функцию с элементом заново с новыми данными. Таким образом, интерфейс, созданный этим composable, будет перерисован, и мы сможем увидеть изменения. Зафиксируем это раз и навсегда. Чтобы перерисовать элемент – нужно повторно вызвать эту же composable функцию.

Введение в Состояние и Рекомпозицию

Хорошо. Jetpack Compose — это декларативный фреймворк для создания пользовательских интерфейсов. В декларативных фреймворках мы описываем что должно отображаться на экране, а не как это сделать пошагово. Это означает, что когда данные изменяются, Compose автоматически обновляет UI, отображая актуальное состояние. И нас не должно волновать как это происходит.

Однако, для этого Compose необходимо отслеживать изменения состояния и выполнять рекомпозицию. Этот сложный на первый взгляд термин на самом деле означает процесс повторного вызова Composable функций для обновления UI.

Итак, зафиксируем. Чтобы автоматически выполнять рекомпозицию – нужно отслеживать изменения состояния, следовательно это состояние потребуется где-то хранить, но об этом позже. Давайте разбираться, как это работает.

Подготовка рабочего пространства

Подготовим рабочее пространство. Я перенес весь код из предыдущих уроков в отдельный файл HomeScreen, внутри которого создал composable функцию HomeScreen. Возможно мы вернемся к нему позже, новый проект я создавать не буду, потому как хочу сохранить все настройки темы и стилей. Которые мы реализовали на прошлом уроке.

В MainActivity оставил только обертки из темы и системной функции Scaffold, для обозначения стандартных отступов. В качестве основного контейнера создам Box с центрированием контента по центру, добавлю стандартные паддинги и растяну его по всем осям.

Итак, объяснение состояния и рекомпозиции начну с простого чекбокса. Создаю функцию с каким-нибудь названием MainCheckBox, внутри объявляю сам Checkbox. У этого метода есть два обязательных параметра.

  • checked – отвечает за состояние отмечен чекбокс в данный момент или нет,
  • onCheckedChange – коллбэк в виде лямбды, который возвращает некое действие, которое должно происходить, в момент, когда чекбокс был переключен. Иначе смысла от него мало. Он принимает один аргумент — новое состояние чекбокса (Boolean), которое указывает, отмечен чекбокс (true) или нет (false).

Поэтому в первый параметр отправлю true, чтобы чекбокс был активен, а во второй пока просто пустую лямбду в качестве заглушки. Только принудительно выведу Boolean аргумент и дополнительно будем отбивать здесь лог Log.i("!!!", "MainCheckBox $it").

Также добавлю модификатор, который увеличивает размер чекбокса, исключительно, чтобы вам на экране было лучше видно .graphicsLayer(scaleX = 4f, scaleY = 4f). Теперь просто вызываю MainCheckBox() внутри основного контейнера Box и смотрим что получилось.

@Composable
@Preview(showBackground = true)
fun MainCheckBox() {
    Checkbox(
        checked = true,
        onCheckedChange = { it: Boolean ->
            Log.i("!!!", "MainCheckBox $it")
        },
        modifier = Modifier
            .graphicsLayer(scaleX = 4f, scaleY = 4f)
    )
}

Проблема управления состоянием

Итак, запускаем логкат и смотрим что происходит. Отбивается “MainCheckBox false” – false потому, что после нажатия мы деактивируем чекбокс. Но так как каждый раз он остается неизменным – ничего не происходит. Мы фактически захардкодили элемент.

Чтобы точно показать что именно тут запускается – добавлю точки останова. В дебаг режиме сначала отрисовался чекбокс с параметром true. При клике вызывается только onCheckedChange и больше ничего. Это важное замечание.

Урок 8: MutableState, remember. Состояние и Рекомпозиция

Как оживить чекбокс?

Попробуем добавить дополнительную переменную isChecked. Теперь в лямбде ей будет присваиваться новое состояние чекбокса и он будет менять свое состояние. Соответственно эту же переменную передаем и в checked. Попробуем.

Урок 8: MutableState, remember. Состояние и Рекомпозиция

Снова не совсем ожидаемое поведение. В LogCat по прежнему отбивается новое состояние. Возможно дебаг поможет разобраться в проблеме.

  • Переменная инициализируется,
  • Вызывается функция чекбокса,
  • После клика отбивается лог,
  • isChecked присваивается новое значение,
  • а дальше ничего не происходит.

Почему? Потому что Compose не знает, что нужно выполнить рекомпозицию при изменении isChecked. Он не знает, что нужно повторно вызывать функцию CheckBox. В декларативных фреймворках, изменения состояния должны приводить к повторному вызову Composable функций для обновления UI. Без этого Compose не сможет отобразить новое состояние.

Хорошо. Определяем новую задачу: как повторно вызвать CheckBox?

Например, мы можем использовать специальный метод, который позволит принудительно повторно вызвать функцию рекомпозиции чекбокса. То есть мы сначала объявим currentRecomposeScope, запишем в лямбде новое состояние чекбокса в isChecked и вызываем у скоупа invalidate.

Но этот вариант тоже оказался не рабочим. Смотрим дебаг. Все проинициализировалось как обычно, меняем состояние чекбокса на false, срабатывает инвалидейт и запускается рекомпозиция. Однако вот где кроется проблема – при перезапуске функции, isChecked инициализируется заново и компонент отображается снова с активным чекбоксом.

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

Но надеюсь эта цепочка из костылей наглядно дала вам понять суть рекомпозиции – это по сути повторный вызов функции с новыми данными. Переходим к следующей части.

Как правильно хранить состояние в Jetpack Compose?

Начнем распутывать этот клубок. Чтобы Compose знал об изменениях состояния и мог выполнить рекомпозицию, нам нужно использовать специальные механизмы управления состоянием. Таким образом мы избавимся и от скоупа и от вызова метода инвалидейт. Их можно уже удалить.

MutableState

Мы подошли к сути концепции реактивного программирования и реагирования на изменение состояния. Этим механизмом здесь является MutableState. С помощью mutableStateOf создаем строго типизированный объект с тем, какой тип данных будем хранить. MutableState будет автоматически отслеживать изменения и реагировать на них.

То есть буквально: теперь при изменении содержимого переменной isChecked в лямбде – будет автоматически вызываться рекомпозиция функции чекбокс. Но у нас все еще подсвечиваются ошибки в коде по понятным причинам. Ожидается тип данных Boolean, а isChecked у нас теперь имеет тип MutableState. Но чтобы достать из стейта хранимое значение достаточно вызвать свойство value. Это работает в обе стороны – на чтение и запись данных.

var isChecked: MutableState<Boolean> = mutableStateOf<Boolean>(true)

@Composable
@Preview(showBackground = true)
fun MainCheckBox() {
    Checkbox(
        checked = isChecked.value,
        onCheckedChange = { it: Boolean ->
            Log.i("!!!", "MainCheckBox $it")
            isChecked.value = it
        },
        modifier = Modifier
            .graphicsLayer(scaleX = 4f, scaleY = 4f)
    )
}

Все, можно проверять. Рекомпозиция происходит сразу после изменения состояния, то есть в момент, когда происходит присваивание нового значения переменной стейта. То есть сразу после клика. Все логично и работает как часы.

Подводя промежуточный итог хочу сказать пару слов про концепцию Unidirectional Data Flow (UDF), то есть однонаправленный поток данных.

Когда мы взаимодействуем с UI (как сейчас, кликаем на чекбокс), это событие инициирует изменение состояния. Источником данных в данном контексте является переменная MutableState, которая хранит информацию о текущем состоянии приложения. В данном случае положение чекбокса.

Когда состояние изменяется, UI «узнает» об этом и запускает рекомпозицию, то есть обновляет свое представление с учетом новых данных. Это обеспечивает предсказуемость: мы всегда знаем, как состояние влияет на UI, и можем легко управлять изменениями.

В итоге, в UDF изменения двигаются от UI к источнику данных, и каждый раз, когда состояние обновляется, интерфейс обновляется в соответствии с этими изменениями.

remember в Jetpack Compose

Великолепно. Но вынесенное состояние за пределы функции MainCheckBox может создавать неудобства. Что, если мы захотим вызвать ее несколько раз для разных частей экрана? Будет одно состояние на всех и приложение будет работать некорректно. Попробуем вернуть объявленный стейт внутрь функции. Наблюдаем такую ошибку “Создание стейта без использования remember”.

Урок 8: MutableState, remember. Состояние и Рекомпозиция

Ниже в подсказке есть примеры использования remember, давайте сделаем также. Ошибка пропала. Что мы сделали и что вообще такое remember?

Это composable функция, которая обращается к памяти composable функции. Да, у composable функций есть память, хранящая информацию о предыдущих вызовах. Когда мы инициализируем MutableState в первый раз – функция remember запоминает этот объект с хранимым его состоянием.

После того, как состояние изменилось – вызывается рекомпозиция. То есть перевызов функции MainCheckBox. И когда начнет выполняться строка с созданием стейта MutableState – функция remember обнаружит уже созданный объект в памяти и вернет его. Вместо того, чтобы создавать новый объект. Это круто влияет на производительность при большом количестве элементов, а также позволяет привязывать каждую функцию элемента к ее уникальному жизненному циклу.

var isChecked: MutableState<Boolean> = remember { mutableStateOf<Boolean>(true) }

Это можно легко проверить, добавив второй вызов той же самой функции чекбокса. Добавлю колонку, центрирую ее и разделю функции спейсером.

Column(
    horizontalAlignment = Alignment.CenterHorizontally,
    verticalArrangement = Arrangement.Center,
    modifier = Modifier
        .padding(innerPadding)
        .fillMaxSize(),
) {
    MainCheckBox()
    Spacer(modifier = Modifier.height(30.dp))
    MainCheckBox()
}

Как видите в коде у нас прописано одно состояние, но на самом деле каждый раз, когда вызывается функция MainCheckBox, создаётся новый локальный стейт isChecked. Это значит, что первый вызов функции создаёт одну переменную isChecked, а второй вызов создаёт другую, независимую переменную isChecked. Каждый чекбокс хранит своё собственное состояние.

Использование делегата by

Далее. Возможности Kotlin позволяют сделать код еще более лаконичным с помощью делегатов. Сейчас переменная isChecked — это состояние, и чтобы получить его значение, нужно обращаться как isChecked.value.

Используя делегат by можно сократить запись до var isChecked: Boolean by remember { mutableStateOf(true) }. Так мы не работаем напрямую с объектом состояния (MutableState). Вместо этого, делегат управляет процессом получения и установки значения, как если бы мы работали с обычной переменной. Это скрывает детали реализации, делая код чище.

Теперь isChecked напрямую является Boolean, и можно использовать его без необходимости обращаться к value. Следовательно, переменная isChecked теперь будет перезаписываться, поэтому должна быть объявлена как var.

Отлично. Вообще Jetpack Compose страшно оптимизированная штука для повышения производительности. Помимо вышеописанных преимуществ стоит добавить, что рекомпозиция производится только у тех элементов, которые действительно изменили свое состояние.

Здесь, чтобы не тратить время, скажу лишь, что Jetpack Compose использует концепцию «умных» обновлений. Это значит, что система «знает», какие параметры и элементы UI зависят от состояния, и когда это состояние изменяется, обновляется только тот UI, который на него реагирует.

Например, если изменяется значение одного чекбокса, обновляется только он, а не другие элементы интерфейса, которые не зависят от этого состояния. Это снижает нагрузку на процессор и улучшает отзывчивость. Благодаря этому, даже если у вас сложный интерфейс с множеством состояний, производительность останется на высоком уровне, и юзер экспириенс не пострадает.

Бесплатные Telegram-боты для обучения

Практика с проверкой кода и помощью ИИ-ментора

AndroidSprint AI Mentor

Проверяет Pull Request'ы в GitHub, проводит тестовые собеседования с голосом и таймером, помогает разбираться с кодом 24/7

Попробовать ИИ-ментора →

KotlinSprint Bot

22 урока Kotlin, 220 тестов, 120 практических задач с код-ревью

Начать обучение Kotlin →

AndroidSprint Bot

Тесты по Android SDK, Jetpack Compose, архитектуре приложений

Тесты по Android →

Тебе также может быть интересно

Узнать подробнее
Курс AndroidSprint

Глубокое обучение Android разработке с 0 до получения оффера. Только персональная практика с гарантией получения продуктового опыта.

Курс AndroidSprint - Глубокое <strong>обучение Android разработке с 0 до получения оффера</strong>. Только персональная практика с гарантией получения продуктового опыта.
Узнать подробнее
Узнать подробнее
Практикум по Kotlin

Изучение Котлин с 0 для профессиональной разработки. Личный ментор и разбор кода задач через git-flow.

Практикум по Kotlin - Изучение Котлин <strong>с 0 для профессиональной разработки</strong>. Личный ментор и разбор кода задач через git-flow.
Узнать подробнее
Узнать подробнее
Бесплатные уроки по Kotlin разработке

Самостоятельное освоение базы по языку для дальнейшего развития в Android/back-end разработке или в автотестах.

Бесплатные уроки по Kotlin разработке - <span>Самостоятельное освоение базы по языку для дальнейшего развития в Android/back-end разработке или в автотестах.</span>
Узнать подробнее
Узнать подробнее
Onboarding в разработку

Полное обучение Android разработке с нуля до получения оффера. Делаем упор на практику и обратную связь

Onboarding в разработку - <span>Полное обучение Android разработке с нуля до получения оффера. Делаем упор на практику и обратную связь</span>
Узнать подробнее
Узнать подробнее
Обучающий Kotlin телеграм бот (с тестами)

Ваш основной инструмент для изучения основ языка. Бесплатные тесты и практика внутри.

Обучающий Kotlin телеграм бот (с тестами) - Ваш основной <span>инструмент для изучения основ языка.</span> Бесплатные тесты и практика внутри.
Узнать подробнее
Узнать подробнее
Бесплатные уроки по Android разработке

Самостоятельное обучение разработке Андроид приложений. Понятные видеоуроки с разжеванными примерами.

Бесплатные уроки  по Android разработке - Самостоятельное <span>обучение разработке Андроид приложений.</span> Понятные видеоуроки с разжеванными примерами.
Узнать подробнее
Узнать подробнее
Курс по UI/Unit тестированию

Для ручных тестировщиков, которые готовы осваивать автотесты с использованием актуального стека технологий. [в разработке]

Курс по UI/Unit тестированию - Для ручных тестировщиков, которые <span>готовы осваивать автотесты</span> с использованием актуального стека технологий. [в разработке]
Узнать подробнее
Узнать подробнее
Обучающий Android телеграм бот (с тестами)

Бесплатные теоретические тесты для самопроверки. А также информер на практических спринтах по Android.

Обучающий Android телеграм бот (с тестами) - Бесплатные <span>теоретические тесты для самопроверки.</span> А также информер на практических спринтах по Android.
Узнать подробнее