Привет! Меня зовут Даниил, я Android-разработчик в команде VK ID SDK в VK. Наша команда создала легковесный SDK для авторизации через приложения экосистемы VK. Он состоит из кнопки One Tap для входа в один клик, кнопки входа в другую учётную запись и виджета для авторизации через Mail или Одноклассники.

Шторка авторизации VK ID Android SDK
Шторка авторизации VK ID Android SDK

Работая над продуктом, мы поняли, что необходимо оценивать его техническое качество: считать размер SDK, тестового покрытия, скорость сборки и многое другое. Нам был нужен сборщик метрик качества кода. 

Я расскажу, как мы писали плагин для сбора метрик и с какими проблемами столкнулись. Вы узнаете, как устроен наш сборщик изнутри, и даже сможете потестить его в своём проекте. 

А теперь обо всём подробнее. 

Начало

Первым делом мы взялись за написание плагина для сбора метрик. Он должен был высчитывать параметры качества кода и публиковать результаты в мерж-реквестах. 

Сделаю небольшое отступление и напомню про важное. Отслеживать качество кода нужно регулярно. Если этого не делать или делать редко, в случае ошибки придётся тратить время на поиски места, где был код, ухудшающий метрики.

В каждом мерж-реквесте публиковался вот такой отчёт:

Отчет плагина
Отчет плагина

В отчёте отображались основные технические метрики, за которыми мы регулярно следили. Если значения заметно отклонялись от нормы, нужно было проанализировать код на нежелательные изменения. Это решение мы оформили как gradle-плагин. 

Выбор решения

Скорость билда удобно измеряется на стороне системы Gradle, потому мы решили ничего не изобретать и остановились на gradle-тасках. Для их вызова можно использовать несколько подходов:

  1. Раннер из командной строки (например, на Kotlin), который запускает gradle-таски метрик.

  2. Gradle-плагин, который напрямую запускает таски.

  3. Gradle-плагин, который запускает таски через exec.

Первый вариант требует написать код, используя разные библиотеки, а вероятно, и языки. Кроме того, код раннера и метрик будет разделён. Звучит совсем непросто. И нельзя не учитывать третий момент: Android-разработчикам всё же привычнее устанавливать gradle-плагины и работать с ними напрямую. 

От второго варианта пришлось отказаться из-за возможного конфликта метрик. Например, метрику скорости билда нужно запускать изолированно. В противном случае она будет считать не только выполнение gradle-таска, а ещё и время запуска остальных метрик. 

В итоге пришли к решению под номером три. 

Архитектура решения

Архитектура плагина
Архитектура плагина

С видом gradle-плагина определились. Для его настройки у нас было рекомендованное решение — Extensions. Осталось разработать общий подход к написанию метрик. 

Начнём с того, что у метрик был интерфейс, который запускал сбор данных и получал текстовый дифф для публикации в мерж-реквестах. Внутри метрика делала необходимые вычисления: запускала gradle-таск для сборки и вычисления размера АРК, сохраняла результаты в репозиторий и оформляла их в Markdown (он публиковался плагином в комментарии к МР). 

Для сохранения метрик использовался Firebase Firestore, а для публикации — GitLab API.

Далее поговорим подробнее, как это устроено. Начнём с деталей в правой части схемы и будем двигаться к началу, чтобы собрать полную картину.

Хранилище метрик

Хранилище метрик должно было решать две проблемы. 

Первая: в каждом МР нужно было посчитать дифф метрики target-ветки (например, с девелопа) и ветки МР. Поэтому для каждого МР сохранялись метрики с последнего коммита в хранилище.

Вторая: каждая метрика считалась отдельным запуском Gradle. Нужно было передать результат в запуск gradle-плагина, который публикует дифф. 

Если упростить, то для запуска метрик использовалась команда ./gradlew publishAllMetrics, которая под капотом запускала ./gradlew publishSpecificMetric. Подробнее о причинах этого рассказывали в разделе «Выбор решения». 

Каждая метрика запускалась изолированно. Чтобы передавать данные между процессами плагина и метрики, нужно было их временно где-то сохранять.

В итоге перед нами встали две задачи:

  1. Сохранить дифф текущего шага, чтобы получить его в плагине.

  2. Сохранить метрики с предыдущего шага, чтобы посчитать дифф.

