В гольфе выигрывает тот, у кого меньше очков.
Применим этот принцип в Android. Мы собираемся поиграть в APK-гольф и создать приложение минимально возможного размера, которое можно установить на Android 8.0 Oreo.
Начнём с дефолтного приложения, который генерирует Android Studio. Создадим хранилище ключей, подпишем приложение и измерим размер файла в байтах командой
Затем установим APK на смартфон Nexus 5x под Oreo, чтобы убедиться, что всё работает.
Прекрасно. Наш APK весит примерно полтора мегабайта.
Полтора мегабайта кажутся слишком большим размером с учётом того, что делает наше приложение (а оно ничего не делает), так что давайте изучим проект и поищем, где по-быстрому сэкономить на объёме. Вот что сгенерировал Android Studio:
Пожалуй, проще всего разобраться с иконками, учитывая, что там в общей сложности 15 изображений и два XML-файла под
Вопреки нашим первоначальным предположениям, похоже, что самый большой файл — Dex, а на ресурсы приходится всего 20% от размера APK.
Исследуем по отдельности, что делает каждый файл.
В пакете
В директории res находится большое количество файлов шаблонов, чертежей (drawables) и анимаций, которые сразу не видны в интерфейсе Android Studio. Опять же, они вытянуты из библиотеки поддержки и занимают около 20% размера APK.
Файл
В папке
В
Здесь нет очевидных целей для оптимизации.
AndroidManifest очень похож на наш оригинальный файл. Единственное отличие — вместо ресурсов вроде строк и drawables здесь указаны их целочисленные идентификаторы, начиная с
Мы ещё не пробовали включить опцию минификации и сжатия ресурсов в файле
Если установить
Мы наполовину уменьшили размер APK без видимого изменения в работе программы.
Если вы ещё не включили
Матерь божья, файл уменьшился почти в десять раз: с 786 КБ до 108 КБ. Единственным заметным изменением стало только изменение цвета тулбара, который окрасился в дефолтную тему ОС.
На директорию res теперь приходится 95% размера APK из-за всех этих иконок лаунчера. Если бы эти иконки делал наш дизайнер, мы бы попытались конвертировать их в WebP, более эффективный формат, который поддерживается в API 15 и более поздних версиях.
К счастью, Google уже оптимизировала наши drawables, хотя в противном случае мы бы и сами могли оптимизировать их и удалить из PNG ненужные метаданные с помощью ImageOptim.
Давайте поступим нешаблонно — и заменим все наши иконки запуска единственной однопиксельной чёрной точкой в папке
Мы избавились почти от всех ресурсов, так что неудивительно, что размер APK уменьшился примерно на 95%. В файле
Пойдём сверху вниз.
Фреймворк Android раздувает наш файл XML и автоматически создаёт объект
Попробуем обойтись без этого посредника, удалив файл XML и программно задав contentView. Объём ресурсов уменьшится, потому что исчезнет файл XML, но увеличится размер файла Dex, поскольку мы упоминаем там дополнительные методы
Выглядит как неплохой обмен.
Давайте удалим
Документация для resources.arsc в репозитории Android Platform объясняет, что каждый ресурс APK упоминается в
Так что произойдёт с нашим APK, если мы поставил ссылку на ресурс в пространстве имён 0x01? По идее, мы получим более красивую иконку и одновременно уменьшим размер своего файла.
Само собой, вам никогда не следует доверять системным ресурсам вроде иконок в реальном рабочем приложении. Такой метод провалит валидацию в Google Play, а некоторые производители ещё и по-своему определяют белый цвет, так что действуйте осторожно.
Мы ещё не трогали манифест.
Удаление этих аттрибутов экономит 48 байт.
Похоже, что классы
Уточнение правила Proguard удалит ненужные классы.
Обфусцируем имя для класса Activity. Для обычных классов Proguard автоматически делает это, но поскольку имя класса Activity вызывается через Intents, его не обфусцировали по умолчанию.
В данный момент мы подписываем приложение одновременно подписями v1 и v2. Это кажется лишней тратой ресурсов, потому что v2 обеспечивает превосходную защиту и производительность, хешируя весь APK целиком.
Подпись v2 не видна из APK Analyser, поскольку включена в бинарный блок в самом файле APK. Подпись v1 видна, в виде файлов
Давайте уберём галочку для подписи v1 в интерфейсе Android Studio и сгенерируем подписанный APK. Попробуем сделать и наоборот.
Похоже, теперь мы будем использовать v2.
Пришло время редактировать APK вручную. Используем следующие команды:
Детальный обзор процесса подписи APK см. здесь. В общем, Gradle генерирует неподписанный архив, zipalign делает выравнивание по границе байта для несжатых ресурсов, чтобы оптимизировать потребление RAM после загрузки APK, и в конце запускается криптографическая процедура подписи APK.
Неподписанный и невыровненный APK весит 1902 байт, то есть процедура добавляет примерно 1 килобайт.
Странно! Если разархивировать невыровненный APK и подписать его вручную, то пропадает файл
Теперь у нас в подписанном APK осталось три файла. Но ведь мы можем ещё избавиться от файла
После этого остаётся только манифест и файл
Теперь изменим все оставшиеся строки на ‘c’, обновив версии до 26, а затем сгенерируем подписанный APK.
Это уменьшает размер ещё на 9 байт.
Хотя количество символов в файле не изменилось, но дело в том, что увеличилась частотность символа ‘c’. В результате алгоритм сжатия сработал более эффективно.
Можно ещё сильнее оптимизировать манифест, удалив фильтр намерения Launch для класса Activity. С этого момента будем запускать приложение следующей командой:
Вот новый манифест:
Мы также избавились от иконки лаунчера.
По изначальным условиям, мы должны подготовить APK, который способен установиться на устройство.
Наше приложение перечисляет методы в классах
Исходные файлы теперь выглядят следующим образом:
Используем adb для проверки, что APK успешно установился, это можно также проверить через «Настройки».
Я потратил несколько часов, изучая формат файла Dex ради этой оптимизации, поскольку разные механизмы вроде контрольных сумм и смещений затрудняют ручное редактирование.
Если вкратце, в итоге выяснилось, что единственным требованием для установки APK является факт существования файла
Иногда глупейшее решение — самое лучшее.
Манифест неподписанного APK — это файл в бинарном формате XML, который вроде бы официально не документирован. Можно изменить содержимое файла с помощью редактора HexFiend.
В заголовке файла угадываются некоторые интересные элементы — первые четыре байта кодируют
Попробуем удалить один байт, установив targetSdkVersion на
А попробуем набросать случайных символов по всему файлу, а затем установить APK, не изменяя указанный размер файла. Так мы проверим, осуществляется ли проверка контрольной суммы, и как наши изменения повлияют на смещения в заголовке файла.
Удивительно, но такой манифест воспринят как валидный APK на Nexus 5X под Oreo:
Мне кажется, я только что услышал, как разработчик фреймворка Android, ответственный за поддержку
Для максимальной выгоды нужно заменить все эти глупые символы нулевыми байтами. Это поможет распознать важные части файла в HexFiend, а также сократит несколько байт благодаря хаку сжатия, упомянутому выше.
Вот важные компоненты Manifest, без которых APK не установится.
Некоторые вещи очевидны, такие как теги манифеста и пакета. В пуле строк видны versionCode и название пакета.
Просмотр файла в шестнадцатиричном виде показывает значения в заголовке файла, которые описывают пул строк и другие значения, вроде размера файла
Но вряд ли здесь можно найти другие варианты для оптимизации.
Изучим окончательный APK.
В течение всего этого имени в APK было указано моё имя в подписи v2. Создадим новое хранилище ключей, в котором используется хак для сжатия.
Мы сэкономили 20 байт.
Однако я разумно полагаю, что кто-нибудь из Android-сообщества способен выполнить дальнейшие оптимизации и ещё улучшить результат. Если вы умудритесь уменьшить файл с нынешних
Применим этот принцип в 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 находится большое количество файлов шаблонов, чертежей (drawables) и анимаций, которые сразу не видны в интерфейсе 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 очень похож на наш оригинальный файл. Единственное отличие — вместо ресурсов вроде строк и drawables здесь указаны их целочисленные идентификаторы, начиная с
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 уже оптимизировала наши drawables, хотя в противном случае мы бы и сами могли оптимизировать их и удалить из 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
байт, присылайте пулл-реквест в репозиторий, где хостится самый маленький APK, или сообщайте в твиттере. (С момента публикации статьи файл уже уменьшили до 820 байт — прим. пер.)