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
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> = 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()?.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.
Ура, новое правило успешно создано! Спасибо Вам за уделенное время. Если возникнут какие-либо вопросы, я с удовольствием отвечу на них в комментариях. А если захотите присоединиться к нашей команде — следите за вакансиями!