以下はPlaying APK Golfの戸田奈津子訳です。

Playing APK Golf

Reducing an Android APK's size by 99.99%

ゴルフでは、最も得点の小さい者が勝利します。

この原則をAndroidにも適用しましょう。
apkのファイルサイズを減らし、Oreoで実行できる最小サイズのアプリを作成するのです。

Measuring a Baseline

Android Studioで生成されたデフォルトのアプリから開始しましょう。
キーストアを生成し、アプリに署名し、そしてstat -f%z $filename.コマンドでファイルサイズをバイト単位で測定します。

また、Oreoが動いているNexus5にapkをインストールし、アプリが実際に動作することも確認します。

01.png

すばら!
apkのファイルサイズは約1.5メガバイトでした。

APK Analyser

アプリの内容に対して、1.5メガバイトの容量はあまりに大きすぎます。
Android Studioが何を生成したのか、プロジェクトを詳しく調査していきましょう。

AppCompatActivityを継承したMainActivity
ConstraintLayoutで作られたレイアウトファイル。
・3つの色、1つの文字列、1つのテーマの入ったリソースファイル。
AppCompatConstraintLayoutのサポートライブラリ。
AndroidManifest.xml
・ランチャーのアイコンpngファイル。

おそらく最も容易なターゲットはアイコン画像でしょう。
なにしろmipmap-anydpi-v26の下に全部で15もの画像と2個のXMLファイルが存在します。
Android Studio付属のAPK Analyserで確認してみます。

02.jpg

予想に反してclasses.dexファイルが最も大きく、リソースはapkのわずか20%に過ぎませんでした。

File Size
classes.dex 74%
res 20%
resources.arsc 4%
META-INF 2%
AndroidManifest.xml <1%

各ファイルがそれぞれ何をしているのか、順に見ていきましょう。

Dex file

classes.dexは全容量の74%を占有している最大の犯人で、従って我々の最初のターゲットです。
ここには我々が書いたコードと、その他のAndroidフレームワークやサポートライブラリなどがまとめてDexフォーマットで格納されています。

android.supportパッケージは13000以上のメソッドに対応していますが、これは我々が作成したHello worldアプリには少々過剰なように思えます。

Resources

resディレクトリには、Android Studioでは表示されていなかった膨大な数のレイアウト、drawable、アニメーションファイル等が存在します。
これらはサポートライブラリから取り込まれたものであり、apkのサイズの20%を占めています。

03.jpg

ファイルresources.arscは、これら各リソースの一覧ファイルです。

Signing

META-INFディレクトリにはCERT.SFMANIFEST.MF、およびCERT.RSAが入っています。
これらはv1 APK署名であり、もし攻撃者がapkを改変した場合、apkと署名が一致しなくなります。
これにより、apkはマルウェアによる汚染から保護されることになります。

MANIFEST.MFはapk内のファイル一覧です。
CERT.SFはmanifestおよび各ファイルのダイジェストが入っています。
CERT.RSAには、CERT.SFの完全性を検証するための公開鍵が含まれています。

04.jpg

ここをどうにかするのは難しそうです。

AndroidManifest

AndroidManifestはapk作成前のものとほとんど同じです。
唯一の違いは、文字列やdrawableなどのリソースが0x7Fで始まるリソースIDに置き換えられていることです。

Enable minification

最初にapkを作成したときは、build.gradleでの最適化やリソースの縮小を有効にしていませんでした。
まずはこれを行ってみましょう。

    android {
        buildTypes {
            release {
                minifyEnabled true
                shrinkResources true
                proguardFiles getDefaultProguardFile(
                  'proguard-android.txt'), 'proguard-rules.pro'
            }
        }
    }
    -keep class com.fractalwrench.** { *; }

minifyEnabledProguardを有効にします。
これは未使用コードを削除します。
またシンボル名を難読化するので、アプリのリバースエンジニアリングが難しくなります。

shrinkResourcesは、apkから直接参照されないリソースを削除します。
リフレクション等を使って間接的にアクセスしていた場合は問題になりますが、今回のアプリでは使ってないので問題ありません。

786 Kb (50% reduction)

apkのサイズが半分になりました。
アプリの動作は特に影響ありません。

05.png

もし作成しているアプリがあるのであれば、minifyEnabledshrinkResourcesは真っ先に有効にしましょう。
たったそれだけで容量を簡単に数メガバイト減らすことができます。
この設定とテストにはほんの数時間しかかからないでしょう。

AppCompat, we hardly knew ye

上記によってclasses.dexはapkの57%にまで減りました。
Dexの容量の多くはandroid.supportパッケージに属しているので、サポートライブラリを使わないようにします。

build.gradleからdependenciesブロックを削除する。

    dependencies {
        implementation 'com.android.support:appcompat-v7:26.1.0'
        implementation 'com.android.support.constraint:constraint-layout:1.0.2'
    }

MainActivityandroid.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!" />

・AndroidManifestのapplication要素からstyles.xmlandroid:themeを削除する。
colors.xmlも削除する。
・Gradleのsync中に50回腕立て伏せする。

108 Kb bytes (87% reduction)

