Привет, Хабр! Меня зовут Иван Кузнецов, я Android‑разработчик в Кинопоиске. Сегодня расскажу историю разработки своего пет‑проекта, которая началась с код‑ревью очередного экрана на Jetpack Compose.

Представьте, вы открываете пул‑реквест и взгляд цепляется за знакомые паттерны: нестабильный параметр в Composable‑функции, remember без ключа, применение трансформаций на Layout‑фазе. Сразу хочется написать комментарий‑лекцию о том, почему это ударит по производительности и почему лучше так не делать.

Вот только объяснения отнимают время и не всегда наглядно доносят суть проблемы. Особенно это актуально для новичков, которым сложно сопоставить абстракции с реальным поведением UI. А ведь для сложных асинхронных штук вроде RxJava или корутин есть визуализаторы — RxMarbles и FlowMarbles, а для самой частой головной боли в Compose до сих пор нет. Вот бы вместо стены текста просто кинуть ссылку со словами: «Смотри, вот что твой код делает на самом деле».

В этой статье я расскажу о разработке собственного приложения, которое в реальном времени визуализирует рекомпозиции. Чтобы заставить его работать, пришлось залезть под капот компилятора Kotlin и подружиться с его внутренними API: FIR и IR.

Прототип на коленке

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

  • Слева на нём расположен живой, интерактивный UI на Jetpack Compose. Кнопочки, счётчики — всё то, с чем пользователь может взаимодействовать.

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

Реализовать это в Compose — дело пяти минут. Для этого нужен Row, который разделит экран по горизонтали, и два Box внутри, каждый из которых получает weight(1f). Эти веса заставляют Box поделить доступное пространство ровно пополам.

Окно приложения, разделённое надвое
Окно приложения, разделённое надвое

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

// Упрощённый пример разметки
@Composable
fun InteractiveScreen(
    sample: @Composable () -> Unit,
	code: @Composable () -> Unit
) {
    Row(modifier = Modifier.fillMaxSize()) {
        Box(modifier = Modifier.weight(1f)) {
            sample()
    	}
        Box(modifier = Modifier.weight(1f).verticalScroll(rememberScrollState())) {
            code()
    	}
	}
}

Демонстрация: простой счётчик с кнопкой и выводом текущего значения:

import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Alignment
import androidx.compose.foundation.layout.Column
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember

InteractiveSample(
    sample = {
        val count = remember { mutableIntStateOf(0) }
        Column(
            verticalArrangement = Arrangement.Center,
            horizontalAlignment = Alignment.CenterHorizontally,
            modifier = Modifier.fillMaxSize(),
        ) {
            Button(
                onClick = { count.value++ },
            ) {
                Text("Increment ${count.value}")
            }
        }
    },
    code = {
        Text(
            modifier = Modifier.padding(8.dp),
            text = """
                val count = remember { mutableIntStateOf(0) }
                Column(
                    verticalArrangement = Arrangement.Center,
                    horizontalAlignment = Alignment.CenterHorizontally,
                    modifier = Modifier.fillMaxSize(),
                ) {
                    Button(
                        onClick = { count.value++ },
                    ) {
                        Text("Increment ${count.value}")
                    }
                }
            """.trimIndent(),
            fontFamily = FontFamily.Monospace,
        )
    }
)

Ручная подсветка

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

Самая очевидная — разбить весь исходный код на строки и для каждой строки использовать отдельный компонент Text. Тогда можно просто менять фон у нужного Text по его номеру. Звучит просто, но этот путь ведёт в тупик, потому что одна строка на экране — это не всегда одна логическая операция.

В лямбде { count.value++ } нас интересует не вся строка, а только само действие. А что, если на одной строке будет несколько вызовов? Этот подход слишком грубый и негибкий. Отметаем. Нужен более точный инструмент.

Гораздо удобнее использовать onTextLayout у компонента Text. Он позволяет получить детальную информацию о том, как именно текст был отрисован на экране.

Когда вы передаёте в Text длинную строку, Compose проделывает огромную работу: вычисляет размеры каждого символа, решает, где сделать переносы, и размещает всё это на холсте. onTextLayout — это способ сказать: «Compose, как только закончишь с расчётами, скинь итоговый чертёж». Этот «чертёж» приходит в виде объекта TextLayoutResult. А у него, в свою очередь, есть метод — getBoundingBox(offset: Int). Этот метод возвращает объект Rect — прямоугольник с точными координатами и размерами для символа по указанному индексу (offset).

