Привет! Меня зовут Владислав Кипнис, я работаю в команде разработки Android-приложений в Авто.ру. Мы часто используем Jetpack Compose — фреймворк для разработки пользовательских интерфейсов. 

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

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

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

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

Что такое рекомпозиции

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

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

Процесс рекомпозиции composable-функции
Процесс рекомпозиции composable-функции

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

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

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

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

Пара слов о схемах

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

Пример дальнейших схем
Пример дальнейших схем

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

Таким образом, этот набор состояний, состоящий из списка и ещё одного параметра, порождает граф, состоящий из треугольника, нового состояния (жёлтого квадрата), звезды и некоторого списка виджетов в виде кругов.

Рассмотрим пример. Предположим, у нас есть следующий код:

@Composable
fun Ver1(items: List<ListItem>, triangleColor: Color) {
  val filtered = items.filter { it.color.isNotBlue() }.toMutableList()
  Triangle(color = triangleColor)
  Feed(filtered)
}

Рассмотрим ситуацию, в которой мы изменяем только свойство triangleColor, не трогая при этом список items. Данному поведению будет соответствовать следующая схема:

Рассмотрим по шагам:

  1. Получаем первичную композицию на основе полученного состояния.

  2. Меняем одно из состояний, вычисляем вложенное состояние, выполняем рекомпозицию — перестройку дерева на изменение состояния.

  3. Повторяем шаг 2.

Вспомогательные функции

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

@Composable
inline fun <T> remember(crossinline calculation: @DisallowComposableCalls () -> T): T

@Composable
inline fun <T> remember(
    vararg keys: Any?,
    crossinline calculation: @DisallowComposableCalls () -> T
): T

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

Чаще всего вместе с функцией remember используется один из методов *StateOf для получения наблюдаемого состояния.

fun <T> mutableStateOf(
	value: T, 
	policy: SnapshotMutationPolicy<T> = structuralEqualityPolicy() 
): MutableState<T>

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

@Stable
interface MutableState<T> : State<T> {
  override var value: T
  operator fun component1(): T
  operator fun component2(): (T) -> Unit
}

MutableState — это интерфейс в Jetpack Compose, предоставляющий удобный способ работы с изменяемым состоянием в приложении. Он позволяет создавать объекты, которые можно изменять, а они, в свою очередь, будут оповещать Compose о своих изменениях. Таким образом, разработчики могут создавать динамические пользовательские интерфейсы, которые обновляются в реальном времени.

var item by mutableStateOf<String?>(null)
val (value, setValue) = mutableStateOf("")

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

Применение remember

В предыдущем примере мы выполняли фильтрацию списка на каждом вызове compose функции, независимо от того, произошли изменения в коллекции или нет. Это может пагубно сказаться на производительности, так что мы постараемся это исправить. Для этого используем функцию remember и обернём в неё фильтрацию списка.

@Composable
fun Ver2(items: List<ListItem>, triangleColor: Color) {
  val filtered = remember { items.filter { it.color.isNotBlue() }.toMutableList() }   
  Triangle(color = triangleColor)
  Feed(filtered)
}

Что получаем в итоге:

  1. На первом шаге строится граф, используя начальное состояние.

  2. Меняем состояние, не затрагивающее коллекцию. Получаем результат, которого хотели добиться: нет лишних вычислений и нет лишних рекомпозиций (на самом деле они есть, но об этом позже).

  3. Меняем состояние коллекции, добавляя ещё один элемент. К сожалению, в этот раз мы не получаем требуемый результат, потому что не сообщили функции remember, что надо повторно вычислить лямбду.

Чтобы исправить эту ситуацию, нам достаточно передать функции remember ключ, при сравнении которого, станет понятно, что коллекция изменилась, например, размер коллекции на входе. Просьба сильно не ругать за ключ size он используется для наглядности и не более.

@Composablefun Ver3(items: List<ListItem>, triangleColor: Color) {    
  val filtered = remember(items.size) { items.filter { it.color.isNotBlue() }.toMutableList() }    
  Triangle(color = triangleColor)
  Feed(filtered)
}

