Привет! Сегодня с вами Максим Кругликов из Surf Android Team с переводом статьи про устройство аннотаций в Kotlin и три основных механизма их обработки: процессинг, рефлексию и lint. Давайте разберёмся.

Разработчики Android могут создавать сложные приложения с разбросанными повсюду аннотациями, например, @Provides из Dagger или @ColorRes из AndroidX. При этом они не до конца понимают, как работают эти аннотации. Они похожи на магию. В этой статье развеется часть этой магии и исследуется три основных механизма обработки аннотаций: процессинг аннотаций, рефлексия и lint.

Во второй части разберём, как реальная библиотека Moshi, основанная на аннотациях, использует все три механизма.

Что такое аннотации в Kotlin?

Аннотации — это средство добавления метаданных к коду. Чтобы объявить аннотацию, достаточно добавить модификатор annotation перед классом:

annotation class CustomAnnotation

Можно снабдить его метааннотациями, чтобы дать дополнительную информацию:

@Target(AnnotationTarget.FUNCTION, AnnotationTarget.TYPE_PARAMETER)
@Retention(AnnotationRetention.SOURCE)
annotation class CustomAnnotation

@Target указывает возможные типы элементов, к которым можно применить аннотацию (например, классы, функции или переменные). @Retention указывает, хранится ли аннотация в бинарнике и доступна ли она только во время компиляции или во время выполнения тоже. В значении по умолчанию RUNTIME аннотация доступна везде.

Процессинг аннотаций и рефлексия относятся к категории метапрограммирования — метода, при котором программы используют другие программы (например, исходный код Kotlin) в качестве своих данных. Оба механизма используют аннотации для сокращения бойлерплейта и автоматизации типовых задач. Например, большинство библиотек внедрения зависимостей (например, Dagger) и сериализации (например, Moshi) используют либо один из подходов, либо оба одновременно. С помощью процессинга аннотаций и рефлексии можно достичь схожего результата. Например, Moshi поддерживает оба механизма генерации адаптеров JSON.

Lint — совсем другой механизм, использующий аннотации для проверки исходных файлов на наличие проблем. Считать ли это метапрограммированием — неясно.

Все три механизма предлагают мощные и гибкие API, поэтому разные аннотации могут давать совершенно разные результаты в зависимости от того, как они обрабатываются в коде. Разработчикам редко приходится писать этот код самостоятельно — чаще используются аннотации из библиотек, которые уже их обрабатывают, чем создаем собственные.

Процессинг аннотаций

Обработчики аннотаций — это плагины компилятора, которые генерируют код на основе аннотаций во время компиляции. Сторонняя библиотека содержит обработчик аннотаций, если при подключении зависимости в build.gradle она требует использовать annotationProcessor, kapt или ksp вместо implementation. Несколько примеров популярных библиотек, использующих обработку аннотаций — это Dagger (@Provides, @Ingect), Moshi (@Json) и Room (@Entity, @Dao).

Обработчик аннотаций должен быть зарегистрирован в компиляторе, чтобы он мог работать во время компиляции. Самый распространенный способ зарегистрировать его — через библиотеку Google AutoService — просто добавить к своему процессору аннотацию @AutoService (Processor.class).

Существует три основных API для создания обработчиков аннотаций: Annotation Processor Tool (APT), Kotlin Annotation Processor Tool (kapt) и Kotlin Symbol Processing (KSP). Процессоры, созданные с помощью APT и kapt, расширяют один и тот же базовый класс AbstractProcessor, тогда как KSP имеет отдельный класс SymbolProcessor.

Обработка аннотаций происходит в несколько раундов. В каждом раунде компилятор ищет аннотации в исходных файлах и вызывает соответствующие процессоры. Если процессоры генерируют новые файлы, то следующий раунд начинается уже со сгенерированных файлов. Процесс продолжается до тех пор, пока не перестанут создаваться новые файлы.

Annotation Processor Tool

