В гольфе выигрывает тот, у кого меньше очков.

Применим этот принцип в 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 байт — прим. пер.)

Комментарии (33)


  1. MonkAlex
    12.10.2017 13:41

    The current size of the APK is 820 bytes.


    1. sumanai
      12.10.2017 13:45
      +4

      Там ещё PR до 678 висит. Ещё чуть-чуть, и можно остановится на 666.


  1. Wild__Recluse
    12.10.2017 14:18

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

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


  1. ozonar
    12.10.2017 14:33
    +3

    Использование DSA Keystore, уменьшение размера манифеста (1295 байт, сокращение на 26%)
    Манифест дополнительно оптимизирован с использованием скомпилированного XML-файла, и использования DSA Keystore меньше по размеру, чем его создаёт Android Studio.

    Сплошное сжатие zopfli (1180 байт, сокращение на 9%)
    Улучшение сжатия APK.

    Использование сигнатуры эллиптической кривой (922 байта, сокращение на 16%)
    Подписи эллиптической кривой еще меньше чем DSA, и поддерживаются в подписи APK v2.

    Удаление classes.dex (824 байта, уменьшение на 11%)
    Если в манифесте отсутствует элементы кода, для PackageParser не требуется файл classes.dex.

    Дальнейшая ручная настройка манифеста (820 байт, сокращение на 0,5%),
    Дальнейшая оптимизация байтового уровня манифеста.


  1. ozonar
    12.10.2017 14:35

    Что происходит с отображением самого apk файла? Он запускается, или после всех ухищрений остаётся только сам факт валидности APK?


    1. JTG
      12.10.2017 15:32

      Второе. Он устанавливается и всё :)


  1. Nagg
    12.10.2017 14:38
    +5

    Скиньте статью разработчикам из фейсбука кто-нибудь


  1. Drag13
    12.10.2017 14:47
    +1

    Простите, но это прекрасно!


  1. wxmaper
    12.10.2017 16:08
    +1

    Теперь сделайте тоже самое с APK, собираемым QtCreator


    1. RPG18
      12.10.2017 16:10

      Да вроде приемлемого размера получается.


      1. wxmaper
        12.10.2017 16:16

        Да там же кутишные библиотеки вшиваются, министро или как их там. Вообще, меня это нисколько не смущает.


        1. RPG18
          12.10.2017 16:23

          министро- это отдельный apk, но да же без него у меня на простых приложениях получалось в районе 9 Mb.


          1. wxmaper
            12.10.2017 16:27

            Кто-то из нас двоих что-то путает. Насколько помню, при сборке можно выбрать встраивать библиотеки qt или нет. Вот с ними получается ~9мб, без них значительно меньше, но без них у меня и с установленным министро никогда ничего не запускалось, даже hello world.


  1. Stochkas
    12.10.2017 17:12
    +1

    круто. разбалованы железом мы.


    1. misha057
      15.10.2017 12:16

      Однако 1,5 Мб для Hello world это очень и очень!


  1. lazexe
    12.10.2017 17:50

    Почитать интересно, но в реальной жизни большинство не пригодится…
    Как минимум как уже обойтись без support library? Не говоря уже о сокращении манифест файла, смене иконки, и тем более о keystore.


  1. TechnoMag
    12.10.2017 18:51

    Был у меня проект (с конфигами по-умолчанию) на Eclipse без Gradle, который по умолчанию собирал apk до размера 200Кб. Перенос в Android Studio увеличил файл до 1Мб.


  1. MisterParser
    12.10.2017 19:10

    Покупал как-то чайник Redmond, который с управлением через Bluetooth, поставил программу на смартфон для удалённого управления чайником. 47 Мб…


    1. RPG18
      12.10.2017 19:19

      Эту штуку делают Ready for Sky, раньше пилили приложение на C++/Qt, но похоже перешли на Java


      1. MisterParser
        12.10.2017 20:25

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


        1. FlameStorm
          12.10.2017 23:58

          Интересно, хоть один заявит, что нет корреляции между качеством чайника и качеством приложения, уровнем инженерной проработки решения девайса и инженерной проработки софта? )


          1. Tallefer
            13.10.2017 01:44

            Я бы лучше спросил, нафига там с самого начала блютуз (понятно, что с ним все становится круче), если он все равно до кухни не добивает? %)


            1. domix32
              13.10.2017 10:24

              Чтобы можно было гордо отвечать статусом 418 I'm a teapot в ближайшем IOT-окружении.


              1. Tallefer
                13.10.2017 12:05

                Ухаха, точно! С другой стороны, и в чайнике уже без веба не обошлось… %)


            1. Rast1234
              13.10.2017 12:16

              В этих чайниках нет самого очевидного — нельзя удаленно узнать, сколько в нем воды. Можно вкл/выкл и цвет подсветки менять. А зачем его включать, если в нем кончилась вода?..


              1. Tallefer
                13.10.2017 12:42

                Неужели ни в одном нет?! Тогда это вообще жесть какая-то. Буквально. %)


            1. yea
              13.10.2017 13:48

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

              но
              (на самом деле всё это не важно и не нужно, я просто поиграться его купил, потому что это клево, и оправдываюсь теперь)


    1. gban
      13.10.2017 07:51

      Представил себе чайник-криптоферму… :)


  1. androidovshchik
    13.10.2017 10:42

    создать приложение минимально возможного размера, которое можно установить на Android 8.0 Oreo

    Почему нельзя было бы установить на android 8?


  1. plastilinko
    13.10.2017 12:19

    Статья как нельзя кстати рассказывает почему не смотря на рост производительности устройств и выходов новых версий ОС устройства в среднем все равно работают медленно)
    Существует такая игрушка .kkrieger 96кб с графикой уровня Q3 но никому не нужна такая оптимизация, нужно чтобы заводы штамповали девайсы а кодеры писали тонны кода под который были бы нужны все более емкие хранители, а потом это все потребляет больше энегрии и так далее… :)


    1. Tallefer
      13.10.2017 12:44

      Ну, ради честности — что пример из статьи (после определенного момента, когда пример уже не мог запускаться), что та игрушка, это не совсем оптимизация, это уже, скорее, выжимание всех соков, экстрим. :)


    1. ITurchenko
      15.10.2017 12:16

      Оптимизация требует времени программиста (а также тестировщика и менеджера), а следовательно большего бюджета. Все хотят оптимизированные программы, но мало кто захочет переплачивать вдвое/втрое за оптимизацию.


      1. Tallefer
        15.10.2017 15:07

        При этом ВСЕ переплачивают за НЕоптимизацию. :) Новым железом, электричеством, временем…