
Привет, Хабр! Меня зовут Иван Кузнецов, я Android‑разработчик в Кинопоиске. В прошлой статье я научил Jetpack Compose показывать рекомпозиции в реальном времени, но это был скорее учебный стенд: куча модификаторов, обёрток и примеры, которые нужно готовить вручную.
Я хотел чего‑то более полезного: чтобы IDE сама показывала, какие composable‑функции перерисовываются прямо сейчас, а какие скипаются и какие параметры реально меняются. Нажал Run — и редактор превратился в живую тепловую карту UI.
Ради этого пришлось сделать то, чего нормальные люди обычно избегают: залезть под капот Kotlin Compiler Plugin и научиться внедрять код в промежуточное представление на этапе компиляции, разобраться в битовых масках Compose и поднять TCP‑сервер внутри IntelliJ, чтобы запущенное приложение могло стучаться прямо в IDE.
Так появился Riflesso — плагин, который переносит идею Layout Inspector прямо в редактор кода и делает Compose прозрачным. В этой статье я разберу его архитектуру и покажу, как компилятор, клиентская библиотека и плагин IDE собираются в один инструмент.
Зачем нам ещё один плагин для рекомпозиций
Закономерный вопрос, который возникает у любого инженера: зачем изобретать свой велосипед? И правда, экосистема Jetpack Compose не страдает от нехватки инструментов:
Есть статические анализаторы вроде VKompose или Compose Stability Analyzer. Первый подсвечивает рекомпозиции цветной рамкой и логирует причины перерисовки, а второй в реальном времени показывает стабильность параметров прямо в редакторе.
Есть логгеры — Rebugger и Demeter от Яндекса, которые помогают отслеживать причины рекомпозиций.
А ещё Layout Inspector — золотой стандарт визуальной отладки от самой компании Google, входящий в Android Studio. Он показывает дерево компонентов, счётчики рекомпозиций и пропусков.
Однако все эти инструменты заточены под Android. Если пишешь на Compose for Desktop или экспериментируешь с KMP, то часто остаёшься один на один с println().
Кроме того, Layout Inspector показывает только текущее дерево компонентов. Эта архитектурная особенность усложняет анализ.
Если composable‑функция покидает композицию (например, элемент списка проскроллился или скрылся по условию if), она моментально исчезает из инспектора. А вместе с ней бесследно исчезает и её счётчик рекомпозиций. Вы не можете постфактум посмотреть, сколько раз перерисовался элемент, который только что пропал с экрана. Я же хочу видеть полную историю и отражение работы приложения прямо там, где пишу код.
Так и появилась идея построить прямой мост между запущенным приложением и IDE, перенести функциональность инспектора внутрь редактора IntelliJ IDEA. Хотелось, чтобы строки кода вспыхивали ровно в тот момент, когда перерисовывается соответствующий UI‑элемент, без отдельных окон, логкатов и танцев с бубном.
Для этого пришлось написать собственный плагин, который я назвал Riflesso (в переводе с итальянского — «отражение»). Он быстро превратился в систему формата «Клиент ↔ Сервер», где роль сервера досталась IDE. Если хотите сразу попробовать его в деле, вам понадобится:
Плагин для IntelliJ IDEA на JetBrains Marketplace (также можете найти его по названию Riflesso прямо в IDE)
Как подключить:
1. В файл build.gradle.kts проекта добавьте:
plugins {
id("ru.ivk1800.riflesso") version "2.0.21-0.0.1" apply false
}2. В файл build.gradle.kts модуля добавьте:
plugins {
id("org.jetbrains.kotlin.plugin.compose")
id("ru.ivk1800.riflesso") // Важно подключить после compose плагина!
}
dependencies {
implementation("ru.ivk1800.riflesso:client:2.0.21-0.0.1")
}3. Вызовите Riflesso.initialize().
Трёхслойная архитектура
Всё начинается на этапе сборки. Compiler Plugin автоматически и полностью прозрачно для пользователя производит хирургическое вмешательство в IR. Он находит каждую composable‑функцию и аккуратно вживляет туда телеметрический «жучок».
Как только приложение запускается, эти жучки начинают генерировать данные. Чтобы передавать их наружу, к проекту подключается небольшая клиентская библиотека. Она буферизирует сигналы о рекомпозициях, чтобы не просадить FPS, и отправляет их через сетевой порт.
На другом конце ждёт IDE Plugin. Он поднимает TCP‑сервер внутри IntelliJ, принимает события от приложения, сопоставляет их с текущими файлами и рисует подсветку поверх кода.
В отличие от Layout Inspector, моё решение опирается на простые TCP‑сокеты. Поэтому архитектура получилась универсальной, практически независимой от платформы. Приложение может крутиться где угодно: в эмуляторе, на macOS или в KMP‑проекте. Пока между ним и IDE есть сетевой канал, плагин будет получать «пульс» Compose и отображать его прямо в редакторе.
Теперь, когда мы разобрались с общей концепцией, можно перейти к самому интересному — к тому, как заставить IntelliJ IDEA подсвечивать рекомпозиции поверх кода.
Слой 1. Художник в IDE
Итак, перед нами почти детская задачка: нарисовать цветные прямоугольники. Что может быть проще? В IntelliJ Platform для этого даже есть удобное API, но любой, кто хотя бы раз писал плагин для этого инструмента, знает: под капотом — старый добрый Swing. Обращаться с ним нужно аккуратно, иначе редактор превращается в слайд‑шоу.
Центральная точка входа — объект Editor, тот самый «холст», на котором отображается код. У него есть MarkupModel, стандартный механизм IDE для подсветки ошибок, предупреждений и результатов поиска. Через него можно добавить свою подсветку с помощью addRangeHighlighter.
Однако сам RangeHighlighter — это всего лишь маркер: «от символа 10 до символа 20». Он ничего не рисует. Чтобы визуализировать элемент, нужен собственный рендерер — класс, реализующий CustomHighlighterRenderer. Чтобы задать внешний вид подсветки, нужно установить свойство customRenderer.
val rangeHighlighter: RangeHighlighter =
editor.markupModel.addRangeHighlighter(
/* startOffset = */ 0,
/* endOffset = */ document.getLineEndOffset(document.lineCount - 1),
/* layer = */ HighlighterLayer.SELECTION - 1,
/* textAttributes = */ null,
/* targetArea = */ HighlighterTargetArea.EXACT_RANGE,
)
// Наш кастомный отрисовщик
val renderer = HighlightRenderer(editor)
rangeHighlighter.customRenderer = rendererИ вот тут всплыла первая серьёзная проблема: как сделать подсветку достаточно частой, но при этом не повесить IDE?
Битва за частоту
Допустим, у вас на экране тяжёлая анимация на Jetpack Compose: индикатор крутится, списки скроллятся, график перерисовывается. Под капотом это десятки, а иногда и сотни рекомпозиций в секунду. Если плагин будет реагировать на каждое такое событие и добавлять новый Highlighter, Swing быстро захлебнётся в попытках отрисовать тысячи полупрозрачных слоёв, наложенных друг на друга.
К счастью, нам не нужно показывать все вспышки за последнюю секунду, достаточно одного текущего состояния. Поэтому в основе рендерера лежит не список, а множество — Set<Rect>.
Когда прилетает команда подсветить функцию, мы просто добавляем её координаты в Set. За счёт природы множества дубликаты отваливаются автоматически: если одна и та же функция рекомпозится 10 раз подряд, в наборе всё равно будет одна запись. Старый прямоугольник заменяется новым, самым свежим.
Чтобы управлять подсветкой, я оставил снаружи всего два метода: addRectangle(rect) и removeRectangle(rect). По сути, это публичный API моего рендерера.
Вызывая addRectangle(rect), мы указываем, что нужно нарисовать. Метод добавляет элемент в Set и вызывает Editor.contentComponent.repaint(), чтобы IDE запланировала перерисовку. Вызывая removeRectangle(rect), мы стираем нарисованное. Метод удаляет элемент из Set и так же вызываете repaint().
За счёт дедупликации и ограниченного числа актуальных прямоугольников нагрузка на отрисовку значительно снижается.
Магия координат
Вторая сложность — объяснить нашему «художнику», где именно рисовать. Данные от приложения приходят в виде офсетов: символ 150 → символ 200. Но на экране всё отображается в пикселях. Поэтому IDE нужны координаты, а не смещения в файле.
Здесь выручает сам Editor. Его методы конвертации выполняют всю грязную работу:
Editor.offsetToVisualPosition()переводит символьное смещение в логическую позицию на экране — например, «строка 5, столбец 10».Editor.visualPositionToXY()превращает эту позицию уже в конкретные пиксельные координаты на холсте редактора.
Но есть важная оптимизация, которая экономит ресурсы. Допустим, у нас файл на 2000 строк, а мы видим только с 50-й до 80-й. Рисовать прямоугольники где‑то там, в невидимой зоне — пустая трата циклов CPU (и батареи ноутбука).
Поэтому сначала стоит вычислить пересечение запрошенного диапазона с видимой областью экрана. Если пересечения нет — return. Если есть — рисуем только видимый кусок.
// Вычисляем границы
val visibleRange = editor.calculateVisibleRange() ?: return
val highlightStart = maxOf(startOffset, visibleRange.startOffset)
val highlightEnd = minOf(endOffset, visibleRange.endOffset)
// Если пересечения нет -- сворачиваемся
if (highlightEnd <= highlightStart) return
// ...и только теперь переводим в пиксели
val startVisual = editor.offsetToVisualPosition(highlightStart)В результате, если разработчик смотрит на 10-ю строку, а рекомпозиция произошла на 500-й — такое событие игнорируется.
Кроме того, есть ещё один нюанс, связанный с интерфейсом IDE: светлая и тёмная тема. Мы же не хотим, чтобы в светлой теме подсветка выглядела как «вырвиглазный» неон, а в тёмной — терялась на белом фоне. Используем проверку JBColor.isBright() и подсовываем разные палитры для Recomposition (обычно жёлтый/оранжевый) и Skip (зелёный/синий).
Светомузыка для кода
Статичная подсветка — это скучно. Куда интереснее, когда она вспыхивает и плавно исчезает, привлекая внимание, но не перекрывая код.
Теоретически можно было бы повесить Thread.sleep(200) в потоке отрисовки, но это прямой путь к задержкам в работе интерфейса. Поэтому за мигание отвечает отдельный компонент — HighlightManager, который работает на Kotlin Coroutines.
Если мы просто создадим один экземпляр рендерера и привяжем его к случайному файлу, он будет рисовать подсветки вообще не там, где нужно.
Нам нужен локатор — компонент, который будет постоянно сообщать: «Эй, пользователь переключился на такой‑то файл». В IntelliJ Platform для этого есть FileEditorManagerListener. Это listener‑based API, но я предпочёл обернуть его в современный ActiveEditorProvider на базе Flow. Он делает три вещи:
Подписывается на события смены редактора.
Превращает эти события в удобный
Flow<Editor?>.Фильтрует шум. Нам не нужны события о переключении на XML‑ или Gradle‑файлы. Я сразу оставляю только редакторы с Kotlin‑файлами.
В коде это выглядит так:
class ActiveEditorProvider(private val project: Project) {
val editorFlow: Flow<Editor?> = callbackFlow {
// Подписываемся на стандартную шину событий IntelliJ
val connection = project.messageBus.connect()
val listener = object : FileEditorManagerListener {
override fun selectionChanged(event: FileEditorManagerEvent) {
// Отправляем новый редактор в поток, если он подходит
trySend(event.manager.selectedTextEditor?.takeIfSuitable())
}
}
connection.subscribe(FileEditorManagerListener.FILE_EDITOR_MANAGER, listener)
awaitClose { connection.disconnect() }
}
.onStart {
// При старте сразу эмитим текущий открытый редактор
emit(FileEditorManager.getInstance(project).selectedTextEditor?.takeIfSuitable())
}
.distinctUntilChanged()
}
// Фильтр: только Kotlin-файлы и только главный редактор
private fun Editor.takeIfSuitable(): Editor? = takeIf { editor ->
editor.editorKind == EditorKind.MAIN_EDITOR &&
editor.virtualFile?.extension == "kt"
}Теперь у нас есть поток, который отслеживает текущий активный Editor для Kotlin‑файлов. На этот поток подписывается HighlightRendererProvider (чтобы создавать/уничтожать рендереры для конкретных файлов), а уже с ним работает HighlightManager.
Его задача — связать HighlightRenderer с каналом данных ClientConnectionManager и добавить эффект мигания.
Код
@OptIn(ExperimentalCoroutinesApi::class)
class HighlightManager(
private val highlightRendererProvider: HighlightRendererProvider,
private val clientConnectionManager: ClientConnectionManager,
private val settings: RiflessoSettings,
) {
private val scope = CoroutineScope(Dispatchers.Main + CoroutineName("HighlightManager") + SupervisorJob())
fun start() {
scope.coroutineContext.cancelChildren()
// Слушаем активный редактор (RendererProvider)
highlightRendererProvider.rendererFlow
.flatMapLatest { renderer ->
if (renderer == null) {
emptyFlow<Unit>()
} else {
createHighlightFlow(renderer)
}
}
.launchIn(scope)
}
private fun createHighlightFlow(renderer: HighlightRenderer): Flow<Rect> =
settings.stateFlow
.map { it.isCodeHighlightingEnabled }
.distinctUntilChanged()
.flatMapLatest { isEnabled ->
if (isEnabled) {
// Фильтруем события только для текущего файла
val fileId = UUID.nameUUIDFromBytes(renderer.editor.virtualFile.path.toByteArray()).toString()
clientConnectionManager.eventsFlow
.filter { event -> event.fileId == fileId }
.map { event -> event.toRect() }
.onEach { newRectangle ->
// Эффект мигания: Рисуем -> Ждём -> Стираем
scope.launch {
renderer.addRectangle(newRectangle)
delay(200.milliseconds)
renderer.removeRectangle(newRectangle)
}
}
.onCompletion {
// Очистка при смене файла или остановке
renderer.removeAllRectangles()
}
} else {
emptyFlow()
}
}
}В коде под спойлером обратите внимание на onEach: когда прилетает сигнал, запускается лёгкая корутина, она вызывает renderer.addRectangle(), делает неблокирующий delay(200.milliseconds), затем вызывает renderer.removeRectangle().
Визуально это превращается в короткий импульс подсветки. Однако стоило это реализовать, как вылезла проблема «призраков». Что будет, если вкладку с файлом закрыть как раз в тот момент, когда корутина спит в delay? Через 200 мс она проснётся и попытается убрать прямоугольник с холста, которого уже не существует.
Чтобы этого не происходило, я завязался на ActiveEditorProvider и Flow: вся логика подсветки живёт ровно столько, сколько живёт текущий Editor. Как только мы закрываем файл, поток Flow завершается и мы вызываем renderer.removeAllRectangles(), аккуратно подчищая мусор.
Так получился быстрый, визуально приятный и безопасный для памяти рендеринг. Осталось разобраться, откуда вообще брать события, которые мы так красиво рисуем, и как данные из эмулятора или реального устройства добираются до этого механизма.
Полная реализация HighlightRenderer, со всеми этими хитростями
import com.intellij.openapi.diagnostic.thisLogger
import com.intellij.openapi.editor.Editor
import com.intellij.openapi.editor.VisualPosition
import com.intellij.openapi.editor.markup.CustomHighlighterOrder
import com.intellij.openapi.editor.markup.CustomHighlighterRenderer
import com.intellij.openapi.editor.markup.RangeHighlighter
import com.intellij.ui.JBColor
import java.awt.Color
import java.awt.Graphics
class HighlightRenderer(val editor: Editor) : CustomHighlighterRenderer {
data class Rect(
val type: Type,
val start: Int,
val end: Int,
) {
enum class Type {
Recomposition,
Skip,
}
}
private val rectangles = mutableSetOf<Rect>()
private val darkRecompositionHighlightColor = Color(255, 255, 0, 50)
private val lightRecompositionHighlightColor = Color(255, 0, 0, 50)
private val darkSkipHighlightColor = Color(0, 255, 0, 50)
private val lightSkipHighlightColor = Color(0, 255, 0, 50)
fun addRectangle(rectangle: Rect) {
this.rectangles.add(rectangle)
editor.contentComponent.repaint()
thisLogger().warn("${this.rectangles.size}")
}
fun removeRectangle(rectangle: Rect) {
this.rectangles.remove(rectangle)
editor.contentComponent.repaint()
}
fun removeAllRectangles() {
this.rectangles.clear()
editor.contentComponent.repaint()
}
override fun paint(
editor: Editor,
highlighter: RangeHighlighter,
graphics: Graphics,
) {
rectangles.forEach { rectangle ->
paintRect(editor = editor, graphics = graphics, rectangle = rectangle)
}
}
private fun paintRect(editor: Editor, graphics: Graphics, rectangle: Rect) {
val startBrace = rectangle.start
val endBrace = rectangle.end
if (startBrace == -1 || endBrace == -1 || endBrace <= startBrace) return
val startOffset = startBrace
val endOffset = endBrace + 1 // inclusive
// Get the visible range
val visibleRange = editor.calculateVisibleRange() ?: return
val visibleStart = visibleRange.startOffset
val visibleEnd = visibleRange.endOffset
// Calculate the intersection of ranges
val highlightStart = maxOf(startOffset, visibleStart)
val highlightEnd = minOf(endOffset, visibleEnd)
if (highlightEnd <= highlightStart) return // No intersection, nothing to draw
val startVisual = editor.offsetToVisualPosition(highlightStart)
val endVisual = editor.offsetToVisualPosition(highlightEnd)
val startXY = editor.visualPositionToXY(startVisual)
val endXY = editor.visualPositionToXY(endVisual)
val lineHeight = editor.lineHeight
val isDark = JBColor.isBright() == false
graphics.color = when (rectangle.type) {
Rect.Type.Recomposition -> {
if (isDark) {
darkRecompositionHighlightColor
} else {
lightRecompositionHighlightColor
}
}
Rect.Type.Skip -> {
if (isDark) {
darkSkipHighlightColor
} else {
lightSkipHighlightColor
}
}
}
// Draw only the visible part (accounting for multiple lines)
if (startXY.y == endXY.y) {
// Single line
graphics.fillRect(startXY.x, startXY.y, endXY.x - startXY.x, lineHeight)
} else {
// First line
graphics.fillRect(startXY.x, startXY.y, editor.contentComponent.width - startXY.x, lineHeight)
// Middle lines
for (line in startVisual.line + 1 until endVisual.line) {
val n = VisualPosition(line, startVisual.column)
val y = editor.visualPositionToXY(n).y
graphics.fillRect(0, y, editor.contentComponent.width, lineHeight)
}
// Last line
graphics.fillRect(0, endXY.y, endXY.x, lineHeight)
}
}
}Слой 2. Транспортная система
Казалось бы, 2025 год на дворе: WebSockets, gRPC, HTTP/2 — бери любой стек и пользуйся. Моя первая мысль была такой: «Сейчас подниму красивый Ktor WebSocket‑сервер внутри плагина, приложение будет клиентом, подключится, и мы будем гонять там JSON». Но вскоре я уткнулся в жёсткое ограничение архитектуры IntelliJ Platform.
Дело в том, что плагины живут здесь в изолированной среде. Даже если поднять HTTP‑сервер внутри плагина, он фактически оказывается заперт в контейнере IDE. Внешний клиент — приложение в эмуляторе или на устройстве — просто не видит этот сервер, а попытки подключиться упираются в сетевые политики хоста.
Пришлось спуститься на уровень TCP‑сокетов, ведь обычный ServerSocket, открытый на системном порту, совершенно не интересуется внутренней изоляцией IntelliJ. Для операционной системы это просто ещё один процесс, который слушает порт, и к нему можно спокойно подключиться.
Оставалась одна проблема: какой порт использовать? В первой версии, чтобы не уходить в дебри полноценного Service Discovery, я принял волевое (и немного стыдное) решение и захардкодил порт 10080.
Да, порт может быть занят, и воо��ще это костыль, но работающий. Для MVP это неплохой компромисс. Можно было спокойно сфокусироваться на главной задаче — научить IDE и приложение разговаривать друг с другом.
ClientConnectionManager
Сетевой код на Java/Kotlin очень легко превращается в лапшу — бесконечные колбэки, ручное управление потоками, try‑catch на try‑catch. Я точно не хотел тащить это в проект. Нужен был механизм, который не только работает асинхронно, но и сам переживает сбои, переподключается и корректно сообщает IDE своё состояние.
Поэтому ClientConnectionManager я построил на Kotlin Flows и Coroutines. Это позволило описать его поведение как простой реактивный автомат состояний, понятный и UI, и самому плагину:
Stopped— спокойный режим, ничего не делаемWaitConnection— сервер уже запущен, ждём подключения клиентаConnected— есть контакт, данные бегутError— порт занят, сокет отвалился или случилось что‑то ещё
Вся логика прозрачная, переиспользуемая и естественным образом восстанавливается при обрыве соединения.
Полный код ClientConnectionManager
import io.ktor.network.selector.SelectorManager
import io.ktor.network.sockets.ServerSocket
import io.ktor.network.sockets.Socket
import io.ktor.network.sockets.aSocket
import io.ktor.network.sockets.openReadChannel
import io.ktor.utils.io.readUTF8Line
import kotlinx.coroutines.CoroutineName
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.channels.awaitClose
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onStart
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.json.Json
import ru.ivk1800.riflesso.ConnectionEvent
import ru.ivk1800.riflesso.HighlightEvent
@OptIn(ExperimentalCoroutinesApi::class)
class ClientConnectionManager : AutoCloseable {
val scope = CoroutineScope(Dispatchers.Main + CoroutineName("ClientConnectionManager") + SupervisorJob())
private val isActiveFlow = MutableStateFlow<Boolean>(false)
private val _stateFlow = MutableStateFlow<State>(State.Stopped)
val stateFlow: StateFlow<State> = _stateFlow
private val _eventsFlow = MutableSharedFlow<HighlightEvent>(extraBufferCapacity = Int.MAX_VALUE)
val eventsFlow: Flow<HighlightEvent> = _eventsFlow
private val _retryEventFlow = MutableSharedFlow<Unit>()
init {
isActiveFlow.flatMapLatest { isActive ->
if (isActive) {
_retryEventFlow.onStart { emit(Unit) }
.flatMapLatest {
createSocketFlow()
}
} else {
_stateFlow.value = State.Stopped
emptyFlow<Unit>()
}
}
.launchIn(scope)
}
fun start() {
if (scope.isActive) {
if (_stateFlow.value is State.Error) {
scope.launch { _retryEventFlow.emit(Unit) }
} else {
isActiveFlow.value = true
}
}
}
fun stop() {
if (scope.isActive) {
isActiveFlow.value = false
}
}
override fun close() {
stop()
scope.coroutineContext.cancel()
}
private fun createSocketFlow(): Flow<Unit> =
callbackFlow<Unit> {
val selectorManager = SelectorManager(Dispatchers.Default)
val serverSocket = aSocket(selectorManager).tcp().bind(hostname = HOSTNAME, port = PORT)
launch {
while (isActive) {
try {
acceptConnection(serverSocket)
} catch (_: Exception) {
// TODO add logs
}
}
}
awaitClose {
selectorManager.close()
serverSocket.close()
}
}
.catch { error ->
_stateFlow.value = State.Error(error)
}
private suspend fun CoroutineScope.acceptConnection(serverSocket: ServerSocket) {
while (this.isActive) {
_stateFlow.value = State.WaitConnection
val socket = serverSocket.accept()
_stateFlow.value = State.Connected
receiveMessages(socket)
}
}
private suspend fun CoroutineScope.receiveMessages(socket: Socket) {
val receiveChannel = socket.openReadChannel()
try {
while (this.isActive) {
val eventRaw = receiveChannel.readUTF8Line()
if (eventRaw == null) {
break
}
val event = Json.decodeFromString<ConnectionEvent>(eventRaw)
when (event) {
is ConnectionEvent.Highlight -> {
_eventsFlow.tryEmit(event.value)
}
is ConnectionEvent.Hello -> {
_stateFlow.value = State.Connected
}
is ConnectionEvent.Ping -> Unit
}
}
} finally {
receiveChannel.cancel(null)
}
}
sealed interface State {
data object Stopped : State
data object WaitConnection : State
object Connected : State
data class Error(val error: Throwable) : State
}
private companion object {
private const val HOSTNAME = "127.0.0.1"
private const val PORT = 10080
}
}Протокол
Как понять, что клиент вообще жив? Android‑приложение может быть убито системой, эмулятор — закрыт, сборка — перезапущена. Сервер не должен вечно висеть в «подключённом» состоянии и ждать ответа. Чтобы избежать такого развития событий, я сделал небольшую клиентскую библиотеку, которую нужно добавить в своё Android‑ или десктоп‑приложение.
Благодаря ей каждые три секунды клиент отправляет короткое сообщение Ping. Если этот пульс пропадает — сервер сразу понимает, что клиент недоступен, и переходит в режим ожидания нового подключения. При этом все данные о композициях прилетают отдельными сообщениями поверх этого же соединения.
Со стороны клиента всё сделано максимально отказоустойчиво. Вызов Riflesso.initialize() запускает фоновую корутину, которая живёт своей жизнью:
IDE не запущена? Клиент спокойно стучится каждые 3 секунды, никому не мешая.
IDE поднялась? Соединение схватывается.
Приложение перезапустили? Клиент переподключается автоматически.
Устройство отключили? Сервер сбросит состояние и будет ждать новое соединение.
В итоге система работает по принципу «поставил и забыл». Никаких кнопок Connect. Просто открыли IDE, запустили приложение — и всё заработало.
Готовность к сборке
Конечно, моя библиотека тянет за собой зависимости для работы с сокетами. Для debug‑версии это некритично, но для production‑сборки вес приложения может быть важен, особенно если пишешь для мобильных платформ.
Чтобы решить эту проблему, я предусмотрел отдельную no‑op‑версию плагина. Это пустышка, у которой те же функции initialize(), но внутри нет никакой реализации. Вызовы просто ничего не делают.
Слой 3. Хирургическое вмешательство в компилятор
Теперь, когда у нас есть надёжный канал связи, пора перейти к самой сложной части проекта. Как вообще заставить приложение генерировать нужные события?
Чтобы Riflesso работал полностью автоматически, нужно внедрить вызов функции highlight() в каждую composable‑функцию. Никто не будет делать это вручную, значит нужно написать для этого отдельный компонент.
Первая неудача: наивный подход
Сперва задача казалась простой, даже тривиальной:
Берём код в промежуточном представлении.
Находим функции с аннотацией
@Composable.Вставляем вызов
highlight()первой строкой в тело функции.
В первой статье я так и делал, но для настоящего рабочего инструмента этот метод не подходит. Я быстро написал прототип, запустил — и получил полную ерунду.
Плагин бодро рапортовал, что рекомпозиции происходят постоянно, даже в тех местах, где Compose на самом деле ничего не выполнял. Просто то, что мы пишем на Kotlin, и то, во что наш код превращается после работы компилятора Compose, — две большие разницы. Compose заворачивает каждую функцию в сложную логику проверки состояний. В упрощённом Java‑подобном псевдокоде это выглядит примерно так:
void MyComposable(Composer composer, int $changed) {
// ... вычисление dirty ...
if (composer.skipping && $dirty == 0) {
// Вставляем вызов для пропуска:
highlight(..., type = Skip);
composer.skipToGroupEnd();
} else {
composer.startDefaults();
// ... вычисление дефолтных параметров ...
composer.endDefaults();
// Вставляем вызов для рекомпозиции строго после дефолтов:
highlight(..., type = Recomposition);
// <-- И только потом начинается тело функции
Text(...);
}
}Я же вшивал вызов highlight() в самое начало функции. По сути, я подавал команду на рекомпозицию ещё до того, как Compose успевал решить, будет ли он вообще что‑то выполнять. Если функция попадала под skip, мой код всё равно отрабатывал и визуализация расходилась с реальностью.
Поиск правильной точки входа
Пришлось заняться реверс‑инжинирингом. Разбирая сгенерированный IR‑код, я заметил маркеры, которые расставляет сам компилятор Compose: вызовы startDefaults() и endDefaults(). В отличие от моего highlight(), их можно использовать в качестве ориентира.
В итоге я поменял логику внедрения:
Я нахожу вызов
endDefaults().Сразу после него находится точка невозврата — момент, когда Compose уже принял решение и рекомпозиция точно будет выполнена.
Именно туда я вставляю
highlight(type = Recomposition).Мне важно видеть, что Compose решил не перерисовывать функцию (сэкономил ресурсы). Поэтому в альтернативную ветку, где вызывается
skipToGroupEnd(), я также добавляю вызовhighlight(...), но уже с типом Skip.
Тут есть подвох. Взгляните на типичную composable‑функцию с параметром по умолчанию:
@Composable
fun Test(state: LazyListState = rememberLazyListState()) { ... }Компилятор генерирует для этой функции сложную структуру if/else, чтобы решить, нужно ли вычислять выражение rememberLazyListState() заново или можно взять старое значение. Весь этот блок обрамляется вызовами startDefaults() и endDefaults().
Реализация искала просто любой блок if/else в начале функции и вставляла вызов highlight туда. Но на реальных проектах это привело к проблемам. Пришлось разделить логику на два кейса.
Кейс 1. У функции нет параметров по умолчанию
Здесь всё просто. Никаких дополнительных блоков компилятор не генерирует. Мы можем смело вставлять наш вызов первой инструкцией в основной ветке (индекс 0).
Кейс 2. Параметры по умолчанию есть
Если вставить вызов в начало (до startDefaults), плагин сработает до того, как Compose проверит битовую маску и решит, нужно ли вообще выполнять функцию. В этом сценарии я получал ложные срабатывания.
Правильное универсальное решение, к которому я пришел, — искать именно вызов endDefaults().
Если он есть — значит, есть дефолтные параметры. Вставляем наш код сразу после него. В этот момент все значения уже вычислены, и мы входим в «истинное» тело функции.
Если его нет — вставляем в начало.
private fun List<IrStatement>.findIndexToInject(): Int =
indexOfFirst {
it is IrCall && it.symbol.owner.name.asString() == "endDefaults"
}
.takeIf { it >= 0 }
?.let { it + 1 } ?: 0 // Вставляем сразу послеПосле этого плагин наконец‑то начал жить в одной логике с Compose: если фреймворк пропускает выполнение функции, он тоже фиксирует это как Skip, а если доходит до тела, считает реальной рекомпозицией.
Код ComposableSkippingFinder
import org.jetbrains.kotlin.ir.IrElement
import org.jetbrains.kotlin.ir.declarations.IrSimpleFunction
import org.jetbrains.kotlin.ir.expressions.IrCall
import org.jetbrains.kotlin.ir.expressions.IrWhen
import org.jetbrains.kotlin.ir.visitors.IrVisitorVoid
import org.jetbrains.kotlin.name.Name
class ComposableSkippingFinder private constructor() : IrVisitorVoid() {
private val irElementStack = ArrayDeque<IrElement>()
private var result: Pair<IrCall, List<IrElement>>? = null
override fun visitElement(element: IrElement) {
irElementStack.addLast(element)
element.acceptChildren(this, null)
irElementStack.removeLast()
}
private val skipToGroupEndName = Name.identifier("skipToGroupEnd")
override fun visitCall(expression: IrCall) {
val calledName = expression.symbol.owner.name
if (calledName == skipToGroupEndName) {
result = expression to irElementStack.toList()
}
}
companion object {
fun find(function: IrSimpleFunction): IrWhen? {
val visitor = ComposableSkippingFinder()
function.accept(visitor, null)
return visitor.result?.second?.lastOrNull { it is IrWhen } as? IrWhen
}
}
}Расшифровка битов
Теперь я задался вопросом: а как сама Android Studio в реальном времени показывает состояния параметров (Uncertain, Same, Different)? Я поставил breakpoint внутри composable‑функции в работающем приложении и заглянул в отладчик.
Оказалось, это результат элегантной оптимизации от Google. Чтобы не передавать информацию о каждом параметре по отдельности (что создавало бы оверхед), Compose использует битовую маску — одно целое число Int.
В исходниках Android Studio я нашёл перечисление ParamState, которое и описывает эту маску. Вот как это работает:
0b000 — Uncertain (непонятно, изменился или нет)
0b001 — Same (не изменился)
0b010 — Different (изменился — причина рекомпозиции!)
0b011 — Static (константный параметр)
0b1xx — Unstable (младшие биты игнорируются)
Получив доступ к переменной $dirty внутри composable‑функции, я смог декодировать эти биты. Плагин собирает число, раскладывает его на группы по 3 бита и отправляет уже человекочитаемую информацию обратно в IDE. В результате видно не просто сырое значение, а реальное состояние: параметр Same, Different, Static или Uncertain. И сразу понятно, почему функция мигнула.
Если интересно, то вот исходный код: ComposeValueContributor и ParamState из Android Studio.
Когда 32 бит недостаточно
На этом этапе всплыла ещё одна интересная деталь. Int — это всего 32 бита. Если каждый параметр занимает по 3 бита, то мы можем закодировать в одном числе максимум 10 параметров. А что делать с функциями, у которых 12 аргументов? 18? 27?
В компиляторе Compose эта проблема решается довольно просто: создаются дополнительные флаги — $dirty1, $dirty2, $dirty3 и так далее. Каждый новый Int продолжает битовую маску предыдущего. Мой плагин вначале неправильно показывал состояние переменной: он ожидал один $dirty, а находил несколько $dirtyN и не понимал, что с ними делать. Во время тестирования значения в Android Studio (я устанавливал breakpoint в composable‑функции, чтобы увидеть состояние параметра) и в моём плагине не совпадали. Пришлось переписать логику.
buildList {
this@getDirties.forEach { statement ->
// Берем всё, что начинается на $dirty
if (statement is IrVariable &&
statement.name.asString().startsWith("\$dirty")) {
add(statement)
}
}
}Теперь HighlightInjector:
находит все переменные, имена которых начинаются с префикса
$dirty;сортирует их по порядку;
последовательно склеивает их биты в единую маску, пока не получится полная карта состояний для всех параметров.
После этого ограничение в 10 аргументов исчезло. Сколько бы параметров ни было у нашей функции, хоть 40, плагин соберёт их состояния и отправит IDE полную, корректную маску изменений.
Итог операции
Таким образом появился HighlightInjector — класс, унаследованный от IrVisitorVoid. Он проходит по дереву IR после стандартного плагина Compose, находит нужные точки и встраивает вызовы клиентской библиотеки туда, где Compose действительно выполняет тело функции.
Код HighlightInjector
import org.jetbrains.kotlin.backend.common.extensions.IrPluginContext
import org.jetbrains.kotlin.ir.IrElement
import org.jetbrains.kotlin.ir.IrFileEntry
import org.jetbrains.kotlin.ir.IrStatement
import org.jetbrains.kotlin.ir.UNDEFINED_OFFSET
import org.jetbrains.kotlin.ir.declarations.IrFile
import org.jetbrains.kotlin.ir.declarations.IrSimpleFunction
import org.jetbrains.kotlin.ir.declarations.IrValueParameter
import org.jetbrains.kotlin.ir.declarations.IrVariable
import org.jetbrains.kotlin.ir.expressions.IrBlock
import org.jetbrains.kotlin.ir.expressions.IrBlockBody
import org.jetbrains.kotlin.ir.expressions.IrCall
import org.jetbrains.kotlin.ir.expressions.IrFunctionExpression
import org.jetbrains.kotlin.ir.expressions.impl.IrBlockImpl
import org.jetbrains.kotlin.ir.expressions.impl.IrCallImpl
import org.jetbrains.kotlin.ir.expressions.impl.fromSymbolOwner
import org.jetbrains.kotlin.ir.symbols.IrSimpleFunctionSymbol
import org.jetbrains.kotlin.ir.util.fqNameWhenAvailable
import org.jetbrains.kotlin.ir.util.hasAnnotation
import org.jetbrains.kotlin.ir.visitors.IrVisitor
import org.jetbrains.kotlin.name.CallableId
import org.jetbrains.kotlin.name.FqName
import org.jetbrains.kotlin.name.Name
import java.util.UUID
class HighlightInjector(
private val context: IrPluginContext,
) : IrVisitor<Unit, HighlightInjector.Data>() {
private val highlightCallArgumentsBuilder = HighlightCallArgumentsBuilder(context)
private val composableAnnotation = FqName("androidx.compose.runtime.Composable")
private val highlightSymbol: IrSimpleFunctionSymbol =
context.referenceFunctions(CallableId(FqName("ru.ivk1800.riflesso"), Name.identifier("highlight"))).first()
private val endDefaultsName = Name.identifier("endDefaults")
class Data(var irFile: IrFile? = null)
private val simpleFunctionStack = ArrayDeque<IrSimpleFunction>()
override fun visitElement(element: IrElement, data: Data) {
element.acceptChildren(this, data)
}
override fun visitFile(declaration: IrFile, data: Data) {
data.irFile = declaration
declaration.acceptChildren(this, data)
}
override fun visitSimpleFunction(declaration: IrSimpleFunction, data: Data) {
if (declaration.annotations.hasAnnotation(composableAnnotation)) {
val irFile = data.irFile ?: return
injectHighlightCall(irFile, declaration)
simpleFunctionStack.addLast(declaration)
declaration.acceptChildren(this, data)
simpleFunctionStack.removeLast()
}
}
override fun visitFunctionExpression(expression: IrFunctionExpression, data: Data) {
val declaration: IrSimpleFunction = expression.function
if (declaration.annotations.hasAnnotation(composableAnnotation)) {
val irFile = data.irFile ?: return
injectHighlightCall(irFile, declaration)
expression.function.acceptChildren(this, data)
}
}
private fun injectHighlightCall(irFile: IrFile, declaration: IrSimpleFunction) {
val body: IrBlockBody = declaration.body as? IrBlockBody ?: return
val composableSkippingIrWhen = ComposableSkippingFinder.find(declaration)
if (composableSkippingIrWhen != null) {
val callableId = UUID.randomUUID().toString()
val callableFqName: FqName? = declaration.fqNameWhenAvailable
val parentFunctionFqName = simpleFunctionStack.lastOrNull()?.fqNameWhenAvailable
val skipToGroupEnd = composableSkippingIrWhen.branches[1].result as IrCall
val statements = mutableListOf<IrStatement>(skipToGroupEnd)
val callableName = callableFqName?.shortName()?.asString() ?: declaration.name.asString()
val callablePackageName = callableFqName?.parent()?.asString()
val parentFunctionName = parentFunctionFqName?.shortName()?.asString()
val parentFunctionPackageName = parentFunctionFqName?.parent()?.asString()
val parameters = declaration.parameters
val variables = body.statements.getDirties()
val fileEntry = irFile.fileEntry
// region skip
injectHighlightCall(
callableId = callableId,
fileEntry = fileEntry,
body = body,
irFile = irFile,
callableName = callableName,
callablePackageName = callablePackageName,
parentFunctionName = parentFunctionName,
parentFunctionPackageName = parentFunctionPackageName,
parameters = parameters,
variables = variables,
type = HighlightType.Skip,
indexToInject = 0,
statementsToInject = statements,
)
composableSkippingIrWhen.branches[1].result = IrBlockImpl(
startOffset = UNDEFINED_OFFSET,
endOffset = UNDEFINED_OFFSET,
type = context.irBuiltIns.unitType,
origin = null,
statements = statements,
)
// endregion
// region recomposition
val blockToInject = composableSkippingIrWhen.branches[0].result as IrBlock
injectHighlightCall(
callableId = callableId,
fileEntry = fileEntry,
body = body,
irFile = irFile,
callableName = callableName,
callablePackageName = callablePackageName,
parentFunctionName = parentFunctionName,
parentFunctionPackageName = parentFunctionPackageName,
parameters = parameters,
variables = variables,
type = HighlightType.Recomposition,
indexToInject = blockToInject.statements.findIndexToInject(),
statementsToInject = blockToInject.statements,
)
// endregion
}
}
private fun List<IrStatement>.getDirties(): List<IrVariable> =
buildList {
this@getDirties.forEach { statement ->
if (statement is IrVariable && statement.name.asString().startsWith("\$dirty")) {
add(statement)
}
}
}
private fun List<IrStatement>.findIndexToInject(): Int =
indexOfFirst { it is IrCall && it.symbol.owner.name == endDefaultsName }
.takeIf { it >= 0 }
?.let { it + 1 } ?: 0
private fun injectHighlightCall(
callableId: String,
fileEntry: IrFileEntry,
body: IrBlockBody,
indexToInject: Int,
statementsToInject: MutableList<IrStatement>,
irFile: IrFile,
callablePackageName: String?,
callableName: String?,
parentFunctionName: String?,
parentFunctionPackageName: String?,
parameters: List<IrValueParameter>,
variables: List<IrVariable>,
type: HighlightType,
) {
val bodySourceRangeInfo = fileEntry.getSourceRangeInfo(
beginOffset = body.startOffset,
endOffset = body.endOffset,
)
val fileName = irFile.fileEntry.name
val arguments = highlightCallArgumentsBuilder.build(
fileName = fileName,
callablePackageName = callablePackageName,
callableName = callableName,
parentFunctionName = parentFunctionName,
parentFunctionPackageName = parentFunctionPackageName,
bodySourceRangeInfo = bodySourceRangeInfo,
parameters = parameters,
variables = variables,
type = type,
callableId = callableId,
)
val highlightCall = IrCallImpl.fromSymbolOwner(
startOffset = UNDEFINED_OFFSET,
endOffset = UNDEFINED_OFFSET,
type = context.irBuiltIns.unitType,
symbol = highlightSymbol,
)
arguments.forEachIndexed { index, arg -> highlightCall.arguments[index] = arg }
statementsToInject.add(indexToInject, highlightCall)
}
}Работает это чисто, без побочных эффектов, не ломает логику приложения, а благодаря no‑op‑версии клиентской библиотеки полностью исчезает из релизных сборок.
Порядок плагинов
Здесь я столкнулся с очередной сложностью. Как заставить мой плагин работать с кодом после того, как над ним поработал Compose?
Оказывается, порядок выполнения Compiler Plugins определяется порядком их подключения в build.gradle.kts.
Если подключить мой плагин первым, он получит на вход чистый IR, где ещё нет никаких переменных $dirty и методов startDefaults. Compose‑плагин запустится вторым и сделает своё дело, но будет уже поздно — я не смогу привязаться к его структурам.
Чтобы получить полностью преобразованный IR и те самые битовые маски, Riflesso должен подключаться строго после Compose Compiler Plugin:
plugins {
id("org.jetbrains.kotlin.plugin.compose")
id("ru.ivk1800.riflesso") // <-- Должен идти вторым!
}Если выполнить gradlew dependencies, можно увидеть, что порядок в списке зависимостей компилятора соответствует порядку в Gradle. Это и гарантирует, что мы увидим результат работы Compose.
Пользовательский интерфейс
Получилась довольно хитрая внутренняя механика, но конечному пользователю видеть её не нужно. Поэтому интерфейс Riflesso (View → Tool Windows → Riflesso) я сделал максимально аскетичным: одно окно, три понятных блока и никаких лишних элементов.

