Всем привет! Меня зовут Данила, я Android-разработчик в команде, которая занимается созданием супераппа WorksPad и почтового клиента RuPost Desktop.

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

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

Большинство поддержало меня в том, чтобы я переложил нашу встречу в формат статьи. Все что вы увидите ниже — и есть тот самый гайд. В нем я постараюсь простыми словами объяснить, как устроен процесс построения UI на Compose:

  1. Как работает рекомпозиция в Compose.

  2. На чём основана рекомпозиция.

  3. Как происходит оптимизация рекомпозиции на фреймворк.

Как работает рекомпозиция в Compose?

Рекомпозиция вызов составной функции с новыми данными. При каждом обновлении данных в Composable-функции происходит повторный вызов функции с новыми данными.

Чтобы разобраться с рекомпозицией необходимо понимать этапы работы Compose. Всего три итерации:

  1. Composition
    Формируется дерево графа Composable-функций. На данном этапе определяется набор используемых layout-ов и их очередность.

Composition этап: код -> дерево графа
Composition этап: код -> дерево графа
  1. Layout
    На основе дерева графа формируется расположение элементов на экране. Другими словами, каждый элемент в дереве размещает свои дочерние элементы в двумерном пространстве экрана.

Layout-этап: дерево графа -> 2d экран
Layout-этап: дерево графа -> 2d экран
  1. Drawing
    Каждый элемент дерева отрисовывает свой контент.

Drawing-этап: 2d экран -> Отрисованный UI
Drawing-этап: 2d экран -> Отрисованный UI

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

Жизненный цикл компонуемой функции
Жизненный цикл компонуемой функции

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

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

@Composable
fun RecompositionExample(){
    /* Счётчик для обновления состояния. Вернёмся к разбору remember позже */
    var count by remember {
        mutableStateOf(0)
    }

    Column {
        ButtonCountWidget { count++ }
        TitleWidget(count.toString())
    }
}

/** Кнопка для обновления стейта для проведения теста */
@Composable
fun ButtonCountWidget(onClickAction: () -> Unit) {
    Button(onClick = onClickAction) {
        Text("Click on me")
    }
}

/** Виджет с текстом */
@Composable
fun TitleWidget(title: String) {
    Text(text = title)
}

В качестве небольшой ремарки: Compose достаточно умно работает с рекомпозицией и сам умеет определять моменты, когда её необходимо произвести. Ниже представлена таблица счетчика рекомпозиций, где n — количество рекомпозиций, а skip — количество пропусков рекомпозиции.

При нажатии кнопки Compose каждый раз сам определял, что необходимо перекомпоновать функцию TitleWidget, так как значение title было изменено. В свою очередь, ButtonCountWidget не был рекомпозирован ни разу, так как данные не были изменены.

На чем основана рекомпозиция?

Рекомпозиция зависит от данных, которые предоставляются для отрисовки UI. В свою очередь данные можем представить как классы. Типы классов разделим на Stable и Unstable.

Стабильный класс — это объект, который не будет изменяться, а если будет изменён, то сообщит об этом.

