Всем привет! Меня зовут Никита Горбунов, я технический лидер Android. Сейчас я работаю над мобильным банком Альфы, поддерживаю его инфраструктуру и CI/CD-систему. Я много работаю с Gradle, и мне это нравится. 

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

На чём я тестировал скорость сборки

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

Все тесты я проводил на своём синтетическом проекте, состоящем из четырёх библиотечных модулей и одного application-модуля. Проект использует последние версии Gradle 8.12.1, Android Gradle Plugin 8.8.0, Kotlin 2.1.10 c K2 компилятором, JDK Correto 21. Тестировал на MacBook с процессором M1 Max и 32 GB оперативной памяти. Мой проект можно скачать на GitHub.

Для замеров я использую утилиты Gradle Build Scan, Gradle Profiler и приложение Visual VM с плагином Visual GC. Вместе эти инструменты способны рассказать многое о вашей сборке. Если ещё не работали с ними, обязательно попробуйте.

Вспомним этапы сборки

До того как мы посмотрим на первые результаты, напомню вам основы Gradle, без которых понять материал дальше будет трудно. И сперва освежим знания о жизненном цикле сборки.

Каждая сборка в Gradle проходит три этапа: инициализация, конфигурация, исполнение.

Инициализация. На первым этапе Gradle анализирует settings-файл проекта и всех включенных в сборку подпроектов и запускает скрипты инициализации при их наличии.  Инициализация занимает около секунды, поэтому обычно её не берут во внимание, если необходимо уменьшить время сборки. Если этап занимает больше пары секунд, это повод для беспокойства: возможно, что у вашего проекта неоптимальная конфигурация в settings-файле.

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

Финальный этап сборки — выполнение ранее сконфигурированных задач.

А теперь «разгоняем» сборку

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

Для начала я оцениваю фазу выполнения. Посмотрим на первый пример: эталонный результат — чистая сборка. 

Все примеры я запускал с помощью сценариев Gradle Profiler. Время выполнения каждого этапа брал из Gradle Build Scan. Для каждого случая я приведу время выполнения и код сценария.

Этапы

Чистая сборка

Инициализация

1.484

Конфигурация

6.602

Выполнение

14.015

Всего

22.190

Сценарий clean_assemble
clean_assemble {
    tasks = [":app:assembleDebug"]
    gradle-args = ["--no-build-cache", "--no-configuration-cache"]

    run-using = cli
    daemon = none

    cleanup-tasks = ["clean"]

    warm-ups = 2
    iterations = 3
}

Далее сравним время эталонной сборки с другими примерами для лучшего понимания способов ускорения Gradle. 

Обращу ваше внимание на параметры сценария. Мы запускаем задачу компиляции приложения, предварительно очищая данные ранее запущенных сборок. Не используем Gradle Daemon. Для прогрева кэшей запускаем две прогревочные сборки, далее три итерации сборки для получения среднего результата. А теперь попробуем уменьшить время нашей сборки.

Ускоряемся с инкрементальной сборкой 

Самый простой вариант — сделать сборку инкрементальной. 

Инкрементальная сборка — это сборка, в которой мы избегаем ранее выполненной работы
Инкрементальная сборка — это сборка, в которой мы избегаем ранее выполненной работы

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

  • у задачи должны быть входные параметры: input,

  • у задачи должны быть выходные параметры: output. 

Если на вход задаче подаются те же самые входные данные, Gradle может взять выходные данные предыдущего запуска. На примере задачи компиляции за входные данные мы можем взять версию JDK и исходный код приложения. В качестве результата регистрируем получившиеся при компиляции class-файлы. По умолчанию большинство Gradle-задач уже поддерживает инкрементальную сборку, поэтому никакие действия для её активации не нужны.

Чтобы превратить чистую сборку в инкрементальную, нам достаточно просто не очищать проект перед повторным запуском сборки. Все задачи, выполнение которых пропущено, помечаются статусом UP-TO-DATE. Так Gradle практически полностью пропускает этап выполнения сборки. 

