Расширяем Android Lint
Часто при разработке собственных фреймворков (или для проверки соответствия кода требованиям организации) возникает необходимость реализовать сложные проверки корректности использования в коде приложения. Это может быть реализовано через расширение возможностей линтера, который используется в 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-файла).