APT — единственный API из эпохи Android до Kotlin. Он используется компилятором Java (javac) и подключается через annotationProcessor. В настоящее время его редко можно увидеть в проектах Android, поскольку он не поддерживает Kotlin-файлы.

Обработчики аннотаций APT работают с исходным кодом на .java. Чтобы сделать такой, нужно создать класс, расширяющий AbstractProcessor. Две основные функции, которые необходимо реализовать, — это getSupportedAnnotationTypes(), которая должна вернуть поддерживаемые этим процессором аннотации, и process(), которая является основным блоком, вызываемым в каждом раунде обработки. Тут нужно думать об исходном коде как о простом тексте, а не как о чем-то исполняемом. Подобно файлу JSON. Исходный код представлен в виде дерева элементов (Element), которое в свою очередь представляет элементы программы, включая пакеты, классы или функции.

Второй параметр метода process() — это roundEnv, объект RoundEnvironment с информацией о текущем и предыдущих раундах обработки. Он обеспечивает доступ к дереву Element’ов и несколько способов исследования аннотаций, связанных с ними, например getElementsAnnotatedWith().

Можно генерировать код в методе process() на основе аннотированных элементов. Интерфейс Filer из ProcessingEnvironment позволяет создавать новые файлы, а затем записывать в них текст, как в любой другой файл. Сгенерированные файлы появятся в каталоге <module>/build/generated/source/ проекта. Именно так Dagger генерирует классы Something_Factory и Something_MembersInjector, а Moshi — классы SomethingAdapter. JavaPoet — популярная библиотека для написания исходного кода Java, использующая этот подход.

Нет нужды подробно останавливаться на реализации собственного AbstractProcessor, потому что на эту тему уже есть множество ресурсов (ссылки в конце статьи. Кстати, в следующей статье мы рассмотрим, как это реализовано в JsonClassCodegenProcessor от Moshi).

Kotlin Annotation Processor Tool

Когда Kotlin получил признание, Kapt стал популярным API-интерфейсом для обработки аннотаций. Это плагин компилятора, созданный на основе APT и поддерживающий исходный код и Kotlin и Java. Он использует тот же AbstractProcessor, что и APT, поэтому информация из предыдущего раздела о создании процессоров аннотаций APT также применима и к kapt. Kapt может запускать любой AbstractProcessor независимо от того, был ли он написан с учетом поддержки Kotlin или Java. KotlinPoet — популярная библиотека для написания исходного кода Kotlin, которая использует этот подход.

Главный недостаток Kapt — долгое время компиляции. Он компилируется, используя Javac, как APT, и работает с Kotlin, генерируя Java-стабы из файлов Kotlin, которые затем могут прочитать процессоры. Создание таких стабов — дорогостоящая операция, которая значительно влияет на скорость сборки.

Kotlin Symbol Processing

KSP представили в 2021 году, и это первая альтернатива kapt, написанная на Kotlin. Благодаря прямой интеграции с компилятором Kotlin (kotlinc) KSP анализирует код Kotlin напрямую, без создания Java-стабов, и работает до 2 раз быстрее, чем kapt. 

Он также лучше понимает языковые конструкции Kotlin. Если в модуле остались процессоры kapt, он все равно будет генерировать стабы во время компиляции. Это означает, что  повысить производительность KSP получится только в том случае, если из модуля будет удалено все использование kapt.

Чтобы создать обработчик аннотаций KSP, нужно реализовать интерфейс SymbolProcessor. KSP также представляет исходный код в виде синтаксического дерева и предоставляет API, аналогичный Kapt

KsDeclaration или KsDeclarationContainer в KSP — это аналог Element из kapt. Resolver, как и RoundEnvironment из kapt, — это параметр в SymbolProcessor.process(), обеспечивающий доступ к синтаксическому дереву. Resolver.getSymbolsWithAnnotation() позволяет исследовать символы с аннотациями. И, наконец, CodeGenerator, как и Filer, позволяет генерировать код во время процессинга.

Рефлексия

