Kotlin
March 1

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

В issue указываем:

  • 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.

Ура, новое правило успешно создано! Спасибо Вам за уделённое время.

Источник