Сборка Android-приложения. Задачка со звёздочкой
Привет, Хабр! Летом я выступал на Summer Droid Meetup с докладом про сборку Android-приложения. Видеоверсию можно найти здесь: habr.com/ru/company/funcorp/blog/462825. А для тех, кто больше любит читать, я как раз и написал эту статью.
Речь пойдёт о том, что же это такое — Android-приложение. Мы соберём разными способами Hello, world!: начнём с консоли и посмотрим, что вообще происходит под капотом систем сборки, потом вернёмся немного в прошлое, вспомним про Maven и изучим современные решения Bazel и Buck. И, наконец, всё это сравним.
Мы задумались о возможной смене системы сборки, когда начинали новый проект. Нам казалось, что это неплохая возможность поискать какие-нибудь альтернативы Gradle. Тем более, что делать это проще на старте, чем переводить существующий проект. К этому шагу нас подтолкнули следующие недостатки Gradle:
- у него определённо есть проблемы с инкрементальной сборкой, хотя видны подвижки в этом направлении;
- он плохо справляется с очень большими монолитными проектами;
- бывает, что весьма долго стартует демон;
- требователен к машине, на которой выполняется.
APK
Прежде всего вспомним, из чего состоит Android-приложение: скомпилированного кода, ресурсов и AndroidManifest.xml.
Исходники находятся в файле classes.dex (файлов может быть несколько, в зависимости от величины приложения) в специальном dex-формате, с которым умеет работать виртуальная машина Android. Нынче это ART, на более старых девайсах — Dalvik. Помимо этого можно встретить папку lib, где по подпапкам разложены нативные исходники. Они будут носить названия в зависимости от целевой архитектуры процессора, например x86, arm и т.д. Если вы используете exoplayer, то lib у вас наверняка присутствует. И папка aidl, которая содержит в себе интерфейсы межпроцессного взаимодействия. Они пригодятся, если нужно обратиться к сервису, запущенному в другом процессе. Такие интерфейсы используются и в самом Android, и внутри GooglePlayServices.
Различные некомпилируемые ресурсы вроде картинок лежат в папке res. Все компилируемые ресурсы, такие как стили, строки и т.д., сливаются в файл resource.arsc. В папку assets, как правило, складывают всё, что не укладывается в ресурсы, например кастомные шрифты.
Кроме всего этого, в APK содержится AndroidManifest.xml. В нём мы описываем различные компоненты приложения, такие как Activity, Service, разные разрешения и т.д. Он лежит в бинарном виде, и чтобы заглянуть внутрь, его надо будет сперва сконвертировать в человекочитаемый файл.
CONSOLE
Теперь, когда мы знаем, из чего состоит приложение, можем попробовать собрать Hello, world! из консоли, используя инструменты, которые предоставляет Android SDK. Это довольно важный этап для понимания того, как работают системы сборки, потому что все они в той или иной мере опираются на эти утилиты. Так как проект написан на Kotlin, нам потребуется его компилятор для командной строки. Его несложно загрузить отдельно.
Сборку приложения можно поделить на следующие этапы:
- загружаем и распаковываем все библиотеки, от которых зависит проект. В моём случае это библиотека обратной совместимости appcompat, которая, в свою очередь, зависит от appcompat-core, поэтому выкачиваем и её;
- генерируем R.java. Этот чудесный класс содержит в себе идентификаторы всех ресурсов в приложении и используется для того, чтобы обращаться к ним в коде;
- компилируем исходники в байт-код и транслируем его в Dex, потому что виртуальная машина Android с обычным байт-кодом работать не умеет;
- упаковываем всё в APK, но сначала выравниваем все несжимаемые ресурсы, такие как картинки, относительно начала файла. Это позволяет ценой совершенно незначительного роста размера APK существенно ускорить его работу. Таким образом система напрямую может отображать ресурсы в оперативную память, используя функцию mmap ().
- подписываем приложение. Эта процедура защищает целостность APK и подтверждает авторство. И благодаря этому, например, Play Market может проверить, что приложение было собрано именно вами.
function preparedir() {
rm -r -f $1
mkdir $1
}
PROJ="src/main"
LIBS="libs"
LIBS_OUT_DIR="$LIBS/out"
BUILD_TOOLS="$ANDROID_HOME/build-tools/28.0.3"
ANDROID_JAR="$ANDROID_HOME/platforms/android-28/android.jar"
DEBUG_KEYSTORE="$(echo ~)/.android/debug.keystore"
GEN_DIR="build/generated"
KOTLIN_OUT_DIR="$GEN_DIR/kotlin"
DEX_OUT_DIR="$GEN_DIR/dex"
OUT_DIR="out"
libs_res=""
libs_classes=""
preparedir $LIBS_OUT_DIR
aars=$(ls -p $LIBS | grep -v /)
for filename in $aars;
do
DESTINATION=$LIBS_OUT_DIR/${filename%.*}
echo "unpacking $filename into $DESTINATION"
unzip -o -q $LIBS/$filename -d $DESTINATION
libs_res="$libs_res -S $DESTINATION/res"
libs_classes="$libs_classes:$DESTINATION/classes.jar"
done
preparedir $GEN_DIR
$BUILD_TOOLS/aapt package -f -m \
-J $GEN_DIR \
-M $PROJ/AndroidManifest.xml \
-S $PROJ/res \
$libs_res \
-I $ANDROID_JAR --auto-add-overlay
preparedir $KOTLIN_OUT_DIR
compiledKotlin=$KOTLIN_OUT_DIR/compiled.jar
kotlinc $PROJ/java $GEN_DIR -include-runtime \
-cp "$ANDROID_JAR$libs_classes"\
-d $compiledKotlin
preparedir $DEX_OUT_DIR
dex=$DEX_OUT_DIR/classes.dex
$BUILD_TOOLS/dx --dex --output=$dex $compiledKotlin
preparedir $OUT_DIR
unaligned_apk=$OUT_DIR/unaligned.apk
$BUILD_TOOLS/aapt package -f -m \
-F $unaligned_apk \
-M $PROJ/AndroidManifest.xml \
-S $PROJ/res \
$libs_res \
-I $ANDROID_JAR --auto-add-overlay
cp $dex .
$BUILD_TOOLS/aapt add $unaligned_apk classes.dex
rm classes.dex
aligned_apk=$OUT_DIR/aligned.apk
$BUILD_TOOLS/zipalign -f 4 $unaligned_apk $aligned_apk
$BUILD_TOOLS/apksigner sign --ks $DEBUG_KEYSTORE $aligned_apk
По цифрам получается, что чистая сборка занимает 7 секунд, и инкрементальная от неё не отстаёт, потому что мы ничего не кешируем и каждый раз пересобираем всё заново.
MAVEN
Он был разработан ребятами из Apache Software Foundation для сборки Java-проектов. Билд-конфиги для него описываются на языке XML. Ранние ревизии Maven собирались Ant, а сейчас они перешли на последний стабильный релиз.
Плюсы Maven:
- он поддерживает кеширование артефактов сборки, т.е. инкрементальный билд должен быть быстрее чистого;
- умеет разрешать сторонние зависимости. Т.е. Когда вы в конфиге Maven или Gradle указываете зависимость от сторонней библиотеки, вам не нужно заботиться о том, от чего она зависит;
- есть куча подробной документации, потому что он уже весьма давно на рынке.
- и он может быть привычным механизмом сборки, если вы недавно пришли в мир Android-разработки из бэкенда.
Минусы Maven:
- зависит от версии Java, установленной на машине, на которой происходит сборка;
- Android-плагин сейчас поддерживается сторонними разработчиками: лично я считаю это весьма существенным недостатком, потому что в один прекрасный день они могут перестать это делать;
- XML не очень подходит для описания билд-конфигов в силу своей избыточности и громоздкости;
- ну и как мы позднее увидим, он работает медленнее Gradle, по крайней мере на тестовом проекте.
Для сборки мы должны создать pom.xml, который содержит описание нашего проекта. В заголовке указываем базовые сведения о собираемом артефакте, а так же версию Kotlin.
4.0.0
com.example
myapplication
1.0.0
apk
My Application
1.3.41
org.jetbrains.kotlin
kotlin-stdlib
${kotlin.version}
com.google.android
android
4.1.1.4
provided
org.jetbrains.kotlin
kotlin-maven-plugin
${kotlin.version}
compile
process-sources
compile
com.simpligility.maven.plugins
android-maven-plugin
true
28
28.0.3
false
По цифрам всё выходит не слишком радужно. Чистая сборка занимает порядка 12 секунд, тогда как инкрементальная — 10. Это говорит о том, что Maven как-то плохо переиспользует артефакты предыдущих сборок, либо, что на мой взгляд более вероятно, плагин для сборки Android-проекта мешает ему это делать
Используют сейчас всё это, я думаю, прежде всего создатели плагина — ребята из simpligility. Больше достоверных сведений об этом вопросе найти не удалось.
BAZEL
Bazel изобрели инженеры в недрах Google для сборки своих проектов и относительно недавно перевели его в open source. Для описания билд-конфигов используется питоноподобный Skylark или Starlark, оба названия имеют место быть. Собирается с использованием своего же последнего стабильного релиза.
Плюсы Bazel:
- поддержка разных языков программирования. Если верить документации, то он умеет собирать проекты для iOs, Android или даже бэкенда;
- умеет кешировать ранее собранные артефакты;
- умеет работать с Maven-зависимостями;
- у Bazel очень крутая, на мой взгляд, поддержка распределённых проектов. Ему можно в качестве зависимостей указывать конкретные ревизии git-репозиториев, и он будет сам их выгружать и кешировать в процессе сборки. Для поддержки масштабируемости Bazel умеет, например, распределять различные таргеты по облачным билдсерверам, что позволяет очень быстро собирать громоздкие проекты.
Минусы Bazel:
- всю эту прелесть весьма тяжело поддерживать, потому что билд-конфиги очень подробные и описывают сборку на низком уровне;
- помимо прочего, кажется, что Bazel сейчас активно развивается. Из-за этого некоторые примеры не собираются, а те, что собираются, могут использовать уже устаревший функционал, который помечен как deprecated;
- документация сейчас также оставляет желать лучшего, особенно в сравнении с Gradle;
- на маленьких проектах прогрев и анализ билд-конфигов может занимать больше времени, чем сама сборка, что не есть хорошо, на мой взгляд.
Концептуально базовый конфиг Bazel состоит из WORKSPACE, где мы описываем всякие глобальные вещи для проекта, и BUILD, который содержит непосредственно таргеты для сборки.
Опишем WORKSPACE. Так как у нас Android-проект, то первое, что мы конфигурируем, — это Android SDK. Также тут импортируется правило для выгрузки конфигов. Потом, так как проект написан на Kotlin, мы должны указать правила для него. Тут мы делаем это, ссылаясь на конкретную ревизию прямо из git-репозитория.
android_sdk_repository(
name = "androidsdk",
api_level = 28,
build_tools_version = "28.0.3"
)
load("@bazel_tools//tools/build_defs/repo:http.bzl", "http_archive")
#
# KOTLIN RULES
#
RULES_KOTLIN_VERSION = "990fcc53689c8b58b3229c7f628f843a60cb9f5c"
http_archive(
name = "io_bazel_rules_kotlin",
url = "https://github.com/bazelbuild/rules_kotlin/archive/%s.zip" % RULES_KOTLIN_VERSION,
strip_prefix = "rules_kotlin-%s" % RULES_KOTLIN_VERSION
)
load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kotlin_repositories", "kt_register_toolchains")
kotlin_repositories()
kt_register_toolchains()
Теперь приступим к BUILD.
Сперва импортируем правило для сборки Kotlin и описываем то, что хотим собрать. В нашем случае это Android-приложение, поэтому используем android_binary, где задаём манифест, минимальный SDK и т.д. Наше приложение будет зависеть от исходников, поэтому упоминаем их в deps и переходим к тому, что они собой представляют и где их найти. Код также будет зависеть от ресурсов и библиотеки appcompat. Для ресурсов используем обычный таргет для сборки андроидных исходников, но задаём ему только ресурсы без java-классов. И описываем пару правил, которые импортируют сторонние библиотеки. Тут также упоминается appcompat_core, от которой зависит appcompat.
load("@io_bazel_rules_kotlin//kotlin:kotlin.bzl", "kt_android_library")
android_binary(
name = "app",
custom_package = "com.example.myapplication",
manifest = "src/main/AndroidManifest.xml",
manifest_values = {
"minSdkVersion": "15",
},
deps = [
":lib",
],
)
kt_android_library(
name = "lib",
srcs = glob(["src/main/java/**/*"]),
deps = [
":res",
":appcompat",
],
)
android_library(
name = "res",
resource_files = glob(["src/main/res/**/*"]),
manifest = "src/main/AndroidManifest.xml",
custom_package = "com.example.myapplication",
)
aar_import(
name = "appcompat",
aar = "libs/appcompat.aar",
deps = [
":appcompat_core",
]
)
aar_import(
name = "appcompat_core",
aar = "libs/core.aar",
)
По цифрам для такого маленького проекта всё выглядит печально. Больше половины минуты на чистую сборку Hello, world! — очень много. Время инкрементальной сборки также далеко от совершенства.
Bazel используют его создатели (Google) для каких-то своих проектов, в том числе серверных, а также Dropbox и Huawei, которые собирают им мобильные приложения. И небезызвестный Dagger 2 также собирается Bazel.
BUCK
Его придумали перебежчики из Google в Facebook. Для описания конфигов раньше он использовал Python, а потом мигрировал на упоминавшийся сегодня Skylark. Собирается же он, внезапно, с помощью системы Ant.
Плюсы Buck:
- поддерживает разные языки программирования и умеет собирать как Andriod, так и iOS;
- умеет кешировать ранее собранные артефакты;
- для Buck сделали свою реализацию dex, которая работает пошустрее стандартной и висит вместе с демоном системы. Так они экономят время на инициализации dex. Инженеры действительно многое оптимизировали. Например, Buck не собирает код, который зависит от библиотеки, если при изменении внутренностей библиотеки не изменился интерфейс. Аналогично и для ресурсов: если идентификаторы не поменялись, то при изменении ресурсов код не пересобирается.
- есть плагин, который умеет прятать Buck за гредловским конфигом. Т.е. вы получаете примерно обычный Gradle-проект, который на самом деле собирается через Buck.
Минусы Buck:
- его так же сложно поддерживать, как Bazel. Т.е. тут так же надо описывать низкоуровневые правила, четко описывающие процесс сборки;
- кроме прочего, Buck не умеет сам разрешать Maven-зависимости.
Итак, как выглядит конфиг сборки Hello, world! посредством Buck? Тут мы описываем один файл конфигурации, где указываем, что хотим собирать Android-проект, который будет подписан дебажным ключом. Приложение аналогичным образом будет зависеть от исходников — lib в массиве deps. Дальше идёт таргет с настройками подписи. Я использую дебажный ключ, который идёт в комплекте с Android SDK. Сразу за ним следует таргет, который соберёт нам исходники Kotlin. Аналогично Bazel, он зависит от ресурсов и библиотек совместимости.
Описываем их. Для ресурсов в Buck есть отдельный таргет, поэтому велосипеды не пригодятся. Следом идут правила для скачанных сторонних библиотек.
android_binary(
name = 'app',
manifest = 'src/main/AndroidManifest.xml',
manifest_entries = {
'min_sdk_version': 15,
},
keystore = ':debug_keystore',
deps = [
':lib',
],
)
keystore(
name = 'debug_keystore',
store = 'debug.keystore',
properties = 'debug.keystore.properties',
)
android_library(
name = 'lib',
srcs = glob(['src/main/java/*.kt']),
deps = [
':res',
':compat',
':compat_core',
],
language = 'kotlin',
)
android_resource(
name = 'res',
res = "src/main/res",
package = 'com.example.myapplication',
)
android_prebuilt_aar(
name = 'compat',
aar = "libs/appcompat.aar",
)
android_prebuilt_aar(
name = 'compat_core',
aar = "libs/core.aar",
)
Собирается всё это дело очень резво. Чистая сборка занимает немногим более 7 секунд, тогда как инкрементальная — совершенно незаметные 200 миллисекунд. Я думаю, это очень хороший результат.
Так делают в Facebook. Кроме своего флагманского приложения, они собирают им Facebook Messenger. И Uber, которые сделали плагин для Gradle и Airbnb с Lyft.
ВЫВОДЫ
Теперь, когда мы поговорили про каждую систему сборки, можно сравнить их между собой на примере Hello, world! Консольная сборка радует своей стабильностью. Время выполнения скрипта из терминала можно считать эталонным для сборки чистых билдов, потому что сторонние затраты на парсинг скриптов тут минимальны. Явным аутсайдером я бы в данном случае назвал Maven за чрезвычайно незначительное убыстрение инкрементальной сборки. Bazel очень долго парсит конфиги и инициализируется: есть мысль, что он кеширует как-то результаты инициализации, потому что инкрементальная сборка у него проходит существенно быстрее чистой. Buck — бесспорный лидер это подборки. Очень быстрая как чистая, так и инкрементальные сборка.
Сравним теперь все за и против. Не буду включать Maven в сравнение, потому что он явно проигрывает Gradle и уже практически не используется на рынке. Buck и Bazel я объединю, потому что они обладают примерно одинаковыми достоинствами и недостатками.
Итак, про Gradle:
- первое и, на мой взгляд, самое важное — он простой. Очень простой;
- из коробки разруливает и выгружает зависимости;
- для него очень много самых разных обучалок и документации;
- активно поддерживается как Google, так и сообществом. Здорово интегрирован с Android Studio, текущим флагманским инструментом разработки. И все новые фичи для сборки Android-приложения сперва появляются именно в Gradle.
Про Buck/Bazel:
- определённо могут быть очень быстрыми в сравнении с Gradle. Полагаю, что это особенно заметно на очень больших проектах
- можно держать один проект, в котором будут исходники и iOS, и Android, и собирать их одной системой сборки. Это позволяет шарить между платформами некоторые части приложения. Например, так собирается Chromium;
- заставляют подробно описывать зависимости и таким образом буквально принуждают разработчика к многомодульности.
Не забудем и про минусы.
Gradle за свою простоту расплачивается тем, что он медленный и неэффективный.
Buck/Bazel же, напротив, из-за своей скорости страдают от необходимости подробнее описывать в конфигах процесс сборки. Ну и так как появились на рынке они относительно недавно, то документации и разных шпаргалок немного.
iFUNNY
Возможно, у вас возник вопрос, как мы собираем iFunny. Так же, как и многие — используя Gradle. И на то есть причины:
- Пока непонятно, какой выигрыш в скорости сборки нам это даст. Чистая сборка iFunny занимает почти 3 минуты, а инкрементальная — около минуты, что на самом деле не особо долго.
- Билд-конфиги Buck или Bazel сложнее поддерживать. В случае Buck нужно ещё и следить за актуальностью подключенных библиотек и библиотек, от которых они зависят.
- Это банально дорого — переводить существующий проект с Gradle на Buck/Bazel, особенно в условиях непонятного профита.
Если у вас проект собирается больше 45 минут и в команде Android-разработки человек 20, то есть смысл задуматься о смене системы сборки. Если вы со своим другом пилите стартап, то пользуйтесь Gradle и отбросьте эти мысли.
Буду рад обсудить перспективы альтернатив Gradle в комментариях!
Ссылка на проект: github.com/FlashLight13/BuildSystems