なんてこった!786Kbから108Kbまで、一気に約10倍の減量に成功してしまったぞ。
アプリ唯一の目に見える変化は、ツールバーの色がOSのデフォルトテーマに変わったところです。

06.png

この時点で、resディレクトリがapkサイズの95%を占めるまでになりました。

アイコンのPNGは、APIレベル15以上であればもっと効率の良いWebP形式に対応しています。

Googleはdrawableを自動的に最適化しますが、ImageOptimを使ってpngファイルから不要なメタデータを削除することもできます。

ここではもっと効率の良い対応を行いましょう。
すなわち、res/drawable以下のアイコンファイルを1ピクセルの黒点画像に差し替えます。
この画像は67バイトでした。

6808 bytes (94% reduction)

ほぼ全てのリソースを取り除いたので、apkサイズが1/20になったとしても特に驚くことではありません。
resources.arscは以下のファイルを参照しています。
・1つのレイアウトファイル
・1つの文字列リソース
・1つのアイコン

順に処理していきましょう。

Layout file (6262 bytes, 9% reduction)

AndroidフレームワークはテンプレートのXMLファイルを自動的にTextViewインフレートし、ActivitycontentViewにセットします。

XMLファイルを削除し、contentViewをプログラムから直接設定することで、このAndroidからの干渉をスキップできます。
XMLファイルを減らせたのでリソースサイズは減少しますが、そのかわりDexファイルの容量が増加します。
TextViewを参照するソースを追加したためです。

    TextView textView = new TextView(this);
    textView.setText("Hello World!");
    setContentView(textView);

このトレードオフはうまくいったように思われ、容量を5710バイトまで削ることができました。

App Name (6034 bytes, 4% reduction)

strings.xmlはいらないので削除して、AndroidManifestのandroid:labelと書かれているところは直接'A'に入れ替えます。
これは小さな変更に見えますが、実際はresources.arscからエントリが消え、AndroidManifestの文字列も減り、resディレクトリからファイル自体も削除されます。
これによって228バイトの削減に成功しました。

Launcher icon (5300 bytes, 13% reduction)

resources.arscのドキュメントによると、apkの各リソースは、resources.arscによって整数のIDで管理されていることが示されています。
このIDは2個のネームスペースを持っています。

    0x01: system resources (システムによって予めインストールされているもの)
    0x7f: application resources (アプリのapkでインストールされるもの)

つまり、0x01名前空間のリソースを参照することで、自前でアイコンを用意する必要がなくなり、ファイルサイズをさらに縮小することができます。

    android:icon="@android:drawable/btn_star"

07.jpg

言うまでもありませんが、プロダクションアプリではこのようなことをしてはいけません。
この手法はGoogle Playでの検証に失敗します。
また一部の端末では仕様が勝手に変更されていることがあるので、使用には注意してください。

Manifest (5252 bytes, 1% reduction)

AndroidManifestの最適化には、まだろくに手を付けていません。

android:allowBackup="true"
android:supportsRtl="true"

不要な属性を削除して、48バイトを節約しました。

Proguard hack (4984 bytes, 5% reduction)

Dexファイルの中にBuildConfigRが未だ含まれているように見えます。

    -keep class com.fractalwrench.MainActivity { *; }

Proguardのルールを見直して、これら不要なクラスを削除しました。

Obfuscation (4936 bytes, 1% reduction)

Activityにも難読化を行いましょう。
Proguardは通常のクラスに対してはデフォルトで行ってくれますが、Activityについては、インテントで呼び出す都合上難読化しません。

    MainActivity -> c.java
    com.fractalwrench.apkgolf -> c.c

META-INF (3307 bytes, 33% reduction)

これまで、apkの署名をv1とv2の両方で行っていました。
v2はapk全体をハッシュすることによって、優れた保護機能とパフォーマンスを提供します。

v2の署名はapkファイル内にバイナリとして含まれているため、apkアナライザには表示されません。
v1の署名はCERT.RSAおよびCERT.SFという実体ファイルで存在します。

Android Studioでv1署名のチェックを外し、v2のみ署名されたapkを作成しましょう。
逆にv1のみのapkも作成してみます。

Signature Size
v1 3511
v2 3307

v2のほうがサイズが小さいので、そちらを使用しましょう。

Where we’re going, we don’t need IDEs

もはやIDEは要りません。
apkを手動で編集するときが来ました。

    # 1. 署名のないapkを作成
    ./gradlew assembleRelease

    # 2. apkを解凍する
    unzip app-release-unsigned.apk -d app

    # なにか編集

    # 3. zip圧縮する
    zip -r app app.zip

    # 4. zipalign
    zipalign -v -p 4 app-release-unsigned.apk app-release-aligned.apk

    # 5. 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

署名の詳細についてはこちらを参照してください。
簡単にまとめると、gradleで署名のないapkを作成し、zipalignはリソースを整形してAndroidがapkを効率よく実行できるようにし、最後にapkに署名しています。

署名もzipalignもしていない状態のapkは1902バイトで、上記のプロセスでおよそ1キロバイトが上乗せされることになります。

File-size discrepancy (2608 bytes, 21% reduction)