В верхней части расположена панель управления — главный рубильник Start/Stop и текстовый статус подключения: Connected, Connecting, Error. Если порт 10 080 занят, приложение исчезло или связь отвалилась — мы узнаем сразу. Под тремя точками спрятаны настройки. Там, например, можно отключить визуальную подсветку кода, если она начинает отвлекать, и оставить только статистику.
Ниже расположена основная рабочая поверхность — таблица, которая в реальном времени заполняется вызванными composable‑функциями. У неё три колонки:
Name — название функции (Cyclone, AppContent)
Recompositions — сколько раз она перерисовывалась
Skips — сколько раз Compose её пропустил
Список сортируется по убыванию количества рекомпозиций, и таблица превращается в своего рода тепловую карту из функций, которые перерисовываются чаще всего. Двойной клик по строке переносит к соответствующей функции в коде. Параметры внизу окна — тот самый инструмент, ради которого я возился с битовыми масками $dirty. Когда мы выбираем функцию в списке, нижняя панель показывает параметры в момент последнего вызова:
Parameter — имя (user, settings)
Value — что именно пришло внутрь
State — состояние параметра, извлечённое из битовой маски
Состояния отображаются человекочитаемыми ярлыками:
Same — параметр не изменился (всё хорошо)
Different — изменился (это и вызвало рекомпозицию)
Static — константа
Unstable/Uncertain — тип нестабилен для Compose
Допустим ProfileRow постоянно перерисовывается, хотя данные визуально те же. Больше не нужно гадать, в чём дело. Смотрим в таблицу и видим, что прилетел новый объект User. Всё понятно: тип нестабилен, нужно рефакторить.
Демонстрация в реальном времени
Простое демоприложение — справа, его исходный код в IntelliJ IDEA с моим плагином — слева.
Когда я нажимаю кнопку Run в демоприложении, начинается анимация. На каждом кадре анимации генерируется новое значение degrees, что вызывает рекомпозицию. Плагин подхватывает это событие через ClientConnectionManager, и функция Cyclone в IDE подсвечивается.
Предварительные итоги
Разработка этого инструмента стала интересным челленджем, который заставил меня залезть вглубь IntelliJ Platform и Kotlin Compiler Plugin API второй раз. В начале статьи я задавался вопросом: «Зачем ещё один плагин?» Теперь ответ однозначный: Riflesso приносит в мир подход Jetpack Compose к анализу кода, который в основном был привилегией Android‑инструментов. Неважно, собираем ли мы UI для десктопа, пишем Android‑приложение или имеем дело с мультиплатформой — этот инструмент будет работать прямо в редакторе кода.
При этом Riflesso пока далёк от идеала, как любой живой инженерный проект. Плагин работает, но местами держится на синей изоленте. Вот что я планирую сделать дальше:
Расхардкодить порт. Заменить фиксированный порт 10 080 на нормальный механизм Service Discovery, чтобы плагин и приложение могли находить друг друга динамически и не конфликтовать с другими сервисами.
Прикрутить Android Debug Bridge (adb). Сейчас всё отлично работает с десктоп‑ и Android‑эмулятором (через loopback). Чтобы комфортно работать с физическим устройством по USB, нужно автоматизировать проброс портов через adb reverse.
Прогнать бенчмарки. По ощущениям, влияние на производительность debug‑сборки минимально, но по‑хорошему нужны цифры: на сколько замедляется сборка из‑за Compiler Plugin, сколько памяти и CPU съедает клиентская библиотека под нагрузкой.
Зову вас испытать Riflesso в деле. Установите плагин, подключите библиотеку, запустите свой проект. Попробуйте его сломать, найдите баг, напишите ишью. А ещё лучше — приходите на GitHub с пул‑реквестом.
Эта серия статей и сам плагин — попытка сделать разработку UI в Jetpack Compose чуть более предсказуемой и честной: когда мы видим, что именно происходит, а не верим на слово фреймворку. Давайте вместе сделаем Jetpack Compose немного более прозрачным и удобным!