Обычно для этого подходит любое внешнее хранилище, которое сохраняет состояние при разных запусках CI. Мы выбрали Firebase Firestore, поскольку бесплатного плана вполне достаточно для всех нужд. К тому же, если решение станет опенсорсным, его сможет использовать каждый.

FIrestore — облачная NoSQL-БД от Google с бесплатным планом, которого вполне хватает для большинства проектов.

Первая задача решалась так: каждая метрика сохраняла результат своей работы текстом в хранилище. После чего главный таск плагина объединяла все эти строки в одну и публиковала комментарием в МР.

Вторую решили подсчётом метрики в каждом МР и сохранением результатов в хранилище. 

Подсчёт диффа метрик

В каждом МР считался дифф между метриками. Для этого брались значения метрик для двух коммитов. В качестве метрики МР, очевидно, стоило взять последний коммит этого МР. А вот с коммитом source-ветки всё было не так просто. 

Есть два варианта:

  1. Взять последний коммит на source-ветке (обычно девелоп).

  2. Взять первого предка source- и target-веток.

Если взять последний коммит с source-ветки, может случиться так, что в неё уже вносили изменения в других МР и это не учлось при измерении. Например, в коммите метрика немного улучшилась, а после этого в девелоп влился МР, который значительно её ухудшил. Разработчик будет искать проблему, потому что последний коммит дал негативные данные. 

Покрутив это в голове, мы выбрали второй вариант:

Логика подсчета диффа метрик
Логика подсчета диффа метрик

Коммит находили комбинацией merge-base и rev-list:

val mergeBase = exec("git merge-base $sourceBranch $targetBranch")

return exec("git rev-list --no-merges -n 1 $mergeBase")

Этот коммит и был последним одного из предыдущих МР, для которого уже посчитали метрики.

Работа с репозиторием

Для работы с репозиторием (в нашем случае это GitLab) мы использовали GitLab API. Перед нами стояли две задачи:

  1. Получить ветки МР, чтобы понять, для каких коммитов брать и сохранять метрики.

  2. Опубликовать комментарии к МР с результатами сбора метрик.

Для решения первой задачи достаточно было запросить из GitLab API информацию о МР. В ней содержались названия target-ветки и самой ветки МР. По ним можно было получить последний коммит на МР и первый коммит перед МР (через git merge-base). С помощью корневого коммита из хранилища мы получили значения метрик, с которыми сравнивали метрики с коммита МР. A последний коммит МР использовали для сохранения метрик.

Со второй задачей всё было просто: для работы с комментариями есть API. Чтобы не плодить комментарии к МР с разных запусков пайплайнов на CI, решили опубликовать один комментарий и изменять его при последующих запусках. Таким образом, в МР всегда будет актуальная информация, а если нужно быстро посмотреть результаты запуска на других коммитах, то эти данные остаются в Firebase.

Внутреннее устройство метрики

Разберём, как метрика устроена изнутри, её логику и составные части. 

Внутреннее устройство метрики
Внутреннее устройство метрики

Вычисление метрики проходит внутри одной функции exec. Обычно процесс состоит из четырёх частей:

  1. Само вычисление метрики, то есть подсчёт значений метрики для текущего коммита.

  2. Получение метрики, с которой подсчитывается дифф из хранилища.

  3. Вычисление диффа, которое состоит из подсчёта разницы значений метрики, определения процента изменения и формирования Markdown с результатом.

  4. Сохранение диффа в хранилище для передачи в процесс плагина.

Метрика размера SDK

Все метрики сделаны по одному сценарию. Каждая работает в рамках одного gradle-таска и сохраняет дифф в хранилище. На схеме показана логика работы метрики, которая считает размер SDK:

Логика работы метрики размера SDK
Логика работы метрики размера SDK

По шагам:

  1. Запускается плагин сбора метрик.

  2. Gradle-плагин через Task.exec запускает gradle-таск вычисления метрики.

  3. Для запуска apkanalyzer используется Runtime.exec.

  4. С помощью apkanalyzer вычисляется размер APK вместе с нашим SDK.

  5. С помощью apkanalyzer подсчитывается размер APK без нашего SDK.

  6. Вычисляется разница между двумя размерами SDK, это и есть размер нашего SDK.

  7. Дифф и метрики сохраняются в хранилище.

После этого плагин берёт дифф и публикует его комментарием в МР. Эта метрика также позволяет подсчитать размер обычного APK. Для этого просто нужно пропустить шаг 5 и публиковать размер APK с шага 4. Все остальные метрики работают аналогично, в рамках одного gradle-таска.

