Привет, Хабр! На связи Алина, старший Android-разработчик в команде Инвестиций «Совкомбанк Технологии». Сегодня поговорим о том, как заставить Android Studio самостоятельно следить за порядком в коде – без ручных проверок и без вечных напоминаний в командном чате.
Представьте, в вашей команде договорились, что все реализации репозитория должны находиться в пакете .data.repository. Но каждую неделю кто-то создает UserRepositoryImpl не в том месте. Или команда решила мигрировать с RxJava на Coroutines – и теперь нужно найти все Single и Observable в 200+ файлах.
Пользовательские правила статического анализа автоматизируют проверки:
Контролируют соблюдение архитектуры проекта
Находят устаревший API при миграциях
Обеспечивают соблюдение внутренних конвенций команды
Экономят время на проверку кода
Android Lint и Detekt – это инструменты статического анализа кода. Lint от Google анализирует код на языках программирования Java/Kotlin, XML-ресурсы и манифесты. Detekt специализируется на Kotlin и предоставляет более современный API для написания правил. Оба инструмента поставляются со стандартным набором правил, но у каждого проекта есть свои специфические требования.
В этой статье мы создадим практические правила для обоих инструментов и разберем, как внедрить их в проект. На примере lint рассмотрим контроль архитектуры пакетов и обязательную документацию с QuickFix, а на примере detekt – проверку неизменяемого состояния представления без Android-зависимостей и миграцию с RxJava на Coroutines.
Lint vs Detekt: выбор инструмента
Критерий | Android Lint | Detekt |
Языки | Java + Kotlin, через UAST | Только Kotlin |
Анализирует | Код, XML, манифесты, ресурсы | Только Kotlin код |
QuickFix | Есть, автоматическое исправление в IDE | Нет |
API | UAST, унифицированный | PSI, нативный Kotlin |
Поддержка | Google, интеграция с AGP | Community, JetBrains ecosystem |
Сложность API | Средняя | Низкая |
Скорость работы | Медленнее, анализирует больше | Быстрее, только Kotlin |
Когда использовать Lint:
Если нужны QuickFix для автоматического исправления проблем
Требуется анализ XML-ресурсов и манифестов
Проект использует Java и Kotlin одновременно
Когда использовать Detekt:
Проект полностью на Kotlin
Нужен простой и современный API для написания правил
Требуется быстрое создание прототипов новых правил
Рекомендация: используйте оба инструмента вместе – они решают разные задачи и дополняют друг друга. Lint незаменим для проверки ресурсов, XML-файлов и манифестов с возможностью автоматических исправлений. Detekt работает быстрее за счет фокуса исключительно на Kotlin-коде и обеспечивает эффективные проверки стиля и качества.
Android Lint
Основные концепции
Issue – описание проблемы с уникальным идентификатором, уровнем серьезности и сообщением об ошибке.
Detector – класс с логикой поиска проблем. Обходит код и создает отчеты о найденных нарушениях.
Scanner – интерфейс-маркер, определяющий тип анализируемого кода (SourceCodeScanner, XmlScanner, GradleScanner).
UAST или Unified Abstract Syntax Tree – унифицированное представление Java и Kotlin кода. Позволяет одному правилу работать с обоими языками.
QuickFix – автоматическое исправление проблем прямо в IDE. Уникальная возможность Lint, которой нет в Detekt.
Настройка проекта
Скрытый текст
// settings.gradle.kts include(":app") include(":lint-checks") // lint-checks/build.gradle.kts plugins { id("java-library") id("kotlin") } dependencies { compileOnly("com.android.tools.lint:lint-api:31.13.0") compileOnly("com.android.tools.lint:lint-checks:31.13.0") testImplementation("com.android.tools.lint:lint:31.13.0") testImplementation("com.android.tools.lint:lint-tests:31.13.0") testImplementation("junit:junit:4.13.2") } tasks.jar { manifest { attributes("Lint-Registry-v2" to "com.example.lint_checks.CustomIssueRegistry") } } // app/build.gradle.kts dependencies { lintChecks(project(":lint-checks")) }
Пример 1: контроль архитектуры пакетов
Задача: *RepositoryImpl должны находиться только в пакете .data.repository.
Зачем это нужно: в Clean Architecture Repository – это часть слоя хранилища данных. Размещение реализаций в едином пакете помогает соблюдать разделение по слоям и упрощает навигацию в больших проектах.
Скрытый текст
package com.example.lint_checks import com.android.tools.lint.detector.api.* import org.jetbrains.uast.UClass // Наследуется от Detector и реализует SourceCodeScanner для анализа исходного кода class RepositoryPackageDetector : Detector(), SourceCodeScanner { // Указываем, какие типы элементов AST (Abstract Syntax Tree) нас интересуют // В данном случае - только классы (UClass) override fun getApplicableUastTypes(): List<Class<out UElement>> = listOf(UClass::class.java) // Создаем обработчик для элементов кода override fun createUastHandler(context: JavaContext): UElementHandler { return object : UElementHandler() { // Метод вызывается для каждого найденного класса в проекте override fun visitClass(node: UClass) { // Получаем имя класса, если null - выходим val className = node.name ?: return // Проверяем только классы-реализации Repository по соглашению об именовании if (!className.endsWith("RepositoryImpl")) return // Получаем полное имя класса с пакетом (например, com.example.app.data.repository.UserRepositoryImpl) val fullQualifiedName = node.qualifiedName ?: return // Извлекаем название пакета, удаляя из полного имени название класса // Например, из "com.example.app.data.repository.UserRepositoryImpl" получаем "com.example.app.data.repository" val packageName = fullQualifiedName.substringBeforeLast(".$className", "") // Проверяем что пакет содержит .data.repository if (!packageName.contains(".data.repository")) { // Создаем отчет о проблеме // location - где подсветить проблему (имя класса) // message - сообщение разработчику с объяснением проблемы context.report( issue = ISSUE, location = context.getNameLocation(node), message = "Реализация Repository `$className` должна быть в пакете `*.data.repository`. " + "Текущий пакет: `$packageName`" ) } } } } companion object { // Issue - описание проблемы, которую ищет детектор val ISSUE = Issue.create( id = "RepositoryPackageViolation", // Уникальный ID для конфигурации и suppress briefDescription = "Реализация Repository в неправильном пакете", // Короткое описание explanation = """ Реализации Repository должны находиться в пакете `.data.repository` для соблюдения Clean Architecture. """.trimIndent(), // Подробное объяснение проблемы category = Category.CORRECTNESS, // Категория - корректность кода priority = 6, // Приоритет от 1 (низкий) до 10 (высокий) severity = Severity.WARNING, // WARNING не блокирует сборку implementation = Implementation( RepositoryPackageDetector::class.java, // Класс детектора Scope.JAVA_FILE_SCOPE // Область проверки - Java/Kotlin файлы ) ) } }
Тесты – обязательная часть любого правила. Без них вы не можете быть уверены, что правило находит проблемы там, где нужно, не создает ложных срабатываний и работает после изменений кода.
Скрытый текст
package com.example.lint_checks import com.android.tools.lint.checks.infrastructure.TestFiles.kotlin import com.android.tools.lint.checks.infrastructure.TestLintTask.lint import org.junit.Test class RepositoryPackageDetectorTest { @Test fun `RepositoryImpl в правильном пакете - не срабатывает`() { lint() .files( kotlin(""" package com.example.app.data.repository class UserRepositoryImpl { fun getUser(id: String) = "User" } """).indented() ) .issues(RepositoryPackageDetector.ISSUE) // Указываем какое правило тестируем .allowMissingSdk() // Разрешаем запуск без полного Android SDK .run() .expectClean() // Ожидаем что проблем не найдено } @Test fun `RepositoryImpl в неправильном пакете - срабатывает`() { lint() .files( kotlin(""" package com.example.app.domain class UserRepositoryImpl { fun getUser(id: String) = "User" } """).indented() ) .issues(RepositoryPackageDetector.ISSUE) .allowMissingSdk() .run() .expect(""" src/com/example/app/domain/UserRepositoryImpl.kt:3: Warning: Реализация Repository UserRepositoryImpl должна быть в пакете *.data.repository. Текущий пакет: com.example.app.domain [RepositoryPackageViolation] class UserRepositoryImpl { ~~~~~~~~~~~~~~~~~~ 0 errors, 1 warnings """.trimIndent()) // Проверяем что получили именно это предупреждение } @Test fun `Не RepositoryImpl класс - не срабатывает`() { lint() .files( kotlin(""" package com.example.app.domain class UserService { fun getUser(id: String) = "User" } """).indented() ) .issues(RepositoryPackageDetector.ISSUE) .allowMissingSdk() .run() .expectClean() } }
Запуск тестов: ./gradlew :lint-checks:test
Пример 2: обязательная документация с QuickFix
Задача: все методы в интерфейсах Repository/Interactor/DataSource должны иметь документацию в формате KDoc.
Зачем это нужно: интерфейсы определяют контракты между слоями приложения. Документация помогает новым разработчикам быстро понять назначение методов без изучения их реализации. QuickFix автоматически создает шаблон документации – разработчику остается только заполнить описания.
Скрытый текст
package com.example.lint_checks import com.android.tools.lint.client.api.UElementHandler import com.android.tools.lint.detector.api.* import org.jetbrains.kotlin.kdoc.lexer.KDocTokens import org.jetbrains.uast.UClass import org.jetbrains.uast.UMethod class KDocRequiredDetector : Detector(), SourceCodeScanner { // Суффиксы интерфейсов, которые требуют документации private val REQUIRED_SUFFIXES = listOf("Repository", "Interactor", "DataSource") // Указываем что хотим проверять UClass элементы override fun getApplicableUastTypes() = listOf(UClass::class.java) // Вызывается для каждого класса в проекте override fun createUastHandler(context: JavaContext) = object : UElementHandler() { override fun visitClass(node: UClass) { // Проверяем только интерфейсы if (!node.isInterface) return val className = node.name ?: return // Проверяем что имя интерфейса заканчивается на нужный суффикс if (!REQUIRED_SUFFIXES.any { className.endsWith(it) }) return // Проверяем все методы интерфейса node.methods.forEach { method -> checkMethodDocumentation(context, method, className) } } } // Проверяет наличие KDoc у метода private fun checkMethodDocumentation( context: JavaContext, method: UMethod, interfaceName: String ) { // Ищем KDoc комментарий (/** ... */) // sourcePsi - это PSI элемент (Program Structure Interface) из компилятора // Через него получаем реальный текст комментария val hasKDoc = method.comments.any { comment -> comment.sourcePsi?.node?.elementType == KDocTokens.KDOC } // Если KDoc отсутствует - создаем отчет с QuickFix if (!hasKDoc) { val quickFix = createKDocQuickFix(context, method) context.report( issue = ISSUE, location = context.getNameLocation(method), // Подсвечиваем имя метода message = "Метод `${method.name}` в `$interfaceName` должен иметь KDoc", quickfixData = quickFix // Передаем QuickFix для автоисправления ) } } // Создает QuickFix с шаблоном KDoc документации private fun createKDocQuickFix(context: JavaContext, method: UMethod): LintFix { val methodName = method.name // Фильтруем синтетические параметры (например $completion для suspend функций) val parameters = method.uastParameters.filter { param -> val paramName = param.name !paramName.startsWith("$") } // Проверяем возвращает ли метод значение (не void и не Unit) val hasReturnValue = method.returnType?.canonicalText?.let { it != "void" && it != "kotlin.Unit" } ?: false // Генерируем шаблон KDoc с TODO для заполнения разработчиком val kdocTemplate = buildString { append("/**\n") append(" * TODO: описать назначение метода $methodName\n") // Добавляем @param для каждого параметра parameters.forEach { param -> append(" * @param ${param.name} TODO: описать параметр\n") } // Добавляем @return если метод возвращает значение if (hasReturnValue) { append(" * @return TODO: описать возвращаемое значение\n") } append(" */\n ") } // Создаем QuickFix который добавит шаблон перед методом return fix() .name("Добавить KDoc шаблон") // Название действия в меню IDE .replace() // Тип действия - замена текста .range(context.getLocation(method)) // Где применять .beginning() // В начале метода .with(kdocTemplate) // Что добавить .autoFix() // Пометка что это автоматическое исправление .build() } companion object { val ISSUE = Issue.create( id = "MissingKDocInDomainInterface", briefDescription = "Отсутствует KDoc", explanation = "Методы в Repository/Interactor/DataSource должны иметь KDoc", category = Category.USABILITY, // Категория - удобство использования priority = 5, severity = Severity.WARNING, implementation = Implementation( KDocRequiredDetector::class.java, Scope.JAVA_FILE_SCOPE ) ) } }
Тесты:
Скрытый текст
package com.example.lint_checks import com.android.tools.lint.checks.infrastructure.TestFiles.kotlin import com.android.tools.lint.checks.infrastructure.TestLintTask.lint import org.junit.Test class KDocRequiredDetectorTest { @Test fun `Repository без KDoc - срабатывает`() { lint() .files( kotlin( """ package com.example.app.domain.repository interface UserRepository { suspend fun getUser(userId: String): User? } data class User(val id: String) """ ).indented() ) .issues(KDocRequiredDetector.ISSUE) .allowMissingSdk() .run() .expect( """ src/com/example/app/domain/repository/UserRepository.kt:4: Warning: Метод getUser в UserRepository должен иметь KDoc [MissingKDocInDomainInterface] suspend fun getUser(userId: String): User? ~~~~~~~ 0 errors, 1 warnings """.trimIndent() ) } @Test fun `Repository с KDoc - не срабатывает`() { lint() .files( kotlin( """ package com.example.app.domain.repository interface UserRepository { /** * Получает пользователя по ID * @param userId идентификатор пользователя * @return данные пользователя или null */ suspend fun getUser(userId: String): User? } data class User(val id: String) """ ).indented() ) .issues(KDocRequiredDetector.ISSUE) .allowMissingSdk() .run() .expectClean() } @Test fun `Обычный интерфейс без KDoc - не срабатывает`() { lint() .files( kotlin( """ interface UserView { fun showUser(name: String) } """ ).indented() ) .issues(KDocRequiredDetector.ISSUE) .allowMissingSdk() .run() .expectClean() } @Test fun `QuickFix добавляет корректный KDoc шаблон`() { lint() .files(kotlin(""" package com.example.app.domain.repository interface UserRepository { suspend fun getUser(userId: String): User? } data class User(val id: String) """.trimIndent())) .issues(KDocRequiredDetector.ISSUE) .allowMissingSdk() .run() .checkFix(null, kotlin(""" package com.example.app.domain.repository interface UserRepository { /** * TODO: описать назначение метода getUser * @param userId TODO: описать параметр * @return TODO: описать возвращаемое значение */ suspend fun getUser(userId: String): User? } data class User(val id: String) """.trimIndent())) } }
Регистрация правил Lint
Скрытый текст
package com.example.lint_checks import com.android.tools.lint.client.api.IssueRegistry import com.android.tools.lint.detector.api.CURRENT_API class CustomIssueRegistry : IssueRegistry() { // Список всех кастомных правил override val issues = listOf( RepositoryPackageDetector.ISSUE, KDocRequiredDetector.ISSUE ) // Версия API Lint (должна соответствовать используемой версии) override val api: Int = CURRENT_API // Информация о вендоре (требуется линтером) override val vendor: Vendor = Vendor( vendorName = "Your Company Name", feedbackUrl = "https://github.com/yourcompany/lint-rules/issues", contact = "team@yourcompany.com" ) }
Запуск: ./gradlew :app:lint
Detekt
Основные концепции
Правило/Rule – содержит логику проверки. Наследуется от базового класса и переопределяет методы для обхода элементов кода.
Интерфейс RuleSetProvider – группирует правила и регистрируется через ServiceLoader – механизм Java для автоматического обнаружения реализаций.
Интерфейс PSI (Program Structure Interface) – дерево элементов Kotlin-кода от JetBrains, используемое в IntelliJ IDEA для анализа структуры программы.
Настройка проекта
Скрытый текст
// build.gradle.kts (project) plugins { id("io.gitlab.arturbosch.detekt") version "1.23.8" apply false } // app/build.gradle.kts plugins { id("io.gitlab.arturbosch.detekt") } dependencies { detektPlugins(project(":detekt-rules")) } // detekt-rules/build.gradle.kts plugins { id("kotlin") } dependencies { compileOnly("io.gitlab.arturbosch.detekt:detekt-api:1.23.8") testImplementation("io.gitlab.arturbosch.detekt:detekt-test:1.23.8") testImplementation("io.kotest:kotest-assertions-core:6.0.3") testImplementation("junit:junit:4.13.2") }
Пример 1: ViewState без Android зависимостей
Задача: состояния ViewState/UiState не должны импортировать android.* пакеты.
Зачем это нужно: ViewState без Android-зависимостей можно переиспользовать в Kotlin Multiplatform проектах и легко тестировать без техники мокирования фреймворка.
Скрытый текст
package com.example.detekt_rules import io.gitlab.arturbosch.detekt.api.* import org.jetbrains.kotlin.psi.KtClass import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.psi.KtImportDirective class NoAndroidInViewStateRule(config: Config = Config.empty) : Rule(config) { // Issue описывает проблему которую находит правило override val issue = Issue( id = "NoAndroidInViewState", severity = Severity.Defect, // Defect = проблема в коде description = "ViewState не должен содержать Android зависимости", debt = Debt.FIVE_MINS // Примерное время на исправление ) // Флаг что в файле есть State класс private var hasStateClass = false // Список найденных android импортов private val androidImports = mutableListOf<KtImportDirective>() // Вызывается для всего файла в начале override fun visitKtFile(file: KtFile) { // Сбрасываем состояние для нового файла hasStateClass = false androidImports.clear() // Продолжаем обход дерева - вызовутся visitClass и visitImportDirective super.visitKtFile(file) // После обхода файла - если есть State класс, проверяем импорты if (hasStateClass) { androidImports.forEach { importDirective -> val importPath = importDirective.importPath?.pathStr ?: return@forEach // Создаем отчет об ошибке для каждого android импорта // Entity.from создает сущность с информацией о местоположении report( CodeSmell( issue = issue, entity = Entity.from(importDirective), // Указываем где нашли проблему message = "ViewState/UiState не должен импортировать " + "Android зависимости: `$importPath`. Это нарушает переиспользование " + "и усложняет тестирование." ) ) } } } // Вызывается для каждого класса в файле override fun visitClass(klass: KtClass) { val className = klass.name ?: "" // Проверяем по соглашению об именовании - это State класс или нет if (className.endsWith("ViewState") || className.endsWith("UiState")) { hasStateClass = true } // Продолжаем обход - вдруг внутри класса есть еще вложенные классы super.visitClass(klass) } // Вызывается для каждого import в файле override fun visitImportDirective(importDirective: KtImportDirective) { super.visitImportDirective(importDirective) // Получаем полный путь импорта (например android.os.Parcelable) val importPath = importDirective.importPath?.pathStr ?: return // Если импорт начинается с android.* - сохраняем его if (importPath.startsWith("android.")) { androidImports.add(importDirective) } } }
Тесты:
Скрытый текст
package com.example.detekt_rules import io.gitlab.arturbosch.detekt.api.Config import io.gitlab.arturbosch.detekt.test.compileAndLint import io.kotest.matchers.collections.shouldHaveSize import org.junit.Test class NoAndroidInViewStateRuleTest { @Test fun `ViewState с android импортом - срабатывает`() { val code = """ import android.os.Parcelable data class UserViewState(val name: String) """.trimIndent() val findings = NoAndroidInViewStateRule(Config.empty) .compileAndLint(code) findings shouldHaveSize 1 } @Test fun `ViewState без android импорта - не срабатывает`() { val code = """ data class UserViewState(val name: String, val age: Int) """.trimIndent() val findings = NoAndroidInViewStateRule(Config.empty) .compileAndLint(code) findings shouldHaveSize 0 } @Test fun `Обычный класс с android импортом - не срабатывает`() { val code = """ import android.os.Bundle class MainActivity { fun onCreate(savedInstanceState: Bundle?) {} } """.trimIndent() val findings = NoAndroidInViewStateRule(Config.empty) .compileAndLint(code) findings shouldHaveSize 0 } }
Пример 2: immutable ViewState
Задача: состояния ViewState/UiState должны содержать только переменные val, не var.
Зачем это нужно: неизменяемое состояние предсказуемо, избегает побочных эффектов и безопасно в многопоточной среде.
Скрытый текст
package com.example.detekt_rules import io.gitlab.arturbosch.detekt.api.* import org.jetbrains.kotlin.lexer.KtTokens import org.jetbrains.kotlin.psi.KtClass import org.jetbrains.kotlin.psi.KtProperty class ImmutableViewStateRule(config: Config = Config.empty) : Rule(config) { override val issue = Issue( id = "ImmutableViewState", severity = Severity.Defect, description = "ViewState должен быть immutable (только val)", debt = Debt.FIVE_MINS ) // Флаг показывает что мы внутри State класса private var isStateClass = false // Вызывается при входе в класс override fun visitClass(klass: KtClass) { val className = klass.name ?: "" // Определяем ViewState/UiState по соглашению об именовании isStateClass = className.endsWith("ViewState") || className.endsWith("UiState") if (isStateClass) { // Получаем первичный конструктор (primary constructor) val primaryConstructor = klass.primaryConstructor // Проходим по всем параметрам конструктора primaryConstructor?.valueParameters?.forEach { parameter -> // Проверяем тип ключевого слова перед параметром val isVar = parameter.valOrVarKeyword?.node?.elementType == KtTokens.VAR_KEYWORD // Если параметр объявлен как var - это ошибка if (isVar) { report( CodeSmell( issue = issue, entity = Entity.from(parameter), message = "Свойство `${parameter.name}` должно быть val. " + "Используйте copy() для изменения состояния." ) ) } } } // Продолжаем обход для проверки вложенных классов super.visitClass(klass) // Сбрасываем флаг после обработки класса isStateClass = false } // Проверяем свойства внутри тела класса override fun visitProperty(property: KtProperty) { super.visitProperty(property) // Проверяем свойства только в State классах if (!isStateClass) return // Определяем тип ключевого слова val isVar = property.valOrVarKeyword?.node?.elementType == KtTokens.VAR_KEYWORD // Если свойство объявлено как var - создаем отчет if (isVar) { report( CodeSmell( issue = issue, entity = Entity.from(property), message = "Свойство `${property.name}` должно быть val. " + "Используйте copy() для изменения состояния." ) ) } } }
Тесты:
Скрытый текст
package com.example.detekt_rules import io.gitlab.arturbosch.detekt.api.Config import io.gitlab.arturbosch.detekt.test.compileAndLint import io.kotest.matchers.collections.shouldHaveSize import org.junit.Test class ImmutableViewStateRuleTest { @Test fun `ViewState с var - срабатывает`() { val code = """ data class UserViewState( val name: String, var age: Int ) """.trimIndent() val findings = ImmutableViewStateRule(Config.empty) .compileAndLint(code) findings shouldHaveSize 1 } @Test fun `ViewState с var в теле - срабатывает`() { val code = """ data class UserViewState( val name: String, val age: Int ) { var email: String = "" } """.trimIndent() val findings = ImmutableViewStateRule(Config.empty) .compileAndLint(code) findings shouldHaveSize 1 } @Test fun `ViewState с val - не срабатывает`() { val code = """ data class UserViewState( val name: String, val age: Int ) """.trimIndent() val findings = ImmutableViewStateRule(Config.empty) .compileAndLint(code) findings shouldHaveSize 0 } @Test fun `Обычный класс с var - не срабатывает`() { val code = """ data class User( val name: String, var age: Int ) { var email: String = "" } """.trimIndent() val findings = ImmutableViewStateRule(Config.empty) .compileAndLint(code) findings shouldHaveSize 0 } }
Пример 3: миграция с RxJava на Coroutines
Задача: находить методы, возвращающие RxJava типы (Single, Observable, Completable) и предлагать миграцию на Coroutines.
Зачем это нужно: при миграции кодовой базы на Coroutines автоматический поиск RxJava методов экономит время и помогает не пропустить участки кода требующие обновления.
Примечание: проверка по именам типов может давать ложные срабатывания на классы с похожими именами из других библиотек. Более надежный способ – проверять также импорты "io.reactivex.", но для простоты примера мы используем только проверку по именам.
Скрытый текст
package com.example.detekt_rules import io.gitlab.arturbosch.detekt.api.* import org.jetbrains.kotlin.psi.KtNamedFunction import org.jetbrains.kotlin.psi.KtTypeReference class RxJavaToCoroutinesMigrationRule(config: Config = Config.empty) : Rule(config) { override val issue = Issue( id = "RxJavaToCoroutinesMigration", severity = Severity.Style, // Style = не ошибка, но стоит улучшить description = "Метод использует RxJava, рассмотрите миграцию на Coroutines", debt = Debt.TWENTY_MINS ) // Маппинг RxJava типов на Coroutines аналоги private val rxToCoroutines = mapOf( "Single" to "suspend fun T", // Single<T> -> suspend fun(): T "Observable" to "Flow<T>", // Observable<T> -> Flow<T> "Flowable" to "Flow<T>", // Flowable<T> -> Flow<T> "Completable" to "suspend fun returning Unit", // Completable -> suspend fun() "Maybe" to "suspend fun T?" // Maybe<T> -> suspend fun(): T? ) // Вызывается для каждой функции в коде override fun visitNamedFunction(function: KtNamedFunction) { super.visitNamedFunction(function) // Получаем тип возвращаемого значения функции val returnType = function.typeReference ?: return val typeName = extractTypeName(returnType) // Проверяем является ли возвращаемый тип одним из RxJava типов val alternative = rxToCoroutines[typeName] ?: return // Извлекаем generic параметр (User из Single<User>) val genericType = extractGenericType(returnType) val suggestion = formatSuggestion(alternative, genericType) // Создаем отчет с предложением миграции report( CodeSmell( issue = issue, entity = Entity.from(function), message = "Метод `${function.name}` возвращает RxJava тип `$typeName`. " + "Рассмотрите миграцию на Coroutines: $suggestion" ) ) } // Извлекает имя типа без generic параметров (Single<User> -> Single) private fun extractTypeName(typeReference: KtTypeReference): String { return typeReference.text.substringBefore("<").trim() } // Извлекает generic параметр (Single<User> -> User) private fun extractGenericType(typeReference: KtTypeReference): String? { val text = typeReference.text val start = text.indexOf('<') val end = text.lastIndexOf('>') return if (start != -1 && end != -1 && start < end) { text.substring(start + 1, end).trim() } else null } // Форматирует предложение по миграции с учетом generic типа // "suspend fun" + "User" -> "suspend fun User" // "Flow<T>" + "Event" -> "Flow<Event>" private fun formatSuggestion(alternative: String, genericType: String?): String { return when { genericType != null && alternative.contains("T") -> alternative.replace("T", genericType) genericType != null && alternative.startsWith("suspend") -> "$alternative -> $genericType" else -> alternative } } }
Тесты:
Скрытый текст
package com.example.detekt_rules import io.gitlab.arturbosch.detekt.api.Config import io.gitlab.arturbosch.detekt.test.compileAndLint import io.kotest.matchers.collections.shouldHaveSize import io.kotest.matchers.string.shouldContain import org.junit.Test class RxJavaToCoroutinesMigrationRuleTest { @Test fun `Single возвращаемый тип - срабатывает`() { val code = """ import io.reactivex.Single class UserRepository { fun getUser(): Single<User> { return Single.just(User("123")) } } data class User(val id: String) """.trimIndent() val findings = RxJavaToCoroutinesMigrationRule(Config.empty) .compileAndLint(code) findings shouldHaveSize 1 findings.first().message shouldContain "suspend" } @Test fun `Observable возвращаемый тип - срабатывает`() { val code = """ import io.reactivex.Observable class EventRepository { fun observeEvents(): Observable<Event> { return Observable.empty() } } data class Event(val name: String) """.trimIndent() val findings = RxJavaToCoroutinesMigrationRule(Config.empty) .compileAndLint(code) findings shouldHaveSize 1 findings.first().message shouldContain "Flow" } @Test fun `Suspend функция - не срабатывает`() { val code = """ class UserRepository { suspend fun getUser(): User { return User("123") } } data class User(val id: String) """.trimIndent() val findings = RxJavaToCoroutinesMigrationRule(Config.empty) .compileAndLint(code) findings shouldHaveSize 0 } }
Регистрация правил
Скрытый текст
package com.example.detekt_rules import io.gitlab.arturbosch.detekt.api.* class CustomRuleSetProvider : RuleSetProvider { override val ruleSetId: String = "custom-rules" override fun instance(config: Config): RuleSet { return RuleSet( ruleSetId, listOf( NoAndroidInViewStateRule(config), ImmutableViewStateRule(config), RxJavaToCoroutinesMigrationRule(config) ) ) } }
Создайте файл detekt-rules/src/main/resources/META-INF/services/io.gitlab.arturbosch.detekt.api.RuleSetProvider, в котором содержится полное имя вашего RuleSetProvider:
com.example.detekt_rules.CustomRuleSetProvider
И конфигурацию config.yml в detekt-rules/src/main/resources/config/config.yml:
Скрытый текст
custom-rules: active: true NoAndroidInViewState: active: true ImmutableViewState: active: true RxJavaToCoroutinesMigration: active: true
Запуск: ./gradlew detekt
Практические советы
Отладка правил
Вывод в консоль:
Скрытый текст
// Lint override fun visitClass(context: JavaContext, declaration: UClass) { println("DEBUG: проверяем ${declaration.name}") } // Detekt override fun visitClass(klass: KtClass) { println("DEBUG: обрабатываем ${klass.name}") super.visitClass(klass) }
Запуск с логами: ./gradlew :app:lint --info или ./gradlew detekt --info
PSI Viewer для Detekt: установите плагин PSI Viewer в Android Studio для просмотра структуры PSI дерева. Помогает понять какие методы нужно переопределить в Rule.
Аннотации Suppress
Для временного отключения правил используйте соответствующие аннотации с обязательным комментарием о причине:
Скрытый текст
// Lint @SuppressLint("RepositoryPackageViolation") class LegacyUserRepositoryImpl { // TODO: переместить после рефакторинга (PROJ-123) } // Detekt @Suppress("NoAndroidInViewState") data class LegacyViewState( val context: Context // TODO: убрать после KMP миграции (PROJ-456) )
Важно: применяйте к минимальной области видимости (метод/класс, не файл).
Файлы Baseline
Baseline фиксирует текущее состояние кода, блокируя новые нарушения. Используйте для:
Добавления строгих правил в легаси проект
Пошагового решения найденных ошибок
Защиты от новых проблем прямо сейчас
Подключение:
Скрытый текст
// app/build.gradle.kts android { lint { baseline = file("lint-baseline.xml") } } detekt { baseline = file("detekt-baseline.xml") }
Создание:
Скрытый текст
# Lint ./gradlew lint --continue # Detekt ./gradlew detektBaseline
Периодически обновляйте baseline, удаляя исправленные проблемы.
Оптимизация производительности
Ранний выход:
override fun visitClass(context: JavaContext, declaration: UClass) { if (!declaration.name?.endsWith("Impl")!!) return // логика только для нужных классов }
Избегайте регулярных выражений:
// Медленно if (className.matches(Regex(".*Repository.*"))) { } // Быстро if (className.contains("Repository")) { }
Специфичные методы:
// Медленно - для каждого элемента override fun visitElement(element: UElement) { } // Быстро - только для классов override fun visitClass(declaration: UClass) { }
Профилируйте правила:
# Lint ./gradlew lint --profile # Detekt ./gradlew detekt --profile
Gradle создаст HTML-отчет с временем выполнения задач в build/reports/profile/.
Лучшие практики
1. Пишите тесты для всех правил
Тесты – обязательная часть любого правила. Минимальный набор:
Позитивный тест – правило находит проблему
Негативный тест – правило не срабатывает на правильном коде
Граничные случаи – пустые классы, специальные символы
2. Пишите понятные сообщения об ошибках
Сообщение должно отвечать на три вопроса: что не так, почему это проблема, как исправить.
Хорошо:
message = "Repository реализация `UserRepositoryImpl` должна быть в пакете " + "`*.data.repository` для соблюдения Clean Architecture. " + "Текущий пакет: `com.example.app.domain`"
Плохо:
message = "Wrong package"
3. Правильно выбирайте Severity
Для Android Lint:
ERROR – блокирует сборку. Используйте только для критичных проблем: гарантированные runtime ошибки, проблемы безопасности
WARNING – не блокирует сборку. Используйте для большинства пользовательских правил: нарушения архитектуры, отсутствие документации
INFORMATIONAL – подсказки для улучшения кода
Для Detekt:
Detekt использует категории проблем вместо уровней строгости:
Security – проблемы безопасности
Defect – ошибки, которые могут привести к неправильному поведению
Performance – проблемы производительности
CodeSmell / Maintainability – проблемы поддерживаемости кода
Style – нарушения стиля кода
Warning – неэффективный код, который не ломает работу
Minor – незначительные проблемы качества
Правило: для lint – если сомневаетесь, ставьте WARNING. Для detekt – выбирайте категорию, которая лучше всего описывает тип проблемы, которую ловит ваше правило.
4. Добавляйте QuickFix, где возможно (только Lint)
QuickFix экономит время команды. Добавляйте для:
Простых замен (пакеты, импорты)
Добавления кода (KDoc, аннотации)
Генерации стандартного кода
Избегайте для сложного перепроектирования нескольких файлов.
5. Балансируйте между качеством и удобством
Слишком строгие правила вредят продуктивности. Признаки проблемы:
Разработчики постоянно жалуются
Половина кода покрыта @Suppress
Проверка кода превращается в споры о правилах
Как избежать:
Определите 3-5 ключевых принципов
Обсуждайте изменения с командой
Убирайте то, что перестало работать
Слушайте обратную связь
Качество важнее количества – 5 полезных правил лучше 20 спорных.
Типичные проблемы
Проблема: правило не срабатывает
Проверьте:
Правило зарегистрировано в IssueRegistry/RuleSetProvider
Модуль подключен в app/build.gradle.kts через lintChecks() или detektPlugins()
Правило активно в конфигурации (для Detekt в detekt.yml / config.yml)
Severity не слишком низкий
Проблема: ложные срабатывания
Решения:
Уточните условия проверки – используйте точные совпадения вместо содержания
Добавьте исключения для тестовых/mock классов
Проверяйте контекст, не применяйте правила к тестовым пакетам
Проблема: медленная работа правила
Решения:
Профилируйте правило
Кешируйте результаты вычислений
Используйте более специфичные методы (visitClass вместо visitElement)
Когда НЕ нужны пользовательские правила
Не пишите правила если:
Можно настроить существующие правила
Правило применимо к паре файлов
Логику невозможно формализовать
Нет времени на поддержку
Команда не согласна
Используйте правила для решения реальных проблем команды.
Заключение
Пользовательские правила Lint и Detekt автоматизируют проверку архитектуры и соблюдение конвенций. Они экономят время на проверку кода и помогают новичкам быстрее влиться в проект.
Ключевые принципы:
Начинайте с малого – одно простое правило
Тестируйте все
Слушайте команду
Документируйте правила
Полезные ресурсы
Если хотите углубиться или разобраться с деталями API – ниже собрала ссылки на официальную документацию, примеры и инструменты, которые пригодятся при написании собственных правил.
Android Lint:
Detekt:
Помните: цель правил – помогать разработке, а не усложнять её.
