Уменьшение размера APK (в разумных пределах)

На Habr.com уже была подобная статья, доказывающая, что можно ужать APK файл с 1.5 МБ до 1757 байт и меньше. Цель данной статьи — уменьшить размер приложения до разумного предела, сохранив его функциональность и осветить некоторые тонкости и неявные моменты.

Начало


Создадим проект в Android Studio, выберем Empty Activity. Затем в файле styles.xml заменим Activity c ActionBar'ом

Theme.AppCompat.Light.DarkActionBar

на Activity без ActionBar'а

Theme.AppCompat.Light.NoActionBar

Итог:

image

В анализаторе APK видим следующее:



Итак, APK весит 1.5 МБ, при том, что оно только выводит надпись «Hello World!».

Этап первый (минификация)


В файле build.gradle пишем:


android {
    buildTypes {
        debug {
            minifyEnabled true
            shrinkResources true
            proguardFiles getDefaultProguardFile
('proguard-android.txt'), 'proguard-rules.pro'
        }
    }
}

Синхронизируем Android Studio, чтобы изменения вступили в силу.

Пояснение:

minifyEnabled true
уберёт ненужный код в приложении
shrinkResources true
удалит из APK не используемые ресурсы.

Вес APK стал 960 КБ, без изменений в работе.

Этап второй (добавление функциональности)


Чтобы приложение имело смысл, добавим ему функциональность, например, кликер.

Код activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:id="@+id/number"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Hello World!"
        android:textSize="20dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintLeft_toLeftOf="parent"
        app:layout_constraintRight_toRightOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <ImageButton
        android:id="@+id/imageButton"
        android:layout_width="90dp"
        android:layout_height="90dp"
        android:layout_marginBottom="32dp"
        android:layout_marginEnd="8dp"
        android:layout_marginStart="8dp"
        android:background="#000000FF"
        android:cropToPadding="false"
        android:scaleType="fitXY"
        android:visibility="visible"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:srcCompat="@mipmap/ic_launcher_round" />

</android.support.constraint.ConstraintLayout>


Код MainActivity.java
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.view.MotionEvent;
import android.view.View;
import android.widget.ImageButton;
import android.widget.TextView;

public class MainActivity extends AppCompatActivity {
    SharedPreferences Settings;
    ImageButton button;
    TextView text;
    int num = 31;

    View.OnTouchListener on = new View.OnTouchListener() {

        @Override
        public boolean onTouch(View v, MotionEvent event) {
            if (event.getAction() == MotionEvent.ACTION_DOWN) {
                num--;
                if(num > 0) {
                    text.setText(Integer.toString(num));
                } else {
                    num = 31;
                    text.setText("Нажмите, чтобы начать заново");
                }
            }
            return false;
        }
    };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        Settings = getSharedPreferences("settings", Context.MODE_PRIVATE);

        if (Settings.contains("left"))
            num = Settings.getInt("left", 0);

        button = findViewById(R.id.imageButton);

        button.setOnTouchListener(on);

        text = findViewById(R.id.number);

        if(num > 0) {
            text.setText(Integer.toString(num));
        } else {
            num = 31;
            text.setText("Нажмите, чтобы начать заново");
        }
    }

    @Override
    protected void onPause() {
        super.onPause();
        SharedPreferences.Editor editor = Settings.edit();
        editor.putInt("left", num);
        editor.apply();
    }
}

Приложение приобрело следующий вид:



Размер приложения 1.1 МБ, увеличение на 140 КБ.

Этап третий (убираем android.support и AppCompat)


На данный момент анализатор APK показывает следующее:



Заменим public class MainActivity extends AppCompatActivity на public class MainActivity extends Activity в MainActivity.java. Нажмите Alt + Enter, чтобы Android Studio импортировала библиотеки.

Размер приложения не изменился, но…



Где же кнопка?

На самом деле всё в порядке, она осталась кликабельной. Но у неё просто нет картинки.

Идем в activity_main.xml, находим внизу строчку

app:srcCompat="@mipmap/ic_launcher_round"

