Возникал ли у тебя когда-нибудь вопрос о том, как посмотреть, во что Compose compiler превращает наши Composable-функции, например, когда ты сделал оптимизацию и хочешь понять, что она работает так, как ты ожидаешь? Если да, то ты по адресу. Привет! Меня зовут Абакар, работаю главным техлидом в Альфа-Банке. В статье попробую разобраться, как Composable-функции меняются при компиляции и как работает аннотация @Composable.
Небольшая ремарка: Compose compiler переехал в репозиторий Kotlin и после версии Kotlin 2.0 Jetbrains будет заниматься выпуском компиляторного плагина Compose.

Compose работает как компиляторный плагин
Возникает вопрос: «А чем вообще компиляторный плагин отличается от annotation processor?». Давай рассмотрим два определения, а затем пример.
Compiler Plugin — это программа, которая расширяет функциональность компилятора Kotlin. Она позволяет выполнять дополнительные действия во время компиляции кода. Как один из примеров, она может модифицировать существующий код.
Annotation Processor — это программа, которая анализирует аннотации в исходном коде и генерирует на их основе дополнительный код или метаданные.
Давай для примера возьмём Dagger2.
-
Annotation Processor даггера генерирует новый код на основе существующего.
-
Но при этом Annotation Processor не может менять существующий код (оставим за скобками магию с манипулированием AST, которую вытворяет Lombok), в отличие от компиляторного плагина.
В этом как раз и состоит разница.
Compose — это компиляторный плагин. Он может менять существующий код на этапе компиляции.
Примечание. Не буду погружаться в то, что Compose на самом деле разделяется на Compose Runtime, Compose UI и Compose Compiler. По сути Compose Runtime и Compose Compiler — это сущности необходимые для правильной манипуляции деревьями. Compose UI — это тулкит с базовым набором компонентиков (можно провести аналогию с View тулкитом в андроиде). Для упрощения будем называть все это просто — Compose. Если интересно узнать более подробную информацию — ссылка. А также ссылки по этой теме будут в конце статьи в источниках.
А если заинтересовала тема того, как работают компиляторы и обвесы вокруг них (compiler plugins, annotation processors и т.д), могу порекомендовать литературу:
-
«Компиляторы: принципы, технологии и инструменты», Ахо, Ульман, Лам.
-
«Теория вычислений для программистов», Том Стюарт.
Итак, мы немного разобрались, что компиляторный плагин может менять исходный код, который мы пишем. В случае Compose, этот компиляторный плагин срабатывает там, где проставлена аннотация @Composable.
А как посмотреть, во что превращаются наши функции?
На GitHub в свободном доступе есть gradle плагин — decomposer, который поможет нам в этом нелегком деле (чуть позже посмотрим, как он работает под капотом).
Важный дисклеймер: это можно сделать и средствами Android Studio. В версии Koala мы получим корректную декомпиляцию через Show Kotlin bytecode. Но рассмотрим плагин, так как он позволяет декомпилировать сразу все файлы проекта, что делает его чуть более удобным.
Подключим его в наш проект и посмотрим первый пример:
class ExampleActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { Example() } } } @Composable fun Example() { println("make compose great again") }
Давай посмотрим, во что его превратит Compose-плагин:
public final class ExampleActivityKt { @Composable public static final void Example(@Nullable Composer $composer, final int $changed) { // тут мы видим, что у нас создается restartable группа $composer = $composer.startRestartGroup(-259780235); ComposerKt.sourceInformation($composer, "C(Example):ExampleActivity.kt#64jxz8"); if ($changed == 0 && $composer.getSkipping()) { // тут мы видим, что у нас создается skippable группа // это очень важная оптимизация Сompose, которая позволяет пропускать // выполнение кода $composer.skipToGroupEnd(); } else { if (ComposerKt.isTraceInProgress()) { ComposerKt.traceEventStart(-259780235, $changed, -1, "com.abocha.composemetrics.Example (ExampleActivity.kt:17)"); } // Вот единственная строчка, которую мы сами написали, все остальное // это обвесы от Compose плагина System.out.println("make compose great again"); if (ComposerKt.isTraceInProgress()) { ComposerKt.traceEventEnd(); } } ScopeUpdateScope var10000 = $composer.endRestartGroup(); if (var10000 != null) { var10000.updateScope((Function2)(new Function2() { public final void invoke(@Nullable Composer $composer, int $force) { ExampleActivityKt.Example($composer, RecomposeScopeImplKt.updateChangedFlags($changed | 1)); } })); } } }
Пока что не будем погружаться в то, что делает с нашим кодом Compose compiler. Но уже видно, что он добавляет много инструкций даже на пустую Composable-функцию.
Единственное, на чём есть смысл заострить внимание, так это на $composer.skipToGroupEnd() и $composer.startRestartGroup(-259780235). Если говорить грубо, то наличие skipToGroupEnd — это хорошо, так как позволяет пропустить большой блок исполнения кода (подробнее в Jetpack Compose internals).
Давайте пойдём дальше и попробуем добавить побольше инструкций в наш пример.
@Composable fun Example() { // Добавили вызов composable функции Text Text("make compose great again") }
А теперь декомпилированный вариант (покажу только отличия):
public final class ExampleActivityKt { @Composable @ComposableTarget( applier = "androidx.compose.ui.UiComposable" ) public static final void Example(@Nullable Composer $composer, final int $changed) { // Единственное отличие в том, что теперь тут вместо вывода в лог // вызвыается Composable фукнция Text, все что было выше и ниже осталось без изменений TextKt.Text--4IGK_g("make compose great again", (Modifier)null, 0L, 0L, (FontStyle)null, (FontWeight)null, (FontFamily)null, 0L, (TextDecoration)null, (TextAlign)null, 0L, 0, false, 0, 0, (Function1)null, (TextStyle)null, $composer, 6, 0, 131070); }
Попробуем усложнить пример и добавим входной аргумент в нашу функцию:
@Composable fun Example(text: String) { Text(text) }
А теперь декомпилированный вариант:
public final class ExampleActivityKt { @Composable @ComposableTarget( applier = "androidx.compose.ui.UiComposable" ) // Появился новый параметр, который мы добавили public static final void Example(@NotNull final String text, @Nullable Composer $composer, final int $changed) { //... тут все что было в примере выше, skippable группа также создается }
String — это стабильный тип, поэтому изменений не произошло. У нас также осталась skipable-группа.
А что, если мы теперь на вход в нашу Composable функцию добавим нестабильный параметр?
class ExampleActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContent { Example(listOf("make compose great again")) } } } @Composable // добавили нестабильный параметр fun Example(texts: List<String>) { Text(texts.toString()) }
Примечание. В статье Осознанная оптимизация Compose хорошо раскрывается тема стабильных и нестабильных типов.
Посмотрим на декомпилированный вариант:
public final class ExampleActivityKt { @Composable @ComposableTarget( applier = "androidx.compose.ui.UiComposable" ) public static final void Example(@NotNull final List texts, @Nullable Composer $composer, final int $changed) { Intrinsics.checkNotNullParameter(texts, "texts"); $composer = $composer.startRestartGroup(1558598647); // Тут мы видим, что restart группа создается, а вот skippable уже нет !! ComposerKt.sourceInformation($composer, "C(Example)19@523L22:ExampleActivity.kt#64jxz8"); if (ComposerKt.isTraceInProgress()) { ComposerKt.traceEventStart(1558598647, $changed, -1, "com.abocha.composemetrics.Example (ExampleActivity.kt:18)"); } TextKt.Text--4IGK_g(texts.toString(), (Modifier)null, 0L, 0L, (FontStyle)null, (FontWeight)null, (FontFamily)null, 0L, (TextDecoration)null, (TextAlign)null, 0L, 0, false, 0, 0, (Function1)null, (TextStyle)null, $composer, 0, 0, 131070); if (ComposerKt.isTraceInProgress()) { ComposerKt.traceEventEnd(); } ScopeUpdateScope var10000 = $composer.endRestartGroup(); if (var10000 != null) { var10000.updateScope((Function2)(new Function2() { public final void invoke(@Nullable Composer $composer, int $force) { ExampleActivityKt.Example(texts, $composer, RecomposeScopeImplKt.updateChangedFlags($changed | 1)); } })); } } }
Заметно, что у нас пропала skipable-группа. Связано это как раз с тем, что теперь наша Composable-функция принимает нестабильный параметр. Понять, какие параметры стабильные, а какие нет, можно также с помощью Compose metrics.
А как же работает этот graddle-плагин?
Не так сложно, как кажется. Попробуем открыть его исходные коды:
class DecomposerPlugin:Plugin<Project> { override fun apply(project: Project) { project.tasks.withType<KotlinCompile>() .whenTaskAdded { val kotlinCompileTask: KotlinCompile = this this.doLast { val kotlinFiles = kotlinCompileTask.destinationDirectory.asFileTree.files .map{it.absolutePath} // тут берутся котлин файлы проекта val output = File(project.buildDir,"decompiled").apply { deleteRecursively() mkdir() } // тут создается папка где появятся декомпилированные файлы val options: MutableList<String> = kotlinFiles.toMutableList() .apply{add(output.absolutePath)} ConsoleDecompiler.main(options.toTypedArray()) // вот тут и происходит декомпиляция output.listFiles() ?.filter { !it.readText().contains("androidx.compose") } ?.forEach { it.delete() } logger.log(LogLevel.LIFECYCLE, "DecomposerPlugin: decomposed in ${output.path}") } } } }
Всё, что нам необходимо, выдает ConsoleDecompiler. Весь остальной код просто готовит необходимую директорию и фильтрует файлы, в которых нет импорта Compose.
ConsoleDecompiler — полезная тулза и не привязана только к Compose. Её также можно использовать, чтобы посмотреть, что происходит с suspend-функциями. Но в целом suspend-функции можно интроспектировать и через возможности IDE:
class ExampleActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) lifecycleScope.launch { kek() } } private suspend fun kek() { delay(500) println("great again") } }А
Вот, что нам выдаст ConsoleDecompiler:
Загляни, если интересно.
public final class ExampleActivity extends ComponentActivity { public static final int $stable; protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); BuildersKt.launch$default((CoroutineScope)LifecycleOwnerKt.getLifecycleScope((LifecycleOwner)this), (CoroutineContext)null, (CoroutineStart)null, (Function2)(new Function2((Continuation)null) { int label; @Nullable public final Object invokeSuspend(@NotNull Object $result) { Object var2 = IntrinsicsKt.getCOROUTINE_SUSPENDED(); switch(this.label) { case 0: ResultKt.throwOnFailure($result); ExampleActivity var10000 = ExampleActivity.this; Continuation var10001 = (Continuation)this; this.label = 1; if (var10000.kek(var10001) == var2) { return var2; } break; case 1: ResultKt.throwOnFailure($result); break; default: throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine"); } return Unit.INSTANCE; } @NotNull public final Continuation create(@Nullable Object value, @NotNull Continuation $completion) { return (Continuation)(new <anonymous constructor>($completion)); } @Nullable public final Object invoke(@NotNull CoroutineScope p1, @Nullable Continuation p2) { return ((<undefinedtype>)this.create(p1, p2)).invokeSuspend(Unit.INSTANCE); } }), 3, (Object)null); } private final Object kek(Continuation var1) { Object $continuation; label20: { if (var1 instanceof <undefinedtype>) { $continuation = (<undefinedtype>)var1; if ((((<undefinedtype>)$continuation).label & Integer.MIN_VALUE) != 0) { ((<undefinedtype>)$continuation).label -= Integer.MIN_VALUE; break label20; } } $continuation = new ContinuationImpl(var1) { // $FF: synthetic field Object result; int label; @Nullable public final Object invokeSuspend(@NotNull Object $result) { this.result = $result; this.label |= Integer.MIN_VALUE; return ExampleActivity.this.kek((Continuation)this); } }; } Object $result = ((<undefinedtype>)$continuation).result; Object var4 = IntrinsicsKt.getCOROUTINE_SUSPENDED(); switch(((<undefinedtype>)$continuation).label) { case 0: ResultKt.throwOnFailure($result); ((<undefinedtype>)$continuation).label = 1; if (DelayKt.delay(500L, (Continuation)$continuation) == var4) { return var4; } break; case 1: ResultKt.throwOnFailure($result); break; default: throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine"); } System.out.println("great again"); return Unit.INSTANCE; } }
Выводы

Иногда бывает полезно иметь возможность посмотреть, во что Compose Compiler превращает наши Composable-функции в каких-то сложных кейсах (например, проверить, сработала ли сделанная оптимизация так, как надо, или нет).
Конечно, это не единственный способ. Ещё есть Compose metrics и инструменты профилирования, которые доступны в Android Studio:
Но всё равно не будет лишним знать о том, что у нас есть возможность посмотреть результаты работы Compose компайлер плагина.
Список полезных источников:
ссылка на оригинал статьи https://habr.com/ru/articles/827510/
Добавить комментарий