以下は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をインストールし、アプリが実際に動作することも確認します。
すばら!
apkのファイルサイズは約1.5メガバイトでした。
APK Analyser
アプリの内容に対して、1.5メガバイトの容量はあまりに大きすぎます。
Android Studioが何を生成したのか、プロジェクトを詳しく調査していきましょう。
・AppCompatActivity
を継承したMainActivity
。
・ConstraintLayout
で作られたレイアウトファイル。
・3つの色、1つの文字列、1つのテーマの入ったリソースファイル。
・AppCompat
とConstraintLayout
のサポートライブラリ。
・AndroidManifest.xml
・ランチャーのアイコンpngファイル。
おそらく最も容易なターゲットはアイコン画像でしょう。
なにしろmipmap-anydpi-v26
の下に全部で15もの画像と2個のXMLファイルが存在します。
Android Studio付属のAPK Analyserで確認してみます。
予想に反して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%を占めています。
ファイルresources.arsc
は、これら各リソースの一覧ファイルです。
Signing
META-INF
ディレクトリにはCERT.SF
、MANIFEST.MF
、およびCERT.RSA
が入っています。
これらはv1 APK署名であり、もし攻撃者がapkを改変した場合、apkと署名が一致しなくなります。
これにより、apkはマルウェアによる汚染から保護されることになります。
MANIFEST.MF
はapk内のファイル一覧です。
CERT.SF
はmanifestおよび各ファイルのダイジェストが入っています。
CERT.RSA
には、CERT.SF
の完全性を検証するための公開鍵が含まれています。
ここをどうにかするのは難しそうです。
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.** { *; }
minifyEnabled
はProguardを有効にします。
これは未使用コードを削除します。
またシンボル名を難読化するので、アプリのリバースエンジニアリングが難しくなります。
shrinkResources
は、apkから直接参照されないリソースを削除します。
リフレクション等を使って間接的にアクセスしていた場合は問題になりますが、今回のアプリでは使ってないので問題ありません。
786 Kb (50% reduction)
apkのサイズが半分になりました。
アプリの動作は特に影響ありません。
もし作成しているアプリがあるのであれば、minifyEnabled
とshrinkResources
は真っ先に有効にしましょう。
たったそれだけで容量を簡単に数メガバイト減らすことができます。
この設定とテストにはほんの数時間しかかからないでしょう。
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'
}
・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!" />
・AndroidManifestのapplication
要素からstyles.xml
とandroid:theme
を削除する。
・colors.xml
も削除する。
・Gradleのsync中に50回腕立て伏せする。
108 Kb bytes (87% reduction)
なんてこった!786Kbから108Kbまで、一気に約10倍の減量に成功してしまったぞ。
アプリ唯一の目に見える変化は、ツールバーの色がOSのデフォルトテーマに変わったところです。
この時点で、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
にインフレートし、Activity
のcontentView
にセットします。
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"
言うまでもありませんが、プロダクションアプリではこのようなことをしてはいけません。
この手法はGoogle Playでの検証に失敗します。
また一部の端末では仕様が勝手に変更されていることがあるので、使用には注意してください。
Manifest (5252 bytes, 1% reduction)
AndroidManifestの最適化には、まだろくに手を付けていません。
android:allowBackup="true"
android:supportsRtl="true"
不要な属性を削除して、48バイトを節約しました。
Proguard hack (4984 bytes, 5% reduction)
Dexファイルの中にBuildConfig
とR
が未だ含まれているように見えます。
-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-INF
とMANIFEST.MF
が消え去って543バイト浮くんだけど。
ちょっとどうしてこんなことがおこるのか知ってる人いたら教えてくだちい。
ここでapkはついに3ファイルだけになりました。
さらに言うと、リソースは全く使ってないのでresources.arsc
も消すことができます。
これによって、AndroidManifest
とclasses.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を作成することでした。
現在のアプリはTextView
、Bundle
、そして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がインストールに成功したことを設定アプリから確認することもできます。
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のオフセットが無効になったかを判断できます。
驚いたことに、こんなAndroidManifest
でもOreoのNexus5上では有効なapkと判断されました。
BinaryXMLParser.java
の開発者はきっと枕に顔を埋めて足をバタバタさせて叫んでいることでしょう。
今後のために、ダミー文字列をnullバイトに置き換えます。
これによってHexFiendで意味のある部分を見やすくなります。
UTF-8 Manifest
これらはAndroidManifest
に最低限必要なものであり、何れかがなくなるとapkのインストールに失敗します。
manifestやpackageなど、すぐに分かるものが幾つかあります。
versionCodeとパッケージ名も含まれています。
Hexadecimal Manifest
16進数で表示すると、ファイルサイズを示す0x9402
など、いくつか意味のある値が出てきます。
また、文字列長が8バイトを超える場合、その長さが文字列の手前2バイトで示されているようです。
しかし、ここでファイルサイズを減らすのは難しそうです。
Done? (1757 bytes, 1% reduction)
完成したapkを見てみましょう。
v2署名で自分の名前が入っていました。
圧縮アルゴリズムが効いてくれる名前に変更しておきます。
これで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
から試すことができます。
みんなも挑戦してみよう。