import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.drawRect
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp

@Composable
fun HighlightedText(text: String) {
    val textLayoutResult = remember { mutableStateOf<TextLayoutResult?>(null) }
    
    Text(
        modifier = Modifier
            .padding(8.dp)
            .drawBehind {
                val layoutResult = textLayoutResult.value
                if (layoutResult != null) {
                    val boundingBox = layoutResult.getBoundingBox(0)
                    drawRect(
                        color = Color.Red.copy(alpha = 0.5F),
                        topLeft = boundingBox.topLeft,
                        size = boundingBox.size,
                    )
                }
            },
        text = text,
        fontFamily = FontFamily.Monospace,
        onTextLayout = { result -> textLayoutResult.value = result },
    )
}

Это именно то, что нужно. Зная координаты любого символа, можно нарисовать под ним всё что угодно. Получается следующий алгоритм:

  1. Берём компонент Text для отображения всего исходного кода.

  2. В колбэке onTextLayout сохраняем полученный TextLayoutResult в state.

  3. Используем модификатор .drawBehind {}, чтобы на фоне Text нарисовать полупрозрачный красный прямоугольник.

  4. Координаты для этого прямоугольника мы получаем, вызывая getBoundingBox() на сохранённом TextLayoutResult для нужных нам индексов символов.

На этом этапе пришлось захардкодить «горячие» диапазоны. Например, для простого счётчика я вручную посчитал, что лямбда onClick начинается с 229-го символа и имеет длину 17 символов.

Пример выделения текста
Пример выделения текста

Чтобы реализовать динамическую подсветку, напишем класс HighlightManager. Он будет использовать SharedFlow для отправки и обработки событий, связанных с вызовами определённых участков исходного кода.

import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow

object HighlightManager {
    private val _events = MutableSharedFlow<Event>(extraBufferCapacity = Int.MAX_VALUE)
    val events: Flow<Event> = _events
    
    fun dispatch(event: Event) {
        _events.tryEmit(event)
    }
    
    data class Event(
        val startOffset: Int,
        val endOffset: Int,
    )
}

fun highlight(
    startOffset: Int,
    endOffset: Int,
) {
    HighlightManager.dispatch(
        HighlightManager.Event(
            startOffset = startOffset,
            endOffset = endOffset,
        )
    )
}

Теперь можно вручную вызвать highlight с заранее заданными координатами:

Button(
    onClick = {
        highlight(startOffset = 229, endOffset = 229 + 17)
        count.value++
    },
    content = {
        highlight(startOffset = 266, endOffset = 266 + 37)
        Text("Increment ${count.value}")
    }
)

Сработало. Но какой ценой?

Прототип ожил, но с этим решением была связана масса проблем.

  • Это неудобно. Для каждого нового примера пришлось бы сидеть и с калькулятором высчитывать смещения символов.

  • Это не масштабируемо. Допустим, у нас 50 примеров. Теперь, что ли, поддерживать базу из 50 захардкоженных констант?

  • Это антипаттерн. Код примера и логика его подсветки жёстко связаны, так что нельзя просто взять и поменять текст примера, не сломав подсветку.

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

Погружение в кроличью нору. Знакомство с FIR и IR

Решение этой задачи лежит не на уровне UI‑фреймворка и даже не на уровне языка Kotlin как такового. Оно скрыто в компиляторе, который превращает человекочитаемый код в байт‑код, понятный машине. К счастью, у современных компиляторов есть специальные точки расширения, плагинные API, которые позволяют встраивать собственную логику прямо в сборочный конвейер.

Есть два этапа, на которых мы можем вести диалог с компилятором: FIR (Frontend Intermediate Representation) и IR (Intermediate Representation). Чтобы понять их роли, воспользуемся простой аналогией. Представьте, что компилятор — это архитектурное бюро, которое строит здание по вашим эскизам (исходному коду).

Важно понимать, что API компиляторных плагинов, особенно FIR и IR, — это дикий запад от мира Kotlin. Они не предназначены для публичного использования в том же смысле, что и стандартная библиотека, и могут меняться от версии к версии без обратной совместимости.

FIR — это архитектор. Его задача — взять ваш код‑эскиз и превратить его в детальный чертёж. Он анализирует код, понимает, где классы, где функции, какие у них типы, — и создаёт из этого точную модель. На этом этапе мы попросим добавить в проект новую комнату — сгенерировать функцию HighlightedSourceCode.

Она будет содержать весь исходный код примера в виде обычной строки и передавать его в компонент HighlightedText для отображения в правой части экрана.

IR — это прораб. Он работает уже с готовым чертежом от FIR и переводит план в машинные инструкции. Он может зайти в любую комнату (в любую функцию или лямбду) и вмонтировать в стену новую розетку (внедрить вызов нашей функции highlight()).

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

Заставляем компилятор работать на нас

Итак, задача — написать плагин, который будет нашим представителем внутри сборочного конвейера.

Подготовка

В ранее созданный проект добавим модуль плагина и назовём его kotlin‑ir‑plugin. Создадим build.gradle.kt:

plugins {
    kotlin("jvm")
}

dependencies {
    compileOnly("org.jetbrains.kotlin:kotlin-compiler-embeddable")
}

В папке src\\main\\resources\\META-INF\\services создадим файл org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar с нашей точкой входа в плагин: ru.ivk1800.MyComponentRegistrar. Затем создадим класс MyComponentRegistrar:

@OptIn(ExperimentalCompilerApi::class)
class MyComponentRegistrar : CompilerPluginRegistrar() {
    override val supportsK2 = true

    override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) {
        error("Test")
    }
}

Подключим плагин к модулю composeApp в build.gradle.kts:

dependencies {
    org.jetbrains.kotlin.gradle.plugin.PLUGIN_CLASSPATH_CONFIGURATION_NAME(project(":kotlin-ir-plugin"))
}

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

FIR, или Как добавить в проект «комнату»

Задача: на первом этапе нам нужно научить компилятор генерировать для каждого класса, помеченного нашей кастомной аннотацией @Highlighted, новую функцию — HighlightedSourceCode(). Эта функция будет служить контейнером, в который мы позже поместим исходный код примера.

Для этого мы используем специальную точку расширения — FirDeclarationGenerationExtension. Можете считать это нашим пропуском в кабинет архитектора (FIR). С помощью этой точки расширения можно добавить в проект новые функции, свойства и даже целые классы.

Код
class HighlightedFirExtension(session: FirSession) : FirDeclarationGenerationExtension(session) {

    companion object {
        private val HighlightedSourceCodeName = Name.identifier("HighlightedSourceCode")
        private val Predicate = LookupPredicate.create { annotated(FqName("ru.ivk1800.Highlighted")) }
    }

    private val matchedClasses by lazy {
        session.predicateBasedProvider.getSymbolsByPredicate(Predicate).filterIsInstance<FirRegularClassSymbol>()
    }

    @OptIn(SymbolInternals::class)
    override fun generateFunctions(
        callableId: CallableId,
        context: MemberGenerationContext?,
    ): List<FirNamedFunctionSymbol> {
        if (callableId.callableName != HighlightedSourceCodeName || context == null) return emptyList()

        val function = createMemberFunction(
            owner = context.owner,
            key = Key,
            name = callableId.callableName,
            returnType = session.builtinTypes.unitType.coneType,
        )
        function.replaceAnnotations(
            listOf(
                buildAnnotation {
                    annotationTypeRef = buildResolvedTypeRef {
                        val constructClassType =
                            ClassId(FqName("androidx.compose.runtime"), Name.identifier("Composable")).toLookupTag()
                                .constructClassType(typeArguments = ConeTypeProjection.EMPTY_ARRAY)
                        coneType = constructClassType
                    }
                    argumentMapping = FirEmptyAnnotationArgumentMapping
                },
            ),
        )
        return listOf(function.symbol)
    }

    override fun getCallableNamesForClass(classSymbol: FirClassSymbol<*>, context: MemberGenerationContext): Set<Name> {
        return when {
            classSymbol in matchedClasses -> setOf(HighlightedSourceCodeName)
            else -> emptySet()
        }
    }

    override fun FirDeclarationPredicateRegistrar.registerPredicates() {
        register(Predicate)
    }

    object Key : GeneratedDeclarationKey() {
        override fun toString(): String = "HighlightedSourceCodeKey"
    }
}

