Если вы разрабатываете на Kotlin, то наверняка сталкивались с генерацией кода: аннотации, которые необычным образом создают за вас кучу шаблонного кода.
Примеры: Dagger/Hilt генерирует DI‑классы, Room генерирует DAO и сущности, Moshi генерирует JSON и т.д. Это экономит тонны времени. Но долгие годы для Kotlin‑проектов приходилось использовать KAPT — Kotlin Annotation Processing, прослойку для совместимости с Java‑аннотациями.
KAPT работал, но имел свои минусы. Он генерирует Java‑стабы из Kotlin‑кода и прогоняет стандартный Java Annotation Processor. Эта махинация замедляет компиляцию: сначала компилятор Kotlin должен переварить ваши исходники в промежуточные Java‑классы, потом снова всё это компилировать. Плюс, KAPT порой криво понимал Kotlin фичи, потому что мыслил категориями Java.
И вот появился Kotlin Symbol Processing (KSP). Это библиотека, которая позволяет писать процессоры напрямую для Kotlin. Обещают до 2х ускорения сборки по сравнению с KAPT, полную поддержку всех фич языка (extension‑функции, Kotlin‑specific типы и так далее), и мультиплатформенность
KSP vs KAPT
Перед тем как засучить рукава и писать код, быстро пробежимся по ключевым отличиям KSP от KAPT (чтобы уж точно понять, зачем всё это). Я тут приберёг мини‑табличку:
Скорость компиляции. KSP быстрее. Почему? Потому что нет промежуточных Java‑стабов. Ваш код анализируется напрямую Kotlin‑компилятором на стадии KSP‑плагина. В результате — меньше фаз компиляции, меньше накладных расходов. По данным команды Google, на примере Room KSP дал почти двукратное ускорение компиляции модуля. Я лично не измерял с линейкой, но субъективно переход с KAPT на KSP в паре модулей уменьшил время сборки с ~60 до ~40 секунд. Неплохо!
Понимание Kotlin‑кода. KAPT живёт в мире Java. Он видит ваши классы через призму сгенерированных Java‑обёрток. Поэтому какие‑то Kotlin‑специфичные вещи для него терялись. KSP же оперирует символами Kotlin: понимает
data class,suspendфункции, generics с аут/ин,internalвидимость — все тонкости. Например, Room через KSP научился определять nullability генериков правильно, чего не мог делать через KAPT. То есть генераторы на KSP могут быть умнее и точнее.Мультиплатформенность. KAPT работает только на JVM, по сути. А KSP — часть комп
Прежде всего, нужно подключить KSP в наш проект. Если есть Gradle, в плагинах указываем Kotlin KSP:
// build.gradle.kts (Project-level) plugins { id("com.google.devtools.ksp") version "1.9.10-1.0.13" // версия актуальная на момент написания }
Затем в модуле, где будут использоваться процессоры или генерироваться код, тоже применяем плагин KSP:
// build.gradle.kts (Module-level) plugins { id("com.google.devtools.ksp") }
И зависимости: процессор будет отдельным модулем/артефактом. Предположим, делаем модуль :builder-processor. Тогда проекты, которые хотят его использовать, должны в dependencies добавить, например:
dependencies { ksp(project(":builder-processor")) // ... другие зависимости }
Gradle тут поймет, что при компиляции надо запускать наш KSP‑processor. Модуль с процессором не должен таскать за собой тонны лишних зависимостей, только то, что нужно для генерации. KSP предоставляет API в виде библиотеки com.google.devtools.ksp:symbol-processing-api:<версия>, её нужно добавить в implementation для модуля процессора.
Теперь объявим аннотацию.
Начнём с маркера, который будем использовать в коде, чтобы указать, где применить генерацию. Создадим аннотацию @AutoBuilder:
// В модуле builder-annotations (желательно вынести аннотации отдельно) @Target(AnnotationTarget.CLASS) @Retention(AnnotationRetention.SOURCE) annotation class AutoBuilder
Ничего особенного, ставится на класс, хранится до стадии компиляции, в рантайме не нужна. Можно сюда и параметры добавить.
Представим, что пользователь этой аннотации будет ею помечать data class, например:
@AutoBuilder data class User(val id: Int, val name: String)
Мы ожидаем сгенерировать для него класс UserBuilder с полями id, name и методом build().
Напишем SymbolProcessor и SymbolProcessorProvider.
KSP ожидает от нас реализовать класс‑процессор, который и будет делать всю работу, а также провайдер этого класса для регистрации. Создаём класс, например, AutoBuilderProcessor:
class AutoBuilderProcessor(private val environment: SymbolProcessorEnvironment) : SymbolProcessor { private val codeGenerator = environment.codeGenerator private val logger = environment.logger override fun process(resolver: Resolver): List<KSAnnotated> { // (Логику обработки напишем чуть ниже) return emptyList() } override fun finish() { // Опционально, завершающие действия после обработки всех файлов } }
Одно это ничего не делает, но важна сигнатура. Нас впустят в компилятор с объектом Resolver, через который мы можем ходить по исходным кодам. codeGenerator — это наш выход: через него будем генерировать файлы. logger — чтобы выводить предупреждения/ошибки компиляции, если что.
Теперь провайдер:
class AutoBuilderProcessorProvider : SymbolProcessorProvider { override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor { return AutoBuilderProcessor(environment) } }
Этот провайдер затем нужно зарегистрировать. В Gradle‑модуле с процессором добавляем ресурс‑файл: resources/META-INF/services/com.google.devtools.ksp.symbolprocessing.SymbolProcessorProvider и вписываем туда строку с полным именем нашего провайдера, например: com.mycompany.builderprocessor.AutoBuilderProcessorProvider. Это дефолтный механизм Service Loader.
Далее выполняем поиск аннотированных классов
Внутри process() нужно найти все классы, помеченные @AutoBuilder. KSP‑API довольно удобное. Можно запросить через резолвер напрямую:
val symbols: Sequence<KSAnnotated> = resolver.getSymbolsWithAnnotation(AutoBuilder::class.qualifiedName!!)
Здесь получаем последовательность всех элементов с нашей аннотацией. Они могут быть разными (классы, функции и тому подобное, ведь@Target мы ограничили до CLASS, но API общий). Пройдёмся по ним:
for (symbol in symbols) { if (symbol is KSClassDeclaration && symbol.classKind == ClassKind.CLASS) { generateBuilderFor(symbol) } }
Здесь проверяем, что аннотация висит именно на классе, а не на, скажем, интерфейсе или чём‑то неожиданном. KSClassDeclaration — представление информации о классе. У него можно получить имя, пакет, свойства и многое другое.
Теперь создаём билдер.
Допустим, нужно, имея класс, сгенерировать новый файл с кодом. Можно строчить строки вручную через appendText, но на мой взгляд, лучше использовать библиотечку KotlinPoet, которая помогает программно строить Kotlin файлы. Добавим зависимость com.squareup:kotlinpoet:1.13.2 (просто для примера) в наш модуль процессора, и вперёд.
Напишем функцию generateBuilderFor(symbol: KSClassDeclaration):
private fun generateBuilderFor(classDecl: KSClassDeclaration) { val className = classDecl.simpleName.asString() val packageName = classDecl.packageName.asString() val builderClassName = "${className}Builder" // Используем KotlinPoet для генерации класса val fileSpecBuilder = FileSpec.builder(packageName, builderClassName) // Создаём класс Builder val classBuilder = TypeSpec.classBuilder(builderClassName) // Добавим поля в билдер на основе свойств оригинального класса for (property in classDecl.getDeclaredProperties()) { val propName = property.simpleName.asString() val typeName = property.type.toTypeName() // KotlinPoet extension, понадобится kotlinpoet-ksp // В билдере поля делаем мутабельными var classBuilder.addProperty( PropertySpec.builder(propName, typeName.copy(nullable = true)) .mutable(true) .initializer("null") .build() ) } // Добавляем метод build() // Он должен собрать экземпляр исходного класса, используя поля билдера val constructorCall = buildString { append("$className(") val props = classDecl.getDeclaredProperties().map { it.simpleName.asString() } append(props.joinToString(", ") { prop -> "$prop ?: throw IllegalStateException(\"$prop must be provided\")" }) append(")") } classBuilder.addFunction( FunSpec.builder("build") .returns(classDecl.toClassName()) // вернёт TypeName оригинального класса .addStatement("return $constructorCall") .build() ) // Финализируем файл fileSpecBuilder.addType(classBuilder.build()) val fileSpec = fileSpecBuilder.build() // Пишем файл через KSP try { val file = codeGenerator.createNewFile( Dependencies(false, *classDecl.containingFile?.let { arrayOf(it) } ?: emptyArray()), packageName, builderClassName ) file.bufferedWriter().use { writer -> fileSpec.writeTo(writer) } } catch (e: IOException) { logger.error("Error generating builder for $className: ${e.message}") } }
Вычисляем имя класса и пакета, придумываем имя билдера (просто ИмяКласса + "Builder"). Далее используем KotlinPoet (через FileSpec, TypeSpec, FunSpec и др.) для декларативной генерации.
После создаём класс‑билдер. Затем итерируемся по всем свойствам оригинального класса (getDeclaredProperties() KSP даёт нам список полей). Для каждого добавляем в билдера var property того же типа (обратите внимание: делаю тип nullable, инициализируя null, чтобы можно было отличить неустановленные значения).
Генерируем функцию build(). Она должна вернуть новый User (или какой там класс) с заполненными полями. Если какое‑то поле осталось null, бросаем исключение — требуем, чтобы билдеру установили всё перед билдом. В более крутом варианте можно и не требовать, а подставлять дефолты.
Через codeGenerator.createNewFile заводим файл (KSP сам решит, куда его положить, обычно в build/generated/...). Dependencies(false, *arrayOf(file)) связывает генерируемый файл с исходным, чтобы инкрементальная сборка знала, что если оригинал не поменялся, то и билдер можно не трогать. Мы берём containingFile исходного класса как зависимость.
Пишем через bufferedWriter() контент FileSpec. Ловим IOException на всякий.
На самом деле кода немало, но это база. Кстати, property.type.toTypeName() — это удобный экстеншн из дополнения KotlinPoet для KSP (kotlinpoet‑ksp). Он преобразует KSP‑тип в KotlinPoet‑тип (TypeName), сохраняя все generic и nullability. Также classDecl.toClassName() превращает декларацию класса в TypeName (класс, который мы возвращаем из build()).
Настало время испытать наш творческий процессор. У нас есть проект с data‑классом User, помеченным @AutoBuilder. После запуска компиляции, KSP должен сгенерировать файл UserBuilder.kt примерно такого вида:
// Auto-generated by AutoBuilderProcessor package com.myapp.models class UserBuilder { var id: Int? = null var name: String? = null fun build(): User { return User( id ?: throw IllegalStateException("id must be provided"), name ?: throw IllegalStateException("name must be provided") ) } }
Можно попробовать использовать этот билдер в коде:
val builder = UserBuilder().apply { id = 123 name = "Alice" } val user = builder.build()
Работает? Да, после компиляции всё должно собраться. Если в IDE имя UserBuilder поначалу красное — просто скомпилируйте проект, студия его заметит.
Для уверенности стоит написать автоматические тесты на наш процессор. Тут может помочь kotlin compile testing, с ней можно запускать компиляцию с KSP‑процессором и проверять, что на выходе получилось.
Инкрементальная обработка в нашем процессоре
Наш AutoBuilderProcessor на самом деле изолирующий: каждый класс порождает свой файл, и ни на что больше он не влияет. Мы указали зависимость от исходного файла при генерации Dependencies(false, containingFile). Это значит, что при следующей компиляции, если класс не изменился, кэш сборки может не запускать процессор заново для него — ведь сгенерированный файл актуален. Если мы изменим класс, Gradle поймёт, что для этого класса нужно перегнать билдер, но остальные билдеры трогать не надо.
KSP сам определяет, какой вывод зависит от каких входов. В нашем случае связь один‑к-одному, это конечно идеальный случай для инкрементальности. Если бы мы писали процессор, который собирает информацию с нескольких файлов, то это был бы aggregate статус. Тогда изменение одного файла заставит перестроить и агрегирующий результат.
В общем, инкрементальность работает из коробки. Нам, правда, следует заботиться о правильном указании Dependencies при генерации файлов, что мы и сделали. В KAPT раньше надо было в AbstractProcessor переопределять getSupportedOptions() с kapt.incremental и указывать тип, иначе Gradle не знал, можно ли инкрементально. В KSP всё проще, по умолчанию включено.
Проверить можно экспериментально, генерируем проект с десятком классов под нашим @AutoBuilder, собираем. Затем изменяем один класс и пересобираем с флагом --info у Gradle, вы увидите, что KSP перегенерировал билдер только для изменённого класса, остальные файлы кешировались.
Ваш собственный код тоже можно улучшить с KSP. Подумайте: нет ли в проекте повторяющихся шаблонов, которые можно автоматизировать? Может, вы пишете много однотипных мапперов или обёрток? Можно например аннотацией помечать класс, а KSP будет генерировать из него DTO‑двойник.

Если вы заходите в Android с нуля, курс «Android Developer. Basic» в OTUS даёт понятный маршрут: Kotlin, Android Studio, Architecture Components, тестирование, многопоточность и DI (Dagger 2/Koin). На курсе много практики и работа над своим проектом — ��тобы уверенно претендовать на junior позицию. Чтобы узнать больше о формате обучения и познакомиться с преподавателями, приходите на бесплатные демо-уроки:
18 февраля 20:00. «Почему все переходят на Kotlin? Секреты успешной миграции с Java для бэкенд-разработчиков». Записаться
17 февраля 20:00. «От API до экрана: создаём Android-приложение на рекомендуемой архитектуре». Записаться
Еще больше бесплатных уроков от преподавателей курсов можно посмотреть в календаре мероприятий.