Этапы

Чистая сборка

Инкрементальная сборка

Инициализация

1.484

1.469

Конфигурация

6.602

6.535

Выполнение

14.015

0.463

Всего

22.190

8.543

Сценарий incremental_assemble
incremental_assemble {
    tasks = [":app:assembleDebug"]
    gradle-args = ["--no-build-cache", "--no-configuration-cache"]

    run-using = cli
    daemon = none

    cleanup-tasks = []

    warm-ups = 2
    iterations = 3
}

Позже мы вернёмся к инкрементальным сборкам, а сейчас подсвечу один существенный недостаток — такая сборка мало репрезентативна и слабо помогает в исследовании ускорения чистой сборки, но её можно использовать как ещё один эталон. Для ускорения чистых сборок нам нужен Gradle Build Cache.

Builde Cache и что с ним делать разработчику

Gradle Build Cache — это локальный или удалённый репозиторий, из которого по хэшу входных параметров Gradle загружает результаты выполнения задач. 

С Build Cache мы можем кэшировать результаты выполнения инкрементальных задач в локальном и/или удалённом репозиториях
С Build Cache мы можем кэшировать результаты выполнения инкрементальных задач в локальном и/или удалённом репозиториях

Принцип работы тот же, что у инкрементальных сборок. Единственное отличие в том, что результаты выполнения сохраняются не только локально в папке проекта, но и кэшируются в домашней директории Gradle или на удалённом сервере. 

Этот механизм полезен при сборках приложения с нуля, например, при сборке приложения в Docker-контейнерах на CI-агентах или при переключении на главную ветку на локальной машине разработчика. Обычно CI-агенты наполняют cache-ноды для дальнейшего использования при повторных сборках на CI или на локальных машинах разработчиков.

Этапы

Чистая сборка

Чистая сборка — Build Cache

Инкрементальная сборка

Инициализация

1.484

1.454

1.469

Конфигурация

6.602

6.656

6.535

Выполнение

14.015

1.576

0.463

Всего

22.190

9.764

8.543

Сценарий clean_assemble_buildcache
clean_assemble_buildcache {
    tasks = [":app:assembleDebug"]
    gradle-args = ["--build-cache", "--no-configuration-cache"]

    run-using = cli
    daemon = none

    cleanup-tasks = ["clean"]

    warm-ups = 2
    iterations = 3
}

Таким образом при использовании Gradle Build Cache можно уменьшить время чистой сборки в разы. Конечно, по сравнению с инкрементальной сборкой этот результат не так впечатляет, но инкрементальной сборке нужна предварительная сборка, а вот сборке с Build Cache — нет.

Нужен ли вам Configuration Cache

Мы вкратце обсудили Build Cache, а теперь посмотрим на новую разработку от Gradle — Configuration Cache. На самом деле Configuration Cache появился довольно давно, ещё в версии Gradle 6.6, но по-настоящему пригодным к использованию стал лишь в версии Gradle 8.1. Сейчас разработчики в Gradle активно его улучшают и планируют сделать Configuration Cache используемым по умолчанию, начиная с версии Gradle 9.0.

В отличие от Build Cache, Configuration кэширует работу этапа конфигурации, причём для каждой задачи отдельно. Всё было бы хорошо, если бы не одно «но» — обратная совместимость. К сожалению, не все популярные Gradle-плагины поддерживают Configuration Cache. По ссылке вы можете посмотреть, какие популярные плагины поддерживают Configuratoin Сache. 

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

Этапы

Чистая сборка

Чистая сборка — Сonfiguration Cache

Инициализация

1.484

0.000

Конфигурация

6.602

3.379

Выполнение

14.015

15.300

Всего

22.190

18.735

Сценарий clean_assemble_configurationcache
clean_assemble_configurationcache {
    tasks = [":app:assembleDebug"]
    gradle-args = ["--no-build-cache", "--configuration-cache"]

    run-using = cli
    daemon = none

    cleanup-tasks = ["clean"]

    warm-ups = 2
    iterations = 3
}