Как это работает на практике: весь наш генератор — это класс, наследующий FirDeclarationGenerationExtension. Внутри него происходит два ключевых действия:

  1. Регистрация предиката (registerPredicates). Компилятор обрабатывает тысячи классов. Проверять, не нужно ли для него что‑то сгенерировать для каждого класса, — безумно дорого. Поэтому мы сразу говорим: «Нас интересуют только те классы, у которых есть аннотация @Highlighted». Это критически важная оптимизация, которая позволяет нашему плагину не тормозить всю сборку.

  1. Генерация функции (generateFunctions). Когда компилятор находит класс, прошедший наш фильтр, он обращается к этому методу и спрашивает: «Что именно нужно сгенерировать для этого класса?». Здесь мы предоставляем «чертёж» новой функции, описываем её сигнатуру:

  • Имя: HighlightedSourceCode

  • Владелец: Класс, который мы анализируем.

  • Тип возвращаемого значения: Unit (то есть ничего).

  • Аннотации: @Composable. Это необходимо, чтобы Jetpack Compose в дальнейшем понял, что эту функцию можно вызывать внутри другого Composable‑контента.

Результат: после подключения этого расширения к плагину происходит маленькое чудо:

@OptIn(ExperimentalCompilerApi::class)
class MyComponentRegistrar : CompilerPluginRegistrar() {
    override val supportsK2 = true

    override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) {
        FirExtensionRegistrarAdapter.registerExtension(MyFirExtensionRegistrar())
    }
}

Открываем IDE, пишем TestSample — и во всплывающем списке автодополнения видим нашу новую функцию: HighlightedSourceCode()!

FIR интегрирован со средой разработки, поэтому IDE узнает о наших сгенерированных декларациях на лету. Но радоваться рано. Если попробовать собрать проект, он упадёт с ошибкой: java.lang.IllegalStateException: Function has no body. Как только дело доходит до компиляции, оказывается, что комната, которую мы добавили в план, пуста: у функции нет тела, нет реализации. За них отвечает IR.

IR, или Как наполнить комнату

На втором этапе нам нужно решить две проблемы:

  1. Найти функцию HighlightedSourceCode и вставить в неё реальный исходный код.

  2. Найти все остальные методы и лямбды внутри класса‑примера и внедрить в самое их начало вызов highlight(...).

Для этого мы используем IrGenerationExtension и реализуем собственные IrVisitor.

IrVisitor — это классический паттерн проектирования, который позволяет обойти сложную структуру данных (в нашем случае — дерево скомпилированного кода) и выполнить какие‑то действия с её узлами.

Подзадача 1: инъекция исходного кода (SourceCodeInjector)

Код
class SourceCodeInjector(
    private val context: IrPluginContext,
) : IrVisitor<Unit, SourceCodeInjector.Data>() {

    private val highlightedAnnotationName = FqName("ru.ivk1800.Highlighted")

    class Data(
        var sourceCode: String? = null,
    )

    fun interestedIn(key: GeneratedDeclarationKey?): Boolean = key == HighlightedFirExtension.Key

    override fun visitElement(element: IrElement, data: Data) {
        element.acceptChildren(this, data)
    }

    override fun visitClass(declaration: IrClass, data: Data) {
        if (declaration.annotations.hasAnnotation(highlightedAnnotationName)) {
            val parent = declaration.parent as IrFile
            val sourceRangeInfo = parent.fileEntry.getSourceRangeInfo(
                beginOffset = declaration.startOffset,
                endOffset = declaration.endOffset,
            )

            val lines = File(sourceRangeInfo.filePath).readLines().joinToString("\n")

            data.sourceCode = lines
            super.visitClass(declaration, data = data)
        }
    }

    override fun visitSimpleFunction(declaration: IrSimpleFunction, data: Data) {
        val origin = declaration.origin
        if (origin !is GeneratedByPlugin || !interestedIn(origin.pluginKey)) {
            super.visitSimpleFunction(declaration, data)
            return
        }

        val sourceCode = data.sourceCode
        if (sourceCode != null) {
            val callableId = CallableId(
                packageName = FqName("ru.ivk1800"),
                callableName = Name.identifier("HighlightedText"),
            )

            val highlightedTextSymbol = context.referenceFunctions(callableId).first()

            val builder = DeclarationIrBuilder(context, declaration.symbol)

            val irCall = builder.irCall(highlightedTextSymbol)
            irCall.arguments[0] = builder.irString(sourceCode)

            val body = context.irFactory.createBlockBody(
                startOffset = UNDEFINED_OFFSET,
                endOffset = UNDEFINED_OFFSET,
            )

            body.statements += irCall
            declaration.body = body
        }

        super.visitSimpleFunction(declaration, data)
    }
}

