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