и меняем её на

android:src="@mipmap/ic_launcher_round" 

Теперь всё в порядке:



Но размер не изменился.

Идём опять в build.gradle и очищаем блок зависимостей:


dependencies {
}

И синхронизируем Android Studio… с ошибками.

1. Идём в файл res/values/styles.xml и заменяем всё его содержимое следующим кодом:


<resources>
    <style name="AppTheme" parent="android:Theme.DeviceDefault.NoActionBar">
        
    </style>
</resources>

2. ConstraintLayout, который используется в Android Studio, зависит от android.support, который уже был удалён из блока dependencies, поэтому заменим ConstraintLayout на RelativeLayout, который не зависит от android.support.

Код activity_main.xml:

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#FFFFFF">

    <TextView
        android:id="@+id/number"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerHorizontal="true"
        android:layout_centerVertical="true"
        android:text="Hello World!"
        android:textSize="20dp" />

    <ImageButton
        android:id="@+id/imageButton"
        android:layout_width="90dp"
        android:layout_height="90dp"
        android:layout_alignParentBottom="true"
        android:layout_centerHorizontal="true"
        android:layout_marginBottom="32dp"
        android:background="#000000FF"
        android:scaleType="fitXY"
        android:src="@mipmap/ic_launcher_round"
        android:visibility="visible" />

</RelativeLayout>


Компилируем APK и смотрим итог:



Наш APK весит 202 КБ, что в 7.5 раз меньше его начального размера.

Большую часть занимают ресурсы, ими и займемся.

1. Удалим файлы в папке res/drawable и очистим папку res/mipmap
2. Нарисуем свою иконку и кнопку, затем уменьшим её размер с помощью ImageOptim.

Кнопка:



Иконка:



3. Загрузим их в Android Studio, для этого выделим их в папке, нажмём Ctrl + C, перейдём в Android Studio, выберем папку res/drawable и нажмём Ctrl + V, после чего Andoid Studio предложит несколько вариантов, куда именно перенести изображения в зависимости от их разрешения.

В папке drawable можно переименовать файл, выбрав Refactor -> Rename.
Иконка приложения имеет имя i.png, картинка для кнопки b.png

4. Теперь переходим в файл AndroidManifest.xml, строчки


android:icon="@mipmap/ic_launcher"
android:roundIcon="@mipmap/ic_launcher_round"

заменим на


android:icon="@drawable/i"
android:roundIcon="@drawable/i"

В файле activity_main.xml заменим поле в ImageButton


android:src="@mipmap/ic_launcher_round"

на


android:src="@drawable/b"

Итог


Итоговый размер файла составляет 13.4 КБ, что в 112 раз меньше начального объёма!

Итоговый код MainActivity.java
import android.app.Activity;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.view.MotionEvent;
import android.view.View;
import android.widget.ImageButton;
import android.widget.TextView;

public class MainActivity extends Activity {
    SharedPreferences Settings;
    ImageButton button;
    TextView text;
    int num = 31;

    View.OnTouchListener on = new View.OnTouchListener() {

        @Override
        public boolean onTouch(View v, MotionEvent event) {
            if (event.getAction() == MotionEvent.ACTION_DOWN) {
                num--;
                if(num > 0) {
                    text.setText(Integer.toString(num));
                } else {
                    num = 31;
                    text.setText("Нажмите, чтобы начать заново");
                }
            }
            return false;
        }
    };

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.m);

        Settings = getSharedPreferences("settings", Context.MODE_PRIVATE);

        if (Settings.contains("left"))
            num = Settings.getInt("left", 0);
        
        text = findViewById(R.id.number);
        button = findViewById(R.id.button);
        button.setOnTouchListener(on);

        if(num > 0) {
            text.setText(Integer.toString(num));
        } else {
            num = 31;
            text.setText("Нажмите, чтобы начать");
        }
    }

    @Override
    protected void onPause() {
        super.onPause();
        SharedPreferences.Editor editor = Settings.edit();
        editor.putInt("left", num);
        editor.apply();
    }
}


Итоговый код activity_main.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#FFFFFF">

    <TextView
        android:id="@+id/number"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerHorizontal="true"
        android:layout_centerVertical="true"
        android:text="Hello World!"
        android:textSize="20dp" />

    <ImageButton
        android:id="@+id/button"
        android:layout_width="90dp"
        android:layout_height="90dp"
        android:layout_alignParentBottom="true"
        android:layout_centerHorizontal="true"
        android:layout_marginBottom="32dp"
        android:background="#000000FF"
        android:scaleType="fitXY"
        android:src="@drawable/b"
        android:visibility="visible" />

</RelativeLayout>


На этом оканчивается разумное уменьшение APK файла, далее идёт инструкция по дальнейшему уменьшению приложения в ущерб удобства разработки.

Удаляем ресурсы


Удалим папку res/values, в файле AndroidManifest.xml заменим блок application на следующий код:


    <application
        android:icon="@drawable/i"
        android:roundIcon="@drawable/i"
        android:label="Clicker"
        android:theme="@style/android:Theme.DeviceDefault.NoActionBar">
        <activity android:name=".MainActivity"><intent-filter><action android:name="android.intent.action.MAIN" />
            <category android:name="android.intent.category.LAUNCHER" /></intent-filter>
        </activity>
    </application>

Также заменим идентификаторы на однобуквенные, файл activity_main.xml переименуем в m.xml

Изменим обработку нажатия:

Удалим строку

button.setOnTouchListener(on);

в MainLayout.java.
Заменим функцию

View.OnTouchListener on = new View.OnTouchListener() {
        @Override
        public boolean onTouch(View v, MotionEvent event) {
            if (event.getAction() == MotionEvent.ACTION_DOWN) {
                num--;
                if(num > 0) {
                    text.setText(Integer.toString(num));
                } else {
                    num = 31;
                    text.setText("Нажмите, чтобы начать заново");
                }
            }
            return false;
        }
    };

на
public void o(View v) {
        num--;
        if(num > 0) {
            text.setText(Integer.toString(num));
        } else {
            num = 31;
            text.setText("Нажмите, чтобы начать заново");
        }
    }

В файле m.xml (бывшем activity_main) в структуре ImageButton добавим строчку

android:onClick="o"

Итоговый размер составил 10.2 КБ, в 147 раз меньше начального размера. Я считаю, что это хороший результат.



Код MainActivity.java
import android.app.Activity;
import android.content.Context;
import android.content.SharedPreferences;
import android.os.Bundle;
import android.view.View;
import android.widget.ImageButton;
import android.widget.TextView;

public class MainActivity extends Activity {
    SharedPreferences Settings;
    ImageButton button;
    TextView text;
    int num = 31;


    public void o(View v) {
        num--;
        if(num > 0) {
            text.setText(Integer.toString(num));
        } else {
            num = 31;
            text.setText("Нажмите, чтобы начать заново");
        }
    }

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.m);

        Settings = getSharedPreferences("settings", Context.MODE_PRIVATE);

        if (Settings.contains("left"))
            num = Settings.getInt("left", 0);

        text = findViewById(R.id.n);
        button = findViewById(R.id.b);

        if(num > 0) {
            text.setText(Integer.toString(num));
        } else {
            num = 31;
            text.setText("Нажмите, чтобы начать");
        }
    }

    @Override
    protected void onPause() {
        super.onPause();
        SharedPreferences.Editor editor = Settings.edit();
        editor.putInt("left", num);
        editor.apply();
    }
}


Код m.xml

<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#FFFFFF">

    <TextView
        android:id="@+id/n"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_centerHorizontal="true"
        android:layout_centerVertical="true"
        android:textColor="#000000"
        android:textSize="20dp" />

    <ImageButton
        android:id="@+id/b"
        android:layout_width="90dp"
        android:layout_height="90dp"
        android:layout_alignParentBottom="true"
        android:layout_centerHorizontal="true"
        android:layout_marginBottom="32dp"
        android:background="#000000FF"
        android:scaleType="fitXY"
        android:src="@drawable/b"
        android:onClick="o"
        android:visibility="visible" />

