
Всем доброго дня! С вами Анна Жаркова, ведущий мобильный разработчик компании 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<KClass<*>> = [], val createdAtStart: Boolean = false) @Target(AnnotationTarget.CLASS,AnnotationTarget.FUNCTION) public annotation class Single(val binds: Array<KClass<*>> = []) @Target(AnnotationTarget.CLASS,AnnotationTarget.FUNCTION) public annotation class Cached(val binds: Array<KClass<*>> = [])
Затем процессор сканирует все исходники на предмет искомых аннотаций. Если такие были найдены, для них запускается работа. Затем полученный код компилируется.
При работе с 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<KSAnnotated> /** * 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<String>() val visitor = CustomVisitor() override fun process(resolver: Resolver): List<KSAnnotated> { /** * Обработка кода с помощью 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>(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<KClass<*>> = [], val createdAtStart: Boolean = false) @Target(AnnotationTarget.CLASS,AnnotationTarget.FUNCTION) public annotation class Single(val binds: Array<KClass<*>> = []) @Target(AnnotationTarget.CLASS,AnnotationTarget.FUNCTION) public annotation class Cached(val binds: Array<KClass<*>> = []) @Target(AnnotationTarget.CLASS,AnnotationTarget.FUNCTION) public annotation class Shared(val binds: Array<KClass<*>> = []) @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<KSAnnotated> { 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<DIMetaData.Definition> { 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<String, DIMetaData.Container> { 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<DIMetaData.Definition.ClassDeclarationDefinition>().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<IMoviesListView>(uiDispatcher), IMoviesListInteractor { private val moviesService: MoviesService? by lazy { AppConfiguratorContainer.newInstance.resolve(MoviesService::class) as MoviesService? } /**...*/ }
Запускаем и радуемся:

В следующей части рассмотрим, как адаптировать приложение под KMM.
Исходные файлы:
