Android Lint: оптимизируем проверку мердж-реквестов
Привет, это Android-разработчик из «МТС Диджитал» Никита Пятаков. Когда я только начал работать над приложением «Мой МТС», мне нужно было время, чтобы адаптироваться и ознакомиться с проектом. На первых МР-ах коллеги подсвечивали готовые решения, которые можно переиспользовать. Когда к нам стали приходить новые разработчики, такие комментарии оставлял уже я. Это натолкнуло меня на мысль, что использование синтаксического анализатора оптимизирует процесс проверки. К тому моменту мы уже использовали Android Lint, так что выбирать не пришлось.
В этой статье расскажу, как добавил новое правило, чтобы lint предлагал использовать внутреннюю функцию нашего проекта. В рамках этой статьи я не буду описывать, какие зависимости и как нужно добавить в проект — информации об этом и так достаточно в этих ваших интернетах.
Функция, которую мы используем вместо конструкции «?: false», имеет вид:
val Boolean?.safeBoolean: Boolean get() { return this == true }
В итоге мы хотим получить такой результат:
Как видите, нужно, чтобы не только подставлялась нужная функция, но и добавлялся новый импорт. Это сделано для того, чтобы вместо нажатия на две кнопочки можно было жмакнуть только одну!
Issue
Сначала нам нужно создать issue — зарегистрировать новое правило для Lint:
class MyMtsIssueRegistry : IssueRegistry() { override val api: Int get() = CURRENT_API override val issues: List<Issue> get() = listOf(ISSUE_ELVIS_OPERATOR_WITH_FALSE) companion object { val ISSUE_ELVIS_OPERATOR_WITH_FALSE = Issue.create( id = "ElvisOperatorWithFalse", briefDescription = "Elvis expression with false is used", explanation = "Replace Elvis expression with .safeBoolean function", category = Category.CORRECTNESS, priority = 10, severity = Severity.WARNING, implementation = Implementation(ElvisOperatorWithFalseDetector::class.java, JAVA_FILE_SCOPE) ) }}
Наследуемся от абстрактного класса IssueRegistry. Переопределяем два поля:
- api — версия, с которой будут скомпилированы наши issue (можно указать актуальную CURRENT_API из Lint)
- issues — список кастомных правил, добавляем ISSUE_ELVIS_OPERATOR_WITH_FALSE
- id — должен быть уникальным
- briefDescription — краткое описание проблемы
- explanation — более подробное описание проблемы
- category, priority — категоризация issue, можно задать любое (обычно используется для репортов)
- severity — уровень серьёзности проблемы, Lint подсвечивает их по-разному. В нашем случае мы хотим, чтобы участок кода подчёркивался зелёным, выбираем WARNING
- implementation — указываем детектор. В нём опишем процесс поиска кейса, который нужно поправить. Также нужно указать тип файлов, по которому Lint будет проходиться. Так как Kotlin-файлы декомпилируются в Java, используем JAVA_FILE_SCOPE. Lint умеет анализировать Gradle, manifest и так далее, здесь можно выбрать соответствующий scope
Детектор
class ElvisOperatorWithFalseDetector : Detector(), SourceCodeScanner { override fun getApplicableUastTypes(): List<Class<out UElement>> = listOf(UIfExpression::class.java) override fun createUastHandler(context: JavaContext): UElementHandler = ElvisOperatorWithFalseHandler(context)}
В Lint для обработки кода используется так называемое Uast-дерево, в виде которого представляется код. Например, для показанного на видео в начале статьи класса Test (до исправления) дерево будет выглядеть вот так:
UFile (package = ru.mts.accordion.presentation.extensions) UImportStatement (isOnDemand = false) UClass (name = Test) UField (name = options) UAnnotation (fqName = org.jetbrains.annotations.Nullable) UMethod (name = boo) UBlockExpression UReturnExpression UExpressionList (elvis) UDeclarationsExpression ULocalVariable (name = var223e1170) UQualifiedReferenceExpression UQualifiedReferenceExpression USimpleNameReferenceExpression (identifier = options) USimpleNameReferenceExpression (identifier = titleFontSize) UCallExpression (kind = UastCallKind(name='method_call'), argCount = 0)) UIdentifier (Identifier (isEmpty)) USimpleNameReferenceExpression (identifier = isEmpty, resolvesTo = null) UIfExpression UBinaryExpression (operator = !=) USimpleNameReferenceExpression (identifier = var223e1170) ULiteralExpression (value = null) USimpleNameReferenceExpression (identifier = var223e1170) ULiteralExpression (value = false) UMethod (name = Test) UParameter (name = options) UAnnotation (fqName = org.jetbrains.annotations.Nullable)
Lint проходится по деревьям файлов в проекте по алгоритму, который мы опишем в детекторе, и если согласно этому алгоритму будет найден соответствующий участок кода, Lint его подсветит. UFile, UImportSatement, UClass — это всё интерфейсы-наследники UElement, об экземплярах которых (в реализации Java или Kotlin) можно получать необходимую информацию — например, представление кода в виде строки.
Чтобы написать своё правило, нужно унаследоваться от Detector, SourceCodeScanner и переопределить две функции:
- getApplicableUastType — указываем, какие Uast-элементы нас интересуют
- createUastHandler — подставляем свой обработчик для выбранных выше Uast-элементов
Так как оператор Элвиса декомпилируется в Java в виде простого if-else, нам нужен UIfExpression.
Обработчик
class ElvisOperatorWithFalseHandler(private val context: JavaContext) : UElementHandler() { override fun visitIfExpression(node: UIfExpression) { node.accept(object : AbstractUastVisitor() { override fun afterVisitIfExpression(node: UIfExpression) { if ((node.uastParent as? UExpressionList)?.kind?.name == "elvis" && node.elseExpression?.asRenderString() == "false") { val elvisExpressionString = node.uastParent?.sourcePsi?.text node.getParentOfType<UFile>()?.let { uClassWithElvis -> reportIssue(context, uClassWithElvis, node, elvisExpressionString) } } } }) }
Наследуемся от UElementHandler и переопределяем visitIfExpression. Если в коде встретится if, то мы попадём сюда. По дереву можно двигаться в двух направлениях — вверх и вниз. Чтобы «провалиться» ниже, используется функция accept, передаём в неё анонимный объект класса AbstractUastVisitor и переопределяем afterIfExpression. Так как в Java нет оператора Элвиса, нет отдельного expression для него, но тем не менее есть поле, в котором хранится информация о его использовании — kind.name. Данный подход по обработке оператора Элвиса был взят из исходников Jetbrains. Помимо того что используется Элвис, надо проверить, что после него стоит false.
Далее, так как мы хотим в этом файле добавить новый импорт, нам нужно подняться «вверх» по дереву и дойти до уровня файла. Для этого используется функция getParentOfType, вызываем и указываем интересующий нас класс — Ufile. После этого переходим в reportIssue, передавая контекст, весь файл и код, в котором используется оператор.
Репорт
Для того чтобы мы в студии получили сообщение о том, что нужно поправить код, необходимо вызвать функцию report:
private fun reportIssue(context: JavaContext, nodeFile: UFile, nodeElvisExpression: UIfExpression, elvisExpressionString: String?) { elvisExpressionString?.let { context.report( ISSUE_ELVIS_OPERATOR_WITH_FALSE, context.getLocation(nodeElvisExpression), "Elvis expression can be replaced with .safeBoolean function", createFix(nodeFile, it) ) }}
- Issue
- Location — то место в коде, которое будет подчёркнуто и заменено при фиксе
- Message — краткое описание
- QuickFixData — объект класса LintFix. Используем, если хотим не только подсветить проблему, но и предложить исправление
Исправление
private fun createFix(nodeFile: UFile, oldText: String): LintFix { val newString = oldText.substringBeforeLast("?:").trim() + ".safeBoolean" val lastFileImport = nodeFile.imports.lastOrNull() val elvisFix = LintFix .create() .name("Replace") .replace() .text(oldText) .with(newString) .reformat(false) .build() return if (lastFileImport != null && "import ru.mts.utils.extensions.safeBoolean" !in nodeFile.imports.map { it.sourcePsi?.text }) { val addImportFix = LintFix.create() .replace() .with("\nimport ru.mts.utils.extensions.safeBoolean") .reformat(true) .range(context.getLocation(lastFileImport)) .end() .autoFix() .build() LintFix.create().composite(addImportFix, elvisFix) } else { elvisFix }}
Как мы помним, фикс будет двойной, нужно вместо оператора Элвиса подставить вызов функции safeBoolean и добавить новый импорт. Для исправления оператора используем функцию replace и указываем, какой код (text) на что меняем (with).
Область исправления определяется location, который мы указали в report выше. Чтобы добавить фикс импортов, необходимо location поменять. Для этого нужно в range передать новый location (поле imports в UFile), в reformat поменять флаг на true. Далее указываем, что хотим добавить новый импорт в конец (end), и используем replace и with с указанием нового текста.
После этого, если мы хотим, чтобы у нас это сработало в рамках одного фикса, необходимо их объединить, для этого используем composite.
Ура, новое правило успешно создано! Спасибо Вам за уделённое время.