なんか、zipalignされてないapkを解凍して手動で署名すると、META-INFMANIFEST.MFが消え去って543バイト浮くんだけど。
ちょっとどうしてこんなことがおこるのか知ってる人いたら教えてくだちい。

ここでapkはついに3ファイルだけになりました。
さらに言うと、リソースは全く使ってないのでresources.arscも消すことができます。

これによって、AndroidManifestclasses.dexの2ファイルだけが残されました。
両ファイルはだいたい同じ大きさです。

Compression Hacks (2599 bytes, 0.5% reduction)

残ってる文字列を全て'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'の出てくる頻度が変わったため、圧縮アルゴリズムはサイズをより小さくしてくれます。

Hello ADB (2462 bytes, 5% reduction)

AndroidManifestをさらに削減するため、Activityを起動するインテントを削除します。
これからはアプリの起動は以下のコマンドで行うことになります。

    adb shell am start -a android.intent.action.MAIN -n c.c/.c

AndroidManifestはこうなりました。

<manifest xmlns:android="http://schemas.android.com/apk/res/android"
    package="c.c">

    <application>
        <activity
            android:name="c"
            android:exported="true" />
    </application>
</manifest>

ランチャーアイコンも削除しました。

Reducing method references (2179 bytes, 12% reduction)

当初の目標は、デバイスにインストール可能なapkを作成することでした。

現在のアプリはTextViewBundle、そしてActivityを使用しています。
直接Applicationを使うようにすることで、それらを参照する必要がなくなり、Dexファイルのサイズをさらに減らすことができます。
今や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がインストールに成功したことを設定アプリから確認することもできます。

08.jpg

Dex Optimisation (1961 bytes, 10% reduction)

ここで数時間を費やしDexファイルのフォーマットを調査しました。
チェックサムやオフセットなど様々な仕組みが、Dexファイルの直接編集を困難なものにしていました。
長い長い道のりを省略して結論だけ言うと、apkインストールが成功するための唯一の条件は、classes.dexファイルが存在していなければならない、ということでした。
従って、apkから元のclasses.dexを削除し、ターミナルからtouch classes.dexとするだけで、ファイルサイズが10%減少します。

時には、愚かとしか思えない策が最も適切な解だったりします。

Understanding the Manifest (1961 bytes, 0% reduction)

未署名apkのAndroidManifestはバイナリのXMLですが、その形式についてのオフィシャルなドキュメントは見つかりませんでした。
HexFiendを使って中身を操作してみます。

ファイルヘッダにいささか興味深い項目を見付けることができました。
最初の4バイトには、Dexファイルと同じくtargetSdkVersion38がありました。
次の2バイトは660が入っており、これはファイルサイズを表しているようです。

そこでtargetSdkVersionを1に変更して不要なバイトを削除し、次の2バイトを659にしてみました。
残念ながら、これは無効なapkとして拒否されてしまいました。

Not understanding the Manifest (1777 bytes, 9% reduction)

AndroidManifestのファイル全体にダミー文字列を入力してから、ファイルサイズを変更せずにapkをインストールしてみます。
これによってチェックサムがあるか、また変更によってapkのオフセットが無効になったかを判断できます。

09.jpg

驚いたことに、こんなAndroidManifestでもOreoのNexus5上では有効なapkと判断されました。
BinaryXMLParser.javaの開発者はきっと枕に顔を埋めて足をバタバタさせて叫んでいることでしょう。

今後のために、ダミー文字列をnullバイトに置き換えます。
これによってHexFiendで意味のある部分を見やすくなります。

UTF-8 Manifest

10.png

これらはAndroidManifestに最低限必要なものであり、何れかがなくなるとapkのインストールに失敗します。
manifestやpackageなど、すぐに分かるものが幾つかあります。
versionCodeとパッケージ名も含まれています。

Hexadecimal Manifest

11.png

16進数で表示すると、ファイルサイズを示す0x9402など、いくつか意味のある値が出てきます。
また、文字列長が8バイトを超える場合、その長さが文字列の手前2バイトで示されているようです。

しかし、ここでファイルサイズを減らすのは難しそうです。

Done? (1757 bytes, 1% reduction)

完成したapkを見てみましょう。

12.png

v2署名で自分の名前が入っていました。
圧縮アルゴリズムが効いてくれる名前に変更しておきます。

13.png

これで20バイト浮きました。

Stage 5: Acceptance

最終的に1757バイトのapkが完成しました。
私が知るかぎり、これが実在する最小のapkです。

しかし間違いなく、Androidコミュニティにいる誰かが、この容量を上回るさらなる最適化ができると確信しています。
もし改善案を見付けたら、GitHubリポジトリにPRを送るか、Twitterで教えてください。

Thank You

Android APKの仕組みを調べることで新たな知見が得られました。
ご意見・ご質問・新たな提案などありましたら、Twitterに連絡してください。

その後

その後はGitHubにおいてプルリクを受け付けており、2017/12/25現在ではなんと678バイトにまで減っています。
実物のapkはリポジトリのsigned-release.apkから試すことができます。
みんなも挑戦してみよう。