Количество просмотров46
9 октября 2021

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<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?
    }
    /**...*/
 }

Запускаем и радуемся: