Как мы ускоряли Android-сборку «селлера»

8038d4c337d29d11960a7420e3615005.jpg

Всем привет! Меня зовут Женя, я руководитель отдела разработки «Аккаунт» в мобильном приложении для продавцов платформы Ozon Seller. Поделюсь нашим опытом работы над улучшением скорости сборки Android-проекта.

Скорость сборки проекта напрямую влияет на time to market продукта и (внезапно) удовольствие от процесса разработки. Если каких-то 50 лет назад время компиляции могло доходить до нескольких дней, и это считалось нормальным, то сейчас даже лишняя пара минут сборки проекта в Android Studio может заставить понервничать. Чтобы сберечь здоровье себе и CI, скорости сборки проекта нужно уделять внимание. Нетерпеливые читатели могут сразу посмотреть итоговую таблицу с результатами в конце статьи.

Немного о проекте

Разработка проекта Ozon Seller ведётся с 2020 года. Приложение позволяет продавцам на Ozon работать с заказами и отправлениями, отзывами покупателей, смотреть аналитику по продажам, обрабатывать заявки на возвраты, запускать всевозможные акции, создавать и редактировать карточки товаров и многое другое.

Разделение приложения на модули делали ещё в 2022 году. На данный момент в проекте уже около 200 модулей и их количество продолжает расти. У нас Clean без существенных архитектурных срезов (три слоя, на каждый слой — своя модель), деление на модули по фичам. Приложение написано полностью на Compose. Разработкой приложения занимаются 4 кросс-функциональные команды — у нас они называются «стримы». Код активно меняется, каждый день открывается ~40 мердж-реквестов в Android-проекте, и CI постоянно нагружен.

Методика измерений

Чтобы понимать влияние оптимизации, её нужно уметь измерять (далее по тексту такое измерение будем называть бенчмарк). Мы выбрали самый топорный путь — смотреть на чистую холодную сборку:

  • чистая — перед каждым прогоном будем производить очистку проекта аналогично команде ./gradlew clean. Нужно для того, чтобы не задействовался локальный кеш сборки, мешающий оценке влияния оптимизации;

  • холодная — здесь подразумевается, что мы не будем использовать «прогретый» Gradle Daemon, который сам по себе вносит оптимизации в процесс сборки. Включён по умолчанию с версии Gradle 3.0.

Это не самый реалистичный вариант сборки — в повседневной разработке чаще всего происходит инкрементальная сборка проекта, оптимизированная за счёт компиляции на основе того кода, который непосредственно изменился. Тем не менее такая сборка периодически может происходить (например, при значительных изменениях в core-модулях или обновлении базовых зависимостей). Дополнительно можно было бы измерять и инкрементальную сборку, но наши эксперименты в основном носят глобальный характер и могут быть не очень показательными при таких измерениях. Ниже приведён подход, который использовали мы.

  • В основном все бенчмарки осуществлялись на debug-сборке — самый частый вид сборки на нашем CI. Это наиболее быстрый вариант для прогона бенчмарков, но можно смотреть на любые типы сборок — в относительных числах эффект будет примерно одинаковый.

  • В качестве инструмента для бенчмаркинга выбрали Gradle Profiler. Мы использовали несколько разных сценариев. Далее по тексту я буду прикладывать их к конкретным экспериментам, чтобы у вас была возможность их повторить.

  • Чтобы получать минимальный разброс в результатах, перед проведением прогонов нужно максимально разгрузить рабочую машину — закрыть все вкладки браузеров и другие мешающие процессы. Этого было достаточно — цифры получались стабильные. Измерения проводились в основном на MacBook Pro Apple M1 Pro 16gb, но также иногда и на CI, когда он был наименее нагружен.

  • Стоит отметить, что Gradle собирает проект всегда чуть-чуть по-разному. В том числе параллельность сборки — она непредсказуема. Поэтому мы допускали погрешность между прогонами около 1–2%.

Теперь посмотрим, какие изменения претерпевал наш проект.

Gradle Caches

Build Cache — кеш для хранения результатов сборки в local-/remote-репозитории. Работает по схожему с инкрементальной сборкой принципу: если входные данные для таски не изменились, то Gradle может переиспользовать выходные данные предыдущих сборок из кеша. Очень полезен, когда по каким-либо причинам отсутствуют локальные результаты сборки, та самая папка build в модуле. Такое бывает, например, при клонировании проекта с нуля или его полной очистке. У нас кеш настроен уже довольно давно.

Configuration Cache — кеш для результатов этапа конфигурации, стал стабильным в Gradle 8.1. Есть нюанс — не все плагины его ещё поддерживают, актуальный список находится здесь. Мы включили его совсем недавно, и можно сказать, что процесс прошёл хорошо — хотя один из наших самописных плагинов пока его не поддерживает. Для нас это не является критичным — плагин вспомогательный и используется только в ручном режиме. В качестве временного решения мы запускаем плагин с аргументом --no-configuration-cache.

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

Файл сценариев Gradle Profiler
default-scenarios = ["no_caches"] // Здесь нужно перечислить все необходимые для запуска сценарии 

no_caches {
  title = "Кеши отключены"
  tasks = [":app:assembleDebug"]
  daemon = none
  gradle-args = ["--no-build-cache", "--no-configuration-cache"]
  cleanup-tasks = ["clean"]
  warm-ups = 1
  iterations = 5
}

build_cache {
  title = "С включённым Build Cache"
  tasks = [":app:assembleDebug"]
  daemon = none
  gradle-args = ["--build-cache", "--no-configuration-cache"]
  cleanup-tasks = ["clean"]
  warm-ups = 1
  iterations = 5
}

configuration_cache {
  title = "С включённым Configuration Cache"
  tasks = [":app:assembleDebug"]
  daemon = none
  gradle-args = ["--no-build-cache", "--configuration-cache"]
  cleanup-tasks = ["clean"]
  warm-ups = 1
  iterations = 5
}

all_caches {
  title = "Оба кеша включены"
  tasks = [":app:assembleDebug"]
  daemon = none
  gradle-args = ["--build-cache", "--configuration-cache"]
  cleanup-tasks = ["clean"]
  warm-ups = 1
  iterations = 5
}

non_abi_change {
  title = "Abi несовместимое изменение в core модуле"
  tasks = [":app:assembleDebug"]
  daemon = none
  apply-non-abi-change-to = "core/ui/src/main/kotlin/ru/ozon/seller/core/ui/widget/oziScaffold/OziScaffoldBase.kt"
  gradle-args = ["--build-cache", "--configuration-cache"]
  cleanup-tasks = ["clean"]
  warm-ups = 1
  iterations = 5
}

single_module_clean_no_cache {
  title = "Чистая сборка impl модуля без кешей"
  tasks = [":edit-create:impl:assembleDebug"]
  daemon = none
  gradle-args = ["--no-build-cache", "--no-configuration-cache"]
  cleanup-tasks = ["clean"]
  warm-ups = 1
  iterations = 5
}

single_module_clean {
  title = "Чистая сборка impl модуля с кешами"
  tasks = [":edit-create:impl:assembleDebug"]
  daemon = none
  gradle-args = ["--build-cache", "--configuration-cache"]
  cleanup-tasks = ["clean"]
  warm-ups = 1
  iterations = 5
}

single_module_abi {
  title = "Изменение одного файла в impl модуле"
  tasks = [":edit-create:impl:assembleDebug"]
  daemon = none
  apply-non-abi-change-to = "products-stream/edit-create/impl/src/main/kotlin/ru/ozon/seller/products/editCreate/presentation/media/MediaViewModel.kt"
  gradle-args = ["--build-cache", "--configuration-cache"]
  cleanup-tasks = ["clean"]
  warm-ups = 1
  iterations = 5
}
Влияние кешей на сборку всего проекта
Влияние кешей на сборку всего проекта
Влияние кешей на сборку одного модуля
Влияние кешей на сборку одного модуля

Результаты: можно заметить, что наибольший эффект даёт Build Cache — до 80% улучшения на чистых сборках и ~50% на инкрементальных (всё зависит от количества внесённых изменений в код). Включённый Configuration Cache экономит около 10% и практически убирает затраты на выполнение фазы конфигурации. А работающие в паре кеши ускоряют вашу сборку на внушительные 85–90%. Согласитесь, неплохо.

Все последующие эксперименты проводились без этих кешей, чтобы полноценно оценить влияние конкретного эксперимента на сборку.

Рефакторинг тяжёлых модулей

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

./gradlew --profile --no-configuration-cache, --no-build-cache, --no-daemon app:assemble

А для получения более наглядного результата в виде таймлайна можно использовать инструмент Build Scan (платный для приватного использования) или использовать плагин.

Можно увидеть, какие модули дольше всего собираются
Можно увидеть, какие модули дольше всего собираются

Главная задача — выявить наиболее долгие в сборке модули. Далее такие модули надо разбивать. Вы спросите — зачем? Gradle пытается максимально параллельно выполнять задачи, и, пока такой тяжёлый модуль компилируется, может возникать простой — те самые белые пропуски на таймлайне между выполнением задач. Если модуль разбить, то его кусочки могут распараллелиться более эффективно и общее время сборки сократится.

Примечание: большинство таких модулей — это, как правило, закрытые impl-реализации фич. Но при анализе стоит обращать внимание не только на самые медленные модули, но и на те, от которых большинство остальных модулей зависит. Как правило, это core-/common-модули. У нас таким модулем является core: ui (выделен на таймлайне в самом начале). Оптимизация таких модулей практически сразу скажется положительно на чистом времени сборки.

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

Важная заметка: не стоит воспринимать многомодульность как серебряную пулю в вопросе ускорения сборки. Больше модулей — быстрее соберём — нет, так это не работает. Всё зависит от конкретного проекта и выделяемых на сборку ресурсов на том же CI. Если существуют модули, размер которых относительно других велик, то и вся сборка проекта будет расти — в моментах это будет просто сборка самых долгих модулей. Изучите свой проект с точки зрения таймлайна выполнения тасок, и картина сложится.

Ретроспектива api-модулей

При модуляризации мы используем уже ставший классическим api-/impl-подход. Важным здесь является то, что api-модули — это точки устойчивости системы, т. е. от них могут зависеть многие другие impl-модули, и при сборке проекта зависимые модули начнут компилироваться только после api-модулей. Они становятся бутылочными горлышками билда, поэтому их сборка должна быть максимально быстрой.

8d780bee9f0dc48c517ee70a15a7dd81.pngzbtuc6tiyuacfheoh0-1ow8qaem.png

Была гипотеза: если сделать такие модули как pure kotlin и убрать нагрузку android-плагина com.android.library с лишними тасками, то такие модули начнут собираться быстрее. В этом направлении мы и стали двигаться.

1sivkv3dljmqqqvbbtlozg1r338.png

В нашем проекте одним из наиболее частых кейсов использования api-модулей стал шаринг навигации и бизнес-логики. Если со вторым пунктом проблем почти не возникло (domain-слой у нас уже построен на простых типах), то с навигацией всё оказалось немного сложнее. Дело в том, что навигация реализовывалась с помощью Android-библиотеки, и нельзя просто так взять и прописать kotlin("jvm") плагин в build-файле вашего модуля, если у этого модуля есть хотя бы одна зависимость с android-плагином.

blbcgc-rm85kxuzo5imcxvkl8ly.png

Поскольку библиотека наша, мы просто распилили её на две, выделив платформонезависимую часть в pure kotlin-модуль. После этого сделали рефакторинг на использование новой библиотеки в нашем приложении, причём получилось сделать аккуратно, сохранив импорты и обойдясь в итоге парой строчек в диффах на МР«е. Во избежание таких ситуаций в будущем мы написали detekt-правило, подсвечивающее api-модули, использующие Android-зависимости.

6hbubzcbp-vdhu5z8lafinca4bg.png

Результаты: в первом подходе без существенного рефакторинга, а только за счёт использования новой библиотеки удалось перевести 34 модуля. При бенчмаркинге также решили посмотреть релизную сборку — нам было интересно видеть импакт на все возможные таски билда и их количество (в релизной сборке их намного больше, чем в дебажной). На prod-сборке на CI мы получили улучшение ~5% в скорости, при этом количество gradle-тасок сократилось с 5782 до 4690. Локальные бенчмарки показали результат чуть меньший — вероятно, из-за разброса значений и нестабильности в используемых ресурсах. Поэтому решили провести ещё один, более атомарный эксперимент. Мы взяли пару модулей, которые подверглись избавлению от android-плагина, и запустили бенчмарки только для них. Разброса значений уже практически не было. Сборка таких модулей показала улучшение на 9–14%. Поэтому, сославшись на отсутствие полноценной калибровки локальной рабочей машины, приняли полученные результаты как положительные, хотя и не очень высокие.

Сборка на примере двух случайным образом выбранных api модулей
Сборка на примере двух случайным образом выбранных api модулей

По результатам такой ретроспективы у нас осталось ещё около 40 модулей, которые потенциально можно перевести по такому же принципу, но уже с более существенными трудозатратами на изменения. При полном рефакторинге можно рассчитывать на улучшение в 10–12% к общему времени сборки, а это уже неплохой результат. Завели задачи и аккуратно расположили на роадмапе нашего техразвития.

Переход с kapt на KSP

Если верить официальным заявлениям, то KSP работает до двух раз быстрее, чем kapt. У нас было всего три зависимости, использовавших кодогенерацию kapt: Moshi, Dagger и Room. Переводить модули можно постепенно, но если в модуле будет и kapt, и KSP, то профит по производительности сборки сводится к нулю, а может и вовсе ухудшиться. А угрозы сообщения из логов сборки ещё больше мотивировали осуществить переход:

lsf3x7ego95d6c9wj07blhjphg4.pngiayg9uclbjtwoesvowjehuwkine.png

Поэтому было принято решение переводить на KSP сразу весь проект. Конечно, здесь были риски, ведь поддержка KSP для Dagger ещё в альфе, но соблазн был велик. На удивление, почти весь процесс перехода прошел легко, и в основном всё свелось к чисто механической замене вхождений «kapt» на «ksp». Пришлось немного иначе прокинуть аргументы для компилятора в части Room-миграций, но всё это подробно описано в официальной документации. При первой сборке были ошибки, но обновление Dagger до 2.55 решило проблемы.

Кстати, помимо багфиксов с KSP, в промежуточной версии Dagger 2.49 обещали некоторые оптимизации производительности. Возможно, что какое-то влияние оказал и релиз, но мы об этом уже никогда не узнаем. Доверившись всем имеющимся тестам и многочисленному прогону сборок всех сортов, а также регрессу всего приложения, решили катиться в прод.

Результаты: рефакторинг затронул около 100 модулей, больше половины всех имеющихся в проекте, а улучшение скорости сборки составило ещё ~15%. Честно говоря, ожидали больше.

Удаление неиспользуемых зависимостей

Для настройки содержимого модулей у нас есть convention-плагины на любой вкус и цвет. Казалось, хаос с зависимостями побеждён. Но так уж исторически сложилось, что появились они у нас не сразу. Ради интереса решили попробовать почистить весь проект от фактически неиспользуемых зависимостей. В теории это должно немного улучшать фазы dependency resolution и configuration. Для этого был использован Dependency Analysis Gradle Plugin. Довольно занятный инструмент, который может подсветить те зависимости, которые используются транзитивно, хотя могли бы быть объявлены напрямую. Последнее становится особенно важно в тех случаях, когда какой-нибудь модуль подключает core-модуль, например, из-за одной лишь java-аннотации.

Результаты: получился довольно скромный результат — меньше 10 секунд чистого времени. При этом было удалено свыше 400 неиспользуемых зависимостей. Изначально идея казалась более оптимистичной, и мы даже думали использовать плагин на постоянной основе. Но его фича автоматических исправлений нам не подошла — проект просто ломался. А модули, которые подсвечивались как возможные к удалению, не всегда оказывались таковыми. Если доработать плагин под конкретный проект, то будет полезнее, но мы пока от такой идеи отказались.

Может быть, что-то ещё?

Напоследок предлагаю посмотреть на небольшой список рекомендаций, которые в статье рассмотрены не были.

  • Использование Non-Transitive R Classes — ускоряет чистую сборку на 10–15%, довольно подробно описано в статье. Включён по умолчанию в AGP 8.0.

  • Набор общих рекомендаций от команды Android для начального погружения в тему. К слову, эксперименты с Parallel GC ничего положительного нам не дали.

  • Некоторые параметры gradle.properties из ранее рекомендуемых в статьях уже включены по дефолту в последних версиях Gradle. Поэтому крайне рекомендуется следить за обновлениями и стараться использовать последние версии AGP + Gradle.

Итоги

Вся работа по улучшению сборок, за исключением Build Cache, выполнялась в рамках техдолга и растянулась во времени примерно на месяц. Если говорить о результатах, то в относительных значениях мы получили ускорение на всех типах сборок ~19% относительно первоначального варианта. При этом фичи разрабатываются постоянно и число gradle-модулей увеличилось на 7. Так выглядит финальное сравнение результатов debug-сборки в Android Studio:

8dz1wdyisxcikmizjplppfxwbdm.png

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

_cqqcahvf2cgkjvrcau-m5afcxs.png

Результат проделанной работы можно назвать положительным, хотя и не очень высоким, потому как основной профит последних изменений был получен от нового кодгена KSP. Но благодаря исследованиям, были пересмотрены архитектурные составляющие относительно api-модулей и заложен бэклог задач на рефакторинг перегруженных модулей, что определённо даст свои плоды в будущем. Особенно учитывая активный рост проекта.

© Habrahabr.ru