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

А как же работает этот gradle-плагин?

Не так сложно, как кажется. Попробуем открыть его исходные коды:

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 компайлер плагина.

Список полезных источников:

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


  1. Danil_Serbin
    24.07.2024 14:00
    +1

    Хорошая статья для понимания кампоуза. Но сложно читать если особо не использовал на проекте его. Скорее для более продвинутых пользователей