
Привет, Хабр! Меня зовут Иван Кузнецов, я 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 },
)
}
Это именно то, что нужно. Зная координаты любого символа, можно нарисовать под ним всё что угодно. Получается следующий алгоритм:
Берём компонент
Text
для отображения всего исходного кода.В колбэке
onTextLayout
сохраняем полученныйTextLayoutResult
вstate
.Используем модификатор
.drawBehind {}
, чтобы на фонеText
нарисовать полупрозрачный красный прямоугольник.Координаты для этого прямоугольника мы получаем, вызывая
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
. Внутри него происходит два ключевых действия:
Регистрация предиката (
registerPredicates
). Компилятор обрабатывает тысячи классов. Проверять, не нужно ли для него что‑то сгенерировать для каждого класса, — безумно дорого. Поэтому мы сразу говорим: «Нас интересуют только те классы, у которых есть аннотация@Highlighted
». Это критически важная оптимизация, которая позволяет нашему плагину не тормозить всю сборку.
Генерация функции (
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, или Как наполнить комнату
На втором этапе нам нужно решить две проблемы:
Найти функцию
HighlightedSourceCode
и вставить в неё реальный исходный код.Найти все остальные методы и лямбды внутри класса‑примера и внедрить в самое их начало вызов
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
отвечает за наполнение пустой функции. Его алгоритм прост:
Он обходит все классы и ищет тот, у которого есть наша аннотация
@Highlighted
.Найдя его, он использует
IrFileEntry
и его методgetSourceRangeInfo
. Это, по сути, GPS‑трекер компилятора. Он даёт точные символьные смещения (startOffset
иendOffset
) нашего класса в исходном файле. Имея эти координаты, прочитать сам исходный код в виде строки — дело техники.Затем
IrVisitor
ищет внутри этого класса функцию с именемHighlightedSourceCode
. Но как убедиться, что это именно наша сгенерированная функция, а не какая‑то другая с таким же именем? Для этого на этапе FIR мы пометили нашу функцию специальным маркером —GeneratedDeclarationKey
.Найдя и опознав функцию, он создаёт тело для неё (
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
делает следующее:
Он снова находит класс, помеченный аннотацией
@Highlighted
.-
Далее начинает методично обходить всё, что находится внутри этого класса. Его интересуют два типа узлов:
Обычные функции (
visitSimpleFunction
).Функциональные выражения, то есть лямбды (
visitFunctionExpression
).
Для каждой найденной функции или лямбды он опять же с помощью
getSourceRangeInfo
получает точные координаты (startOffset
,endOffset
) в исходном файле.Затем он формирует новый вызов —
IrCall
— к нашей глобальной функцииhighlight()
. В качестве аргументов он передаёт полученныеstartOffset
иendOffset
.И наконец, он вставляет этот новый вызов в самое начало тела (
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 или присылать пул‑реквесты. Ведь по‑настоящему крутые вещи начинаются тогда, когда мы перестаём воспринимать наши инструменты как данность и начинаем задавать им вопрос: «А что, если?..».