Применение mutableStateOf 

Чтобы посмотреть, как работать с mutableStateOf, в полученное решение попробуем добавить вставку в начало коллекции. Немного изменим код и добавим кнопку, которая и будет добавлять в начало один элемент.

val filtered = remember(items.size) {
  items.filter { /*...*/ }.toMutableList()
}
Button(onClick = { filtered.add(0? getListItem()) }) {
  Text(/*...*/)
}
Triangle(color = triangleColor)
Feed(filtered)

Схематично мы ожидаем такое поведение:

Опустим первый шаг и перейдём к моменту, где мы нажали на кнопку и пытаемся добавить элемент в начало коллекции. Несмотря на то, что наше внутреннее состояние — это mutable-список, при добавлении элемента в начало, у нас никак не меняется compose-граф. 

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

Здесь показан итоговый результат после добавления функции. По сути, ничего не изменилось: мы не меняли значение самого наблюдаемого состояния. Изменение значения внутри структуры никак не уведомляет Compose об этом, потому что не было присваивания ни через компонент, ни через значение интерфейса MutableState. Чтобы исправить эту ошибку, необходимо сделать список неизменяемым и обновлять значение состояния путём присваивания нового состояния в переменную. 

var filtered by remember(items.size) { mutableStateOf(items.filter { it.color.isNotBlue() }) }
Button(onClick = { filtered.toMutableList().apply { add(0, ListItem()) } }) {}

var (filtered, setValue) = remember(items.size) {mutableStateOf(items.filter { /../}) }
Button(onClick = { setValue(filtered.toMutableList().apply { add(0, ListItem()) }) }) {}

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

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

Стабильные типы данных

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

  1. Когда значение меняется, нужно оповестить Compose.

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

  3. Все публичные поля класса также должны быть стабильными.

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

Immutable

Аннотация Immutable позволяет пометить класс, в котором поля не могут быть изменены после создания экземпляра класса. Таким образом, состояние объекта может изменяться только путём создания нового экземпляра класса. Кроме того, аннотация отменяет проверку компилятором свойства из пункта 1 в условиях стабильности класса.

Использование аннотации Immutable налагает на разработчика ответственность за соблюдение правила неизменяемости объекта и защиту его состояния от несанкционированного изменения.

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

class Foo(val id: String, val items: List<String>)

Предположим, у нас есть некоторый класс, который содержит всего два поля: первое из них это строка, а второе — неизменяемый список строк. Если открыть отчёт компилятора Compose, мы увидим, что данный класс не считается стабильным. Это происходит, потому что коллекция хоть и неизменяемая, но при этом является нестабильным типом данных.

unstable class Foo {
  stable val id: String
  unstable val items: List<String>
  <runtime stability> = Unstable
}

Когда мы будем использовать класс Foo в качестве одного из параметров compose-функции, она будет каждый раз рекомпозироваться. Это позволит поддерживать UI в правильном состоянии. Если теперь к классу Foo применить аннотацию Immutable, мы получим следующее.

stable class Foo {
  satble val id: String
  unstable val items: List<String>
}

Теперь класс помечен как стабильный, при этом поле так и осталось нестабильным. 

Чтобы понять причину и к чему это может привести, посмотрите на следующий пример:

class Boo {
  private val items = mutableListOf<String>()
  fun createInitFoo(): Foo = Foo("", items)
  fun addItem(item: String) {
    items.add(item)
  }
}

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

Stable

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

Пропускаемые функции

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

restartable scheme("[androidx.compose.ui.UiComposable]")
fun DrawFoo(
  stable name: String
  unstable foo: Foo
)

Рассмотрим функцию,  которая принимает два параметра: один заведомо стабильный, а другой нестабильный класс Foo. Компилятор помечает данный composable-узел как restartable, что говорит о том, что каждый раз, когда родительский узел или один из параметров изменятся, функция перезапустится. Если к классу Foo теперь применить, аннотацию Stable, то представление узла DrawFoo изменится.

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

