Как переход на AGP&Gradle 8.* изменил взгляд на работу с производительностью сборки

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

Меня зовут Богдан Мащенко. Я Android-разработчик в платформенной команде Одноклассников. В этой статье я расскажу о нашем опыте перехода на AGP (Android Gradle Plugin) и Gradle версий 8.*: что стало причиной перехода, как преодолевали трудности, и что мы смогли получить в результате.

f3ed53ca3ec5161e29f230ef8b4a0580.jpg

Исходные условия и задачи

ОК — большой проект, в работе с которым мы активно используем разнообразные технологии, в том числе compileSdk 33. Но в первом квартале 2024 года в процессе поднятия версии одной SDK нам потребовалось перейти на compileSdk 34. 

В результате мы столкнулись с неочевидными проблемами совместимости: при сборке оказалось, что версия AGP 7.3.1, которая в тот момент использовалась у нас, тестировалась только с compileSdk 33 и параметры (в том числе стабильности) ее работы с compileSdk 34 были неизвестны. 

a5daebd1985104d78010e5f8dfb463ec.jpeg

Как таковых проблем не возникло, но это стало предпосылкой для перехода на стек новой версии. 

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

Обновления и изменения

Переход на AGP версии 8.* ожидаемо затронул часть уже существующих зависимостей. В итоге, в момент перехода на AGP версии 8.2.2, мы были вынуждены также поднять:

  • Gradle 8.6;

  • Java 17;

  • Kotlin 1.9.22;

  • Room 2.6.1.

В итоге такой массовый апгрейд принес нам несколько ключевых изменений. Далее о них подробнее, и начнём с Gradle 8 версии.

Gradle 8.6

Configuration Cache

  • Task.getProject (). Ранее вызов Task.getProject() при работе Configuration Cache выдавал просто предупреждение (warning). Но с переходом на версию Gradle 8.6, configuration cache начал выдавать ошибку. Для продолжения корректной работы с инструментом нам пришлось исправлять ситуацию.

    7032aec897833dbb50a03e655f3e8a93.png

    Чтобы обойти ограничение, мы начали провайдить нужные зависимости, которые использовали из Project.

    11216170225a0bf0fce3af2314cfba26.jpeg

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

  • Project.exec и Project.javaexec. Помимо этого, с переходом на Gradle 8.6 ошибки стали появляться и при вызове методов Project.exec и Project.javaexec

    86515cc9ea2ae411e037f39085ddf9ea.pngfb2fa840ba19c5bde936e98842cc8be3.png

    Чтобы продолжать нормально работать с командной строкой, нам надо было реагировать на этот вызов. 

    Решение оказалось довольно простым: у Gradle с версии 7.5 появился интерфейс ValueSource, который позволял нативно обойти эту проблему. Фактически нам потребовалось только перегнать работу с командной строкой в реализацию этого интерфейса. 

    f407afd5cdeaf345c399835fc0f6f6a8.png3662facaca8793101d9e2643a3a602c5.png

Eager APIs to avoid

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

AGP 8.2.2

Namespace required in module-level build script

Ранее мы могли указывать namespace (фактически package name модулей) в манифесте. Но с переходом на AGP 8.2.2 стало обязательным указание namespace в build-скрипте файла модуля.

a63f2000ea80a63a6beef22f5acac2be.png

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

Build option default values

Вместе с новой версией AGP мы получили «в распоряжение» большой набор новых дефолтных значений для флагов, отвечающих за улучшение перформанса сборки. Google сам нас подталкивает к оптимизации производительности сборки, а мы и не против!

  • android.defaults.buildfeatures.buildconfig. Ранее по умолчанию BuildConfig генерировался для каждого модуля. Если он был не нужен и вы не хотели, чтобы он создавался — это надо было явно указывать в build-скрипте файла модуля. Теперь они не генерируются и указание, наоборот, нужно только в ситуациях, когда buildconfig требуется.

  • android.defaults.buildfeatures.aidl.  Это текстовые файлы, содержащие интерфейсы для связи между приложениями.Принцип изменений похож с BuildConfig. Но мы изначально не использовали aidl, поэтому подобные нововведения нас не затронули. 

  • android.defaults.buildfeatures.renderscript. Теперь renderscript по умолчанию выключен. В наших проектах он присутствовал до недавнего времени, были использования из legacy-кода.

  • android.nonFinalResIds. Ранее на каждое изменение ресурса нужно было регенерировать R-класс. Но теперь ID ресурсов по умолчанию nonFinal, то есть теперь просто меняется значение поля для ресурса, что потенциально должно ускорять инкрементальные сборки. Мы реализовали поддержку этого значения и успешно работаем с ним.

  • android.enableR8.fullMode. В AGP 8.* fullMode для R8 теперь по умолчанию включен. Но мы пока не реализовали поддержку новых значений, так как столкнулись с проблемами во время сборки. В ближайшее время планируем углубится в исследование проблем.

