Kotlin Symbol Processing. Работаем с аннотациями по-новому
Всем доброго дня! С вами Анна Жаркова, ведущий мобильный разработчик компании Usetech. В феврале 2021 года компания Google анонсировали экспериментальный релиз технологии Kotlin Symbol Processing (совместима с Kotlin с 1.4.30), как более эффективную альтернативу KAPT (Kotlin Annotation Processing Tool). Она сразу привлекла внимание многих разработчиков, помышляющих о внедрении аннотаций в мультиплатформенные проекты, несмотря на рекомендации создателей не использовать ее в продакте. В сентябре вышел первый стабильный релиз, и теперь она официальна готова к работе в боевых проектах.
В этой статье предлагаю рассмотреть нюансы работы с KSP как в приложениях для Android, так и Kotlin Multiplatform.
Итак, начнем с назначения. Kotlin Symbol Processing предназначена для разработки легковесных плагинов компиляции Kotlin и процессоров аннотаций. Последние нас и интересуют. По сути аннотации нужны в приложении для того, чтобы упростить работу и избавить нас от лишнего кода. Например, когда нам нужно проанализировать код для определенной цели и затем сделать какие-то действия. Либо убрать лишнюю абстракцию из приложения. Гораздо привлекательнее выглядит добавить буквально 1 команду над конкретным объектом/методом/типов, и вместо того, чтобы писать тонны бойлерплейта для каждого случая, поручить это библиотеке, которая сделает все сама.
Давайте посмотрим, как работает в своей механике процессор аннотаций. Например, такой, как мы используем в Java коде:
Сначала мы регистрируем процессор для распознавания аннотаций определенного типа. Например, вот таких:
import kotlin.reflect.KClass
@Target(AnnotationTarget.CLASS,AnnotationTarget.FUNCTION)
public annotation class Graph(val binds: Array> = [], val createdAtStart: Boolean = false)
@Target(AnnotationTarget.CLASS,AnnotationTarget.FUNCTION)
public annotation class Single(val binds: Array> = [])
@Target(AnnotationTarget.CLASS,AnnotationTarget.FUNCTION)
public annotation class Cached(val binds: Array> = [])
Затем процессор сканирует все исходники на предмет искомых аннотаций. Если такие были найдены, для них запускается работа. Затем полученный код компилируется.
При работе с KSP и KAPT мы не модифицируем текущие файлы, а генерируем новый код, который компилируется с нашими исходниками.
Генерация новых данных с помощью KAPT — это вещь долгая. Все мы знаем, как долго может выполняться задача gradle, когда в нашем коде есть тот же Dagger.
При обработке Kotlin кода с помощью Kotlin Annotation Processing Tool нам нужно сгенерировать Java Stub, которые уже как скомпилированные Java классы использовать при компиляции вместе с остальными исходниками. За счет этого промежуточного этапа весь процесс может занимать довольно много времени, особенно, если мы имеем многомодульное приложение.
В отличие от KAPT в KSP нет никакой генерации Java заглушек. Процессор работает с AST (абстрактное синтаксическое дерево) Kotlin напрямую, что позволяет генерировать сразу Kotlin код, причем сразу именно тот, который мы будем использовать в приложении. За счет этого работа с KSP получается быстрее, существенно эффективнее и чище.
Теперь посмотрим, как работает символьный процессор, и как создать свой. Для этого нам потребуется использовать специальные интерфейсы для провайдера и процессора:
interface SymbolProcessor {
/**
* Called by Kotlin Symbol Processing to run the processing task.
*
* @param resolver provides [SymbolProcessor] with access to compiler details such as Symbols.
* @return A list of deferred symbols that the processor can't process.
*/
fun process(resolver: Resolver): List
/**
* Called by Kotlin Symbol Processing to finalize the processing of a compilation.
*/
fun finish() {}
/**
* Called by Kotlin Symbol Processing to handle errors after a round of processing.
*/
fun onError() {}
}
interface SymbolProcessorProvider {
/**
* Called by Kotlin Symbol Processing to create the processor.
*/
fun create(environment: SymbolProcessorEnvironment): SymbolProcessor
}
Провайдер (используется для создания процессора) необходимо задекларировать как ресурс:
DIBuilderProcessorProvider
В самом файле ресурса просто указываем полное имя используемого провайдера.
Перейдем к структуре самого процессора:
class CustomProcessor(
val codeGenerator: CodeGenerator,
val logger: KSPLogger
) : SymbolProcessor {
/**....Какой-то нужный код*/
var data = arrayListOf()
val visitor = CustomVisitor()
override fun process(resolver: Resolver): List {
/**
* Обработка кода с помощью Resolver
**/
resolver.getAllFiles().map {
it.accept(visitor, Unit)
}
return emptyList()
}
}
Для доступа к исходным файлам и коду используется специальных ресолвер. Полученный код анализируется с помощью механизмов рефлексии Kotlin для получения информации о типах и параметрах, например, в специальном Visitor, имплементирующем KSVisitorVoid:
open class BaseVisitor : KSVisitorVoid() {
override fun visitClassDeclaration(type: KSClassDeclaration, data: Unit) {
for (declaration in type.declarations) {
declaration.accept(this, Unit)
}
}
override fun visitFile(file: KSFile, data: Unit) {
for (declaration in file.declarations) {
declaration.accept(this, Unit)
}
}
override fun visitFunctionDeclaration(function: KSFunctionDeclaration, data: Unit) {
for (declaration in function.declarations) {
declaration.accept(this, Unit)
}
}
}
Также мы можем использовать собственный сканнер, работающий по тем же принципам. Нам нужно определить, с каким элементом мы имеем дело, какие есть настройки и свойства у аннотации, какие параметры нам еще нужны для работы, и собрать всю эту информацию для следующих действий.
После этого можно генерировать код. Для этого используем библиотеку KotlinPoet. Она позволяет гибко прописать структуру генерируемых классов и метод, включая значения свойств:
//Было
val greeterClass = ClassName("", "Greeter")
val file = FileSpec.builder("", "HelloWorld")
.addType(TypeSpec.classBuilder("Greeter")
.primaryConstructor(FunSpec.constructorBuilder()
.addParameter("name", String::class)
.build())
.addProperty(PropertySpec.builder("name", String::class)
.initializer("name")
.build())
.addFunction(FunSpec.builder("greet")
.addStatement("println(%P)", "Hello, \$name")
.build())
.build())
.addFunction(FunSpec.builder("main")
.addParameter("args", String::class, VARARG)
.addStatement("%T(args[0]).greet()", greeterClass)
.build())
.build()
file.writeTo(System.out)
//Стало
class Greeter(val name: String) {
fun greet() {
println("""Hello, $name""")
}
}
fun main(vararg args: String) {
Greeter(args[0]).greet()
}
Перейдем к практическому использованию KSP. Одним из самых эффективных и ожидаемых примеров работы с данной технологией является Dependency Injection. И не только в Androd, но и мультиплатформенных приложениях. И если в предыдущих релизах (альфа и бета) можно было использовать только в приложениях c таргетами JS и JVM/Android, то с cентябрьского релиза мы можем работать и с Kotlin Native.
В качестве примера я буду использовать свою же библиотеку Multiplatform-DI, но начнем с подключения к Android приложению.
Внутри библиотеки идет работа со специальными контейнерами, в которых сущности-ресолверы управляют хранением ссылок с типом, параметрами и фабриками создания экземпляров типа в тех или иных областях действия (скоупов).
Регистрация/получение типов идет с помощью ручного Dependency Injection:
class ConfigurationApp {
val appContainer: DIManager = DIManager()
init {
setup()
}
fun setup() {
appContainer.addToScope(
ScopeType.Container,
NetworkClient::class
) {
NetworkClient()
}
appContainer.addToScope(
ScopeType.Container,
MoviesService::class
) {
val nc = appContainer.resolve(com.azharkova.kmmdi.shared.network.NetworkClient::class) as? com.azharkova.kmmdi.shared.network.NetworkClient
com.azharkova.kmmdi.shared.service.MoviesService(nc)
}
}
}
На лицо очень много лишнего кода и нашего труда. Попробуем автоматизировать с помощью аннотаций (спасибо Koin за вдохновение):
@Target(AnnotationTarget.CLASS,AnnotationTarget.FUNCTION)
public annotation class Graph(val binds: Array> = [], val createdAtStart: Boolean = false)
@Target(AnnotationTarget.CLASS,AnnotationTarget.FUNCTION)
public annotation class Single(val binds: Array> = [])
@Target(AnnotationTarget.CLASS,AnnotationTarget.FUNCTION)
public annotation class Cached(val binds: Array> = [])
@Target(AnnotationTarget.CLASS,AnnotationTarget.FUNCTION)
public annotation class Shared(val binds: Array> = [])
@Target(AnnotationTarget.CLASS)
public annotation class Container()
@Target(AnnotationTarget.CLASS, AnnotationTarget.FIELD)
public annotation class ComponentScan(val value: String = "")
Т.е мы сделаем аннотации для скоупов и контейнера и меняем код для регистрации следующим образом:
@Container
@ComponentScan("com.azharkova.kmmdi.shared")
class AppConfigurator
@Single
class NetworkClient {...}
@Single
class MoviesService(val networkClient: NetworkClient?) {...}
Кода у нас станет существенно меньше.
Теперь нам надо создать специальный модуль, в котором мы расположим наш провайдер, процессор, генератор и сканнер.
val kspVersion: String by project
val koinVersion: String by project
plugins {
kotlin("jvm")
}
group = "com.azharkova"
version = "1.0-SNAPSHOT"
repositories {
mavenLocal()
mavenCentral()
google()
}
dependencies {
implementation(kotlin("stdlib"))
implementation(project(":di-multiplatform-core"))
implementation(project(":ksp-annotation"))
//Нужна для генерации
implementation("com.google.devtools.ksp:symbol-processing-api:$kspVersion")
}
sourceSets.main {
java.srcDirs("src/main/kotlin")
}
Текущая версия KSP 1.5.31–1.0.0. Т.к в основе KSP идет JVM, то таргетируем модуль на него. Также подключаем сюда выделенный модуль с теми компонентами, ссылки на которые мы будем использовать для генерации кода. В этом случае di-multiplatform-core. Для работы с ksp используем «com.google.devtools.ksp: symbol-processing-api:$kspVersion». Также нам потребуется KotlinPoet:
val kotlinpoetVersion = "1.8.0"
implementation("com.squareup:kotlinpoet:$kotlinpoetVersion")
implementation("com.squareup:kotlinpoet-metadata:$kotlinpoetVersion")
implementation("com.squareup:kotlinpoet-metadata-specs:$kotlinpoetVersion")
implementation("com.squareup:kotlinpoet-classinspector-elements:$kotlinpoetVersion")
Теперь займемся самим провайдером и процессором. Будем использовать свой сканнер и генератор кода:
lass DIBuilderProcessor(
val codeGenerator: CodeGenerator,
val logger: KSPLogger
) : SymbolProcessor {
val diCodeGenerator = DICodeGenerator(codeGenerator, logger)
val metaDataScanner = DIMetaDataScanner(logger)
override fun process(resolver: Resolver): List {
val defaultModule = DIMetaData.Container(
packageName = "",
name = "defaultModule"
)
//Вызов сканнера
//Вызов генератора
return emptyList()
}
}
class DIBuilderProcessorProvider : SymbolProcessorProvider {
override fun create(
environment: SymbolProcessorEnvironment
): SymbolProcessor {
return DIBuilderProcessor(environment.codeGenerator, environment.logger)
}
}
В сканнере используем ресолвер для анализа и проработки данных. Основной упор на метод getSymbolsWithAnnotation:
private fun Resolver.scanDefinition(
annotationClass: KClass<*>,
mapDefinition: (KSAnnotated) -> DIMetaData.Definition
): List {
logger.warn("annotation name: ${annotationClass.qualifiedName}")
return getSymbolsWithAnnotation(annotationClass.qualifiedName!!)
.filter {
it is KSClassDeclaration}
.map { mapDefinition(it) }
.toList()
}
Именно такая логика используется для сканирования всех искомых типов:
private fun scanContainerModules(
resolver: Resolver,
defaultModule: DIMetaData.Container
): Map {
logger.warn("scan modules ...")
// class modules
moduleMap = resolver.getSymbolsWithAnnotation(Container::class.qualifiedName!!)
.filter { it is KSClassDeclaration && it.validate() }
.map { moduleMetadataScanner.createClassModule(it) }
.toMap()
return moduleMap
}
Более подробно смотрите в исходниках.
Для генерации нам потребуется прописать шаблон с помощью Kotlin Poet. Т.е весь исходный файл с описанием регистрации превращаем в шаблон, куда будут прописываться нужные типы и их параметры:
fun generateClassModule(classFile: OutputStream, module: DIMetaData.Container) {
classFile.appendText(
"""
package com.azharkova.kmm_di.ksp.generated
import com.azharkova.di.container.*
//import kotlin.native.concurrent.ThreadLocal
import com.azharkova.kmmdi.shared.*
import com.azharkova.di.scope.*
import com.azharkova.kmmdi.shared.di.DIManager
""".trimIndent()
)
val generatedClass = "\n\n\nclass ${module.name}Container : BaseDIComponent() {"
classFile.appendText(generatedClass+"\n")
val generatedField = "${module.name}ConfigContainer"
val classModule = "${module.packageName}.${module.name}"
classFile.appendText("\noverride fun setup() {\n")
module.definitions.filterIsInstance().forEach { def ->
classFile.generateClassDeclarationDefinition(def)
}
classFile.appendText("\n " +
"}" + "\n" +
"" +
"\n//@ThreadLocal\ncompanion object {\n" +
" \n" + "val newInstance = ${module.name}Container()" +
" \n}\n}")
classFile.flush()
classFile.close()
}
Теперь собираем проект и наблюдаем создание новых элементов:
И получаем сгенерированный файл:
И подключаем полученный класс в наш код и используем по назначению:
class MoviesListInteractor :
BaseInteractor(uiDispatcher),
IMoviesListInteractor {
private val moviesService: MoviesService? by lazy {
AppConfiguratorContainer.newInstance.resolve(MoviesService::class) as MoviesService?
}
/**...*/
}
Запускаем и радуемся:
В следующей части рассмотрим, как адаптировать приложение под KMM.
Исходные файлы: