Аннотации легко воспринимать как декоративную часть языка. Поставили @Deprecated, добавили @Suppress, пометили DTO через @JsonClass - и пошли дальше. Но в большом Android-проекте аннотации быстро перестают быть просто подсказками для IDE. Они становятся контрактом между кодом и инструментами: компилятором, статическим анализатором, генератором кода, DI-фреймворком, сериализатором или внутренней платформенной инфраструктурой.
В этой статье разберем, что на самом деле происходит вокруг аннотаций в Kotlin, чем отличаются SOURCE, BINARY и RUNTIME, почему @Composable - это не просто красивая метка, чем kapt отличается от KSP, и на каких практических задачах annotation processing может быть полезен в Android-разработке.
Аннотация - это метаданные для кода
Самый простой пример:
@Deprecated("Use deliveryType instead") val isManualPickup: Boolean
На первый взгляд это просто сообщение для разработчика. Но уже здесь есть несколько важных деталей:
аннотация добавляет к элементу кода метаданные;
эти метаданные может прочитать не только человек, но и инструмент;
инструмент может принять решение: подсветить warning, сгенерировать код, запретить использование, изменить поведение сборки.
Аннотации встречаются почти везде:
@JsonClass(generateAdapter = true) data class CreateChatRequest( @Json(name = "chat") val params: CreateChatParams, ) @Volatile private var constructorRef: Constructor<CreateChatParams>? = null @POST("user/reviews/") suspend fun postFeedback( @Body request: PostFeedbackRequest, ): PostFeedbackResponse @Suppress("CyclomaticComplexMethod") override fun checkResponse(response: Response): ProjectException?
Каждая из них решает свою задачу. @JsonClass включает генерацию адаптера, Retrofit-аннотации описывают HTTP-контракт, @Suppress влияет на статический анализ, @Volatile меняет семантику доступа к полю на JVM.
То есть аннотация сама по себе почти ничего не делает. Смысл появляется там, где есть инструмент, который умеет ее прочитать.
![Аннотация как контракт с инструментами]n(https://habrastorage.org/webt/cd/e6/42/cde642aa079f79cf58a1ded94c243db4.png)
Аннотации тоже аннотируются
У аннотаций есть собственные аннотации. Например, @Deprecated в Kotlin объявлен примерно так:
@Target( AnnotationTarget.CLASS, AnnotationTarget.FUNCTION, AnnotationTarget.PROPERTY, AnnotationTarget.CONSTRUCTOR, AnnotationTarget.TYPEALIAS, ) @MustBeDocumented public annotation class Deprecated( val message: String, val replaceWith: ReplaceWith = ReplaceWith(""), val level: DeprecationLevel = DeprecationLevel.WARNING, )
@Target определяет, к каким элементам можно применить аннотацию: к классу, функции, свойству, параметру, другой аннотации и так далее.
@MustBeDocumented говорит инструментам документации, что эту аннотацию стоит показывать в сгенерированной документации.
Есть забавная рекурсивная деталь: @Target сам является аннотацией, которая применяется к аннотациям:
@Target(AnnotationTarget.ANNOTATION_CLASS) @MustBeDocumented public annotation class Target( vararg val allowedTargets: AnnotationTarget, )
Это хороший пример того, что система аннотаций не является плоской. Она задает маленький язык поверх языка программирования.
Retention: где живет аннотация
Одна из самых важных мета-аннотаций - @Retention. Она определяет, на каком этапе жизненного цикла кода аннотация будет доступна.
В Kotlin есть три варианта:
public enum class AnnotationRetention { SOURCE, BINARY, RUNTIME, }
SOURCE
Аннотация доступна только на этапе компиляции и не попадает в бинарный результат.
Это хороший выбор для:
генерации кода;
статического анализа;
compile-time проверок;
внутренних маркеров, которые не нужны в рантайме.
Пример:
@Target(AnnotationTarget.CLASS) @Retention(AnnotationRetention.SOURCE) annotation class GenerateFactory
Если аннотацию читает KSP-процессор и после сборки она больше не нужна, SOURCE обычно достаточно.
BINARY
Аннотация сохраняется в скомпилированных class-файлах, но не видна через reflection в рантайме.
Это полезно, когда метаданные нужны инструментам, работающим с байткодом, но не нужны приложению во время выполнения. Типичный Android-пример - аннотации, которые учитываются при минификации, обфускации или анализе байткода.
@Keep sealed class AppLanguage( val name: String, val locale: String, val icon: Int, )
RUNTIME
Аннотация попадает в бинарный результат и доступна во время выполнения через reflection.
@Target(AnnotationTarget.CLASS) @Retention(AnnotationRetention.RUNTIME) annotation class ScreenName(val value: String) @ScreenName("checkout") class CheckoutScreen val annotation = CheckoutScreen::class .annotations .filterIsInstance<ScreenName>() .firstOrNull()
RUNTIME нужен для сценариев, где решение действительно принимается во время выполнения: reflection-based DI, сериализация, runtime discovery, тестовые фреймворки.
Но за удобство приходится платить. Reflection сложнее валидировать на этапе компиляции, его легче сломать переименованием или обфускацией, а ошибки часто приезжают позже, чем хотелось бы.
Правило простое: выбирайте минимальный retention, который решает задачу. Если аннотация нужна только процессору, не стоит тащить ее в runtime.

Где здесь метапрограммирование
Метапрограммирование - это когда программа работает с другой программой как с данными: читает ее структуру, анализирует, генерирует новый код или меняет поведение.
Аннотации удобны именно как маркеры для такого процесса.

Например, мы можем явно сказать инструменту:
@GenerateAdapter data class UserDto( val id: String, val name: String, )
А дальше уже не писать руками однотипный adapter/factory/registry. Процессор найдет класс, проверит его контракт и создаст дополнительный код.
Важно разделять два уровня:
annotation processing обычно читает код и генерирует новые файлы;
compiler plugin может глубже интегрироваться в компиляцию и менять семантику или форму кода.
KSP относится к первому уровню. Он не должен модифицировать исходники и не видит тела функций как полноценное дерево выражений. А вот Compose Compiler - это уже пример полноценного compiler plugin.

@Composable как пример compiler plugin
@Composable выглядит как обычная аннотация:
@Composable fun Counter() { val count = remember { mutableStateOf(1) } Button( onClick = { count.value += 1 }, ) { Text("Count: ${count.value}") } }
Но за ней стоит compiler plugin, который трансформирует код. Упрощенно можно представить, что компилятор добавляет служебный Composer, ключи, группы, проверки изменений и все, что нужно runtime для рекомпозиции.
То есть @Composable не просто “помечает функцию для красоты”. Она включает другой режим компиляции функции и вводит правила:
composable-функции можно вызывать только из composable-контекста;
compiler plugin может строить модель для рекомпозиции;
часть ошибок можно поймать еще на этапе компиляции.
Поэтому @Composable часто сравнивают с suspend: обе конструкции меняют способ вызова функции. Разница в том, что suspend стал частью языка, а @Composable реализован через библиотеку и compiler plugin.
С Kotlin 2.0 Compose Compiler переехал в Kotlin repository и получил Gradle plugin с версией, совпадающей с Kotlin. Это хороший пример того, как близко такие инструменты живут к компилятору.
Annotation processing: зачем он нужен
Annotation processing обычно нужен по трем причинам.
Первая - перенести работу из runtime в compile time. Лучше узнать об ошибке на сборке, чем получить crash в пользовательской сессии.
Вторая - избавиться от reflection там, где это возможно. Reflection мощная, но хрупкая: она хуже дружит с обфускацией, сложнее покрывается compile-time проверками и часто прячет ошибки до момента выполнения.
Третья - убрать шаблонный код. Именно это сделали популярные библиотеки вроде Room, Dagger, Moshi и многих других: разработчик описывает контракт, а скучную часть пишет генератор.
Упрощенный flow выглядит так:
Компилятор запускает процессор.
Процессор сканирует исходники и ищет нужные аннотации или символы.
Процессор валидирует контракт.
Процессор генерирует новые файлы.
Компилятор компилирует исходный и сгенерированный код вместе.
Если сгенерированный код сам добавил новые символы, возможен следующий раунд обработки.

Раунды важны. Иногда один процессор генерирует код, который должен быть увиден другим процессором или следующим проходом того же процессора. На этом месте часто появляются неожиданные проблемы с порядком, incremental build и кешированием.
kapt и KSP
Исторически в Kotlin долго использовали kapt. Он позволяет запускать Java annotation processors на Kotlin-коде.
Цена этого подхода - Java stubs. kapt генерирует stub-файлы из Kotlin source code, а потом Java-процессоры работают уже с ними. Это дает совместимость с большой Java-экосистемой, но добавляет отдельный этап в сборку.
KSP - Kotlin-first альтернатива. Он анализирует Kotlin-код напрямую и предоставляет API на уровне символов: файлы, классы, функции, свойства, параметры, типы, generics, visibility modifiers и так далее.

Коротко:
Вопрос |
kapt |
KSP |
|---|---|---|
Основной сценарий |
Java annotation processors в Kotlin-проекте |
Kotlin-first symbol processing |
Как читает Kotlin |
Через Java stubs |
Напрямую через KSP API |
Kotlin-specific constructs |
Ограниченно |
Лучше понимает Kotlin-модель |
Производительность |
Часто медленнее из-за stub generation |
Часто быстрее, но зависит от processor и проекта |
Миграция |
Хорош для старых Java-процессоров |
Хорош для новых Kotlin-first решений |
Не стоит превращать это в религиозный выбор. Если библиотека поддерживает только kapt, вы используете kapt. Если есть стабильная KSP-версия и особенно если вы пишете свой процессор под Kotlin, логичнее смотреть в сторону KSP.
Также важно: kapt и KSP можно мигрировать по модулям. Но если в модуле остался хотя бы один kapt-процессор, stub generation в этом модуле все еще будет выполняться.
Ограничения KSP
KSP не является полноценным compiler plugin. Он работает на уровне символов.
Он видит:
файлы;
классы, enum, sealed/data/inline классы;
функции и конструкторы;
свойства и параметры;
типы, generics, visibility modifiers;
аннотации.
Но он не видит код внутри функции как полноценную структуру для анализа:
fun calculate(price: Money): Money { if (price.value > 1000) { return price * 0.9 } return price }
KSP не предназначен для того, чтобы анализировать if, локальные переменные, циклы и произвольные выражения внутри тела функции. Он также не модифицирует исходный код. Он может читать символы и генерировать новые файлы.
Обычно этого достаточно. Если процессору нужно больше информации, можно вынести контракт наружу:
interface PerformanceTrace { val key: String val type: TraceType } @GenerateTraceDoc class RequestAuthTokenTrace : PerformanceTrace { override val key: String = "request_auth_token" override val type: TraceType = TraceType.NETWORK_REQUEST }
Или явно передать нужные данные через параметры аннотации:
@TraceDoc( key = "request_auth_token", type = TraceType.NETWORK_REQUEST, screen = PerformanceScreen.AUTH, ) class RequestAuthTokenTrace
Такой контракт проще проверить, проще документировать и проще генерировать.
Как выглядит свой процессор
В kapt минимальный процессор наследуется от AbstractProcessor:
@AutoService(Processor::class) @SupportedAnnotationTypes("com.example.Interesting") class InterestingProcessor : AbstractProcessor() { override fun process( annotations: MutableSet<out TypeElement>, roundEnv: RoundEnvironment, ): Boolean { roundEnv.getElementsAnnotatedWith(Interesting::class.java) .forEach { element -> processingEnv.messager.printMessage( Diagnostic.Kind.WARNING, "${element.simpleName} is interesting.", ) } return true } }
В KSP форма другая: есть SymbolProcessorProvider, который создает SymbolProcessor, а processor работает через Resolver.
class InterestingProcessorProvider : SymbolProcessorProvider { override fun create(environment: SymbolProcessorEnvironment): SymbolProcessor { return InterestingProcessor( codeGenerator = environment.codeGenerator, logger = environment.logger, ) } } class InterestingProcessor( private val codeGenerator: CodeGenerator, private val logger: KSPLogger, ) : SymbolProcessor { override fun process(resolver: Resolver): List<KSAnnotated> { val symbols = resolver.getSymbolsWithAnnotation( Interesting::class.qualifiedName.orEmpty(), ) symbols .filterIsInstance<KSDeclaration>() .forEach { declaration -> logger.warn( "${declaration.qualifiedName?.asString()} is interesting", declaration, ) } return emptyList() } }
Реальный процессор обычно делает больше:
фильтрует символы нужного типа;
валидирует корректность использования;
пишет понятные ошибки через logger;
генерирует Kotlin/Java/JSON/ресурсные файлы;
корректно указывает dependencies для incremental build.
Практический кейс 1: debug menu для экспериментов
Представим большой Android-проект, где есть много A/B-экспериментов и feature flags. Источников несколько: условный Firebase Remote Config, GrowthBook, собственная платформа или что-то еще.

Проблемы быстро становятся одинаковыми:
разработчикам нужно понимать, какие эксперименты вообще есть в коде;
QA нужно локально переключать значения без доступа к админкам;
источники экспериментов могут меняться;
руками поддерживать список флагов в debug menu скучно и легко ошибиться.
Можно решить это reflection. На старте приложения просканировать классы, найти реализации нужных интерфейсов и собрать список.
Но для мобильного приложения это не самый приятный путь. Reflection добавляет стоимость в runtime, хуже валидируется и требует аккуратности с обфускацией.
Альтернатива - собрать индекс на этапе компиляции.
interface RemoteFeatureFlag { val name: String } interface RemoteExperiment { val name: String } @Retention(AnnotationRetention.SOURCE) @Target(AnnotationTarget.CLASS) annotation class CollectSubclasses @CollectSubclasses interface FirebaseExperiment : RemoteExperiment @CollectSubclasses interface GrowthBookExperiment : RemoteExperiment
Конкретный эксперимент просто реализует нужный интерфейс:
class PaymentMethodsExperiment : FirebaseExperiment { override val name: String = "checkout_payment_methods" data class Structure( val experimentId: String, val groupId: String, val enabled: Boolean, ) }
Processor находит все такие реализации и генерирует индекс. Например, через META-INF/services, чтобы в runtime использовать ServiceLoader:
val experiments = ServiceLoader .load(FirebaseExperiment::class.java) .toList()
Что мы получили:
debug menu знает обо всех экспериментах из кода;
QA может переключать их локально;
добавление нового источника не требует переписывать весь механизм;
runtime не занимается дорогим сканированием classpath;
список синхронизируется с кодом на этапе сборки.
Это не обязательно должно быть именно ServiceLoader. Можно генерировать Kotlin registry, JSON-файл, protobuf, ресурс или любой другой формат. Важно, что источник правды - код.
Практический кейс 2: безопасное удаление экспериментов
Вторая боль появляется позже. Эксперименты добавляются быстро, а удаляются медленно.
Пока эксперимент живет неделю, все хорошо. Через несколько месяцев он может врасти в бизнес-логику: часть условий в use case, часть в mapper, часть в UI, часть в аналитике.
Когда эксперимент заканчивается, нужно удалить не только сам flag, но и все завязанные на него ветки.
Для этого можно завести аннотации:
@Target(AnnotationTarget.CLASS) @Retention(AnnotationRetention.SOURCE) annotation class ExperimentalClass( val experiment: KClass<*>, ) @Target(AnnotationTarget.FUNCTION, AnnotationTarget.CLASS) @Retention(AnnotationRetention.SOURCE) annotation class ExperimentalMethod( vararg val experiment: KClass<*>, ) @Target(AnnotationTarget.FIELD, AnnotationTarget.PROPERTY) @Retention(AnnotationRetention.SOURCE) annotation class ExperimentalVariable( val experiment: KClass<*>, )
Использование:
@ExperimentalMethod(SearchBoxFeatureFlag::class) internal class IsSearchBoxEnabled( private val featureFlagInteractor: FeatureFlagInteractor, ) { operator fun invoke(): Boolean { return featureFlagInteractor.isEnabled(SearchBoxFeatureFlag()) } } class SearchBoxFeatureFlag : RemoteFeatureFlag { override val name: String = "draft_common_searchbox_android" }
Когда эксперимент нужно удалить, разработчик удаляет SearchBoxFeatureFlag. После этого компилятор сам покажет места, которые были завязаны на этот флаг через SearchBoxFeatureFlag::class.
Это простой прием, но он работает как страховочная сетка. Мы не пытаемся “магически” удалить код. Мы делаем связи явными и заставляем компилятор подсветить все места, где нужно принять решение руками.
Здесь аннотация полезна не потому, что она генерирует много кода, а потому что она фиксирует архитектурное намерение: “этот метод существует из-за такого-то эксперимента”.
Практический кейс 3: каталог performance-метрик
Еще один пример: бизнесу и продуктовым командам нужно понимать, какие performance-метрики собирает приложение.

Внутри Android-кода может быть много trace’ов:
время сетевого запроса;
время рендера важного экрана;
длительность критического блока кода;
путь авторизации;
checkout-сценарии.
Сами метрики отправляются в Firebase Performance, внутреннюю аналитику или другой backend. Но появляется отдельная проблема: документация устаревает.
Если Android назвал метрику request_auth_token, iOS назвал ее auth_token_request, а в Confluence написано token_loading, продуктовая аналитика превращается в ручной квест.
Аннотация может стать частью контракта:
enum class TraceType { BLOCK_OF_CODE, NETWORK_REQUEST, UI_RENDERING, } enum class PerformanceScreen { AUTH, CART, CHECKOUT, SEARCH, APP, } @Retention(AnnotationRetention.SOURCE) @Target(AnnotationTarget.CLASS) annotation class PerformanceTraceDoc( val description: String, val key: String, val traceType: TraceType, val screen: PerformanceScreen, )
Пример trace:
@PerformanceTraceDoc( description = "Getting auth token", key = "request_auth_token", traceType = TraceType.NETWORK_REQUEST, screen = PerformanceScreen.AUTH, ) data class GetAuthTokenTrace( override val key: String = "request_auth_token", ) : FirebasePerformanceTrace(key)
Использование в коде остается явным:
suspend fun updateSession() { performanceMeasure.measure(GetAuthTokenTrace()) { authApi.updateSession() } }
measure внутри может быть простым:
inline fun <T> measure( traceType: PerformanceTrace, block: () -> T, ): T { val trace = firebasePerformance.newTrace(traceType.key) trace.start() return try { block() } finally { trace.stop() } }
А processor на этапе сборки собирает все @PerformanceTraceDoc и генерирует файл, например:
[ { "key": "request_auth_token", "description": "Getting auth token", "traceType": "NETWORK_REQUEST", "screen": "AUTH" } ]
Дальше CI может забрать этот файл и обновить страницу в Confluence, Markdown-документ, Google Sheet или внутренний каталог метрик.
Главная идея: документация становится производной от кода. Разработчик добавляет новую метрику - и вместе с ней обязан описать key, screen, type и description. Если чего-то не хватает, processor может завалить сборку понятной ошибкой.
Где аннотации не нужны
Аннотации удобно переиспользовать, но они легко превращаются в магию.
Плохой кандидат для annotation processing:
локальная логика, которую проще выразить функцией;
поведение, которое важно видеть прямо в call site;
одноразовая задача без повторяемого шаблона;
код, где ошибка процессора будет дороже, чем ручная реализация;
сценарий, где непонятно, кто и когда читает аннотацию.
Например, для кеширования в Kotlin часто достаточно функции высшего порядка:
fun <T> cachedBy( key: String, block: () -> T, ): T { // cache logic return block() } fun getAddress(customer: Customer): Address { return cachedBy(customer.id) { customer.address } }
Это явно, тестируемо и не требует генерации кода.
Аннотация была бы полезнее, если нужно единообразно применить поведение к большому числу деклараций, проверить контракт на этапе компиляции или сгенерировать инфраструктурный код, который руками поддерживать дорого.
Практические советы для своих процессоров
Если вы пишете свой processor, лучше сразу заложить несколько правил.
1. Генерируйте читаемый код
Сгенерированный код будут читать. Не каждый день, но будут: на ревью, при отладке, при падениях сборки, при миграциях.
Плохой generated code экономит десять минут генератору и тратит часы людям.
2. Пишите нормальные ошибки
Не надо:
Invalid annotation usage
Лучше:
@PerformanceTraceDoc can be applied only to classes implementing PerformanceTrace. Class: com.example.auth.GetAuthTokenTrace
Если возможно, привязывайте ошибку к конкретному символу, чтобы IDE показала нужную строку.
3. Не завязывайтесь на порядок процессоров
Если один processor должен прочитать результат другого, вы почти наверняка получите хрупкую сборку. Иногда это неизбежно, но тогда нужно явно понимать раунды, generated sources и ограничения инструмента.
4. Следите за incremental build
В KSP важно правильно указывать dependencies при генерации файлов:

val dependencies = Dependencies( aggregating = false, annotatedClass.containingFile!!, ) val file = codeGenerator.createNewFile( dependencies = dependencies, packageName = packageName, fileName = fileName, )
Так KSP понимает, какие исходные файлы влияют на generated output и что нужно считать dirty при следующей сборке.
Если processor агрегирует данные из множества файлов в один общий registry, это уже другой сценарий. Там нужно отдельно думать про aggregating = true, invalidation и цену пересборки.
5. Покрывайте processor тестами
Процессор - это не “скрипт для генерации”. Это часть build pipeline. Ошибка в нем может положить сборку всей команды.
Минимальный набор тестов:
корректная генерация для happy path;
понятная ошибка при неправильном использовании;
несколько модулей/пакетов, если processor должен их поддерживать;
проверка generated output как текста или через компиляцию;
сценарий, где нет подходящих символов.
6. Не тащите аннотации в runtime без необходимости
Если processor читает аннотацию на этапе компиляции, почти всегда достаточно SOURCE. RUNTIME должен быть осознанным выбором, а не дефолтом.
Аннотации как composition over inheritance
Часто вопросы про аннотации сводятся к одному: “А есть ли задачи, которые невозможно решить без них?”
Обычно нет. Почти всегда можно решить иначе: reflection, inheritance, ручной registry, конфигурационный файл, Gradle task, DSL, plain Kotlin-функция.
Но вопрос не в том, можно ли обойтись без аннотаций. Вопрос в том, какой вариант будет лучше масштабироваться.
Inheritance плохо подходит для сквозных задач. Сегодня вам нужно поведение для класса A, завтра для B, потом для метода внутри C, потом для свойства в D. Иерархия начинает трещать.
Аннотации в таких случаях ближе к composition over inheritance. Вы не заставляете код наследоваться от специальной базы только ради инфраструктуры. Вы помечаете нужный элемент, а инструмент строит вокруг него дополнительное поведение.
Это хорошо работает для:
DI и registry;
сериализации;
навигационных контрактов;
feature flags и экспериментов;
performance traces;
документации, производной от кода;
compile-time валидаций.
Но это плохо работает, когда аннотация скрывает бизнес-логику и делает поведение неочевидным.
Вывод
Аннотации - это не магия и не просто синтаксический сахар. Это способ добавить к коду метаданные, которые могут быть прочитаны инструментами на разных этапах: в IDE, во время компиляции, при обработке байткода или в runtime.
В маленьком проекте аннотации часто остаются на уровне @Deprecated и @Suppress. В большом Android-проекте они становятся частью платформенной инфраструктуры:
помогают убрать reflection;
генерируют boilerplate;
валидируют архитектурные правила;
собирают registry;
синхронизируют документацию с кодом;
позволяют ловить ошибки раньше.
Главное - не начинать с инструмента. Начинайте с повторяющейся боли. Если есть шаблонный код, runtime discovery, ручной список, устаревающая документация или правило, которое люди постоянно забывают выполнять, annotation processing может быть хорошим решением.
А если задачу можно просто и явно решить функцией, интерфейсом или обычным Kotlin-кодом, лучше так и сделать.
Спасибо за внимание, если есть вопросы пишите в телеграм @timkabor