Чтобы ваши функции были всегда пропускаемыми старайтесь использовать пользоваться классами состояний, параметры которых являются примитивами или стабильными классами. Если же ваше состояние содержит коллекции используйте Immutable, но не забудьте обговорить это с коллегами. Так же с компилятора версии 1.2 ввели поддержку Immutable классов. Однако в данный момент библиотека все еще в alpha версии и пользоваться ей вы можете на свой страх и риск.

Как узнать, как компилятор видит composable-функции

Если вы хотите узнать, как выглядят composable-узлы вашего проекта, добавьте следующий код в ваш проектный файл build.gradle.

subprojects {
	tasks.withType(org.jetbrains.kotlin.gradle.tasks.KotlinCompile).configureEach {
    	kotlinOptions {
        	if (project.findProperty("app.enableComposeCompilerReports") == "true") {
            	freeCompilerArgs += [
                    	"-P",
                 	   "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=" +
                            	project.buildDir.absolutePath + "/compose_metrics"
            	]
            	freeCompilerArgs += [
                    	"-P",
                    	"plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=" +
                            	project.buildDir.absolutePath + "/compose_metrics"
            	]
        	}
    	}
	}
}

После чего в терминале вызовите следующую команду:

./gradlew assembleRelease -Papp.enableComposeCompilerReports=true

В вашей папке build появится ещё одна папка — compose_metrics. В ней будут лежать описания ваших compose-функций в том виде, как их себе представляет компилятор.

Немного для тех, кому интересно чуть больше

Мы рассмотрели, как сделать некоторую функцию пропускаемой, но не посмотрели на то, как сам Compose видит это всё внутри. Начнём с того, что для компилятора все функции, помеченные @Composable, превращаются в нечто подобное:

@Composable
fun A(x: Int, $composer: Composer<*>, $changed: Int) {
  var $dirty = $changed
  if ($chaged and 0b0110 ≡ 0) {
    $dirty = $dirty or if ($composer.changed(x)) 0b0010 else 0b0100
  }
if (%dirty and 0b1011 ≢ 0b1010 || !$composer.skipping) {
  f(x)
} else {
  $composer.skipToGroupEnd()
  }
}

Я привёл пример из исходников самого компилятора. При генерации кода добавляются дополнительные параметры: $composer и маска $changed. $composer — это точка доступа к данным Compose. Маска $changed описывает состояние всех параметров, передаваемых в функцию. Обратите внимание, что здесь используются битовые маски для значений $changed и $dirty. Они работают в другом битовом пространстве в отличие от $default.

В компиляторе Compose используется шесть масок:

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

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

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

  • Static(0b011) — значение не может быть изменено.

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

  • Unknown(0b100) — в коде компилятора не было упоминаний работы с этой маской.

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

Теперь разберём, как работает определение состояния параметров в маске changed. В рамках битовой маски они будут называться слотами. Каждый слот будет занимать 3 бита: например, если хотим взять Mask по слоту 0, это будет 0b111_0, а для слота 1 это будет 0b111_000_0 и так далее. Младший бит маски отвечает за принудительное выполнение функции.

Функция получения маски для определённого слота выглядит так:

val SLOTS_PER_INT = 10
val BITS_PER_SLOT = 3

fun bitsForSlot(bits:Int, slot: Int): Int {
  val realSlot = slot.rem(SLOTS_PER_INT)
  return bits shl (realSlot * BITS_PER_SLOT + 1)
}

В функции видно, что одна маска $changed вмещает в себя не более 10 параметров. Однако это не означает, что в функцию нельзя передать больше, просто в этом случае появится $changed1 и далее.

В итоге получаем следующую логику: находим номер слота как остаток от деления номера слота на максимальное количество слотов, после чего сдвигаем бит влево на i * 3 + 1. Единицу необходимо прибавить, потому что младший бит не относится к самой маске, а несёт иную смысловую нагрузку.

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

@Composable
fun A(x: Int){
  B(x = x, y = 123)
}

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

