Как заглянуть под капот Composable функции

от автора

Возникал ли у тебя когда-нибудь вопрос о том, как посмотреть, во что 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.

А как посмотреть, во что превращаются наши функции?

Magic

Magic

На 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/