Первый IrVisitor отвечает за наполнение пустой функции. Его алгоритм прост:

  1. Он обходит все классы и ищет тот, у которого есть наша аннотация @Highlighted.

  2. Найдя его, он использует IrFileEntry и его метод getSourceRangeInfo. Это, по сути, GPS‑трекер компилятора. Он даёт точные символьные смещения (startOffset и endOffset) нашего класса в исходном файле. Имея эти координаты, прочитать сам исходный код в виде строки — дело техники.

  3. Затем IrVisitor ищет внутри этого класса функцию с именем HighlightedSourceCode. Но как убедиться, что это именно наша сгенерированная функция, а не какая‑то другая с таким же именем? Для этого на этапе FIR мы пометили нашу функцию специальным маркером — GeneratedDeclarationKey.

  4. Найдя и опознав функцию, он создаёт тело для неё (IrBlockBody), генерирует вызов нашего компонента HighlightedText(...) и в качестве аргумента передаёт ту самую строку с исходным кодом, которую мы прочитали на шаге 2.

Как подключить SourceCodeInjector к плагину

Шаг 1. Объявить IrGenerationExtension:

class MyIrGenerationExtension : IrGenerationExtension {
    override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) {
        moduleFragment.acceptChildren(SourceCodeInjector(pluginContext), SourceCodeInjector.Data())
    }
}

Шаг 2. Зарегистрировать его в MyComponentRegistrar:

@OptIn(ExperimentalCompilerApi::class)
class MyComponentRegistrar : CompilerPluginRegistrar() {
    override val supportsK2 = true

    override fun ExtensionStorage.registerExtensions(configuration: CompilerConfiguration) {
        FirExtensionRegistrarAdapter.registerExtension(MyFirExtensionRegistrar())
        IrGenerationExtension.registerExtension(MyIrGenerationExtension())
    }
}

Поскольку функция с исходным кодом генерируется автоматически, минимально наш код теперь выглядит так:

@Highlighted
object TestSample {
    @Composable
    fun EntryPoint() {
        Text(text = "Test")
    }
}

Использовать сгенерированную функцию в InteractiveSample можно так:

InteractiveSample(
    sample = {
        TestSample.EntryPoint()
    },
    code = {
        TestSample.HighlightedSourceCode()
    },
)

Подзадача 2: расстановка HighlightInjector

Теперь самое интересное — автоматическая вставка вызовов highlight().

Код
class HighlightInjector(
    private val context: IrPluginContext,
) : IrVisitor<Unit, HighlightInjector.Data>() {

    private val highlightedAnnotation = FqName("ru.ivk1800.Highlighted")

    private val highlightSymbol: IrSimpleFunctionSymbol =
        context.referenceFunctions(CallableId(FqName("ru.ivk1800"), Name.identifier("highlight"))).first()

    class Data(var irClass: IrClass? = null)

    override fun visitElement(element: IrElement, data: Data) {
        element.acceptChildren(this, data)
    }

    override fun visitClass(declaration: IrClass, data: Data) {
        if (declaration.annotations.hasAnnotation(highlightedAnnotation)) {
            data.irClass = declaration
            super.visitClass(declaration, data)
        }
    }

    override fun visitSimpleFunction(declaration: IrSimpleFunction, data: Data) {
        val irClass = data.irClass ?: return
        val body: IrBlockBody = declaration.body as? IrBlockBody ?: return
        injectHighlightCall(fileEntry = irClass.fileEntry, body = body)
        super.visitSimpleFunction(declaration, data)
    }

    override fun visitFunctionExpression(expression: IrFunctionExpression, data: Data) {
        val irClass = data.irClass ?: return
        val body = expression.function.body as? IrBlockBody ?: return
        injectHighlightCall(fileEntry = irClass.fileEntry, body = body)
        super.visitFunctionExpression(expression, data)
    }

    private fun injectHighlightCall(fileEntry: IrFileEntry, body: IrBlockBody) {
        val bodySourceRangeInfo = fileEntry.getSourceRangeInfo(
            beginOffset = body.startOffset,
            endOffset = body.endOffset,
        )

        val highlightCall = IrCallImpl.fromSymbolOwner(
            startOffset = UNDEFINED_OFFSET,
            endOffset = UNDEFINED_OFFSET,
            type = context.irBuiltIns.unitType,
            symbol = highlightSymbol,
        )
        highlightCall.arguments[0] = IrConstImpl.int(
            startOffset = UNDEFINED_OFFSET,
            endOffset = UNDEFINED_OFFSET,
            type = context.irBuiltIns.intType,
            value = bodySourceRangeInfo.startOffset,
        )
        highlightCall.arguments[1] = IrConstImpl.int(
            startOffset = UNDEFINED_OFFSET,
            endOffset = UNDEFINED_OFFSET,
            type = context.irBuiltIns.intType,
            value = bodySourceRangeInfo.endOffset,
        )
        body.statements.add(0, highlightCall)
    }
}

