Часто при разработке собственных фреймворков (или для проверки соответствия кода требованиям организации) возникает необходимость реализовать сложные проверки корректности использования в коде приложения. Это может быть реализовано через расширение возможностей линтера, который используется в Android Studio. В этой статье мы рассмотрим общие подходы к созданию таких расширений для Android-приложений и несколько примеров для проверки названий функций и наличия аннотаций.
Прежде чем мы перейдем к рассмотрению расширения нужно разобраться с порядком выполнения компиляции Kotlin-кода и представлением дерева элементов PSI (Program Structure Interface). PSI является минимальной единицей представления исходного текста и он может отображать как ключевые слова, так и идентификаторы, константы и операторы. PSI создается при выполнении парсера (может быть сгенерирован с использованием Gradle Grammar Kit Plugin) и создается с использованием объекта класса PsiBuilder, который может сохранять промежуточное представление и накапливать дерево PSI-элементов на основе потока токенов, которые поступают от лексического анализатора. Именно анализ PSI-структуры поможет нам в создании расширения для проверки корректности вызова методов фреймворка (а также, например, может использоваться для проверки принятой в организации схемы именования). Для удобства работы с сущностями языков, основанных на JVM (включая Kotlin) также доступен API UAST (Unified Abstract Syntax Tree) для извлечения и анализа высокоуровневых единиц (определений классов, методов, операторов языка, литералов и констант).
Для создания расширения линтера мы добавим зависимость от библиотеки com.android.tools.lint:lint-api, а также подключим com.android.tools.lint:lint-tests для автоматических проверок расширения.
plugins {
kotlin("jvm") version "1.7.21"
application
}
group = "org.example"
version = "1.0-SNAPSHOT"
repositories {
mavenCentral()
google()
}
dependencies {
implementation("com.android.tools.lint:lint-api:30.3.1")
testImplementation("com.android.tools.lint:lint-tests:30.3.1")
testImplementation(kotlin("test"))
}
tasks.test {
useJUnitPlatform()
}
kotlin {
jvmToolchain(8)
}
Для уведомления об ошибке используется объект класса Issue (входит в пакет com.android.tools.lint.detector.api), при создании которого необходимо указать идентификатор проблемы, короткое и подробное описание (также будет выводиться во всплывающей подсказке в IDE), уровень проблемы (может быть предупреждение, ошибкой, фатальной ошибкой, информацией, а также быть помечен как Severity.IGNORE для временного отключения проверки), категорию (часто используется Category.CUSTOM_LINT_CHECKS), приоритет (целое число от 1 до 10, где 10 наиболее значимая проблема), а также класс реализации детектора ошибки.
Класс детектора создается как реализация абстрактного класса Detector
и часто добавляется интерфейс Detector.UastScanner
для работы с высокоуровневыми абстракциями языка (Java или Kotlin). UastScanner
представляет для определения несколько групп методов:
getApplicableMethodNames
- переопределяет список названий вызываемых методов, для которых будет применяться детекторvisitMethodCall
- будет вызываться при обнаружении вызова методов (всех или перечисленных в результате метода getApplicableMethodNames)getApplicableConstructorTypes
,visitConstructor
- аналогично предыдущим методам, но для создания объектовgetApplicableReferenceNames
,visitReference
- при обнаружении ссылок на переменныеappliesToResourceRefs
(возвращает логическое значение) иvisitResourceReference
- при обнаружении ссылок на ресурсы Android (через класс R)applicableSuperClasses
,visitClass
- при определении класса, наследуемого от разрешенных суперклассов (или всех, если applicableSuperClasses не определен), либо лямбды в Kotlin (транслируется в класс с одним методом).applicableAnnotations
,visitAnnotationUsage
- при обнаружении применения аннотации к полю, методу, классу или пакетуgetApplicableUastTypes
- перечисляет поддерживаемые типы UElement, важно переопределить для указания поддерживаемых visit-методовcreateUastHandler
- регистрирует обработчик для обработки элементов синтаксического дерева (наследуется от UElementHandler)
Также от класса Detector наследуются методы жизненного цикла before* и after* (например, перед и после проверки нового файла, проекта, …).
Класс обработчика элемента (от UElementHandler) может переопределять методы, которые будут вызываться при обнаружении элемента в синтаксическом дереве, например:
visitBreakExpression
- при обнаружении оператора breakvisitCallExpression
- при вызове функций или методовvisitClass
- определение классаvisitDeclaration
- определение переменныхvisitIfExpression
- обнаружено выражение ifvisitImportStatement
- найден импорт пакетаvisitLambdaExpression
- обнаружено лямбда-выражениеvisitMethod
- обнаружено определение методаvisitReturnExpression
- обнаружено выражение return
Полный список методов можно посмотреть в официальной документации.
Определим visitMethod для проверки префикса в названии метода, в случае ошибки сообщим о ней через вызов context.report, в который нужно передать объект Issue, уточнить в какой строке произошла ошибка (можно получить из контекста через вызов метода getNameLocation или getLocation). Полный код класса-расширения линтера может выглядеть следующим образом:
object NamingIssue {
val ISSUE = Issue.create(
id = "NamingIssue",
briefDescription = "Wrong prefix in method name",
explanation = "You must use prefix 'my' or 'on' for any method",
category = Category.CUSTOM_LINT_CHECKS,
priority = 5,
severity = Severity.ERROR,
Implementation(NameIssueDetector::class.java, Scope.JAVA_FILE_SCOPE)
)
}
class NameIssueDetector : Detector(), Detector.UastScanner {
override fun getApplicableUastTypes(): List<Class<out UElement>>? = listOf(UMethod::class.java)
override fun createUastHandler(context: JavaContext): UElementHandler? = NameVisitor(context)
}
class NameVisitor(private val context: JavaContext) : UElementHandler() {
override fun visitMethod(node: UMethod) {
if (!node.name.startsWith("my") && !node.name.startsWith("on")) {
context.report(issue=NamingIssue.ISSUE, scopeClass=node, location=context.getNameLocation(node), message="""
Function name must have prefix "my" or "on"
""".trimIndent())
}
super.visitMethod(node)
}
}
Теперь для подключения нового правила нужно создать реестр Issue и подключить его к проекту:
class CustomLintRegistry : IssueRegistry() {
override val issues = listOf(NamingIssue.ISSUE)
override val api: Int = CURRENT_API
override val minApi: Int = 6
}
И добавим запись в манифест для JAR-файла (поскольку он будет добавляться во внешний проект)
tasks.withType<Jar> {
manifest {
attributes["Lint-Registry-v2"] = "com.example.CustomLintRegistry"
}
}
Для подключения линтера к проекту Android Studio нужно добавить его в блок dependencies через lintChecks, например:
dependencies {
//...зависимости проекта
lintChecks(project(":mylint"))
}
Подключенный линтер будет работать после запуска ./gradlew lint
Теперь при выполнении синтаксического анализа будет дополнительно для каждого названия метода в исходных текстах проекта проверяться, что он начинается со слов my или on.
Теперь расширим наш линтер и добавим к нему возможность проверки наличия аннотации EventHandler перед методами, которые называются с префиксом handle. Добавим поддержку аннотаций в список типов:
override fun getApplicableUastTypes(): List<Class<out UElement>>? = listOf(UMethod::class.java, UAnnotation::class.java)
и реализуем метод, который будет также сохранять состояние наличия аннотации, которое будет использоваться в visitMethod
:
class NameVisitor(private val context: JavaContext) : UElementHandler() {
var annotationFound = false
override fun visitAnnotation(node: UAnnotation) {
if (node.qualifiedName=="EventHandler") {
annotationFound = true
}
super.visitAnnotation(node)
}
override fun visitMethod(node: UMethod) {
if (node.name.startsWith("handle") && !annotationFound) {
context.report(issue=NamingIssue.ISSUE, scopeClass=node, location=context.getNameLocation(node), message="""
Handler functions must be annotated with @EventHandler
""".trimIndent())
}
annotationFound = false
super.visitMethod(node)
}
}
При этом сам класс аннотации должен быть помечен для использования только с методами:
@Target(AnnotationTarget.FUNCTION)
annotation class EventHandler
Аналогично могут быть сделаны проверки по типу возвращаемого значения и аргументов функции (или метода), вся информация о сигнатуре вызова доступа в объекте класса UMethod
. Также могут быть выполнены проверки выражений (ArrayAccessExpression
, BinaryExpression
, UnaryExpression
, BreakExpression
, PrefixExpression
, PostfixExpression
), вызовов функций (CallExpression
), определений классов (ClassExpression
), операторы языка (BlockExpression
, DoWhileExpression
, IfExpression
, SwitchExpression
, SwitchClauseExpression
, TryExpression
, ThrowExpression
, WhileExpression
) и многие другие. Благодаря возможностям расширения статического анализа можно создавать дополнительные проверки исходных кодов и интегрировать их с библиотеками, как подключаемую зависимость (поскольку информация для настройки lintChecks извлекается из манифеста jar-файла).
В завершение приглашаю вас на бесплатный урок по теме: "Создаем приложение таймер за 60 минут с использованием MVVM, StateFlow и Coroutines со звуковой сигнализацией и вибрацией."