Gradle-плагин

Логика работы gradle плагина
Логика работы gradle плагина

Gradle-плагин состоит из двух сущностей: вычисления метрик и их публикации. Вычисление метрик запускается поочерёдно, а публикация выходит в виде комментария в МР. 

Интерфейс gradle-плагина

Интерфейс gradle-плагина очень простой, основная часть — это конфигурация метрик, которая выглядит вот так:

// build.gradle.kts
healthMetrics {
   val localProperties by lazy {
       Properties().apply { load(rootProject.file("local.properties").inputStream()) }
   }
   gitlab(
       host = { localProperties.getProperty("healthmetrics.gitlab.host") },
       token = { localProperties.getProperty("healthmetrics.gitlab.token") }
   )
   firestore(rootProject.file("build-logic/metrics/service-credentials.json"))
   codeCoverage {
       title = "Code coverage"
       targetProject = rootProject
   }
   buildSpeed {
       title = "Build speed of :assembleDebug"
       measuredTaskPaths = setOf(":assembleDebug")
       iterations = 3
       warmUps = 2
       cleanAfterEachBuild = true
   }
   apkSize {
       title = "SDK size with all dependencies"
       targetProject = projects.sampleMetricsApp.dependencyProject
       targetBuildType = "withSdk"
       sourceBuildType = "debug"
       apkAnalyzerPath = { localProperties.getProperty("healthmetrics.apksize.apkanalyzerpath") }
   }
   apkSize {
       title = "Pure SDK size"
       targetProject = projects.sampleMetricsApp.dependencyProject
       targetBuildType = "withSdk"
       sourceBuildType = "withDeps"
       apkAnalyzerPath = { localProperties.getProperty("healthmetrics.apksize.apkanalyzerpath") }
   }
   publicApiChanges()
}

В конфигурации описываются метрики, настраиваются GitLab и Firestore.

Тестирование плагина

Сейчас плагин находится в ранней альфа-версии. Он обкатывается в рамках VK ID SDK, потихонечку начинается тестирование в других командах. Плагин пока рассчитан на внутреннее пользование, поэтому у него нет поддержки GitHub в качестве репозитория. Если вы хотите попробовать его у себя в проекте, можете подключить из нашего Artifactory:

// settings.gradle.kts
pluginManagement {
   repositories {
       ...
       maven(url = "<https://artifactory-external.vkpartner.ru/artifactory/vkid-sdk-android/>")
   }
}

// build.gradle.kts
plugins {
   id("vkid.health.metrics") version "1.0.0-alpha03" apply true
}

Код плагина доступен в репозитории Android VK ID SDK. Там же можно узнать, как его настроить. 

Заключение

Спасибо, что дочитали статью. Надеюсь, вы узнали что-то новое, а может, и вдохновились написать свой собственный плагин для сборщика метрик. 

Мы рады любому фидбэку. Задавайте вопросы, оставляйте пожелания и комментарии под статьёй. Также вы можете поделиться мнением о нашем плагине в issues в репозитории.

Комментарии (2)


  1. ChPr
    03.10.2024 09:56

    1. Насколько стабильны build speed report метрики? Учитывая что это все билдится в контейнерах/виртуалках с общим железом, которые могут влиять на перформанс друг друга. Прыгает ~10% каждый билд?

    2. Если да, то имеет ли вообще смысл эта метрика? Если каждый билд она будет так флаковать, то на нее просто перестанут обращать внимание.

    3. Я вижу вы вызываете :clean перед сборкой. Чтобы мерить чистую сборку с нуля? А кеши Gradle тогда тоже отключаете? Иначе из них опять подтянется.


    1. diklimchuk Автор
      03.10.2024 09:56
      +1

      Спасибо за комментарий! Отвечу на все по порядку:

      1. У нас разброс небольшой около 3%, если не было изменений. Это достигается за счет того, что в плагине можно настраивать количество итераций билда и прогрев сборки

      2. Конечно прям совсем точно померять не получится, но самую главную функцию - отслеживать крупные проблемы со скоростью сборки она выполняет

      3. Сейчас :clean опциональный, это нужно, чтобы "честно" померять время сборки. Может быть кейс отслеживание времени инкрементальной сборки, плагин это тоже поддерживает. Кеши сейчас отключены, в планах сделать это настраиваемым