Tech Info

  1. Main page
  2. Android
  3. Main content

Implement Android APK Slimming 99.99% - Gold Slimming

2023-09-25 687hotness 0likes 0comments

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.

img

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 extending AppCompatActivity;
  • A layout file using ConstraintLayout as the root view;
  • Value files containing three colors, one string resource, and one theme;
  • AppCompat and ConstraintLayout 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...).

img

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.

img

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.

img

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.

img

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 extend android.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 and android:theme attribute from the <application> element in AndroidManifest.

  • 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.

img

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"

img

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.

img

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:

img

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:

img

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

img

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.

img

We did leave our v2 signature in the APK for posterity. Let's create a new keystore exploiting compression hacks.

img

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.

Related

This article is licensed with Creative Commons Attribution 4.0 International License
Tag: Android
Last updated:2023-09-25

jimmychen

This person is a lazy dog and has left nothing

Like
< Last article
Next article >

Comments

razz evil exclaim smile redface biggrin eek confused idea lol mad twisted rolleyes wink cool arrow neutral cry mrgreen drooling persevering
Cancel

Archives
  • October 2023
  • September 2023
Categories
  • Algorithm
  • Android
  • Backend
  • Embedded
  • Security
Ads

COPYRIGHT © 2023 Tech Info. ALL RIGHTS RESERVED.

Theme Kratos Made By Seaton Jiang