Рефлексия — это способность языка исследовать свои классы, интерфейсы, поля и методы во время выполнения, не зная их имен во время компиляции. Он позволяет программам динамически создавать экземпляры новых объектов и вызывать методы. А также изменять их структуру и поведение во время выполнения. Примеры популярных библиотек, которые используют рефлексию для изменения поведения программы: Moshi (@Json) и Retrofit (@GET, @POST).

Стандартная библиотека рефлексии Java работает и в Kotlin. Но у Kotlin есть свой собственный API рефлексии, доступный в пакете kotlin-reflect. Он предоставляет несколько дополнительных функций, включая доступ к nullable-свойствам и типам.

Вот пример вызова метода во время выполнения с помощью рефлексии:

class HelloPrinter {
 fun printHello() {
   println("Hello, world!!!")
 }
}

val printer = HelloPrinter()
val methods = printer::class.java.methods
val helloFunction = methods.find { it.name == "printHello" }
helloFunction?.invoke(printer)

Рефлексия предназначена не только для работы с аннотациями, она предоставляет надежный API для работы с ними, а аннотации, в свою очередь, могут сделать её использование более безопасным и эффективным. 

Например, можно обновить предыдущий код, чтобы вызывать все методы, помеченные аннотацией @Greeter, без указания их имен в строках:

@Target(AnnotationTarget.FUNCTION)
annotation class Greeter

class HelloPrinter {
 @Greeter
 fun printHello() {
   println("Hello, world!!!")
 }
}

val printer = HelloPrinter()
val methods = printer::class.java.methods
val helloFunctions = methods.filter {
 it.isAnnotationPresent(Greeter::class.java)
}
helloFunctions.forEach {
 it.invoke(printer)
}

В API рефлексии Kotlin доступны и другие функции, связанные с аннотациями. Например, KAnnotatedElement.findAnnotations() и KAnnotatedElement.hasAnnotation(). Чтобы аннотация работала с рефлексией, она должна быть помечена @Retention со значением RUNTIME (по умолчанию).

При выборе между рефлексией и процессингом аннотаций стоит учитывать пару компромиссов:

  1. Использование рефлексии влечёт за собой более долгое время выполнения, а процессинг аннотаций — более долгое время компиляции.

  2. В случае ошибки, рефлексия выбросит ошибку прямо в рантайме, а процессинг аннотаций — во время компиляции.

Большинство библиотек предпочитают процессинг аннотаций, поскольку более медленная компиляция не повлияет на работу конечного пользователя, а ошибки времени компиляции будут замечены до того, как дойдут до пользователя. Из-за этого Dagger полностью избавился от рефлексии при переходе на вторую версию.

Retrofit, популярная клиентская библиотека HTTP,  полностью полагается на рефлексию. Она использует Proxy для создания экземпляров аннотированных интерфейсов. Рефлексия здесь имеет смысл, поскольку задержка выполнения сетевого запроса намного превышает любую задержку, вызванную рефлексией. При этом сбои во время выполнения относительно легко идентифицировать, поскольку можно протестировать новый код Retrofit, просто вызвав нужный метод API во время отладки нового кода.

Lint

Lint — это инструмент сканирования кода, который проверяет исходные файлы проекта Android на наличие потенциальных ошибок и возможных улучшений безопасности, производительности, accessibility и другого. Речь пойдёт о встроенном lint без рассмотрения сторонних инструментов, вроде Detekt.

Lint глубоко интегрирован в Android Studio и подсвечивает проблемы прямо в IDE.

Многие проверки, даже приведенный выше «Unnecessary safe call», не используют аннотации. Однако аннотации могут помочь lint обнаружить более тонкие проблемы в коде. Поскольку в Java нет встроенной обработки nullable значений, как в Kotlin, @Nonnul и @Nullable позволяют lint выдавать те же предупреждения о null-safetty для Kotlin. 

Другой пример — @ColorRes, который сообщает lint, что параметр Int должен быть ссылкой на цветовой ресурс (например, android.R.color.black).

