Summary: How to slim down APKs is an important optimization technique for Android. Both installation and updating of APKs require network downloading to devices, and the smaller the APK, the better the user experience. Through detailed analysis of the internal mechanisms of APKs, the author provides optimization methods and techniques for each component of APKs, and implements a minimization process for a basic APK.
Body:
In golf, the player with the lowest score wins.
Let's apply this principle to Android app development. We will play with an APK called "ApkGolf", with the goal of creating an app with the smallest number of bytes possible that can be installed on a device running Oreo.
Baseline Measurement
To start, we generate a default app using Android Studio, create a keystore, and sign the app. We then use the command stat -f%z $filename
to measure the number of bytes of the generated APK file.
Furthermore, to ensure the APK works properly, we install it on a Nexus 5x phone running Oreo.
Looks good. But now our APK size is close to 1.5Mb.
APK Analyser
Considering our app's functionality is very simple, the 1.5Mb size seems bloated. So let's dig into the project to see if there are any obvious areas we can trim to immediately reduce the file size. Android Studio generated:
- A
MainActivity
extendingAppCompatActivity
; - A layout file using
ConstraintLayout
as the root view; - Value files containing three colors, one string resource, and one theme;
AppCompat
andConstraintLayout
support libraries;- An
AndroidManifest.xml
file; - PNG format launcher icons, square, round, and foreground.
The launcher icon files seem like a prime target, as there are 15 image files in the APK, plus two XML files under mipmap-anydpi-v26
. Let's do a quantitative analysis of the APK file using Android Studio's APK Analyser (developer.android.com/studio/buil...).
The results are quite different from our initial assumption, showing the Dex file as the heavyweight and the above resources accounting for only 20% of the APK size.
File | % of Size |
---|---|
classes.dex | 74% |
res | 20% |
resources.arsc | 4% |
META-INF | 2% |
AndroidManifest.xml | <1% |
Let's analyze each file's behavior individually.
Dex File
The classes.dex
file appears to be the culprit, taking up 73% of the space, so it will be our first target for reduction. This file contains all of our compiled code, along with references to external methods in the Android frameworks and support libraries.
However the android.support
packages reference over 13,000 methods which are completely unnecessary for a simple "Hello World" app.
Resources
The "res" directory contains many layout, drawable and animation files that aren't immediately visible in the Android Studio UI. Again, these have been pulled in by the support libraries and account for around 20% of the APK size.
The resources.arsc
file also contains references to each resource.
Signing
The META-INF
directory contains CERT.SF
, MANIFEST.MF
and CERT.RSA
files required for v1 APK signing (source.android.com/security/ap...).
This prevents attackers from modifying the code in our APK, as the signatures won't match. It ensures users don't risk running malicious third party software.
The MANIFEST.MF
file lists all files in the APK. The CERT.SF
file contains the manifest digest and digests of individual files. The CERT.RSA
file contains a public key for verifying integrity of the CERT.SF
.
There are no obvious areas to optimize in the signing files.
AndroidManifest File
The AndroidManifest
file looks very similar to our original input file. The only difference is resources like strings and drawables have been replaced with integer resource IDs starting with 0x7F
.
Enable Minification
We haven't yet enabled minification and resource shrinking in the app's build.gradle
file. Let's do that now:
android {
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile(
'proguard-android.txt'), 'proguard-rules.pro'
}
}
}
-keep class com.fractalwrench.** { *; }
Setting minifyEnabled
to true
will enable Proguard (www.guardsquare.com/en/proguard), which strips unused code from the app and obfuscates symbol names to make it harder to reverse engineer.
Setting shrinkResources
will remove any resources not directly referenced from the APK. This can cause issues if reflecting to access resources, but our example app doesn't do this.
Optimized to 786 Kb (50% reduction)
We've already halved the size of the APK with no visible effect on our app.
The only visible change is the toolbar color, which now uses the default OS theme.
This is the most impactful and easy to implement technique for developers who haven't enabled AndroidManifest.xml
and shrinkResources
in their apps yet. Just a few hours of configuration and testing can easily shave off megabytes.
We don't understand how AppCompat works yet
Now the classes.dex
file accounts for 57% of the APK. Most of the method references in our Dex file belong to the android.support
packages, so we'll remove the support libraries entirely by:
- Completely clearing out the dependencies block in
build.gradle
.dependencies { implementation 'com.android.support:appcompat-v7:26.1.0' implementation 'com.android.support.constraint:constraint-layout:1.0.2' }
- Updating
MainActivity
to extendandroid.app.Activity
.public class MainActivity extends Activity
- Updating the layout to use a single
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!" />
- Removing the
styles.xml
file andandroid:theme
attribute from the<application>
element inAndroidManifest
. - Deleting the
colors.xml
file. - Doing 50 push-ups on gradle sync.
Optimized to 108 Kb (87% reduction)
Wow, we just achieved nearly a 10x reduction from 786Kb to 108Kb. The only visible change is the toolbar color, which now uses the default OS theme.
The "res" directory now accounts for about 95% of the APK size due to all the launcher icons. If these PNGs were provided by our own designers we could try converting them to the more efficient WebP format supported by API 15+.
Luckily Google has already optimized our drawables. Even without that, ImageOptim could optimize the PNGs and strip unnecessary metadata.
Let's be bad and replace all our launcher icons with a single 1px black dot in an unchecked res/drawable
directory. This image is around 67 bytes.
Optimized to 6808 bytes (94% reduction)
We've removed almost all resources now, so it's no surprise the APK size has dropped by around 95%. But resources.arsc
still references:
- One layout file
- One string resource
- One launcher icon
Let's tackle the first item.
Layout File (9% reduction to 6262 bytes)
The Android framework inflates our XML files and automatically creates a TextView
for the Activity
contentView
.
We could try to skip some of this by removing the XML and programmatically setting the contentView
. This would reduce resource size by eliminating an XML file. But Dex would grow because we'd reference extra TextView
methods.
TextView textView = new TextView(this);
textView.setText("Hello World!");
setContentView(textView);
Let's see how this tradeoff works - it removed 5710 bytes.
App Name (4% reduction to 6034 bytes)
Next we'll delete the strings.xml
file and change the android:label
attribute in AndroidManifest
to "A". This seems minor but it removes an item from resources.arsc
, reduces characters in the manifest file, and deletes a file from the "res" directory. A modest gain, shaving off 228 bytes.
Launcher Icon (13% reduction to 5300 bytes)
The resources.arsc
documentation in the Android platform codebase tells us each resource in the APK is referenced by an integer ID in resources.arsc
. These IDs exist in two namespaces:
0x01: System resources (preloaded in framework-res.apk)
0x7f: App resources (bundled in the app's .apk file)
So what if we reference a resource from the 0x01
namespace? We should be able to trim filesize and get a nicer icon.
android:icon="@android:drawable/btn_star"
While the docs suggest this should work, in a production app we should stick to the "never trust system resources" principle. This step would fail Google Play validation, and some manufacturers have been known to redefine white... so caution is advised in practice.
Manifest File (1% reduction to 5252 bytes)
So far we haven't touched the manifest file.
android:allowBackup="true"
android:supportsRtl="true"
Removing these attributes shaves off 48 bytes.
Obfuscation Guards (5% reduction to 4984 bytes)
It looks like BuildConfig
and R
are still in the Dex.
-keep class com.fractalwrench.MainActivity { *; }
We can clear these classes by tightening up the Proguard rules.
Obfuscate Names (1% reduction to 4936 bytes)
Now let's give our Activity
an obfuscated name. Proguard automatically obfuscates normal classes, but by default avoids activities since they can be launched by Intents
referring to the name.
MainActivity -> c.java
com.fractalwrench.apkgolf -> c.c
META-INF (33% reduction to 3307 bytes)
Currently the app is signed with both v1 and v2 signatures. This seems redundant, especially as v2 hashes the entire APK providing stronger guarantees and performance (source.android.com/security/ap...).
The v2 signature isn't visible in APK Analyzer since it's embedded as a binary blob in the APK itself. The v1 signature is visible as CERT.RSA
and CERT.SF
files.
Android Studio provides a checkbox for v1 signing that we need to unset, then generate a signed APK. We also need to do the opposite.
Signature | Size (bytes) |
---|---|
v1 | 3511 |
v2 | 3307 |
Looks like we're using v2 from now on.
We Need to Go Offline
Now we'll have to manually edit our APK. We'll use the following commands:
# 1. Generate an unsigned APK.
./gradlew assembleRelease
# 2. Extract the archive.
unzip app-release-unsigned.apk -d app
# Edit files.
# 3. Re-archive the files.
zip -r app app.zip
# 4. Run zipalign.
zipalign -v -p 4 app-release-unsigned.apk app-release-aligned.apk
# 5. Sign v2 with apksigner.
apksigner sign --v1-signing-enabled false --ks $HOME/fake.jks --out signed-release.apk app-release-unsigned.apk
# 6. Verify signature.
apksigner verify signed-release.apk
This outlines the APK signing process. Gradle generates an unsigned archive, zipalign optimizes uncompressed resource alignment for RAM usage when loading the APK, and finally the APK is encrypted with signatures.
The unsigned, unaligned APK is 1902 bytes. This means signing and aligning adds around 1Kb.
File Size Discrepancy (21% reduction to 2608 bytes)
Oddly, extracting and manually signing the unaligned APK, then manually removing META-INF/MANIFEST.MF
, shaved 543 bytes. If anyone knows why please enlighten me!
Now our signed APK only contains three files, and we could even remove resources.arsc
since we define no resources!
This would leave just the manifest and classes.dex
file, which are roughly equal in size.
Compression Hack (0.5% reduction to 2599 bytes)
Let's change any remaining strings to 'c', bump the version to 26, and generate a signed 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">
This removed 9 bytes despite no change to character count in files. Changing frequency of 'c' characters allowed the compression algorithm to reduce size slightly more.
Hello ADB (5% reduction to 2462 bytes)
We can optimize the manifest further by removing the Activity
launch intent filter. We'll then load the app with:
adb shell am start -a android.intent.action.MAIN -n c.c/.c
The new manifest:
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="c.c">
<application>
<activity
android:name="c"
android:exported="true" />
</application>
</manifest>
We also removed the launcher icon.
Reduce Method References (12% reduction to 2179 bytes)
Our original goal was an installable APK. Now it's "Hello World" time.
Our app references methods in TextView
, Bundle
, and Activity
. Removing Activity
and replacing with a custom Application
class reduces Dex size further by ensuring the Dex only references a single method - the Application
constructor.
Now our source is:
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>
We can confirm with adb that this APK installs, and shows in Settings.
Dex Optimization (10% reduction to 1961 bytes)
For this optimization I spent a few hours researching the Dex file format trying to understand mechanisms like checksums and offsets that make manual editing tricky.
Long story short, it turns out that just having a classes.dex
file makes the APK installable, regardless. So we can simply delete the original and touch classes.dex
in the terminal for an easy ~10% reduction.
Sometimes the dumbest solutions are the most effective.
Understanding the Manifest (No change, 1961 bytes)
The manifest in an unsigned APK is a binary XML format without official documentation. We can modify it with a hex editor like HexFiend (github.com/ridiculousf...).
We can guess some areas of interest near the start. The first four bytes encode 38
, matching the version used by Dex. The next two bytes encode 660
, undoubtedly the file size.
Let's try removing a byte by setting targetSdkVersion to 1
and updating the file size header to 659
. Unfortunately the system rejected this illegal APK, so there's more at play here.
No Need to Understand the Manifest (9% reduction to 1777 bytes)
Let's replace the entire file with null characters without changing the size, and try to install the APK. This will determine if checksums are in play and if changing header offsets invalidates parsing.
Amazingly, the manifest below is interpreted as a valid APK that runs on a Nexus 5X on Oreo:
I think I can hear the Android framework engineer maintaining BinaryXMLParser.java
screaming into their pillow.
For maximum gain we'll replace the nulls with null bytes. This simplifies reviewing important sections in HexFiend, and allows the earlier compression hack to shave off some bytes.
UTF-8 Manifest
Here are some key components of the manifest:
Some things are immediately obvious, like the manifest and package markers. The package name and versionCode can also be found in the string pool.
Hex Manifest
Viewing the file in hex shows header values describing the string pool and other values like 0x9402
for file size. Strings also have an interesting encoding - if a field is larger than 8 bytes the total length is specified in the next two bytes.
But it doesn't look like we can trim much further here.
Final Stretch? (1% reduction to 1757 bytes)
Let's look at the final APK.
We did leave our v2 signature in the APK for posterity. Let's create a new keystore exploiting compression hacks.
This removed 20 bytes.
Stage 5: Final Acceptance
At 1757
bytes this is remarkably small. To my knowledge it's the smallest APK in existence.
But I have every reason to believe someone in the Android community can optimize further and beat my record.
Comments