</RelativeLayout>

Поделиться публикацией
Ой, у вас баннер убежал!

Ну. И что?
Реклама
Комментарии 23
    +1
    Спасибо за статью, а использование ресурсов по типу:
    android:theme="@style/android:Theme.DeviceDefault.NoActionBar"

    не может вызвать ResNotFound на каких-нибудь китайских устройствах?
      0
      Если в файле res/values/styles.xml зажать Ctrl и кликнуть мышью в строке по «Theme.AppCompat.Light», то Вы попадёте в другой файл, в котором будет указан стиль, от которого идет наследование «AppTheme» если операцию повторить несколько раз, то можно дойти до «android:Theme.DeviceDefault.NoActionBar», или, например, до «android:Theme.Holo.Light».
      На счёт китайский устройств не могу быть уверен, но скорее всего не вызовет ошибку.
      +5

      Ок, а смысл? За исключением вот этого:


      shrinkResources true
      minifyEnabled true

      Все остальное – не очень понятно, что автор предлагает с этим делать. С практической точки зрения разрабатывать реальное приложение без support library – это надо быть очень изощренным мазохистом. С академической – ну вроде и так понятно, что если убрать зависимости, то приложение должно весить меньше.

        +1
        Смысл в том, что размер приложения должен зависить от его функциональности.
        И если функциональность ограничивается простым кликером, то приложение не должно весить более 50 КБ, иначе его размер повлияет на количество скачиваний. Вот ссылка, в которой показано влияние размера приложения на количество загрузок.
        Да, Вы правы, для приложения размером 50 МБ или 51 МБ прирост не велик ценой потери support library, и здесь лучше оставить библиотеки поддержки ради удобства разработки. Но для очень простых приложений, может быть эффективен отказ от support library ради уменьшения размера.
          +6

          Ну вообще приложение с функциональностью Hello World может весить хоть 100Кб, хоть 100Мб – оно все равно нафиг никому не нужно, и скачивать его никто не будет.


          В 99.99% случаев стартовый размер в 1Мб – это нормально, если у вас в приложение будет хоть что-то полезное. Уменьшать дальше – ну разве что для хаба "Ненормальное программирование".

            0
            А потом телефоны с 2 Гб оперативной памяти становятся «бюджетными».
              +1

              Да я обеими руками за уменьшение размера приложений. Но только работающими на практике способами, вон ниже товарищ все правильно написал, а не экономией на иконке или support library (которая сейчас вполне себе модульная).

                0
                Вот у меня в телефоне есть приложение PowerTorch, которое включает вспышку (то есть фонарик). Весит оно 124 Кб. Я не знаю, какими способами автор его уменьшал, но свои функции оно выполняет, и мегабайт памяти (а телефон действительно бюджетный, а не «бюджетный») экономит.
                  +1

                  К сожалению, это единственное подобное приложение во всей экосистеме Android. Все остальные приложения имеют запредельные размеры. Какое-нибудь приложение магазина или кафе, нужное только для того, чтобы получить скидку, и имеющую всего одну функциональность: показать штрихкод и размер скидки, будет весить мегабайт 80-160. Каждый раз когда смотрю на размер устанавливаемых приложений, в голове не укладывается.


                  Совсем недавно в эти размеры могла уместиться операционная система, с набором офисных приложений и игр. А сейчас — одно приложение с одной картинкой и примитивным функционалом.


                  И таки да, покупать Android телефон с размером ОЗУ менее 3Гб и размером флешки менее 32 Гб — стрелять себе в ногу, потому что все эти приложения выжрут все место на флешке и в ОЗУ моментально. После опыта использования двух бюджетых андроидофонов, я окончательно усвоил истину, что покупать что-то с худшими характеристиками, чем у флагманов — обрекать себя на невыносимые страдания (такие как невозможность обновления приложений).

                    +1
                    > К сожалению, это единственное подобное приложение во всей экосистеме Android.

                    Полно таких приложений. Root Explorer — ≈500 кб, Nesoid (эмулятор NES) — 300 кб, SQLite Editor — 200 кб…

                    > Все остальные приложения имеют запредельные размеры. Какое-нибудь приложение магазина или кафе, нужное только для того, чтобы получить скидку, и имеющую всего одну функциональность: показать штрихкод и размер скидки, будет весить мегабайт 80-160.

                    Ну а подобные приложения всегда писались даже не задней ногой, а хвостом.
                      0
                      Ну а подобные приложения всегда писались даже не задней ногой, а хвостом.

                      Скажите спасибо таким кроссплатформенным фреймворкам как ReactNative, Xamarin, Qt, Flutter. Бизнес выбирает где дешевле, их размер бинарника не сильно волнует.


                      Я общаюсь с людьми которые пишут кроссплатформенные приложения, они считают что приложения с минимальным функционалом размером в 10-20 мб это нормально.

                  0
                  ну вот я, лично, критично смотрю на вес приложений… было приложение у меня с клавой, в какой-то момент (точных причин не знаю, я на кастоме и после обновления) перестало работать. привлекало тем, что делает что нужно, лишних прав не требует и весило мало. тогда я написал своё, но размер меня не устроил и тут вышла прошлая статья… уменьшил и то что надо вышло.
          +1
          Также заменим идентификаторы на однобуквенные

          А это то чем поможет? Сейчас запретили сжатие и эти биты дают какой-то выигрыш по сравнению с читабельным кодом?
            0
            Верно, читабельность кода уменьшается, ради 30-100 байт. Если вес приложения исчисляется в единицах килобайт, то небольшой выигрыш может быть.
              +2
              Если уж хотите поизвращаться, то ничего переименовывать не нужно.
              После компиляции, в dex и xml файлах все пользовательские атрибуты и ссылки заменяются на resId (например, 0x7f0b0004) и пакуется в файл resources.arsc, где и хранятся все строки, включая пути к файлам, все типы (drawable, string, etc) и все ключи (например, ic_launcher_round). Можно взять, разобрать этот нехитрый файл, удалить все ключи, переименовать любые файлы из ресурсов, переместив их из res/ хоть в корень архива, провести некоторые оптимизации, заново запаковать файл ресурсов и подписать архив.
              Так вы не потеряете читабельный код и сможете оптимизировать и даже обфусцировать ссылки на ресурсы, атрибуты и даже «поломать» этим великий и могучий apktool. Только выигрыш в плане уменьшения байт с этого всё равно минимальный.
            0

            Вот все бы так код писали… В плане размера.

              +4
              Пусть лучше все пишут код, который легко поддерживать.
              0
              А можно ли при перепаковке APK внедрить туда какой-то вредоносный код? Давно вызывают подозрение приложения выложенные на 4PDA и подобных местах.
                +1

                Можно, конечно, просто подписано оно будет не подписью оригинального разработчика.

                  0
                  Можно. Но при установке же можно проверить подпись. Да и уже установленных тоже.
                  +3
                  Гугл для уменьшения веса предлагает App bundle и dynamic features. Это есть смысл использовать в реальных приложениях. А вырезать support library? Зачем?
                    +4
                    Для меня смысл данной статьи сводится к «Удаляем зависимости которые не используем и это сокращает обьем APK» — что собственно и так крайне очевидно. Пример со сферическим конем в вакууме. Как часто вы создаете приложение с одной кнопкой? Вот знаете, когда у вас будет стоять настоящая задача минификации (допустим с 4х dex файлов до какого то минимума), переименовыванием ресурсов тут явно не обойтись.

                    Я надеялся увидеть хотя бы кастомные настройки для proguard или что то в этом духе.

                    Установка минификации в gradle конфигурации является чем то обыденным для релиза.

                    Обработка нажатия через OnTouchListener? Серьезно?

                      +1

                      Вот пример того, что в apk весом в 11,2 кБ может уместиться полезное приложение с некоторым функционалом.
                      https://github.com/qwert2603/CryptoPrice

                      Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                      Самое читаемое