По официальной документации (<https://developer.android.com/reference/kotlin/androidx/compose/runtime/Stable#equals(kotlin.Any)>)) к стабильным типам в Compose относятся классы, у которых:

  • Результат equals всегда возвращает одинаковый результат для одних и тех же двух экземпляров.

  • При изменении публичного поля, Compose будет уведомлен об этом.

  • Все публичные свойства стабильны.

По умолчанию стабильными считаются:

  • Примитивы;

  • String;

  • Enum;

  • Лямбды, которые захватывают стабильные типы;

  • Классы со всеми стабильными атрибутами;

  • Классы из библиотек, которые совместимы с Compose и реализовали поддержку стабильности;

  • Дженерики, которые явлются стабильными типами.

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

К примеру: List, Set, Map и так далее. Они являются не стабильными и вызывают рекомпозицию при каждом обновлении родительской компонуемой функции.

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

Пример: В реализации List  находится MutableList, который будет изменяться внешне, но Compose не узнает об этом.

Входящие данные делятся на стабильные и не стабильные классы, подобная ситуация происходит и с функциями. Их можно разделить на skippable и restartable.

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

Рассмотрим особенность работы на абстрактных примерах, где yes — данные изменились, а no — остались без изменения:

Рекомпозиция при нестабильных типах без их изменения
Рекомпозиция при нестабильных типах без их изменения

Screen начал рекомпозицию. Для функции EventList данные не изменились, но она начала рекомпозицию, потому что имеет нестабильные типы. За счёт этого, при каждом старте рекомпозиции родительской функции, дочерняя функция с нестабильными типами будет рекомпозироваться. Но лишние рекомпозиции здесь явно ни к чему — они только расходуют ресурсы устройства пользователя.

Пропуск рекомпозиции при нестабильных типах
Пропуск рекомпозиции при нестабильных типах

Обратим внимание на блок Day. Он имеет нестабильные типы, но рекомпозировался, так как родительская функция её пропустила. Это Compose сам корректно обработал.

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

Решить проблему с нестабильностью можно несколькими способами:

  1. Использовать kotlinx.ImmutableCollections — это библиотека в альфа-версии, которая предоставляет стабильные типы коллекций.

  2. С помощью аннотаций 

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

А вот аннотации подробно разберем и посмотрим на примере

Сделать класс стабильным можно за счет добавления всего лишь одной строчки. Рассмотрим пример data class-а со списком в одном из полей:

/**
 * List не стабильный класс, поэтому при каждом обновлении Composer всегда его обработает
 *
 * Отчёт компилятора Сompose:
 *
 * unstable class IncorrectTestRecomposeState {
 *   unstable val list: List<Int>
 *   <runtime stability> = Unstable
 * }
 */
data class RecomposeExampleState(
    val list: List<Int>
)

@Composable
fun ListRecompositionExample(recompositionList: RecomposeExampleState) {
    Log.i("RecompositionExample", "ListRecompositionExample - $recompositionList")
}

Руководство для получения отчёта компилятора Compose описано Google: https://developer.android.com/jetpack/compose/performance/stability/diagnose#compose-compiler

Используем эту функцию в контексте рассмотренного ранее примера:

val recompositionList = RecomposeExampleState(listOf(1, 2, 3, 4, 5, 6, 7, 8, 9))

@Composable
fun RecompositionExample(){
    /* Счётчик для обновления состояния. Вернёмся к разбору remember позже */
    var count by remember {
        mutableStateOf(0)
    }

    Column {
        ButtonCountWidget { count++ }
        TitleWidget(count.toString())
        ListRecompositionExample(recompositionList)
    }
}

/** Кнопка для обновления стейта для проведения теста */
@Composable
fun ButtonCountWidget(onClickAction: () -> Unit) {
    Button(onClick = onClickAction) {
        Text("Click on me")
    }
}

/** Виджет с текстом */
@Composable
fun TitleWidget(title: String) {
    Text(text = title)
}

При каждой рекомпозиции родительской функции срабатывает рекомпозиция в ListRecompositionExample. Отсюда появляется вопрос, «как можно исправить данное поведение»?

Решение: добавить к data class-у аннотацию @Immutable. Существует ещё аннотация @Stable, которая имеет подобное поведение, но смысл по терминологии отличается.

@Immutable — аннотация, которая утверждает, что значение не изменится без изменения инстанта.

@Stable — аннотация, которая утверждает, что значение может измениться, но класс сообщит о том, что данные были изменены.

Поясню работу с @Immutable. Когда у класса проставляется аннотация, то Compose будет считать класс неизменяемым, не смотря на его аттрибуты. А если класс является не изменяемым, то он классифицируется как стабильный. Здесь нужно быть максимально аккуратными, так как если изменить данные в классе, не создавая новый инстанс, то Compose об этом не узнает и не будет выполнять рекомпозицию.

Пример: Есть data class с параметром типа List, но при инициализации реализацией был MutableList, который изменяется из вне. В таком случае Compose не обработает данные изменения и UI будет некорректен.

@Immutable
data class RecomposeExampleState(
    val list: List<Int>
)

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

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

Как происходит оптимизация?

Для этого рассмотрим, как выглядит сгенерированная функция:

@Composable
fun Example(value: String, $composer: Composer<*>, $changed: Int){
 $composer.startRestartGroup( /* id */ );
 
  /* Вызов функции */ 
  f(...)
 
  $composer.endRestartGroup()
}

Объясню необходимость каждого из параметров функции.

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

Changed  —  передаёт состояние параметров обрабатываемой функции. 

Внутри функции весь написанный нами код делится на три типа групп, которые кэшируют результат своей операции по правилам:

  1. Restartable group
    Группа по умолчанию, которая используется для перезапуска компонуемых функций.

@Composable
fun RestartableWidget(){
    Text(text = "Restartable Widget")
}
-------
@Composable
fun RestartableWidget($composer: Composer<*>){
 $composer.startRestartGroup( /* id */ )
  Text(text = "Restartable Widget", $composer)
  $composer.endRestartGroup()
}
  1. Movable
    Группа, которая используется для работы с последовательностью компонуемых функций. К примеру, работа в цикле и вызов компонуемых функций внутри. Она сохраняет состояние элементов внутри цикла и позволяет изменять порядок без дополнительных затрат.

@Composable
fun MovableWidget(){
  for (i in 0..100){
         Text(text = "Movable")
     }
}
-------
@Composable
fun MovableWidget($composer: Composer<*>){
 $composer.startRestartGroup( 1111 )
  for (i in 0..100){
         $composer.startMovableGroup( 2222 )
         Text(text = "Movable $i")
         $composer.endMovableGroup()
     }
  $composer.endRestartGroup()
}
  1. Replaceable
    Группа, которая используется для работы с несколькими различными блоками по условию. К примеру если будет «if» то, соответственно, блоки «if» будут обернуты в Replaceable group.

@Composable
fun ReplaceableWidget(count: Int){
  if(count > 10){
         Text(text = "Replaceable 1")
     } else {
         Text(text = "Replaceable 1")     
     }
}
-------
@Composable
fun MovableWidget($composer: Composer<*>){
 $composer.startRestartGroup( 11)
  f(count > 10){
         $composer.startReplaceableGroup(22)
         Text(text = "Replaceable 1")
         $composer.endReplaceableGroup()
     } else {
         $composer.startReplaceableGroup( 33)
         Text(text = "Replaceable 2")     
         $composer.endReplaceableGroup()
  }
  $composer.endRestartGroup()
}

В результате деления по группам, код для их использования сохраняется в слот-таблице, которую можно изобразить так:

Тип хранения результатов вычисленных значений группы
Тип хранения результатов вычисленных значений группы

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

Мемоизация — кэширование вычисленного значения, для быстрого получения результата при последующем вызове функции.

Но почему позиционная?

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

Вернёмся к сгенерированному параметру $changed. Он используется для определения изменений данных, которые использует компонуемая функция.

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

Наиболее часто встречаются значения масок:

  • Uncertain (0b000) — значение ещё не было установлено.

  • Same (0b001) — значение не изменилось.

  • Different (0b010) — значение изменилось.

  • Static (0b011) — значение может быть изменено (к примеру, константы).

  • Mask (0b111) — служебная маска, используется компилятором, чтобы получить состояние определенного параметра.

Именно они помогают определить необходимо ли вызывать рекомпозицию функции или получить ранее сохраненное значение.

Рассмотрим на примере:

@Composable
fun Examle(a:Int, b:Int)
<-------------------------->
@Composable
fun Examle(a:Int, b:Int, $composer: Composer<*>, $changed: Int)

Пусть A изменилось, а B — нет. Тогда маска будет принимать значение:
changed = 010_001_0  (different_same_0 - ноль в конце это системный бит).

По данным маскам и будет вычисляться рекомпозиция зависящих элементов за счёт побитовых операций внутри Compose. Не будем подробно разбираться в алгоритмах определения состояний для каждого из параметров. Достаточно знать, что за счёт побитовых операций из значения changed вычисляются состояния параметров для рекомпозиции определенных групп в рамках компонуемой функции. Соответственно, если данные не изменились, то произойдет переход к концу группы, которая возьмётся из кэша вычисленное ранее значение.

Заключение

Я рассмотрел основной и базовый механизм работы Сompose, на котором строится все остальное. В рамках статьи затронута только часть реализации, но именно это позволит понять, как все работает под капотом. Уже на данном этапе, даже не описывая реализацию remember, ясна суть устройства алгоритма работы данной функции.

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

Интересный факт:

C выходом новый версии Compose 1.5.4 в экспериментальной версии появился новый режим работы: Strong skipping mode. Он смягчает правила стабильности данных, что позволяет сделать большее количество классов сразу же стабильными без дополнительного кода со стороны разработчика. Но сейчас ещё нельзя предусмотреть всех подводных камней, которые могут возникнуть. Технология движется вперед и не известно, что нас ждёт в будущем. Может быть в будущем разработчикам не придётся задумываться о многих тонкостях использования, но мы живём здесь и сейчас.

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

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