Салют, Хабр! На связи Вадим, Android Developer из Clevertec. Когда начинал разбираться с Compose, он показался простым. Но первое впечатление обманчиво. Поэтому написал этот туториал для начинающих, который сэкономит время на погружение. Предлагаю рассмотреть ключевые аспекты Compose – State и Composition, практические примеры стабильных и нестабильных типов данных. Обсудить, как Jetpack Compose управляет рекомпозицией пользовательского интерфейса, каким образом разработчики могут оптимизировать производительность приложений.

Как происходит отрисовка кадров

В современной мобильной разработке наблюдается явный переход от императивного к декларативному пользовательскому интерфейсу. XML для макетов Android-приложений имеет свои ограничения: разрозненный код и сложности в поддержке. С появлением Jetpack Compose разработчики могут описывать интерфейс в Kotlin. Это делает его более читаемым и гибким к изменениям. Подход также устраняет проблемы разрозненности кода и макетов, хотя XML все еще используется. Jetpack Compose становится все более популярным благодаря своей простоте и гибкости.

Мой путь в изучении Jetpack Compose начался с туториала

– Какое же это крутое и простое решение, – первая мысль. 

Разобрался глубже, попробовал написать первый полноценный проект на Compose, понял, что есть свои нюансы. Моё приложение выглядело дергающимся и лагающим, хотя с аналогом на View всё было в порядке. 

Чтобы понять почему, стоит разобраться, как именно происходит отрисовка кадров в Jetpack Compose.

Как и большинство UI-инструментов, сompose отрисовывает кадры через несколько фаз. 

1. Композиция (Composition):

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

2. Макет (Layout):

В этой фазе выполняются измерение и размещение элементов на экране. Процесс включает три шага:

Измерение детей: Каждый узел измеряет свои дочерние элементы.

Определение собственного размера: На основе измерений узел определяет свой размер.

Размещение детей: Узел размещает своих дочерних элементов относительно своей позиции.

В результате у каждого элемента будет задана ширина, высота и координаты (x, y) для отрисовки.

3. Рисование (Drawing):

На этом этапе дерево снова проходит сверху вниз, и каждый узел отрисовывается на экране. 

Стадии Layuot и Drawing не буду подробно затрагивать. Можно самостоятельно изучить здесь.

Особенностью Compose является фаза сomposition (композиция)/recomposition (рекомпозиция), которая добавлена в начало процесса. Когда Jetpack Compose впервые запускает ваши composables, выполняется сomposition (композиция). В ходе этого процесса он отслеживает вызываемые вами composables для описания пользовательского интерфейса и формирует дерево, в котором каждый элемент нашего UI является узлом. Всякий раз, когда состояние приложения изменяется, Jetpack Compose планирует recomposition (рекомпозиция) которая является повторным выполнением composables, которые могли измениться из-за изменения состояния, и обновление Composition для отражения этих изменений:

Простыми словами, Composition (композиция) – процесс построения пользовательского интерфейса приложения. Это означает создание структуры экрана: что на него выводится и как выглядит. Каждый раз, когда данные изменяются в приложении (например, выросла температура воздуха), Jetpack Compose перестраивает пользовательский интерфейс, чтобы он отображал новые показатели. 

Процесс перестройки пользовательского интерфейса на основе новых данных называется recomposition (рекомпозиция). Таким образом компоненты, такие как TextField, не обновляются автоматически, как это происходит в императивных представлениях, основанных на XML. Компоненту нужно явно сообщить о новом состоянии, чтобы он соответствующим образом обновился. 

State (состояние) –  просто информация, которая может изменяться со временем. Например, у приложения погоды состояние – температура. Когда она растет или падает – состояние меняется. 

Таким образом, мы подобрались к основной проблеме, с которой я столкнулся. Это лишние рекомпозиции, которые тормозили UI моего приложения. 

Стабильные и нестабильные типы: особенности 

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

Функции могут быть помечены как:

Skippable (пропускаемая): Если компилятор помечает composable как пропускаемую, Compose может пропустить её при рекомпозиции, если все её аргументы стабильны (об этом позже) и равны предыдущим значениям.

Restartable (перезапускаемая): Composable, которая является перезапускаемой, служит “областью”, где может начаться рекомпозиция. Эта функция может быть точкой входа для повторного выполнения кода после изменений состояния.

Compose помечает типы как стабильные (stable), неизменяемые (immutable), либо как не стабильные (unstable)

Неизменяемые типы – Compose отмечает тип как неизменяемый, если значения его свойств никогда не могут изменяться.

Стабильные типы – данные, значения которых могут меняться, но Compose может определить, изменилось ли его значение между рекомпозициями. 

Нестабильные типы – значения данных могут изменяться между рекомпозициями, и Compose не может понять, что они изменились.

Компилятор решает, нужна ли рекомпозиция. Если все параметры, которые не изменились, стабильные, он пропускает ее. Если у композиции есть нестабильные параметры, Compose всегда рекомпонует при рекомпозиции родительского компонента. Если в приложении содержится много ненужных и нестабильных компонентов, которые Compose всегда рекомпонует, это может привести к проблемам с производительностью и другими неполадкам.

На этапе компиляции Compose compiler добавляет ко всем нашим классам специальный флаг.

 public static final int $stable

По этому флагу определяется стабильность класса и определяется, каким образом нужно производить сравнение.

Разберем на примерах 

1. Примитивные типы данных и String.

stable class PrimitiveDataTypes {
 stable val byte: Byte
 stable val short: Short
 stable val int: Int
 stable val long: Long
 stable val float: Float
 stable val double: Double
 stable val char: Char
 stable val boolean: Boolean
 stable val string: String
 <runtime stability> = Stable
}

Как видно из отчёта Compose compiler, неизменяемые примитивы и String помечаются как stable.

 2. Функциональные типы 

stable class FunDataTypes {
 stable val funDataType1: Function0<Unit>
 stable val funDataType2: Function1<String, Unit>
 stable val funDataType3: Function2<String, Int, Unit>
 stable val funDataType4: Function3<String, Int, Double, Float>
 <runtime stability> = Stable
}

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

Чтобы разобраться в причинах происходящего, рассмотрим, как лямбды реализуются на самом деле. При написании лямбды компилятор создает анонимный класс с этим кодом. Если лямбда нуждается в доступе к внешним переменным, компилятор добавляет их в виде полей, которые передаются в конструктор лямбды. Это называется захватом переменных. Например, для лямбды:

itemClickListener: (ExampleViewModel) -> Unit

компилятор создаст класс вроде следующего:

class ItemClickListener(val viewModel: ExampleViewModel) {
  operator fun invoke() {
     viewModel.handleClick()
  }
}

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

3. Классы, которые в качестве параметров используют другие стабильные классы.

stable class CustomDataTypes {
 stable val id: Int
 stable val text: String
 stable val someClass: SomeStableClass
 <runtime stability> = Stable
}
stable class SomeStableClass {
 stable val id: Int
 <runtime stability> = Stable
}

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

unstable class CustomDataTypes {
 stable val id: Int
 stable val text: String
 unstable val someClass: SomeUnstableClass
 <runtime stability> = Unstable
}
unstable class SomeUnstableClass {
 stable var id: Int
 <runtime stability> = Unstable
}

4. Enum.

data class EnumDataTypes(val enum: EnumExample)
enum class EnumExample(var arg: String) {
   ONE("1"), TWO("2"), THREE("3")
}
stable class EnumDataTypes {
 stable val enum: EnumExample
 <runtime stability> = Stable
}

Enum считаются стабильными типами данных, при этом в отличие от обычных классов enum будет стабильным, даже если имеет публичные изменяемые поля.

 5. Дженерики (обобщённые типы). 

runtime class GenericDataTypes {
 runtime val type: T
 <runtime stability> = Parameter(T)
}

С дженериками немного сложнее. Как видно из отчёта compose compiler, стабильность класса помечена как runtime и будет определятся по типу “T”. Если “T” будет стабильным, то и класс будет считаться стабильным и наоборот. 

stable class StableGenericExample {
 stable val generic: GenericDataTypes<SomeStableClass>
 <runtime stability> = Stable
}
unstable class UnstableGenericExample {
 unstable val generic: GenericDataTypes<SomeUnstableClass>
 <runtime stability> = Unstable
}

6. Любые типы помеченные @Immutable или @Stable. 

stable class ImmutableAnnotatedTypeExample {
 unstable var list: List<String>
}

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

Аннотирование класса перезаписывает, что компилятор думает о вашем классе. Это похоже на оператор double-bang (!!) в Kotlin. Будьте осторожны с аннотациями. Изменение поведения компилятора может привести к неожиданным ошибкам, например, ваш компонент перестанет перекомпоновываться, когда этого ожидаете. 

Разница между @Immutable и @Stable в том, что Immutable должны помечаться классы, которые не будут меняться вообще. Stable помечаются стабильные типы, которые при изменении будут оповещать об этом компилятор (например, при помощи MutableState).  

@Immutable
data class ImmutableAnnotatedTypeExample(
   val list: List<String>
)
@Stable
data class StableAnnotatedTypeExample(
   val list: MutableState<BigDecimal>
)

Судя по всему, на данный момент компилятор воспринимает @Immutable и @Stable как эквивалентные аннотации. Тем не менее правильное использование этих аннотаций в соответствии с их назначением сделает код более ясным и читаемым, а также поможет избежать потенциальных проблем, если в будущем их поведение изменится.

Нестабильными типами данных считаются: 

1. Классы из внешних модулей и библиотек, в которых нет компилятора Compose. 

В том числе стандартные коллекции (List, Set, Map и т.д.). 

unstable class UnstableCollectionsExample {
 unstable val list: List<String>
 <runtime stability> = Unstable
}

2. Классы, у которых хотя бы одно поле имеет нестабильный тип или объявлено как var.

unstable class UnstableMutableTypeExample {
 stable var text: String
 <runtime stability> = Unstable
}

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

Что делать, чтобы класс содержал какие-то изменяемые данные и при этом оставался стабильным?

Разработчики jetapck compose позаботились об этом и специально для этой цели есть MutableState<T>.

MutableState<T>  – объект, который можно использовать в Compose для отслеживания изменений значения, которое он содержит. Когда значение изменяется, любой компонент, использующий это значение, будет автоматически обновлен. При этом сам MutableState считается стабильным. 

stable class StableMutableStateTypeExample {
 stable val text: MutableState<String>
 <runtime stability> = Stable
}

Внутренности Jetpack Compose

Если разобраться немного глубже, при генерации кода к функциям, помеченным аннотацией @Composable, добавляются два параметра $composer и маска $changed.

@Composable
fun ComposableExample(arg: String, $composer: Composer<*>, $changed: Int)

Composer является контекстом выполнения для composable-функций. Он передается во все вложенные функции, обеспечивая доступ к необходимой информации и управлению выполнением кода.

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

Все параметры обозначаются слотами, и каждый слот представлен трехбитным значением. Есть функция, которая получает маску для определенного слота. Значение каждого определяется по маске changed, а затем используется для принятия решений в коде.

Например, функция A вызывает функцию B с дополнительным параметром. Когда вызывается функция A, ее состояние анализируется, и на основе состояния параметров решается, должна ли быть вызвана функция B или нет. Если параметры функции A не изменились и нет необходимости в повторной компоновке (recomposition), то вызов функции B может быть пропущен.

Итоги и рекомендации

Мы рассмотрели основные понятия в Jetpack Compose. Делая функции Skippable и Restartable, а типы стабильными можем избежать большей части излишних рекомпозиций и значительно увеличить производительность UI.

Вот советы, которые помогут решить проблемы со стабильностью:

1. Включение режима строгого пропуска: Попробуйте сначала использовать режим строгого пропуска. Он позволяет пропускать композируемые элементы с нестабильными параметрами, что проще всего решает проблемы с производительностью.

2. Сделайте класс неизменяемым: Если возможно, сделайте класс полностью неизменяемым. Все свойства класса должны быть val и иметь неизменяемые типы. Если это невозможно, используйте MutableState для любых изменяемых свойств.

3. Использование неизменяемых коллекций: Если в классе используются коллекции, замените их на неизменяемые коллекции, поддерживаемые компилятором Compose, такие как Kotlinx Immutable Collections.

4. Аннотирование с @Stable или @Immutable: Вы можете аннотировать нестабильные классы как @Stable или @Immutable, чтобы сообщить компилятору, что класс должен считаться стабильным. Будьте осторожны, так как неправильное аннотирование может вызвать ошибки.

5. Конфигурационный файл: Начиная с Compose Compiler 1.5.5, вы можете указать в конфигурационном файле классы, которые следует считать стабильными. Это особенно полезно для классов из стандартных библиотек или сторонних библиотек.

6. Многомодульная архитектура: При использовании многомодульной архитектуры убедитесь, что все модули и классы, с которыми работает Compose, также помечены как стабильные или настроены должным образом в конфигурационных файлах.

7. Не все компоненты должны быть пропускаемыми: Не всегда полезно делать каждый композируемый элемент пропускаемым. Иногда это может привести к чрезмерной оптимизации и сложному для поддержки коду.

Подробнее можно прочитать, как диагностировать проблемы со стабильностью или советы от Google, как их решать. 

Этот туториал, на мой взгляд, должен помочь новичку на старте разобраться с основами. Дальше – глубокое погружение и практика.

Что думаете о Jetpack Compose и с какими сложностями столкнулись на старте? Поделитесь в комментариях.

Комментарии (0)