Сборка приложения с включенным Сonfiguration Сache не стала медленнее, время исполнения задач практически не изменилось. Зато время инициализации полностью исчезло, а время конфигурации существенно сократилось. Если бы на тестовом проекте было большое модулей, разница была бы ещё более ощутимой. Большую часть времени из этих трех секунд занимает загрузка кэша с диска. Вы можете самостоятельно в этом убедиться, если посмотрите в свой Build Scan.

Симбиоз Gradle-кэшей

Теперь можем объединить Build и Configuration Cache для получения ещё более значимых результатов. 

Этапы

Чистая сборка

Чистая сборка — Build&Configuration Cache

Инициализация

1.484

0.000

Конфигурация

6.602

3.239

Выполнение

14.015

1.886

Всего

22.190

5.167

Сценарий clean_assemble_buildcache_configurationcache
clean_assemble_buildcache_configurationcache {
    tasks = [":app:assembleDebug"]
    gradle-args = ["--build-cache", "--configuration-cache"]

    run-using = cli
    daemon = none

    cleanup-tasks = ["clean"]

    warm-ups = 2
    iterations = 3
}

Я использовал вместе два вида оптимизации, и моя тестовая сборка стала собираться в 4 раза быстрее. Это ли не чудо? Нет, чудо ждёт вас впереди. Как думаете, можно ли ещё сильнее ускорить чистую сборку?

Поговорим о демонах

Чтобы прокачать скорость сборки ещё больше, используем Gradle Daemon. Если кратко, Daemon позволяет кэшировать результаты сборки в оперативной памяти. Таким образом вторая и последующая сборки будут быстрее, чем первая. 

Так ИИ видит Gradle Daemon

Когда говорят про «Демонов», часто упоминают, что они могут быть холодными или тёплыми. Холодный «Демон» — это тот, что мы используем впервые. Тёплый — тот, который пережил хотя бы одну сборку. Иногда выделяют понятие горячего «Демона» — в случае, если он уже пережил множество сборок. 

За счёт непрерывной оптимизации JVM каждая последующая сборка с одним и тем же «Демоном» будет быстрее предыдущей. Раньше Gradle Daemon на CI рекомендовали отключать, так как он провоцировал нестабильные сборки. Сейчас проблема исчерпана, и Gradle в официальной документации рекомендует применять Gradle Daemon всегда, даже на CI. 

Это всё здорово, но важно помнить и про оверхед. Обычно первая сборка с использованием холодного Gradle Daemon медленнее, чем если бы его вообще не использовали. Преимущество Gradle Daemon появляется только на второй и последующих сборках, когда он уже тёплый. Отмечу, что при использовании «Демона» потребляется больше оперативной памяти по сравнению со сборкой без него.

Этапы

Чистая сборка

Cold Daemon

Warm Daemon

Инициализация

0.000

0.000

0.000

Конфигурация

3.239

3.219

0.442

Выполнение

1.886

1.794

1.110

Всего

5.167

5.060

1.570

Сценарий clean_assemble_cold_buildcache_configurationcache
clean_assemble_cold_buildcache_configurationcache {
    tasks = [":app:assembleDebug"]
    gradle-args = ["--build-cache", "--configuration-cache"]

    run-using = cli
    daemon = cold

    cleanup-tasks = ["clean"]

    warm-ups = 2
    iterations = 3
}

Сценарий clean_assemble_warm_buildcache_configurationcache
clean_assemble_warm_buildcache_configurationcache {
    tasks = [":app:assembleDebug"]
    gradle-args = ["--build-cache", "--configuration-cache"]

    run-using = cli
    daemon = warm

    cleanup-tasks = ["clean"]

    warm-ups = 2
    iterations = 3
}

Как видно из результатов, сборка без «Демона» или с холодным «Демоном» практически не отличается. Совсем другое дело при использовании теплого «Демона». Конфигурация и время выполнения существенно сократились, что делает Gradle Daemon отличным инструментом. 

