В этих уроках мы создаем символьный процессор, который генерирует фабричный класс для Фрагмента. Фабричный класс позволяет передавать данные фрагменту через Bundle во время инициализации.
Начало: Написание символьного процессора с помощью Kotlin Symbol Processing (Часть 1)
Обработка аннотаций
Прежде чем приступить к обработке и фильтрации аннотаций, важно понять, как KSP смотрит на ваш код. На приведенной ниже диаграмме показана сокращенная версия того, как KSP моделирует исходный код.
Здесь следует обратить внимание на то, как оператор объявления класса сопоставляется с нодой KSClassDeclaration. Он будет содержать больше нод, представляющих элементы, образующие тело класса, такие как функции и свойства. KSP строит дерево этих узлов из вашего исходного кода, которое затем становится доступным для вашего SymbolProcessor. Все классы, которые вы определяете в Android, и практически все сущности Kotlin, доступны процессору в виде списка символов.
Фильтрация аннотаций
Поскольку вас интересуют только фрагменты, аннотированные с помощью FragmentFactory, вы хотите отфильтровать все предоставленные символы. Начните с добавления следующих импортов в класс FragmentFactoryProcessor
:
import com.google.devtools.ksp.validate import com.yourcompany.fragmentfactory.annotation.FragmentFactory
Далее замените функцию process
в том же классе следующим кодом:
override fun process(resolver: Resolver): List<KSAnnotated> { var unresolvedSymbols: List<KSAnnotated> = emptyList() val annotationName = FragmentFactory::class.qualifiedName if (annotationName != null) { val resolved = resolver .getSymbolsWithAnnotation(annotationName) .toList() // 1 val validatedSymbols = resolved.filter { it.validate() }.toList() // 2 validatedSymbols .filter { //TODO: add more validations true } .forEach { //TODO: visit and process this symbol } // 3 unresolvedSymbols = resolved - validatedSymbols //4 } return unresolvedSymbols }
Вот краткое описание приведенного выше кода:
- Функция
getSymbolsWithAnnotation
получает все символы, аннотированные аннотацией FragmentFactory. Вы также можете использоватьgetClassDeclarationByName
,getDeclarationsFromPackage
, когда ваш процессор опирается на логику вне аннотационных целей. - Здесь используется функция
validate
, предлагаемая KSP по умолчанию, для фильтрации символов в области видимости, которые можно ресолвить. Для этого внутри процессора используетсяKSValidateVisitor
, который посещает каждое объявление и ресолвит все параметры типа. - Этот оператор пытается обработать каждый из допустимых символов для текущего раунда. Код обработки будет добавлен чуть позже, а пока с этой задачей справятся комментарии-заместители.
- Наконец, возвращаются все неразрешенные символы, для которых потребуются дополнительные раунды. В данном примере это будет пустой список, поскольку все символы должны разрешиться в первом раунде.
Теперь ваш класс будет выглядеть примерно так:
Валидация символов
KSP предлагает встроенный валидатор, который гарантирует, что символ разрешим. Однако, как правило, необходимо проверить ввод аннотации.
Начните с создания файла SymbolValidator.kt в пакете com.yourcompany.fragmentfactory.processor.validator
.
Теперь добавьте следующий код, чтобы валидатор был готов к работе:
package com.yourcompany.fragmentfactory.processor.validator import com.google.devtools.ksp.processing.KSPLogger import com.google.devtools.ksp.symbol.KSAnnotated import com.google.devtools.ksp.symbol.KSAnnotation import com.google.devtools.ksp.symbol.KSClassDeclaration import com.google.devtools.ksp.symbol.KSType import com.google.devtools.ksp.validate import com.yourcompany.fragmentfactory.annotation.FragmentFactory class SymbolValidator(private val logger: KSPLogger) { fun isValid(symbol: KSAnnotated): Boolean { return symbol is KSClassDeclaration //1 && symbol.validate() //2 } }
Валидатор раскрывает функцию isValid, которая
- Проверяет, был ли символ аннотирован в классе.
- Убеждается, что его можно разрешить с помощью валидатора по умолчанию, который предоставляет KSP. Вскоре это будет дополнено другими проверками.
Добавление проверок иерархии
Одной из первых проверок будет проверка того, является ли цель аннотации фрагментом. Другая проверка заключается в том, чтобы убедиться, что поставляемый класс является Parcelable. Оба эти условия требуют проверки иерархии.
Поэтому добавим для этого функцию расширения в класс SymbolValidator:
private fun KSClassDeclaration.isSubclassOf( superClassName: String, //1 ): Boolean { val superClasses = superTypes.toMutableList() //2 while (superClasses.isNotEmpty()) { //3 val current = superClasses.first() val declaration = current.resolve().declaration //4 when { declaration is KSClassDeclaration && declaration.qualifiedName?.asString() == superClassName -> { //5 return true } declaration is KSClassDeclaration -> { superClasses.removeAt(0) //6 superClasses.addAll(0, declaration.superTypes.toList()) } else -> { superClasses.removeAt(0) //7 } } } return false //8 }
Это кажется большим объемом кода, но суть его работы проста:
- Функция принимает полное имя класса в качестве
superClassName
. - Этот оператор извлекает все суперклассы текущего класса.
- Запускается цикл, который завершается при отсутствии суперклассов.
- Это ресолвится объявления класса. В KSP разрешение символа приводит к получению более точных данных о нем. Это дорогостоящая операция, поэтому она всегда выполняется явно.
- Проверяется, совпадает ли полное квалифицированное имя первого суперкласса. Если да, то выполняется выход и возвращается true.
- Если не совпадает и это другой класс, то текущий класс удаляется из списка, а его супертипы добавляются в текущий список супертипов.
- Если это не класс, то удаляем текущий класс из списка.
- Код завершается и возвращает false, если он дошел до вершины иерархии классов и не нашел ни одного совпадения.
Сразу под функцией isSubclassOf добавьте функцию для получения аннотации FragmentFactory из токена объявления класса:
private fun KSClassDeclaration.getFactoryAnnotation(): KSAnnotation { val annotationKClass = FragmentFactory::class return annotations.filter { it.annotationType .resolve() .declaration.qualifiedName?.asString() == annotationKClass.qualifiedName }.first() }
Приведенный выше код перебирает все аннотации класса и находит ту, чье квалифицированное имя совпадает с именем FragmentFactory.
Проверка данных аннотации
Теперь, когда у вас есть способ извлечения аннотации, пришло время проверить данные, помеченные ею. Начнем с проверки того, является ли класс, который необходимо упаковать, Parcelized классом.
Добавьте приведенный ниже код сразу после функции getFactoryAnnotation в SymbolValidator:
private fun KSClassDeclaration.isValidParcelableData(): Boolean { val factorAnnotation = getFactoryAnnotation() val argumentType = (factorAnnotation.arguments.first().value as? KSType) //1 val argument = argumentType?.declaration as? KSClassDeclaration val androidParcelable = "android.os.Parcelable" //2 if (argument == null || !argument.isSubclassOf(androidParcelable)) { //3 logger.error( "FragmentFactory parameter must implement $androidParcelable" ) //4 return false } val parcelKey = (factorAnnotation.arguments[1].value as? String) //5 if (parcelKey.isNullOrBlank()) { //6 logger.error("FragmentFactory parcel key cannot be empty")//7 return false } return true //8 }
Вот что он делает.
- В
argumentType
хранится тип первого аргумента, переданного аннотации. - Для проверки иерархии вы будете использовать квалифицированное имя класса
Parcelable
. - Проверяется, является ли переданный аргумент объявлением класса. Также проверяется, является ли он подклассом Parcelable.
- Если проверка не удается, в лог записывается ошибка.
- Получаем параметр
parcelKey
. - Убеждаемся, что этот ключ не пуст.
- Если это не удается, то в лог выводится ошибка, сообщающая, что
parcelKey
должен быть предоставлен. - Поскольку все проверки пройдены, вы возвращаете true.
Последняя проверка заключается в определении того, что аннотируемый класс является фрагментом. Добавьте приведенный ниже код в конец класса SymbolValidator:
private fun KSClassDeclaration.isFragment(): Boolean { val androidFragment = "androidx.fragment.app.Fragment" return isSubclassOf(androidFragment) }
Здесь используется проверка полного имени для класса Fragment.
Фух! Это большое количество проверок. Пришло время их объединить. Заменим функцию isValid
, определенную в SymbolValidator:
fun isValid(symbol: KSAnnotated): Boolean { return symbol is KSClassDeclaration && symbol.validate() && symbol.isFragment() && symbol.isValidParcelableData() }
Ваш валидатор завершен.
Использование валидатора
Для того чтобы использовать SymbolValidator, добавьте в класс FragmentFactoryProcessor следующее выражение:
private val validator = SymbolValidator(logger)
И затем заменить блок //TODO: add more validations
(включая утверждение true сразу под ним) на:
validator.isValid(it)
Теперь ваш класс FragmentFactoryProcessor должен выглядеть так, как показано на рисунке ниже.
Генерация фабрики фрагментов
Теперь, когда процессор настроен, пришло время обработать отфильтрованные символы и сгенерировать код.
Создание посетителя
Первым шагом является создание посетителя (visitor). Посетитель KSP позволяет посетить символ и затем обработать его. Создайте новый класс FragmentVisitor в пакете com.yourcompany.fragmentfactory.processor.visitor
и добавьте в него приведенный ниже код:
package com.yourcompany.fragmentfactory.processor.visitor import com.google.devtools.ksp.processing.CodeGenerator import com.google.devtools.ksp.symbol.* class FragmentVisitor( codeGenerator: CodeGenerator ) : KSVisitorVoid() { //1 override fun visitClassDeclaration( classDeclaration: KSClassDeclaration, data: Unit ) { val arguments = classDeclaration.annotations.iterator().next().arguments val annotatedParameter = arguments[0].value as KSType //2 val parcelKey = arguments[1].value as String //3 } }
Вот для чего нужен этот класс:
- Каждый посетитель KSP расширяет класс KSVisitor. Здесь вы расширяете его подкласс KSVisitorVoid, который является более простой реализацией, предоставляемой KSP. Каждый символ, который посещает этот посетитель, обрабатывается вызовом visitClassDeclaration.
- В сгенерированном коде вы создадите экземпляр
annotatedParameter
и упаковываете его в бандл. Это первый аргумент аннотации. parcelKey
используется в бандле фрагмента для передачи данных. Он доступен в качестве второго аргумента аннотации.
Вы можете использовать его и обновить свой FragmentFactoryProcessor:
private val visitor = FragmentVisitor(codeGenerator)
Замените комментарий //TODO: visit and process this symbol
на следующий код:
it.accept(visitor, Unit)
Функция accept
внутренне вызывает переопределенный метод visitClassDeclaration
. Этот метод также будет генерировать код для фабричного класса.
Использование KotlinPoet для генерации кода
Kotlin Poet предоставляет чистый API для генерации Kotlin-кода. Он поддерживает добавление импортов на основе KSP-токенов, что делает его удобным для нашего случая. С этим проще работать, чем с большой неструктурированной строкой.
Начнем с добавления класса FragmentFactoryGenerator в пакет com.yourcompany.fragmentfactory.processor
:
package com.yourcompany.fragmentfactory.processor import com.google.devtools.ksp.processing.CodeGenerator import com.google.devtools.ksp.symbol.KSClassDeclaration import com.google.devtools.ksp.symbol.KSType import com.squareup.kotlinpoet.* import com.squareup.kotlinpoet.ksp.* @OptIn(KotlinPoetKspPreview::class) class FragmentFactoryGenerator( private val codeGenerator: CodeGenerator ) { fun generate( fragment: KSClassDeclaration, parcelKey: String, parcelledClass: KSType ) { val packageName = fragment.packageName.asString() //1 val factoryName = "${fragment.simpleName.asString()}Factory" //2 val fragmentClass = fragment.asType(emptyList()) .toTypeName(TypeParameterResolver.EMPTY) //3 //TODO: code generation logic } }
Вот что происходит в приведенном выше коде:
- Вы извлекаете
packageName
, которое представляет собой пакет для класса-фабрики, который вы создаете. - Вы также сохраняете
factoryName
, которое представляет собой имя фрагмента с суффиксом «Factory» — DetailFragmentFactory. - Наконец,
fragmentClass
— это ссылка KotlinPoet на ваш аннотированный фрагмент. Вы направите KotlinPoet на добавление этой ссылки в операторы возврата и на создание экземпляров фрагмента.
Далее создается класс-фабрика. Это довольно большой кусок кода, который мы рассмотрим пошагово. Начните с добавления приведенного ниже кода в логику генерации класса сразу после строки //TODO: code generation logic
:
val fileSpec = FileSpec.builder( packageName = packageName, fileName = factoryName ).apply { addImport("android.os", "Bundle") //1 addType( TypeSpec.classBuilder(factoryName).addType( TypeSpec.companionObjectBuilder() //2 // Todo add function for creating Fragment .build() ).build() ) }.build() fileSpec.writeTo(codeGenerator = codeGenerator, aggregating = false) //3
Давайте рассмотрим, что здесь происходит.
- Здесь добавляется импорт Android-класса Bundle.
- Это выражение начинает определение объекта-компаньона, так что вы получаете доступ к этой фабрике статически, т.е.
DetailFragmentFactory.create(...)
. - Используя функцию расширения KotlinPoet, можно напрямую записывать в файл. Флаг
aggregating
указывает, зависит ли вывод процессора от новых и измененных файлов. Вы установите значение false. Ваш процессор действительно не зависит от создания новых файлов.
Далее создадим функцию, заключенную в объект-компаньон, которая генерирует экземпляр Fragment. Замените // Todo add function for creating Fragment
на:
.addFunction( //1 FunSpec.builder("create").returns(fragmentClass) //2 .addParameter(parcelKey, parcelledClass.toClassName())//3 .addStatement("val bundle = Bundle()") .addStatement( "bundle.putParcelable(%S,$parcelKey)", parcelKey )//4 .addStatement( "return %T().apply { arguments = bundle }", fragmentClass )//5 .build() )
Давайте разберемся с ним пошагово.
- Это означает начало определения функции
create
, которая добавляется внутрь объекта-компаньона. - Вы определяете функцию
create
для возврата аннотированного типа фрагмента. - Функция
create
принимает единственный параметр, которым будет являться экземпляр класса Parcelable. - Это добавляет оператор, который помещает объект Parcelable в бандл. Для простоты имя параметра
create
и ключ бандла совпадают — этоparcelKey
, поэтому оно повторяется дважды. - Здесь вы добавляете блок
apply
на экземпляр фрагмента и впоследствии задаете аргументы фрагмента в качестве экземпляра бандла.
Теперь ваш код должен выглядеть примерно так:
Обновление посетителя для генерации кода
Для того чтобы использовать генератор, создайте его экземпляр внутри FragmentVisitor:
private val generator = FragmentFactoryGenerator(codeGenerator)
Далее добавьте приведенный ниже код в конец декларации visitClassDeclaration
:
generator.generate( fragment = classDeclaration, parcelKey = parcelKey, parcelledClass = annotatedParameter )
Это вызовет процесс генерации кода.
Соберите и запустите приложение. Вы должны увидеть сгенерированный файл DetailFragmentFactory.kt в папке app/build/generated/ksp/debug/kotlin/com/raywenderlich/android/fragmentfactory
. Просмотрите код. Он должен выглядеть примерно так, как показано на скриншоте ниже:
Интеграция обработанного кода
Когда вы обновите код в ShuffleFragment, чтобы использовать только что сгенерированную фабрику:
DetailFragmentFactory.create(dataProvider.getRandomPokemon())
На экране появится ошибка, приведенная ниже.
По сути, сгенерированные файлы еще не являются частью Android-кода. Чтобы исправить это, обновите секцию android
в build.gradle приложения:
sourceSets.configureEach { kotlin.srcDir("$buildDir/generated/ksp/$name/kotlin/") }
Ваш обновленный build.gradle должен выглядеть следующим образом:
Параметр name
позволяет сконфигурировать нужный вариант сборки с соответствующим набором исходников. В приложении для Android это, в основном, будет либо debug, либо release.
Наконец, выполните сборку и запуск. Теперь вы должны иметь возможность беспрепятственно использовать DetailFragmentFactory.
Куда двигаться дальше?
Вы можете скачать окончательный вариант этого проекта, воспользовавшись кнопкой «Скачать материалы» в оригинале данного руководства.
Похвально, что вы дошли до конца. Вам удалось создать удобный SymbolProcessor и попутно узнать о том, что входит в процесс генерации кода.
Вы можете улучшить этот пример, написав генератор, который сможет разложить аргументы по полям Fragment. Это избавит вас от всей церемонии, связанной с упаковкой и распаковкой данных в исходном коде.
Чтобы узнать больше о распространенных плагинах для компиляторов и мощных API для генерации кода, обратитесь к репозиторию плагинов JetBrains.
Я надеюсь, что вам понравилось знакомство с KSP. Не стесняйтесь делиться своими мыслями.