В отличие от процессинга аннотаций и рефлексии, Lint выполняет только статический анализ кода и не может влиять на поведение программы. Это отличает его от двух других механизмов, где аннотации обычно поступают из сторонних библиотек. Часто используемые аннотации, связанные с lint, взяты из пакета androidx.annotations и обрабатываются встроенными проверками Android Studio.

Если нужно добавить кастомную проверку lint — собственную или из сторонней библиотеки, необходимо использовать конфигурацию зависимостей lintChecks или lintPublish. lintChecks делает правило lint доступным только для одного текущего модуля, тогда как lintPublish делает его доступным для всех.

Чтобы добавить новую проверку, нам нужно создать подкласс Detector. Такие детекторы могут работать с файлами разных типов в зависимости от того, какой FileScanner он реализует. Например, SourceCodeScanner может анализировать исходные файлы Kotlin или Java, а GradleScanner — файлы Gradle.

SourceCodeScanner — сканер для обработки аннотаций. Он предлагает два API абстрактного синтаксического дерева (Abstract Syntax Tree, AST), разработанные Jetbrains, для анализа исходного кода. 

Universal Abstract Syntax Tree (UAST) представляет универсальное API для нескольких языков, что позволяет написать один анализатор, который работает «универсально» на всех языках, поддерживаемых UAST, включая Kotlin и Java.

До UAST был Program Structure Interface (PSI). PSI представляет Kotlin и Java по-разному и все еще необходим для учета нюансов, специфичных для отдельно взятого языка. UAST построен на основе PSI, поэтому его в API иногда попадают элементы PSI. Навигация по AST похожа на навигацию по деревьям элементов при обработке аннотаций.

В руководстве по пользовательским lint правилам от Google есть полезная страница о том, как добавить проверку, которая проходит по аннотированным элементам. Если нужно сразу просмотреть использование самой аннотации, а не только элементы, аннотированные ею, можно переопределить метод getApplicatbleUastTypes() из SourceCodeScanner, чтобы вернуть UAnnotation::class.java, а затем переопределить createUastHandler(), чтобы вернуть кастомный UElementHandler. Любое обнаружение проблем, связанных с пользовательской аннотацией, может быть выполнено в методе visitAnnotation() класс UElementHandler.

Мистические кастомные аннотации

Иногда можно столкнуться с кастомной аннотацией в коде, на которую нет ссылок ни в каких обработчиках аннотаций, коде рефлексии или lint правилах. Что же она делает?

Скорее всего, она помечена метааннотацией из сторонней библиотеки. Некоторые библиотеки предоставляют функции, основанные на метааннотациях. Например, в Dagger есть метааннотация @Qualifier для случаев, когда одного типа недостаточно для идентификации зависимости. @Qualifier позволяет нам создавать такие пользовательские аннотации:

@Qualifier
@Target(AnnotationTarget.FIELD, AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.FUNCTION)
annotation class Authorized

@Qualifier
@Target(AnnotationTarget.FIELD, AnnotationTarget.VALUE_PARAMETER, AnnotationTarget.FUNCTION)
annotation class Unauthorized

Можно использовать их, например, для внедрения различных экземпляров класса в зависимости от того, вошел ли пользователь в систему:

@Provides
@Authorized
OkHttpClient.Builder provideAuthorizedOkHttpClientBuilder(…)

@Provides
@Unauthorized
OkHttpClient.Builder provideUnauthorizedOkHttpClientBuilder(…)

Не нужно писать код для обработки этих пользовательских аннотаций. Обработчик аннотаций Dagger выполнит поиск всех пользовательских аннотаций @Qualifier и обработает их. У Moshi есть аналогичная метааннотация @JsonQualifier, позволяющая указать, как тип сериализуется для конкретного поля, не меняя его сериализацию повсюду.

Полезные ссылки

Больше полезного про Android — в Telegram-канале Surf Android Team. 

Кейсы, лучшие практики, новости и вакансии в команду Android Surf в одном месте. Присоединяйтесь!

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