@Composable
public static final void A(final int x, @Nullable Composer $composer, final int $changed) {
  $composer = $composer.startRestartGroup(-1673623075);
  ComposerKt.sourceInformation($composer, "C(A)34@804L15:Stability.kt#p4r5jq");
  int $dirty = $chaged;
  if (($changed & 14) ═ 0) {
    $dirty = $changed | ($composer.changed(x) ? 4 : 2);
  }

if (($dirty & 11) ═ 2 && $composer.getSkipping()) {
  $composer.skipToGroupEnd();
} else {
  B(x, LiveLiterals$StabilityKt.INSTANCE.Int$arg-1$call-b$fun-A(), $composer, 14 & $dirty);
}

ScopeUpdatwScope var10000 = $composer.endRestartGroup();
if (var10000 ≠ null) {
  var10000.updateScope((Function2)(new Function2() {
    public final void invoke(@Nullable Composer $composer, int $force) {
      StabilityKt.A(x, $composer, $changed | 1);
    }
  }));
  }

}

Но мы рассмотрим упрощённую версию:

fun a(x: Int, composer: Composer, changed: Int) {
  composer.startRestartGroup("a")
  var dirty = changed
  var slotState = changed and ParamState.Mask.bitsForSlot(0)
  if (slotState ═ ParamState.Uncertain.bitsFotSlot(0)) {
    dirty = changed or
            (if (composer.changed(x)) ParamState.Different else ParamState.Same).bitsForSlot(0)
  }
 if (dirty and sameOrUnknownForced(0) ═ ParamState.Same.bitsForSlot(0) && composer.getSkipping()) {
     composer.skipToGroupEnd()
  } else {
    b(x = x, y = 123, composer = composer, changed = (ParamState.Mask.bitsForSlot(0) and dirty))
  }
  composer.endRestartGroup()
}

Composer в данном случае — моя функция, которая не выполняет заложенных в неё функций, а просто логирует в консоль действия. Строки 2, 10 и 14 относятся к управлению группой: они позволяют компилятору понимать, в контексте какого блока он сейчас находится. Но нас интересует то, что идёт со строки 3. Раз changed используется для проверки состояния параметров, изначально присваиваем ему значение dirty. Далее берём из changed только тот слот, который необходим сейчас.

Предположим, у функции есть три параметра: один  не менялся, а два других изменились. Маска changed в этом случае будет выглядеть так: Different_Different_Same_0 или 010_010_001_0. Чтобы получить значение для определённого слота, проводим битовое перемножение на Mask (chaged & Mask), приведённую к нужному слоту. Для первого параметра мы получим 010_010_001_0 & 111_0 == 000_000_001_0. Я намеренно не отбрасываю нули в старших битах, чтобы было нагляднее. На самом же деле мы получим 1_0, что будет соответствовать значению Same(0b001) для первого слота. 

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

fun getSlotState(changed: Int, slot: Int) =
  (cnanged and ParamState.Mask.bitsForSlot(slot)) shr (BITS_PER_SLOT * slot + 1)

В ней оставляем нетронутыми только значимые биты. После чего делаем сдвиг вправо, убирая младшие биты, которые на данный момент неинтересны. Таким образом, для слота 2 будет верно значение 010_010_001_0 & 111_000_0 == 000_010_000_0. Сдвигаем вправо на 3 (по принципу «бит на слот») * 1 + 1 == 010. Это соответствует маске Different(0b010).

Вернёмся к тому, для чего это было нужно. Ещё раз посмотрите на эти строки. 

var slotState = changed and ParamState.Mask.bitsForSlot(0)
  if (slotState ═ ParamState.Uncertain.bitsFotSlot(0)) {
    dirty = changed or
            (if (composer.changed(x)) ParamState.Different else ParamState.Same).bitsForSlot(0)
            }

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

После того как определено текущее состояние параметра, происходит проверка при помощи маски, о которой, к сожалению, практически нет никакой информации в исходниках 0b1011. Чтобы упростить код, я назвал данную маску sameOrUnkownForced. Судя по тому, как она используется, данная маска позволяет нам привести маски Same и Static к одной маске Same и перейти к проверке следующего условия. Остальные вызовут else-блок.

 if (dirty and sameOrUnknownForced(0) ═ ParamState.Same.bitsForSlot(0) && composer.getSkipping()) {
     composer.skipToGroupEnd()
  } else {
    b(x = x, y = 123, composer = composer, changed = (ParamState.Mask.bitsForSlot(0) and dirty))
  }

В конце условия if есть дополнительное условие, в котором мы обращаемся непосредственно в Composer за разрешением пропустить рекомпозицию. Но зачем это делать, если в changed уже есть вся информация об изменении параметров функции? В качестве объяснения приведу цитату из документации, которая чётко описывает причину вызова этой функции: 

«Даже если функция Composable вызывается с теми же параметрами, возможно, её всё равно потребуется запустить. Например, потому что было предоставлено новое значение для CompositionLocal, созданного staticCompositionLocalOf» .

В случае когда все условия соблюдены, вызовется основной блок условной операции: метод composer.skipToGroupEnd(). Он переведёт Composer к концу группы, после чего она будет закрыта и мы покинем функцию.

Но если необходима рекомпозиция, вызовется else-блок, в котором может вызваться дочерняя composable-функция. 

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

@Composable fun A(x: Int, $composer: Composer<*>, $changed Int) {
  var $dirty = ...
  // ...
  B(
    x,
    123,
    $composer,
    (0b110 and $dirty) or    // 1st param has same state that our 1st param does
    0b11000                  // 2nd parameter is "static"
  )
}

Однако я не смог найти подтверждения этому в декомпилированном коде.

B(x, LiveLiterals$StabilityKt.INSTANCE.INT$arg-1$call-B$fun-A(), $composer, 14 & $dirty);

b(x = x, y = 123, composer = composer, changed = (ParamState.Mask.bitsForSlot(0) and dirty))

Если посмотреть на декомпилированный код, в поле $changed (всегда последнее), передаётся только Mask(0b111) по слоту 0 и флаг dirty, несущий в себе информацию о слоте x (слот 0). В таком случае у второго параметра y всегда будет состояние Uncertain, для которого будет производиться проверка $composer.chaged(y). Он вернёт состояние Same, что позволит пропускать выполнение функции до тех пор, пока не изменится параметр x или $composer.getSkipping() не заставит выполнить код.

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

Вот небольшой кусок с примером:

fun bitsForSlot(bits: Int, slot: Int): Int {
    val realSlot = slot.rem(SLOTS_PER_INT)
    return bits shl (realSlot * BITS_PER_SLOT + 1)
}

fun a(x: Int, composer: Composer, changed: Int) {
    composer.startRestartGroup("a")
    var dirty = changed
    val slotState = changed and ParamState.Mask.bitsForSlot(0)
    print("check (changed 'and' mask)", slotState)
    if (slotState == ParamState.Uncertain.bitsForSlot(0)) {
        println("ask composer if 'a' is different")
        dirty = changed or
                (if (composer.changed(x)) ParamState.Different else ParamState.Same).bitsForSlot(0)
    }
    print("dirty: ${valueOfBits(dirty)}", dirty)
    if (dirty and sameOrUnknownForced(0) == ParamState.Same.bitsForSlot(0) && composer.getSkipping()) {
        composer.skipToGroupEnd()
    } else {
        println("invoke 'a' body")
        b(x = x, y = 123, composer = composer, changed =
        (ParamState.Mask.bitsForSlot(0) and dirty) or ParamState.Static.bitsForSlot(1)
        )
    }
    composer.endRestartGroup()
}

