В гольфе выигрывает тот, у кого меньше очков.
Применим этот принцип в Android. Мы собираемся поиграть в APK-гольф и создать приложение минимально возможного размера, которое можно установить на Android 8.0 Oreo.
Базовый уровень
Начнём с дефолтного приложения, который генерирует Android Studio. Создадим хранилище ключей, подпишем приложение и измерим размер файла в байтах командой stat -f%z $filename
.
Затем установим APK на смартфон Nexus 5x под Oreo, чтобы убедиться, что всё работает.
Прекрасно. Наш APK весит примерно полтора мегабайта.
APK Analyser
Полтора мегабайта кажутся слишком большим размером с учётом того, что делает наше приложение (а оно ничего не делает), так что давайте изучим проект и поищем, где по-быстрому сэкономить на объёме. Вот что сгенерировал Android Studio:
MainActivity
, который расширяетAppCompatActivity
.- Файл макета с
ConstraintLayout
для главного окна. - Файлы ресурсов с тремя цветами, одним строковым ресурсом и темой.
- Библиотеки поддержки
AppCompat
иConstraintLayout
. - Один
AndroidManifest.xml
. - Файлы PNG для квадратной, круглой и фоновой иконок.
Пожалуй, проще всего разобраться с иконками, учитывая, что там в общей сложности 15 изображений и два XML-файла под mipmap-anydpi-v26
. Давайте посчитаем всё это в APK Analyser из Android Studio.
Вопреки нашим первоначальным предположениям, похоже, что самый большой файл — Dex, а на ресурсы приходится всего 20% от размера APK.
Файл | Размер |
---|---|
classes.dex |
74% |
res |
20% |
resources.arsc |
4% |
META-INF |
2% |
AndroidManifest.xml |
<1% |
Исследуем по отдельности, что делает каждый файл.
Файл Dex
classes.dex
— главный виновник раздутого APK, он занимает 73% всего объёма и поэтому станет первой целью оптимизации. Этот файл содержит весь наш скомпилированный код в формате Dex, а также список внешних методов во фреймворке Android и библиотеку поддержки.
В пакете android.support
перечисляется более 13 000 методов, что кажется излишним для приложения типа "Hello World".
Ресурсы
В директории res находится большое количество файлов шаблонов, чертежей и анимаций, которые сразу не видны в интерфейсе Android Studio. Опять же, они вытянуты из библиотеки поддержки и занимают около 20% размера APK.
Файл resources.arsc
также содержит список всех этих ресурсов.
Подпись
В папке META-INF
находятся файлы CERT.SF
, MANIFEST.MF
и CERT.RSA
, которые нужны для подписи v1 APK. Если злоумышленник изменит код внутри APK, то подписи не совпадут, что защищает пользователя от запуска постороннего зловреда.
В MANIFEST.MF
перечисляются файлы из APK, а CERT.SF
содержит контрольные суммы манифеста и каждого отдельного файла. В CERT.RSA
хранится открытый ключ, которым проверяется цельность CERT.SF
.
Здесь нет очевидных целей для оптимизации.
AndroidManifest
AndroidManifest очень похож на наш оригинальный файл. Единственное отличие — вместо ресурсов вроде строк и чертежей здесь указаны их целочисленные идентификаторы, начиная с 0x7F
.
Включаем минификацию
Мы ещё не пробовали включить опцию минификации и сжатия ресурсов в файле build.gradle
для нашего приложения. Сделаем это.
android {
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile(
'proguard-android.txt'), 'proguard-rules.pro'
}
}
}
-keep class com.fractalwrench.** { *; }
Если установить minifyEnabled
в значение true
, то активируется Proguard, который очищает приложение от ненужного кода. А также обфусцирует имена символов, затрудняя обратную разработку приложения.
shrinkResources
удалит из APK любые ресурсы, на которые нет прямой ссылки. Могут возникнуть проблемы, если вы получаете доступ к ресурсам не напрямую, но к нашему приложению это не относится.
786 КБ (уменьшение на 50%)
Мы наполовину уменьшили размер APK без видимого изменения в работе программы.
Если вы ещё не включили minifyEnabled
и shrinkResources
в своём приложении, это самая главная вещь, которую следует вынести из этой статьи. Можно легко сэкономить несколько мегабайт, потратив всего парочку часов на конфигурацию и тестирование.
Прощай, AppCompat, мы едва тебя узнали
classes.dex
теперь занимает 57% всего APK. Основная часть списка методов из файла Dex принадлежит пакету android.support
, так что мы собираемся удалить библиотеку поддержки. Для этого нужно сделать следующее:
- Полностью удалить блок зависимостей из
build.gradle
.dependencies { implementation 'com.android.support:appcompat-v7:26.1.0' implementation 'com.android.support.constraint:constraint-layout:1.0.2' }
- Обновить MainActivity для расширения класса
android.app.Activity
.public class MainActivity extends Activity
- Обновить наш шаблон для использования единого
TextView
.<?xml version="1.0" encoding="utf-8"?> <TextView xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:gravity="center" android:text="Hello World!" />
- Удалить
styles.xml
и аттрибутandroid:theme
из элемента<application>
вAndroidManifest
. - Удалить
colors.xml
. - Сделать 50 отжиманий, пока Gradle синхронизируется.
108 КБ (уменьшение на 87%)
Матерь божья, файл уменьшился почти в десять раз: с 786 КБ до 108 КБ. Единственным заметным изменением стало только изменение цвета тулбара, который окрасился в дефолтную тему ОС.
На директорию res теперь приходится 95% размера APK из-за всех этих иконок лаунчера. Если бы эти иконки делал наш дизайнер, мы бы попытались конвертировать их в WebP, более эффективный формат, который поддерживается в API 15 и более поздних версиях.
К счастью, Google уже оптимизировала наши чертежи, хотя в противном случае мы бы и сами могли оптимизировать их и удалить из PNG ненужные метаданные с помощью ImageOptim.
Давайте поступим нешаблонно — и заменим все наши иконки запуска единственной однопиксельной чёрной точкой в папке res/drawable
. Эта картинка весит 67 байт.
6808 байт (уменьшение на 94%)
Мы избавились почти от всех ресурсов, так что неудивительно, что размер APK уменьшился примерно на 95%. В файле resources.arsc
по-прежнему упоминаются следующие ресурсы:
- 1 файл шаблона
- 1 строковый ресурс
- 1 иконка лаунчера
Пойдём сверху вниз.
Файл шаблона (6262 байта, сокращение на 9%)
Фреймворк Android раздувает наш файл XML и автоматически создаёт объект TextView
, который используется как contentView
для Activity
.
Попробуем обойтись без этого посредника, удалив файл XML и программно задав contentView. Объём ресурсов уменьшится, потому что исчезнет файл XML, но увеличится размер файла Dex, поскольку мы упоминаем там дополнительные методы TextView
.
TextView textView = new TextView(this);
textView.setText("Hello World!");
setContentView(textView);
Выглядит как неплохой обмен.
Имя приложения (6034 байта, сокращение на 4%)
Давайте удалим strings.xml
и заменим android:label
в манифесте AndroidManifest буквой "A". Это кажется маленьким изменением, но удаление записи из resources.arsc
уменьшает количество символов в манифесте и удаляет файл из директории res. Каждая мелочь идёт на пользу — мы только что сэкономили 228 байт.
Иконка лаунчера (5300 байт, сокращение на 13%)
Документация для resources.arsc в репозитории Android Platform объясняет, что каждый ресурс APK упоминается в resources.arsc
с целочисленным идентификатором. У этих ID два пространства имён:
0x01: системные ресурсы (предустановленные в framework-res.apk)
0x7f: ресурсы приложения (в файле .apk приложения)
Так что произойдёт с нашим APK, если мы поставил ссылку на ресурс в пространстве имён 0x01? По идее, мы получим более красивую иконку и одновременно уменьшим размер своего файла.
android:icon="@android:drawable/btn_star"
Само собой, вам никогда не следует доверять системным ресурсам вроде иконок в реальном рабочем приложении. Такой метод провалит валидацию в Google Play, а некоторые производители ещё и по-своему определяют белый цвет, так что действуйте осторожно.
Манифест (5252 байта, сокращение на 1%)
Мы ещё не трогали манифест.
android:allowBackup="true"
android:supportsRtl="true"
Удаление этих аттрибутов экономит 48 байт.
Хак Proguard (4984 байта, сокращение на 5%)
Похоже, что классы BuildConfig
и R
ещё остались в файле Dex.
-keep class com.fractalwrench.MainActivity { *; }
Уточнение правила Proguard удалит ненужные классы.
Обфускация (4936 байт, сокращение на 1%)
Обфусцируем имя для класса Activity. Для обычных классов Proguard автоматически делает это, но поскольку имя класса Activity вызывается через Intents, его не обфусцировали по умолчанию.
MainActivity -> c.java
com.fractalwrench.apkgolf -> c.c
META-INF (3307 байт, сокращение на 33%)
В данный момент мы подписываем приложение одновременно подписями v1 и v2. Это кажется лишней тратой ресурсов, потому что v2 обеспечивает превосходную защиту и производительность, хешируя весь APK целиком.
Подпись v2 не видна из APK Analyser, поскольку включена в бинарный блок в самом файле APK. Подпись v1 видна, в виде файлов CERT.RSA
и CERT.SF
.
Давайте уберём галочку для подписи v1 в интерфейсе Android Studio и сгенерируем подписанный APK. Попробуем сделать и наоборот.
Подпись | Размер |
---|---|
v1 | 3511 |
v2 | 3307 |
Похоже, теперь мы будем использовать v2.
Куда мы идём — там не нужны IDE
Пришло время редактировать APK вручную. Используем следующие команды:
# 1. Создать неподписанный apk
./gradlew assembleRelease
# 2. Разархивировать архив
unzip app-release-unsigned.apk -d app
# Сделать необходимые правки
# 3. Заархивировать архив
zip -r app app.zip
# 4. Запустить zipalign
zipalign -v -p 4 app-release-unsigned.apk app-release-aligned.apk
# 5. Запустить apksigner с подписью v2
apksigner sign --v1-signing-enabled false --ks $HOME/fake.jks --out signed-release.apk app-release-unsigned.apk
# 6. Проверить подпись
apksigner verify signed-release.apk
Детальный обзор процесса подписи APK см. здесь. В общем, Gradle генерирует неподписанный архив, zipalign делает выравнивание по границе байта для несжатых ресурсов, чтобы оптимизировать потребление RAM после загрузки APK, и в конце запускается криптографическая процедура подписи APK.
Неподписанный и невыровненный APK весит 1902 байт, то есть процедура добавляет примерно 1 килобайт.
Несоответствие размеров файлов (2608 байт, сжатие на 21%)
Странно! Если разархивировать невыровненный APK и подписать его вручную, то пропадает файл META-INF/MANIFEST.MF
, что экономит 543 байта. Если кто-то знает, почему так происходит, то дайте знать!
Теперь у нас в подписанном APK осталось три файла. Но ведь мы можем ещё избавиться от файла resources.arsc
, потому что не устанавливаем никаких ресурсов!
После этого остаётся только манифест и файл classes.dex
, оба примерно одинакового размера.
Хаки со сжатием (2599 байт, сокращение на 0,5%)
Теперь изменим все оставшиеся строки на ‘c’, обновив версии до 26, а затем сгенерируем подписанный APK.
compileSdkVersion 26
buildToolsVersion "26.0.1"
defaultConfig {
applicationId "c.c"
minSdkVersion 26
targetSdkVersion 26
versionCode 26
versionName "26"
}
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="c.c">
<application
android:icon="@android:drawable/btn_star"
android:label="c"
>
<activity android:name="c.c.c">
Это уменьшает размер ещё на 9 байт.
Хотя количество символов в файле не изменилось, но дело в том, что увеличилась частотность символа ‘c’. В результате алгоритм сжатия сработал более эффективно.
Привет, ADB (2462 байт, сокращение на 5%)
Можно ещё сильнее оптимизировать манифест, удалив фильтр намерения Launch для класса Activity. С этого момента будем запускать приложение следующей командой:
adb shell am start -a android.intent.action.MAIN -n c.c/.c
Вот новый манифест:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="c.c">
<application>
<activity
android:name="c"
android:exported="true" />
</application>
</manifest>
Мы также избавились от иконки лаунчера.
Очистка от ссылок на методы (2179 байт, сокращение на 12%)
По изначальным условиям, мы должны подготовить APK, который способен установиться на устройство.
Наше приложение перечисляет методы в классах TextView
, Bundle
и Activity
. Можно уменьшить размер файла Dex, удалив эти ссылки и заменив их новым классом Application
. Таким образом, файл Dex теперь будет ссылаться на единственный метод — конструктор класса Application
.
Исходные файлы теперь выглядят следующим образом:
package c.c;
import android.app.Application;
public class c extends Application {}
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="c.c">
<application android:name=".c" />
</manifest>
Используем adb для проверки, что APK успешно установился, это можно также проверить через «Настройки».
Оптимизация Dex (1961 байт, сокращение на 10%)
Я потратил несколько часов, изучая формат файла Dex ради этой оптимизации, поскольку разные механизмы вроде контрольных сумм и смещений затрудняют ручное редактирование.
Если вкратце, в итоге выяснилось, что единственным требованием для установки APK является факт существования файла classes.dex
. Поэтому мы просто удалим оригинальный файл, запустим touch classes.dex
в консоли и сэкономим 10% от размера, используя пустой файл.
Иногда глупейшее решение — самое лучшее.
Понимание манифеста (1961 байт, сокращение на 0%)
Манифест неподписанного APK — это файл в бинарном формате XML, который вроде бы официально не документирован. Можно изменить содержимое файла с помощью редактора HexFiend.
В заголовке файла угадываются некоторые интересные элементы — первые четыре байта кодируют 38
, что совпадает с номером версии файла Dex. Следующие два байта кодируют 660
, что совпадает с размером файла.
Попробуем удалить один байт, установив targetSdkVersion на 1
, и изменив размер файла в заголовке на 659
. К сожалению, система Android отвергает новый файл как неправильный APK. Похоже, тут всё устроено как-то посложнее…
Непонимание манифеста (1777 байт, сокращение на 9%)
А попробуем набросать случайных символов по всему файлу, а затем установить APK, не изменяя указанный размер файла. Так мы проверим, осуществляется ли проверка контрольной суммы, и как наши изменения повлияют на смещения в заголовке файла.
Удивительно, но такой манифест воспринят как валидный APK на Nexus 5X под Oreo:
Мне кажется, я только что услышал, как разработчик фреймворка Android, ответственный за поддержку BinaryXMLParser.java
, очень громко закричал в подушку.
Для максимальной выгоды нужно заменить все эти глупые символы нулевыми байтами. Это поможет распознать важные части файла в HexFiend, а также сократит несколько байт благодаря хаку сжатия, упомянутому выше.
Манифест UTF-8
Вот важные компоненты Manifest, без которых APK не установится.
Некоторые вещи очевидны, такие как теги манифеста и пакета. В пуле строк видны versionCode и название пакета.
Шестнадцатиричный манифест
Просмотр файла в шестнадцатиричном виде показывает значения в заголовке файла, которые описывают пул строк и другие значения, вроде размера файла 0x9402
. Строки тоже интересно закодированы — если они больше 8 байт, то общая длина указывается в двух предыдущих байтах.
Но вряд ли здесь можно найти другие варианты для оптимизации.
Готово? (1757 байт, сокращение 1%)
Изучим окончательный APK.
В течение всего этого имени в APK было указано моё имя в подписи v2. Создадим новое хранилище ключей, в котором используется хак для сжатия.
Мы сэкономили 20 байт.
Шаг 5: Признание
1757
байт — это очень мало, чёрт возьми. И насколько я знаю, это самый маленький существующий APK.
Однако я разумно полагаю, что кто-нибудь из Android-сообщества способен выполнить дальнейшие оптимизации и ещё улучшить результат. Если вы умудритесь уменьшить файл с нынешних 1757
байт, присылайте пулл-реквест в репозиторий, где
Автор: m1rko