IrVisitor делает следующее:

  1. Он снова находит класс, помеченный аннотацией @Highlighted.

  2. Далее начинает методично обходить всё, что находится внутри этого класса. Его интересуют два типа узлов:

    • Обычные функции (visitSimpleFunction).

    • Функциональные выражения, то есть лямбды (visitFunctionExpression).

  3. Для каждой найденной функции или лямбды он опять же с помощью getSourceRangeInfo получает точные координаты (startOffset, endOffset) в исходном файле.

  4. Затем он формирует новый вызов — IrCall — к нашей глобальной функции highlight(). В качестве аргументов он передаёт полученные startOffset и endOffset.

  5. И наконец, он вставляет этот новый вызов в самое начало тела (IrBlockBody) найденной функции или лямбды. Именно в начало, чтобы подсветка срабатывала до выполнения остального кода.

Чтобы подключить HighlightInjector, необходимо зарегистрировать HighlightInjector в MyIrGenerationExtension:

class MyIrGenerationExtension : IrGenerationExtension {
    override fun generate(moduleFragment: IrModuleFragment, pluginContext: IrPluginContext) {
        moduleFragment.acceptChildren(HighlightInjector(pluginContext), HighlightInjector.Data())
        moduleFragment.acceptChildren(SourceCodeInjector(pluginContext), SourceCodeInjector.Data())
    }
}

После того как оба IR‑IrVisitor отработают, мы получим полностью преобразованный код.

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

Результат: магия одной аннотации

Неудобный прототип превратился в автоматизированное решение. Помните, как выглядел вызов подсветки в самом начале? Это была громоздкая конструкция с захардкоженными числами:

// Было: боль, страдания и ручной подсчёт
Button(
    onClick = {
        highlight(startOffset = 229, endOffset = 229 + 17) // Магические числа
        count.value++
    },
// ...
)

Теперь, чтобы превратить любой object с Composable‑функциями в интерактивный пример, достаточно сделать две вещи.

Во‑первых, добавить всего одну аннотацию:

// Стало: чистота, порядок и автоматизация
@Highlighted
object MyAwesomeSample {
    @Composable
    fun EntryPoint() {
        var count by remember { mutableIntStateOf(0) }

        Button(onClick = { count++ }) {
            Text("Clicked: $count")
        }
    }
}

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

Во‑вторых, использовать сгенерированный код в UI:

// Используем результат в нашем двухоконном интерфейсе
InteractiveSample(
    sample = {
        MyAwesomeSample.EntryPoint() // Слева показываем живой пример
    },
    code = {
        MyAwesomeSample.HighlightedSourceCode() // Справа показываем его исходный код
    }
)

Код примера (MyAwesomeSample) ничего не знает о том, что его будут визуализировать, а UI для визуализации (InteractiveSample) ничего не знает о внутреннем устройстве примера. Всё происходит на этапе компиляции.

Готовое приложение доступно по ссылке. Исходный код примеров и плагинов размещён на GitHub.

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

Вместо заключения

Как видите, компилятор — не просто чёрный ящик, который разжёвывает ваш код. Это мощный инструмент, с которым можно и нужно договариваться.

Конечно, документация по внутренним API, мягко говоря, оставляет желать лучшего. Часто приходилось действовать методом проб и ошибок, изучая исходный код других плагинов (огромный респект команде kotlinx‑serialization, их github стал настоящим учебником). Но каждая работающая трансформация даёт приятное чувство контроля над своими инструментами. Перестаёшь быть простым пользователем языка или фреймворка. Вместо этого понимаешь, как он работает изнутри, и получаешь возможность изменять поведение инструмента под свои, порой очень узкие, уникальные задачи.

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

Кстати, если у вас есть хитрый кейс рекомпозиции, который можно добавить в сборник примеров, — не стесняйтесь создавать issue или присылать пул‑реквесты. Ведь по‑настоящему крутые вещи начинаются тогда, когда мы перестаём воспринимать наши инструменты как данность и начинаем задавать им вопрос: «А что, если?..».

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