Итог

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

  • Если вам необходимо создать наблюдаемое состояние внутри вашей compose функции, используйте подходящую *StateOf. Не используйте Mutable-элементы, внутренние состояния которых могут измениться. Используйте mutableStateListOf или аналоги, когда вам нужно следить за изменениями коллекции.

  • Будьте осторожны, используя аннотации Immutable и Stable, потому что их применение может привести к неожиданным последствиям.

  • Старайтесь осознанно подходить к созданию классов - состояний для ваших compose функций с упором на стабильность. Если необходимо использовать нестабильный тип данных внутри состояния или как состояния, используйте Immutable, но не забывайте про предыдущий пункт.

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

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


  1. quaer
    13.06.2023 15:52

    В этой статье еще есть описание работы Compose.

    Вы смотрели как это в итоге всё рисуется на экране? Судя по описанию, используется Canvas API.

    Интересует сравнение по объёму кода, скорости работы, сложности написания интерфейса в сравнении с подходом на xml вёрстке и чисто программного подхода с использованием addView, ViewParam. А также какие проблемы помог решить переход на Compose?


    1. wgjuh Автор
      13.06.2023 15:52

      Да, compose использует Canvas API под капотом. Существует отличная статья на эту тему с небольшими примерами.
      Если сравнивать, то вот моё субъективное мнение.

      1. Объем кода:

        • Jetpack Compose. Тенденция к меньшему объему кода, поскольку Compose позволяет создавать UI-компоненты в декларативном стиле, что обычно делает код более чистым и кратким.

        • XML. Обычно требует больше шаблонного кода. XML разделяет структуру интерфейса и логику, что может увеличивать объем кода.

        • Программное добавление вьюх. Зависит от сложности интерфейса, но часто может быть громоздким.

      2. Скорость работы:

        • Jetpack Compose. Оптимизирован для производительности и создан с учетом современных практик.

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

        • Про производительность. Мы не проводили замеры производительности и скорости отрисовки элементов. Могу сказать, что визуально опыт использования никак не ухудшился. Мне понравилась статья на эту темя, очень рекомендую.

      3. Сложность написания интерфейса:

        • Jetpack Compose. Относительно прост (на самом деле кому как, но кажется, что для большинства это справедливо) в изучении для новых разработчиков и позволяет быстро создавать сложные интерфейсы с помощью встроенных компонентов.

        • XML. Может быть сложным для создания динамических и сложных интерфейсов.

        • Программное добавление вьюх. Может быть гибким, но часто требует больше кода и времени на реализацию сложных интерфейсов.

      4. Решение проблем:

        • Jetpack Compose. Упростилось создание динамических и реактивных интерфейсов. Было легко интегрировать с нашей существующей архитектурой. Улучшило поддержку тем и стилей.

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


      1. quaer
        13.06.2023 15:52

        Jetpack Compose. Тенденция к меньшему объему кода,
        поскольку Compose позволяет создавать UI-компоненты в декларативном
        стиле, что обычно делает код более чистым и кратким.

        Программное добавление вьюх. Зависит от сложности интерфейса, но часто может быть громоздким.

        Почему так? Ведь в программном добавлении вюьх точно также организуются блоки.

        Улучшило поддержку тем и стилей.

        А как реализуется когда надо несколько тем и разные экраны? if или switch в composable фунциях? Скажем, если на экране мобильника 5 кнопок помещается, а на планшете 10.

        И вдогонку: как думаете, технология будет развиваться или через годик её объявят устаревшей и выкатят compose2?


        1. SPOGS
          13.06.2023 15:52

          Обычно делается тема приложения и она лежит на самом верхнем уровне (fragment/activity/composeview), а все контейнеры с конкретными compose экранами уже складываются внутрь неё
          В итоге получается что то вроде
          setContent {
          AppTheme() {
          ComposeScreen()
          }
          }

          Где

          1. setContent - выставление Compose контента

          2. AppTheme с набором ресурсов приложения, привязанных к dark/light/fold

          3. Compose screen - любой экран на Compose, тут в приложении будет скорее всего лежать NavHost


          Когда меняется тема приложения - меняется AppTheme все Compose функции внутри неё, которые пользуются её dimens/colors/etc. получают сигнал о рекомпозии и перестраиваются. По идее то же самое должно происходить при изменении любой конфигурации устройства, включая складывание foldable устройства, но я пока не занимался поддержкой foldable на Compose и не могу дать точный ответ