Сборка apk из Android-приложения

от
Android    aapt, dex

Недавно я делал сборку apk в одном приложении, так что хочу рассказать, какие есть для этого решения.


Процесс сборки Android-приложения
Для начала о самом процессе сборки apk.

Когда вы запускаете сборку, первым делом читается AndroidManifest.xml, в нём есть важные параметры, такие как package (например, com.example.app) и targetSdkVersion.

Затем вызывается программа aapt (Android Asset Packaging Tool), которой передаётся AndroidManifest.xml, папка с ресурсами res/, assets/, путь к android.jar нужной target-версии. aapt проверяет ресурсы и компилирует их, создавая при этом класс R.java в котором содержатся идентификаторы ресурсов и файл resources.arsc в котором содержится информация об xml-ресурсах и их атрибутах.

Далее подхватываются все библиотеки, которые используются в проекте и запускается Java-компилятор javac. Полученные class-файлы передаются в программу dx, которая переводит их в Dalvik dex-формат. Причём для оптимизации, готовые библиотеки дексируются отдельно, а классы проекта отдельно (оптимизация в том, что дексированные библиотеки можно закешировать). Если собралось несколько dex-файлов, то они все объединяются при помощи Dex Merger Tool. В конечном итоге мы получаем единственный файл classes.dex (или несколько, если используется multidex).

Теперь у нас есть все компоненты и можно собирать apk. По сути это просто упаковка всех файлов в zip-архив, но используется специальная программа apkbuilder. После её выполнения получаем неподписанный apk-файл, то есть без папки META-INF внутри.

Последний этап - подпись apk. Берётся заранее сгенерированный ключ и передаётся в jarsigner вместе с неподписанным apk-файлом. На выходе имеем готовое приложение, которое можно устанавливать.

build-flow1.png

:ps: Картинка взята отсюда.



Сборка из Android-приложения
К счастью, Android достаточно мощная платформа, так что все вышеперечисленные приложения также портированы для неё.

aapt можно взять из этого репозитория. Там же другие собранные библиотеки.

Компилятор Java присутствует в ECJ.

Последнюю версию dx можно собрать из исходников в официальном репозитории.

Apkbuilder идёт как часть sdklib, но вместо него можно воспользоваться любой библиотекой для создания zip-архива.

Наконец, библиотека для подписи apk - zip-signer.


Следует заметить, что для полноценной сборки, нам понадобятся не только эти библиотеки, но ещё и android.jar последней версии. В итоге мы имеем 20 Мб зависимостей, что в прочем, не так уж и много. А тот же android.jar и aapt можно скачать при первом запуске, что значительно сэкономит место и трафик пользователям.

1. Запуск aapt в зависимости от архитектуры
Больше всего проблем с aapt, так как это не библиотека, а исполняемая программа. Нужно запускать совместимую версию, предварительно выставив права на запуск.

  1. private String getAaptBinaryName() {
  2.     final String architecture = Build.CPU_ABI.substring(0, 3).toLowerCase(Locale.US);
  3.     final boolean usePie = Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN;
  4.     final String pieSuffix = usePie ? "-pie" : "";
  5.     switch (architecture) {
  6.         case "mip": return "aapt-mips";
  7.         case "x86": return "aapt-x86" + pieSuffix;
  8.         case "arm":
  9.         default:    return "aapt-arm" + pieSuffix;
  10.     }
  11. }
  12.  
  13. // Распаковываем или качаем нужную версию во внутреннее хранилище
  14. final File aaptFile = new File(...);
  15. final FileOutputStream fos = new FileOutputStream(aaptFile);
  16. final InputStream is = context.getAssets().open(getAaptBinaryName());
  17. IOUtils.copy(is, fos);
  18. is.close();
  19. fos.close();
  20.  
  21. // Устанавливаем права на запуск
  22. String[] args = {
  23.   "chmod", "744", aaptFile.getAbsolutePath()
  24. };
  25. Runtime.getRuntime().exec(args).waitFor();
  26.  
  27. // Запускаем aapt
  28. String[] args = {
  29.   aaptFile.getAbsolutePath(), "p", "-f", "-m",
  30.   "-S", resFolder,
  31.   "-J", genFolder,
  32.   "-A", assetsFolder,
  33.   "-M", manifestFile,
  34.   "-I", androidJarFile,
  35.   "-F", resourcesArscFile
  36. };
  37. Runtime.getRuntime().exec(args).waitFor();

Аргументы программы можно посмотреть здесь.