До перехода к самому интересному обсудим ещё один момент — инкрементальную сборку. Все предыдущие сборки, за исключением второй, были чистыми, то есть кэши сборки и конфигурации они, конечно, использовали, но из-за запуска команды clean перед каждой сборкой, Workspace был практически чист. Сейчас же мы поговорим про прямое переиспользование build-файлов предыдущей сборки.

Сборка на бинарном интерфейсе: ABI vs non-ABI

Чтобы корректно замерить инкрементальную сборку, нужно каждый раз менять исходный код проекта перед сборкой. У всех изменений есть одно важное свойство: бинарная совместимость интерфейсов. Сам по себе бинарный интерфейс модуля называют коротко ABI

Любое изменение исходного кода в модуле либо повлечёт изменение ABI, либо нет. Мы вносим изменение в ABI каждый раз, когда изменяем сигнатуры публичных классов/методов (или коротко — объявлений), когда создаём новые публичные объявления и в особенности когда мы их удаляем. Всё это будет изменением бинарных интерфейсов. 

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

В качестве примера не-изменения бинарного интерфейса можно привести изменение тела метода или манипуляции с приватными объявлениями. Gradle умеет определять ABI и не-ABI изменения и, соответственно, запускать только необходимые задачи компиляции. Давайте посмотрим на цифры.

Если вы хотите узнать больше про ABI совместимость и её отличие от API, рекомендую обратиться к статье моего хорошего коллеги Абакара: API vs ABI: разницу видят не только лишь все.

Этапы

ABI change

non-ABI change

No change

Инициализация

0.000

0.000

0.000

Конфигурация

0.492

0.444

0.442

Выполнение

2.937

2.669

1.110

Всего

3.455

3.141

1.570

Сценарий incremental_assemble_warm_buildcache_configurationcache_abi {
incremental_assemble_warm_buildcache_configurationcache_abi {
    tasks = [":app:assembleDebug"]
    gradle-args = ["--build-cache", "--configuration-cache"]

    run-using = cli
    daemon = warm

    apply-abi-change-to = "modules/m0/src/main/kotlin/com/example/oom/m0/models/Model0.kt"

    cleanup-tasks = []

    warm-ups = 2
    iterations = 3
}

Сценарий incremental_assemble_warm_buildcache_configurationcache_nonabi
incremental_assemble_warm_buildcache_configurationcache_nonabi {
    tasks = [":app:assembleDebug"]
    gradle-args = ["--build-cache", "--configuration-cache"]

    run-using = cli
    daemon = warm

    apply-non-abi-change-to = "modules/m0/src/main/kotlin/com/example/oom/m0/models/Model0.kt"

    cleanup-tasks = []

    warm-ups = 2
    iterations = 3
}

Как видно в таблице, от вида изменения зависит, насколько долго будет пересобираться проект. Когда мы нарушаем ABI-контракт модуля, мы пересобираем все зависимые от него модули. В большом проекте, если мы делаем ABI-изменение в core-модуле, часто нужна пересборка всего проекта. 

При внесении не-ABI-изменений Gradle может опустить пересборку всех зависимых модулей, поскольку ссылки в Classpath остались неизменными. В этом случае нам нужно пересобрать лишь изменившийся модуль и заново упаковать весь код в APK.


Поздравляю! Всё самое сложное позади. Мы разобрали основные концепции Gradle. В следующий статье я рассмотрю более продвинутые способы оптимизации. Как раз вышли новые версии Kotlin и Gradle, самое время перезапустить старые сборки. А пока рекомендую обратиться к другой моей статье: Как подружить JUnit 5 и Robolectric.

Расскажите, работаете ли вы с Gradle, как часто обновляетесь и делаете замеры.

Список полезных источников

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


  1. Ab0cha
    10.02.2025 15:49

    Про gradle daemon было интересно откровение)
    Спасибо за статью)