e537c46d16442af91dab0a735aefa5f4.png

Оптимальный план перехода на новые версии AGP&Gradle

  • На переход, поддержку и тестирование инструментов новой версии очевидно нужны ресурсы команды. И лучше их выделять не только заранее, но и с запасом — закладывать время на преодоление неочевидных трудностей и решение возникающих в процессе задач.

  • Необязательно внедрять «всё и сразу». Сначала можно перейти на новые версии со старыми дефолтными значениями AGP-настроек. Это сделает миграцию более плавной и менее ресурсозатратной. 

  • Переходить к новым настройкам можно выборочно. Например, сначала можно определить настройки, которые хочется или есть возможность поддержать сейчас. При этом надо понимать, что полностью игнорировать новые настройки вряд ли получится — рано или поздно параметры устаревших версий могут просто «выпилить».

  • Не стоит полагаться на работоспособность инструментов миграции Android Studio и их компонентов — проблемы могут возникать всегда. Поэтому после апгрейда важно тщательно проверять корректность работы инструментов и влияние обновлений на проект. Также важно проверить, всё ли хорошо со сборкой и тасками после перехода на 8 Gradle и обновления configuration cache.

  • При переходе на новые версии инструментов важно смотреть на метрики — даже если апгрейд «не ломает» проект, он может влиять на показатели (например, на скорость сборки). Причем не всегда это влияние будет положительным. Быстрое обнаружение сильной просадки показателей даст возможность откатиться до прошлых версий с наименьшими издержками.

История о неожиданном и счастливом результате

Наряду с упомянутыми нововведениями, изменения затронули и флаг android.nonTransitiveRClass — теперь он по умолчанию включен. Причем эта фича в результате довольно значительно повлияла на наш проект. Об этом подробнее.

Для начала давайте разберемся, как работают нетранзитивные R-классы.

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

Но с помощью флага android.nonTransitiveRClass=true, который добавляется в файл gradle.properties, можно избежать добавления всех используемых ресурсов в R.java-класс модуля, что значительно уменьшит его размер. С другой стороны, разработчику придётся явно указывать package name R-класса нужного модуля при использовании ресурсов из разных модулей или библиотек. Но издержки оправданны — благодаря такому подходу, получаем ускорение чистой и инкрементальной сборки. 

Но вопрос: на сколько?

В одной из статей на Хабре, Кирилл Розов приводит результаты внедрения нетранзитивных R-классов в одном проекте, согласно которым сборка ускорилась на 13%, а размер apk-файла уменьшился на 5–7%. Вдохновившись этими цифрами, мы стали пробовать поддержать эту фичу и у нас.

То есть в теории, с переходом на android.nonTransitiveRClass=true мы должны будем получить три важных преимущества:

  • ускорение сборки, поскольку не приходится тратить время на этап мержинга всех используемых ресурсов в R-класс модуля;

  • уменьшение размера сборки за счёт меньших по объему R-классов;

  • обращение к ресурсам по полному пути модуля, в котором он объявлен. 

Безусловно, реализация с android.nonTransitiveRClass=true не лишена недостатков. Так:

  • Миграция становится тяжелой из-за большого количества используемых в проекте ресурсов (в нашем случае полноценно с этим не помогают даже инструменты Android Studio).

  • Теперь надо точно знать, где именно объявлен ресурс, поскольку требуется указание полного пути к нему. Из-за этого работа с ресурсами становится менее удобной. Проблема, которая вытекает из плюса.

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

Шаги реализации

Внедрение поддержки в нашем случае проходило в три этапа.

  • На первом мы с помощью инструмента Android Studio выполнили основной объем миграции. Таким образом за 5 итераций мы смогли заменить до 95% использований ресурсов. 

  • На втором этапе мигрировали оставшийся код (оставшихся использований) вручную.

  • На третьем этапе — исправляли ошибки, возникающие во время сборки.

Благодаря такому пайплайну, мы смогли последовательно внедрить новый подход к работе с ресурсами с минимальными затратами и без «слепых зон». Но без «подводных камней» тоже не обошлось.На своем пути мы столкнулись с несколькими.

  • Конфликт имён. В случае конфликтов имён инструмент может ошибочно подставить ресурс не из нужного модуля. Например, если в разных частях приложения используются ресурсы с одинаковым именем. Это предъявляет дополнительные требования к «чистоте» нейминга во всем проекте, чтобы названия «пересекались» минимально. В нашем случае это не стало большой проблемой, но потребовалось дополнительное время на мелкие правки и проверку. Да и сам Android, как оказалось, нормально это воспринимает. Если вдруг не заметить использование ресурса не из нужного модуля, то ошибки не возникнет.

  • Резолвинг нетранзитивных R-классов. Оказалось, что в Java-тестах Android Studio не резолвит нетранзитивные R-классы. То есть в Android Studio отображалось, словно R-классов для конкретных модулей просто нет. При этом в действительности они были, и при сборке проблем не возникало. С тестами, написанными на Kotlin, таких проблем не было.

    4f024b399e3b386d78436e6c4ace1751.jpeg

    Проблема оказалась на уровне Android Studio — в тот момент она еще не умела работать с нетранзитивными R-классами в android-тестах. 

    Это создавало трудности для наших тестировщиков. Поэтому мы написали небольшой «костыль», который позволял обходить ограничение, — класс в основном коде проекта, где просто хранятся ссылки на ресурсы. Таким образом, в тестах используются поля из этого класса и проблем с резолвом не возникает.

    e3c151a3e821ac885bba75608948026d.jpeg

    Вместе с тем, работать с таким «костылем» нам пришлось недолго — в Android Studio Iguana Canary 1 (2023.2.1.1) проблема была устранена и мы получили возможность полноценной работы с нетранзитивными R-классами в тестах. То есть выкинули «костыль» и продолжили предсказуемо и стабильно работать без него.

    34af5ae69338e928362e61a29c4f1b91.pngcb093d51ea7ccfb6da5a78324b232644.jpeg

Результаты

После внедрения нововведений мы проверили эффект на CI сборках — они не подвержены влиянию внешних факторов, из-за которых скорость сборки может сильно отличаться от случая к случаю. Оказалось, что чистые сборки ускорились примерно на 25%. Причем мы отслеживали показатели «вдолгую», чтобы гарантировать, что полученный эффект не краткосрочный.

9505776bf9f9a08775ad8e99f19483d8.jpeg

Также мы получили буст до 10% на инкрементальных сборках. Таким образом, мы полностью оправдали все затраченные на переход к AGP и Gradle 8.* ресурсы и время.

Примечательно, что наряду с бустом по скорости, мы также улучшили показатели и по размеру сборки:

Таким образом, на 10 МБ стала легче и сборка, которую получают пользователи. То есть эффект от перехода на новую версию стека ощутили не только мы, но и пользователи.

Жизнь после внедрения нетранзитивных R-классов

И вот вроде мы уже летим на космолёте и всё хорошо, но останавливаться на этом не хотелось. У нас в Android-направлении ОК есть постоянные встречи RnD, на которых мы обсуждаем актуальные изменения рынка, насколько возможно ту или иную технологию применить в нашем проекте и какой от неё будет профит. Так для нашего проекта я выделил K2 и Java 21. О них немного и поговорим.

Java 21

В статье про сборку на Gradle 8.5 и Java 21 приводится сравнение производительности сборки на Java 17 и Java 21, где Java 21 по всем аспектам дает профит в скорости.

e634fde2170685abff6aca436f761506.png

Я сразу понял, что эту историю нужно проверить на нашем проекте. Но перейти на новую версию Java — это всегда проблема для всех разработчиков и не только: нужно всем установить jdk, добавить сертификат для неё, настроить JAVA_HOME и студию, также всё это сделать для сборок на teamcity и mainframer. Не хотелось всё это делать, не убедившись, что есть действительно какая-то польза для сборки.

Для проведения независимых замеров мы сделали все эти действия только для teamcity и стали смотреть результаты на моей ветке с Java 21. Но каких-то изменений не увидели, поэтому отложили переход на будущее и не стали тревожить наших разработчиков.

K2

JetBrains в своём руководстве по миграции приводит примеры ускорения этапов компиляции, инициализации и анализа для чистой и инкрементальной сборки. 

f93e25f91747ec5a493d60bc1f6f3d94.png

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

Переход с версии 1.9.22 до 2.0.20-RC оказался не бесшовным. Этому последовало большое количество ошибок, связанных, например, с вариативностью дженериков и обновлений версий других библиотек. На ошибках подробно останавливаться не будем, но приведу в пример, что пришлось поднять:

  • appcompat (1.6.1 → 1.7.0);

  • activity (1.7.2 → 1.9.1);

  • lifecycle (2.6.1 → 2.6.2);

  • mockito (5.10.0 → 5.12.0);

  • mockito-kotlin (5.2.1 → 5.4.0).

Доведя задачу до develop бранча (рабочая ветка Android-разработчиков в ОК), мы стали наблюдать за изменениями скорости сборки. И знаете что?…  А ничего, вообще ничего — никакого ускорения не произошло. Для нас это стало неким уроком — не всё, что так хвалят и обещают разработчики этого или иного решения может действительно давать профит. Вместе с тем, мы не расстроились — в любом случае очень хотелось попробовать перейти на новый, недавно вышедший, K2 и поделиться результатом с комьюнити. 

Примечание: Если на вашем проекте новый Kotlin всё же дал улучшение скорости сборки — будем рады узнать про ваш опыт.

Вывод

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

  • оперативнее получать обратную связь;

  • чаще проводить тестирование;

  • своевременно вносить изменения.

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

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

© Habrahabr.ru