2. Запуск компилятора ECJ
C ECJ всё проще, мы так же составляем аргументы командной строки, но теперь не создаём процесс, а передаём аргументы в метод compile класса org.eclipse.jdt.core.compiler.batch.BatchCompiler.

  1. String[] args = {
  2.     "-extdirs", libsFolder,
  3.     "-bootclasspath", androidJarFile,
  4.     "-classpath", String.format("%s:%s:%s", srcFolder, genFolder, libsFolder),
  5.     "-1.6", "-target", "1.6",
  6.     "-d", binFolder,
  7.     mainActivityFile
  8. }
  9. BatchCompiler.compile(args, new PrintWriter(System.out), new PrintWriter(System.err), null);

Аргументы можно посмотреть здесь.


3. Запуск dx
Дексирование - самый ресурсоёмкий процесс, поэтому важно оптимизировать его, заменив на объединение готовых dex-файлов при помощи DX Merger. Если есть возможность поставлять пользователю уже дексированные большие библиотеки, лучше это сделать, иначе процесс сборки будет долгим, а то и вовсе получим OutOfMemory.

Тем не менее, для запуска dx используются аргументы, передаваемые классу com.android.dx.command.dexer.Main.

  1. String[] args = {
  2.   "--output=" + classesDexFile,
  3.   classesFolder
  4. };
  5. Main.Arguments dxArgs = new Main.Arguments();
  6. dxArgs.parse(args);
  7. Main.run(dxArgs);

Для объединения нескольких dex-файлов, запускаем DexMerger. Но так как он может объединять только два dex-файла, работу придётся проводить в цикле.

  1. final List<String> dexs = getDexFiles();
  2. for (String currentDex : dexs) {
  3.     DexMerger.main(new String[] {
  4.             classesDexFile,
  5.             currentDex,
  6.             classesDexFile,
  7.     });
  8. }

Объединение dex-файлов происходит значительно быстрее, чем дексирование.
Кстати, в dx.jar ещё много интересных инструментов, например поиск зависимостей, дампер и т.д.


4. Запуск ApkBuilder
  1. ApkBuilder builder = new ApkBuilder(
  2.     apkOutputFile,
  3.     resFolder,
  4.     classesDexFile,
  5.     debugKeyStoreFile,
  6.     new PrintWriter(System.out)
  7. );
  8. builder.addSourceFolder(srcFolder);
  9. // builder.addNativeLibraries(..);
  10. // builder.addFile(..)
  11. builder.sealApk();


ApkBuilder умеет подписывать приложение debug-ключом, если нужно собрать неподписанный apk, вместо debugKeyStoreFile можно передать null.


5. Запуск zip-signer
Подпись debug-ключом.

  1. ZipSigner signer = new ZipSigner();
  2. signer.setKeymode("test");
  3. signer.signZip(unsignedApk, signedApk);

Подпись release-ключом (требуется библиотека bcprov).

  1. Security.addProvider(new BouncyCastleProvider());
  2. ZipSigner signer = new ZipSigner();
  3. CustomKeySigner.signZip(signer, keystore, keystorePassword, keyAlias, keyAliasPassword, "SHA1WITHRSA", unsignedApk, signedApk);

Вот и всё, после выполнения всех этих шагов мы получим подписанный apk файл, который тут же можно и установить.

:ps: Часть кода была подсмотрена здесь.



Оптимизация
Если вам не нужно при сборке добавлять ресурсы в папку res/, так как они неизменны, то можно подумать об оптимизации - обойтись без aapt. Либо, если не требуется компилировать java-исходники, то можно вместо ECJ воспользоваться Jasmin, либо генерировать class-файлы при помощи библиотеки ASM или из smali-файлов ассемблировать классы, а то и вовсе брать готовые dex-файлы и просто объединять их при сборке.

В моём случае классы уже были готовы, поэтому я заранее дексировал их, а потом генерировал MainActivity с нужным package и создавал AndroidManifest.

Тело MainActivity у меня было постоянным, необходимо было менять только package. Поэтому основную часть класса я хранил в виде ресурса, а нужный package просто дописывал при сборке.

  1. DataOutputStream dos = new DataOutputStream(
  2.         new FileOutputStream(new File(packageDir, "MainActivity.class")));
  3. dos.writeInt(0xCAFEBABE);
  4. dos.writeInt(0x00000032);
  5. dos.write(new byte[] {0x00, (byte) 0xA6, 0x01});
  6. dos.writeUTF(String.format("%s/MainActivity", slashesPackage));
  7. copyResource("activity", dos);
  8. dos.flush();
  9. dos.close();

hex-activity1.png
hex-activity2.png

Путём нехитрых преобразований com.annimon.app.AutorunActivity превращается в com.test.app.MainActivity.


Гораздо интереснее было с AndroidManifest. При сборке его кодирует aapt, но ради одного манифеста добавлять 8 Мб зависимостей не хотелось, поэтому я воспользовался библиотекой xxml, которая может как декодировать бинарные ресурсы, так и создавать их динамически.

Открыть спойлер

Идентификаторы взяты экспериментально путём обхода дерева на существующих AndroidManifest файлах.
  • +18
  • views 13357