Аннотации легко воспринимать как декоративную часть языка. Поставили @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.

Retention: где живет аннотация
Retention: где живет аннотация

Где здесь метапрограммирование

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

Аннотации удобны именно как маркеры для такого процесса.

Как выбрать механизм метапрограммирования
Как выбрать механизм метапрограммирования

Например, мы можем явно сказать инструменту:

@GenerateAdapter
data class UserDto(
    val id: String,
    val name: String,
)

А дальше уже не писать руками однотипный adapter/factory/registry. Процессор найдет класс, проверит его контракт и создаст дополнительный код.

Важно разделять два уровня:

  • annotation processing обычно читает код и генерирует новые файлы;

  • compiler plugin может глубже интегрироваться в компиляцию и менять семантику или форму кода.

KSP относится к первому уровню. Он не должен модифицировать исходники и не видит тела функций как полноценное дерево выражений. А вот Compose Compiler - это уже пример полноценного compiler plugin.

Где в сборке живут KSP, kapt и compiler plugins
Где в сборке живут KSP, kapt и compiler plugins

@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 выглядит так:

  1. Компилятор запускает процессор.

  2. Процессор сканирует исходники и ищет нужные аннотации или символы.

  3. Процессор валидирует контракт.

  4. Процессор генерирует новые файлы.

  5. Компилятор компилирует исходный и сгенерированный код вместе.

  6. Если сгенерированный код сам добавил новые символы, возможен следующий раунд обработки.

Annotation processing: раунды обработки
Annotation processing: раунды обработки

Раунды важны. Иногда один процессор генерирует код, который должен быть увиден другим процессором или следующим проходом того же процессора. На этом месте часто появляются неожиданные проблемы с порядком, 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 vs KSP: два пути к generated code
kapt vs KSP: два пути к generated code

Коротко:

Вопрос

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, собственная платформа или что-то еще.

Debug menu для экспериментов без runtime reflection
Debug menu для экспериментов без runtime reflection

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

  • разработчикам нужно понимать, какие эксперименты вообще есть в коде;

  • 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-метрики собирает приложение.

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 при генерации файлов:

KSP incremental processing: dirtiness и dependencies
KSP incremental processing: dirtiness и 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

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