Зупини баги до ready for test: як забезпечити якість коду на Kotlin із detekt
Привіт, я — Вова, Team Lead Android Hily в українській продуктовій IT-компанії appflame. Упродовж останніх девʼяти років я змушую Android працювати, люблю інструменти автоматизації (адже автоматизація на відміну від мене не помиляється) та не уявляю сучасний проєкт без статичного аналізу коду.
За останнє десятиліття інструменти статичного аналізу пройшли шлях від перевірників форматування коду до справжніх помічників у забезпеченні його якості. Зараз завдяки цим інструментам можна виявити потенційні баги, проблеми з перформансом і місця, де код можна написати краще та зрозуміліше.
За час своєї роботи я впроваджував статичний аналіз у три проєкти — на кожному з них було від 100K до кілька мільйонів рядків коду. Наразі моїм фаворитом для роботи над код-аналізом є detekt — він має велику базу правил для перевірки коду, які також контролюють його складність. Його єдиний «конкурент» це ktlint, але на відміну від ktlint, detekt має багато доповнень для різних фреймворків, зокрема для Jetpack Compose.
Тож у цій статті я поділюся своїм досвідом роботи із detekt і розповім:
- як можна використовувати detekt для підтримки читабельності коду;
- як detekt допомагає уникати критичних помилок ще до фази тестування;
- як detekt контролює дотримання кастомних правил на проєкті;
- як він контролює дотримання кращих практик у Jetpack Compose;
- а також про те, як я поступово впроваджував статичний аналіз у застосунок Hily й що із цього вийшло.
Ця стаття буде цікавою Android-розробникам — особливо тим, хто працює з Kotlin і Jetpack Compose. А також технічним або тімлідам та іншим програмістам, котрі підтримують велику кодову базу або хочуть автоматизувати рутину.
Що таке detekt і як він допомагає проєкту на Kotlin
Detekt — це статичний аналізатор коду для Kotlin, котрий допомагає вам писати більш охайний код, щоби ви могли зосередитися на найголовнішому — створенні чудового програмного забезпечення. Розберемо від найпростіших до найскладніших задач із контролю якості коду, які detekt допомагає розвʼязувати щодня.
Базова гігієна проєкту
Це найбазовіші перевірки коду, які є в індустрії вже десятки років:
- форматування коду;
- обмеження на кількість рядків у файлі, кількість методів у класі чи інтерфейсі, обмеження кількості аргументів для методів;
- також контроль за складністю в примітивних операторах на кшталт if та when, де інструменти допомагають уникати надто складних умов.
На цьому прикладі добре видно, наскільки простіше стане код, якщо розбити умову з if на кілька змінних.
fun runBarFlowBad() { if ( user.role == UserDto.Role.ADMIN || (user.status == UserDto.Status.VIP || (user.status == UserDto.Status.TRIAL_VIP && user.paymentTransactionList.isNotEmpty()) && user.age.isAdult()) ) { // do something } else { // do something else } } fun runBarFlowGood() { val isAdmin = user.role == UserDto.Role.ADMIN val isUserHasUnCompletePayment = user.status == UserDto.Status.TRIAL_VIP && user.paymentTransactionList.isNotEmpty() val isUserHasPayments = user.status == UserDto.Status.VIP || isUserHasUnCompletePayment val isUserAdult = user.age.isAdult() val isNormalUserHasAccessToFeature = isUserAdult && isUserHasPayments if (isAdmin || isNormalUserHasAccessToFeature) ) { // do something } else { // do something else } }
Велика вкладеність коду (коли перший символ знаходиться на
У результаті ніякий код із гарними назвами змінних і методів не влізає і розробники починають скорочувати назви до ctx, res, a, b, tmp, що із часом може призвести до плутанини. detekt підсвічує такі ситуації й вказує на місця, де метод потрібно розбити на менші частини.
Ще одна корисна можливість detekt — він підвищує читабельність коду й підсвічує місця, де відбувається підміна context, через яку код стає нечитабельним. Гарний приклад — це використання блоків apply один в одному, що робить код незрозумілим.
Нижче мінімальний приклад, який це демонструє. Та в реальному проєкті все може бути ще гірше, адже метод належить класу, і також може бути extension-методом, через що this у такому коді може мати чотири різні типи.
// Try to figure out, what changed, without knowing the details fun makeConnection(first: User, second: User) { first.apply { second.apply { b = a c = b } } } fun makeConnection(first: User, second: User) { first.b = second.a second.c = first.b }
У цьому блоці я навів лише кілька прикладів для того, щоби показати спектр задач, котрі закриває detekt, але ви можете прочитати детальніше про всі базові задачі detekt тут.
Запобігання потенційним проблемам із корутинами
Корутини (coroutines) — механізм в Kotlin для простішого написання асинхронного коду. Вони, як і будь-яка інша технологія, самі по собі не гарантують якість коду. Їхній справжній потенціал розкривається за умов правильного використання та дотримання best practices. Це особливо важливо розуміти в командах, де є багато junior-розробників, котрі допускають типові помилки в коді. У таких випадках автоматичні перевірки сигнатур економлять час досвідчених колег на code review. Ось найпростіший приклад непродуманих сигнатур методу, яких краще уникати:
suspend fun CoroutineScope.badFoo() { launch { delay(1.seconds) } } fun CoroutineScope.goodFoo() { launch { delay(1.seconds) } } suspend fun badObserveSignals(): Flow<Unit> { val pollingInterval = getPollingInterval() // Done outside of the flow builder block. return flow { while (true) { delay(pollingInterval) emit(Unit) } } } fun goodObserveSignals(): Flow<Unit> { return flow { val pollingInterval = getPollingInterval() // Moved into the flow builder block. while (true) { delay(pollingInterval) emit(Unit) } } }
На перший погляд, у методах badFoo та badObserveSignals немає нічого поганого. Але не варто писати suspend-функції, які одночасно є extension-функціями для CoroutineScope. Так само не варто писати suspend-методи, які повертають Flow — це змушує користувача одночасно контролювати два scope: один для suspend-виклику і другий для Flow. У результаті код, який викликає таку функцію, стає складнішим для розуміння та підтримки.
Ви як розробники завжди маєте памʼятати: частиною API suspend-функції є контекст, у якому вона працює. Кожна suspend-функція запускається в певному CoroutineScope, і користувач вашого методу може в будь-який момент скасувати цей scope. Відповідно, ваша задача як автора функції — зробити так, щоб вона була прив’язана до одного scope. Це зробить використання вашого методу максимально комфортним для інших програмістів. До того ж detekt відловлює методи, які мають непотрібний модифікатор suspend або де suspend-виклик загорнутий у runCatching, що може приховати проблеми. Детальніше можна прочитати тут.
Запобігання багам та крешам
Ніхто не любить баги, особливо ті, які є механічними помилками — десь пропустили символ або ж поставили в неправильному місці. Ось приклад коду, який гарно демонструє, як один знак питання в неправильному місці призведе до крешу.
fun fooBad(a: Any?) { val x: String? = a as String? // If 'a' is not String, ClassCastException will be thrown. } fun fooGood(a: Any?) { val x: String? = a as? String }
Ці два методи виглядають майже однаково: обидва перетворюють Any? на String?. Але в першому випадку, якщо змінна A виявиться не типу String, оператор as викличе ClassCastException, що призведе до крешу.
Не треба бути великим програмістом, щоби побачити цю проблему в коді на 10 рядків, але при цьому дуже важко побачити потенційну проблему, коли цей рядок — один із тисячі рядків коду.
Але такий креш — не найцікавіший баг, який можна знайти ще до того, як віддати задачу в тестування. На зображенні нижче приклад, де звичайне перенесення рядка змінює логіку роботи коду. Завдання просте: знайти суму чотирьох чисел. Але результат буде відрізнятися залежно від того, чи натиснете ви Enter, чи додасте «зайві», на перший погляд, дужки.
Якщо хочете особисто відтворити цю поведінку, залишаю посилання на Kotlin Playground.
Як enter змінює роботу оператора +
Значна частина багів у коді пов’язана з некоректним станом даних. Найпростіший спосіб зменшити їхню кількість — це зменшити варіативність станів, у яких перебуває система. Наприклад, заборонити мутабельні поля в data class, або заборонити «подвійну мутабельність» (mutable поле всередині mutable обʼєкта). Якщо ж використовується мутабельна структура даних, то вона має бути обов’язково val. Для цього в detekt теж є правила.
У результаті ми бачимо, що detekt працює як перша лінія оборони та виявляє баги ще до етапу тестування. Як і в попередніх абзацах, я навмисно не зупиняюсь на всіх можливостях detekt у виявленні типових помилок. Натомість я залишив лише ті кейси, які, на мою думку, найкраще описують силу інструменту. Прочитати про всі можливості detekt із запобігання багів можна тут.
Далі ж розглянемо, як detekt допомагає зберігати найкращі практики в середовищах на кшталт Jetpack Compose.
Як detekt контролює дотримання best practices у Jetpack Compose
Сучасний проєкт визначається не лише мовою програмування, а і стеком технологій та фреймворками, які в ньому використовуються. Навіть якщо всі ці проєкти написані однією мовою. Наприклад, на Kotlin можна:
- створити Android-застосунок (Jetpack Compose + Android SDK);
- написати бекенд за допомогою фреймворку Ktor;
- реалізувати консольну утиліту або Gradle-плагін, як-от detekt;
- чи навіть розробити середовище розробки, як Android Studio (Java + Kotlin).
За замовчуванням detekt надає базовий набір універсальних правил, які підходять для будь-якого Kotlin-проєкту, незалежно від фреймворку. Та в реальних проєктах цього недостатньо. Щоб аналіз коду був ефективним, інструмент має враховувати фреймворк, архітектуру, тип проєкту та внутрішні стандарти команди. Тому різні спільноти програмістів дописали свої набори правил для detekt, щоби підтримувати найкращі практики для своїх фреймворків. Частину з них можна знайти в detekt marketplace, а ще більше — просторами GitHub.
Для Jetpack Compose є плагін Jetpack Compose Rules, він додає до detekt ще ~30 специфічних правил, які перевіряють, чи не допущено типових помилок і напряму впливають на якість вашого застосунку. Це особливо актуально, якщо ви або ваша команда тільки починає писати UI на Compose. Або ж якщо у вашій команді багато людей, які тільки починають свою кар’єру, і ще не встигли побачити, до чого призводять ті чи інші помилки. До типових помилок можна віднести:
- передачу об’єктів, які ви інжектите в @Composable-функцію вниз по UI-ієрархії;
- відсутність remember для mutableStateOf — це буде призводити до створення нового state при кожній рекомпозиції, що тягне за собою проблеми з перфомансом застосунку та баги в анімаціях.
Гарний приклад того, як detekt впливає на якість застосунку — це правило, що перевіряє правильну послідовність викликів методів у Modifier (вже не знаю, скільки разів воно врятувало ripple-анімацією).
Суть дуже проста — методи clip(), background(), clickable() потрібно викликати саме в такій послідовності, тому що ми маємо спочатку обрізати view, потім задати її background, і тільки потім обробляти кліки. Це дасть нам змогу мати гарну ripple-анімацію, яка не вилазить за заокруглені кути нашого компонента.
Цю помилку я особисто часто помічав за розробниками, які тільки переходять з XML на Compose. І це логічно, що на початку програмісти про це не думають, тому що в XML порядок викликів properties нічого не вирішує, а в Compose усе навпаки. Звісно, такі баги знайдуть на етапі тестування або дизайн рев’ю, але наскільки ж класно не допустити їх ще до того, як передати задачу в тестування.
Повний список правил для Jetpack Compose у detekt можна знайти тут.
Правила detekt для тих, хто пише бібліотеки
Ми вже згадували, що для якісного статичного аналізу важливо не тільки, якою мовою програмування написаний проєкт, а й на якому фреймворку. Та окрім особливостей фреймворків, під час аналізу також важливо враховувати тип проєкту.
Наприклад, якщо ви пишете бібліотеку, вам критично важливо забезпечити стабільність публічного API та уникати неочікуваних змін при рефакторингу. detekt враховує ці вимоги й для цього в нього є свій набір правил Library Rule Set.
У розробці бібліотеки дуже важливо не зламати її сумісність із попередніми версіями бібліотеки. Тому я додав правило в detekt — LibraryCodeMustSpecifyReturnType. Воно перевіряє, щоби всі публічні методи бібліотеки мали явний тип повернення. Це важливо, тому що це унеможливлює не явну зміну типу повернення, що зламає зворотну сумісність із попередніми версіями бібліотеки.
Якщо ви вважаєте, що зараз detekt не вистачає того чи іншого правила, ви можете додати його. Ком’юніті detekt надзвичайно відкрите до нових розробників, які хочуть покращити проєкт.
Мій pull request з правилом можна подивитися тут. А тут усі інші прийняті й не прийняті pull requests від розробників з усього світу.
Як додати detekt у проєкт
Використовувати detekt на проєкті можна двома способами: як
- Це робить наявність detekt в проєкті явним — залежність прописана у build.gradle, що робить його використання очевидним. І це означає, що вам не потрібно додатково встановлювати detekt CLI на будь-якому комп’ютері або сервері, адже все підтягується автоматично.
- Це дає можливість явно вказати додаткові залежності з правилами, як от Jetpack Compose Rules або Library Rule Set. У випадку з CLI вам доведеться додатково звантажувати .jar-файли цих розширень і передавати шлях до них в аргументи CLI. Це робить складнішим налаштування оточення кожного розробника й роботу з CI/CD.
- Вам не треба буде вирішувати окремо проблему кешування detekt CLI та додаткових .jar-файлів на CI/CD воркерах. Усі ці залежності стають частиною вашого Gradle cache.
Для запуску аналізу коду detekt gradle plugin дає наступні задачі:
- detekt — запускає перевірку всього коду;
- detekt<Variant> — цих задач буде стільки, скільки build type є на вашому проєкті.
Майте на увазі, що detekt<Variant> запускає компіляцію всього проєкту, оскільки деякі правила вимагають type resolution (детальніше про це тут). В detekt 2.0 це має бути виправлено, тож аналіз має стати швидшим.
У розробників також є ідея відмовитися від Gradle-плагіна на користь плагіна компілятора (compiler plugin), який краще інтегрується в процес компіляції. Стежити за цим проєктом можна тут.
Специфіка налаштування detekt для багатомодульного Android-проєкту
Підключення detekt до Android-проєкту з кількома модулями завдання не складне, але має свою специфіку. Особливо якщо у вас використовується build flavors або різні build type.
detekt Gradle-плагін автоматично будує свої jobs поверх ваших flavor та build type. Це зроблено через те, що в проєкті може бути різний код для різних flavor чи build type (наприклад у free-версії застосунку є реклама, а в paid — ні). Тому Gradle-плагін дає вам можливість перевірити всі варіанти кодової бази, а не лише один (наприклад, основний).
З мого досвіду, у більшості Android-проєктів такі відмінності між flavor чи build type мінімальні:
- build type зазвичай відрізняються лише ключами підпису;
- flavor — кількома значеннями в BuildConfig та suffix до версії застосунку.
То ж не дивлячись на те, що detekt-плагін додасть до вашого проєкту декілька jobs, я рекомендую вибрати один із варіантів вашої збірки та запускати й запускати статичний аналіз лише для неї.
Зазвичай я запускаю аналіз для основного варіанту збірки, тому що чистота коду — це насамперед потреба команди розробки. Користувачам байдуже, як написаний код, аби застосунок працював. А компілятор усе одно перевірить код на технічному рівні — тобто, якщо десь буде помилка, збірка просто не збереться.
У випадку мультимодульного проєкту, де є один app-модуль, а решта — це фіче-модулі (Android-бібліотеки), назви detekt-задач будуть відрізнятись:
- для app-модуля треба запускати job detektDevDebug;
- а для всіх інших — detektDebug.
Мій досвід впровадження detekt у застосунку Hily
У більш-менш великому проєкті не можна просто взяти та виправити всі помилки, котрі вам підсвітив detekt. Наприклад, коли я вперше включив detekt для Hily, він видав понад 30 тисяч зауважень. Але ж ми не могли просто зупинити наші поточні задачі й почати виправляти всі зауваження від detekt.
Правило, яким я керуюся при роботі з кодом — це правило скаута: «Залишай ліс чистішим, ніж він був до тебе» та інтерпретую його до «Залишай код кращим, ніж він був до твоїх змін». Це означає, що ми не намагаємося одразу виправити весь старий код. Натомість кожен розробник поступово покращує код, з яким працює.
Щоб це працювало на практиці, в detekt є інструмент baseline. Він дозволяє зафіксувати всі поточні помилки як відправну точку і з того моменту detekt буде показувати тільки нові або змінені порушення. Це допомагає поступово рухатися до чистішого коду.
Алгоритм по роботі із baseline такий:
- Додаємо detekt до проєкту як Gradle-плагін.
- Запускаємо задачу detektBaseline для кожного модуля (або на рівні всього проєкту). Цей job створює файли baseline.xml для кожного модуля із переліком усіх наявних порушень.
- При кожному запуску аналізу detekt порівнює знайдені порушення із цим baseline, та ігнорує порушення, якщо воно є в baseline.
Отже, ваш код стає чистішим із кожним днем. Також цей підхід гарантує, що насамперед будуть виправлені помилки в «живому коді», який змінюється кожного дня.
Завдяки поступовому впровадженню detekt і використанню baseline, ми в Hily зменшили кількість помилок від 30K до кількох тисяч за рік без необхідності зупиняти розробку.
Зараз наша команда дотримується узгоджених підходів до написання коду: сигнатури @Composable-функцій мають однакову структуру, у ViewModel немає публічних гетерів, методи залишаються лаконічними та зрозумілими. Немає великої вкладеності, ми дотримуємося єдиного стилю форматування, що помітно знижує кількість merge-конфліктів. Усі Composable-функції написані по best practices, що спрощує нам підтримку й розвиток UI-коду.
Інтеграції detekt в SDLC
Перевірка коду статичним аналізатором — це така ж важлива частина циклу розробки, як написання тестів чи зрозуміле найменування змін. Тому ми додали прогін усіх правил detekt на кожний merge request, що потрапляє в основну гілку develop.
Ми додали GitLab job, яка виконує команду gradle detektDebug для всіх модулів (крім app), та gradle detektDevDebug для модуля app. Це гарантує, що в develop буде тільки код, який пройшов перевірку.
Лайфхаки з використання detekt
detekt конфігурується через файл config.yml, який містить перелік усіх правил і дозволяє вмикати/вимикати правила статично або через regex. Наприклад, корисно вимкнути правило MagicNumber для regex за допомогою фільтра **/test/**.
Крім цього config.yml дозволяє налаштовувати параметри самих правил. Це можуть бути порогові значення для речей на кшталт:
- максимальна кількість рядків у методі;
- кількість аргументів у конструкторі;
- кількість методів в інтерфейсі тощо.
Тримайте файл config.yml у git-репозиторії вашого проєкту, адже це:
- зробить вас незалежним від стандартного config.yml, який може оновитися в будь-який момент без попередження;
- зробить вас незалежними від remote-файлу та додасть неявну залежність до вашого проєкту, яке підведе в найбільш незручний момент;
- зберігання файлу config.yml в git-репозиторії дозволить вам бачити історії змін конфігурації вашого лінтера. Детальніше про конфігурацію можна прочитати тут.
Деяким розробникам зручно запускати detekt локально перед тим, як створювати merge request. Для цього я у внутрішньому android-dev закріпив наступну команду:
./gradlew detektDebug -q —console=plain.
Вона запускає detektDebug для всіх модулів проєкту, не зупиняється при першій помилці й робить вивід у консоль лаконічним (виводить виключно логи від detekt). Це дає змогу за один запуск одразу побачити та виправити всі зауваження від detekt.
Висновки та поради
Автоматизація завжди допомагала програмістам писати надійніший код. Колись це була перевірка типів від компілятора, а із часом почали зʼявлятися нові інструменти із більшими перевагами, як от статичний аналіз коду, який підняв якість перевірок на новий рівень.
Звісно, жоден з інструментів не здатен на 100 % виявити всі можливі помилки або ризики в коді. Але саме комбінація перевірок — компілятора, статичного аналізатора, AI-асистентів, і, звісно, живого code review від інших розробників, створює максимально надійне середовище для розробки. Наприклад, зараз ми в Hily крім detekt використовуємо Code Rabbit — AI який аналізує код та підсвічує моменти, які розробник міг не врахувати.
Але такі інструменти як detekt не просто економлять час на code review — із цими інструментами один розробник може якісно перевірити кілька merge request за годину, тому що не потрібно витрачати час та концентрацію і на прості перевірки, і можна сконцентруватися на складніших концепціях, як от взаємозв’язок між класами чи модулями проєкту. І це якраз той випадок, коли машина бере на себе рутинну та повторювану роботу, а людина — складне й цікаве.
Сподіваюся, що вам було цікаво й корисно. Якщо ж є питання щодо статті або нашого проєкту — прошу в коментарі або ж в особисті в LinkedIn.
P.S. Трохи детальніше на цю тему спілкуємося у моєму подкасті нижче.
Немає коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів