DEV Community

Mustafa ERBAY
Mustafa ERBAY

Posted on • Originally published at mustafaerbay.com.tr

Mobile App Size Optimization: The Burden of the Development Process

Mobile app size optimization is a technical debt that directly impacts conversion rates from the moment of initial upload to the market, yet it's often relegated to the final stages of development. I've personally witnessed many projects operate under the "let it work, we'll fix the size later" mentality, only to be confronted with bloated packages reaching 150 MB. In my own Android spam blocking app, I managed to reduce the APK size from 42 MB on the first build to 8.4 MB with an optimized production pipeline I established. In this guide, I'm sharing the field experience I gained, the "build" explosions I encountered, and the practical methods I applied, step by step.

According to Google Play Store data, every 6 MB increase beyond 100 MB reduces the installation conversion rate by approximately 1%. Considering users' cellular data limits and storage pressure on their devices, minimizing package size isn't a technical luxury but a necessity that directly impacts business success. In this article, we will dissect the anatomy of packages on modern mobile platforms and explore how to shed unnecessary loads.


APK and IPA Anatomy: What Bloats File Size

Before embarking on any optimization process, we need to know our enemy. A compiled Android (APK) or iOS (IPA) package is essentially a specially compressed ZIP archive. When you open this archive, each file that appears represents a different component required for the application to run.

In the table below, you can see the typical distribution of a package's content for an unoptimized standard hybrid/native mobile application and the optimized ratios we should aim for:

Component Name Purpose Unoptimized Ratio Target Ratio Solution to Apply
classes.dex / Mach-O Compiled source code 25% - 35% 10% - 15% ProGuard, R8, Tree Shaking
res / Assets Images, icons, fonts 40% - 50% 15% - 20% WebP conversion, SVG, Lottie
lib (Native .so files) C/C++ libraries, NDK 20% - 30% 5% - 10% ABI Split, App Bundle (AAB)
resources.arsc XML resource map 5% - 10% 2% - 3% String and ID optimization

As you can see, image resources (assets) and native libraries constitute almost half of the package size. If your application uses an external C++ library (e.g., SQLCipher or a custom image processing engine), you've likely noticed that the compiler generates a separate .so file for each processor architecture (arm64-v8a, armeabi-v7a, x86_64). Sending all these architectures in a single package means forcing users to download processor code they will never use.

Similar to my previous article on [related: Docker image size reduction methods], analyzing layers and extracting unnecessary dependencies on the mobile side is based on a similar philosophy. Now, let's delve into how to shrink these components one by one with practical code examples.


Shrinking at the Code Level with R8 and ProGuard

In the Android ecosystem, code shrinking is the process of identifying and removing unused classes, methods, and attributes. Google now uses the R8 compiler by default instead of the older ProGuard. R8 not only removes unused code but also optimizes classes, shortens their names (obfuscation), and makes it harder to read the code through reverse engineering.

To activate R8, we need to apply the following configuration in your android/app/build.gradle file. I personally observed a reduction in classes.dex size from 18 MB to 4.2 MB after enabling this setting in a client project:

android {
    compileSdkVersion 34

    buildTypes {
        release {
            // Enable code shrinking
            minifyEnabled true

            // Clean up unused resources (res)
            shrinkResources true

            // Define files containing R8 rules
            proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

However, simply enabling minifyEnabled true often leads to the application crashing on the first build. This is because of "Reflection" mechanisms present in your application or the third-party libraries you use. For example, libraries like Gson or Moshi, which convert JSON data to Java/Kotlin objects, look for class names at runtime. R8 shortens these class names to a, b, c, causing the library to fail to find the class and throw a NullPointerException.

To resolve this, we need to add rules (Keep Rules) to the proguard-rules.pro file to preserve the classes of the relevant libraries. Here's a stable base rule set I use in production environments:

# Reflection protection for Gson library
-keepattributes Signature, *Annotation*, EnclosingMethod, InnerClasses
-keep class com.google.gson.reflect.TypeToken { *; }
-keep class * implements com.google.gson.TypeAdapterFactory
-keep class * implements com.google.gson.JsonSerializer
-keep class * implements com.google.gson.JsonDeserializer

# Keep your own data models (Data Transfer Objects)
-keep class com.mustafaerbay.app.models.** { *; }

# Completely remove log lines from production builds
-assumenosideeffects class android.util.Log {
    public static boolean isLoggable(java.lang.String, int);
    public static int v(...);
    public static int d(...);
}
Enter fullscreen mode Exit fullscreen mode

⚠️ Risks of Reflection and Dynamic Class Loading

When you enable the shrinkResources feature, situations where you dynamically generate names and load resources (e.g., resources.getIdentifier("img_" + id, "drawable", packageName)) can crash. R8, thinking this resource is not directly called in the code, will remove it from the package. To prevent these situations, you must manually define exceptions by creating a res/raw/keep.xml file.


Asset Management: WebP Conversion and Vector Solutions

The biggest culprit for bloating mobile app sizes is always images. When designers export 3x PNG files from Figma and add them directly to the project, even a single splash screen can add 5-10 MB of overhead to the package.

Our first rule is clear: Never use static images in PNG or JPEG format. We must convert all raster images to the modern WebP format. WebP provides an average of 30-50% better compression than PNG without sacrificing quality. You can use the following shell script I wrote to automatically convert all PNG files in your project folder to WebP via the terminal within your CI/CD processes:

#!/bin/bash
# png_to_webp.sh
# Scans all PNG files within the project and converts them to WebP with 85% quality.

find ./app/src/main/res/ -name "*.png" | while read -r png_file; do
    webp_file="${png_file%.png}.webp"
    # Convert using the cwebp tool
    cwebp -q 85 "$png_file" -o "$webp_file"

    if [ -f "$webp_file" ]; then
        echo "Converted: $png_file -> $webp_file"
        rm "$png_file" # Delete the original PNG file
    fi
done
Enter fullscreen mode Exit fullscreen mode

The second step is to absolutely use VectorDrawable (Android) or PDF/SVG (iOS) for icons. Vector files are only a few kilobytes in size and look perfect at every screen resolution without pixelation. If your application has complex animations, convert them to vector-based animations using Lottie (a JSON-based animation library) instead of embedding them as video or GIF. This way, you can replace a 20 MB promotional video with just a 150 KB JSON file.


Native Libraries and ABI Split Configuration

Modern mobile applications frequently require native C/C++ libraries. For example, SQLite database encryption, image processing libraries, or performance-critical cryptographic operations require natively compiled .so (Shared Object) files.

In the default configuration, the compiler embeds these binary files for all target architectures into a single APK (Universal APK). However, a user's device is either arm64-v8a architecture or, rarely, armeabi-v7a; it can never be both. To prevent this unnecessary load, we should set up the ndk.abiFilters configuration in the build.gradle file or prefer the Android App Bundle (AAB) format when uploading to the market.

If you need to distribute your application directly as an APK (e.g., for internal corporate distribution or alternative app stores), you can enable the "ABI Split" feature on Gradle to generate separate APKs for each architecture:

android {
    splits {
        // ABI level split configuration
        abi {
            enable true
            reset()
            include "armeabi-v7a", "arm64-v8a"
            universalApk false // Do not produce a single universal APK containing all architectures
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This configuration slightly increases build time because Gradle compiles multiple APKs sequentially. However, at the end of the day, the APK size downloaded by the user is almost halved. If you are building for the Google Play Store, you only need to output in .aab format instead of ABI split; Google Play dynamically generates and serves the most suitable APK for the user's device architecture.


On-Demand Installation with Dynamic Feature Delivery

Not every feature of your application is necessary for every user at all times. For example, the "View Invoice/Generate PDF" module or the "Customer Support Live Chat" screen in an e-commerce application should only be downloaded when the user needs it. We call this architecture Dynamic Feature Delivery.

This approach allows us to keep the main application package (base module) extremely light. Additional features are downloaded in the background via the Play Core API when the user clicks the relevant button within the app. While developing the mobile client for a production ERP system, we made the detailed graphical reporting module a dynamic module. This allowed us to reduce the initial download size from 54 MB to 18 MB, enabling field operators to update the application quickly over cellular data.

To create a dynamic module, you need to add a new "Dynamic Feature Module" to your project and specify it in the build.gradle file of the main module:

// app/build.gradle
android {
    ...
    dynamicFeatures = [':features:reporting_chart']
}
Enter fullscreen mode Exit fullscreen mode

In the build.gradle file of the dynamic module, you need to specify the delivery type:

// features/reporting_chart/build.gradle
plugins {
    id 'com.android.dynamic-feature'
}

dist {
    moduleName = "reporting_chart"
    onDemand {
        active true // Will be downloaded when the user requests it
    }
    fusing {
        include true // Include in the main package for older devices that do not support dynamic delivery
    }
}
Enter fullscreen mode Exit fullscreen mode

At runtime, we use the SplitInstallManager API on the Kotlin side to load this module. This code block monitors the module's download status and redirects the user to the relevant screen once the download is complete:

val splitInstallManager = SplitInstallManagerFactory.create(context)

val request = SplitInstallRequest.newBuilder()
    .addModule("reporting_chart")
    .build()

splitInstallManager.startInstall(request)
    .addOnSuccessListener { sessionId ->
        // Module downloaded successfully, you can start the activity
    }
    .addOnFailureListener { exception ->
        // Handle download error
    }
Enter fullscreen mode Exit fullscreen mode

Preventing Size Regression on the CI/CD Pipeline

Size optimization is not a one-time process. An uncontrolled library added by a new developer joining the team or an unoptimized image can render all your efforts useless overnight. Therefore, automating size tracking and integrating it into the CI/CD pipeline is crucial.

I've prepared a simple yet effective workflow on GitHub Actions that measures the package size compiled with every Pull Request and fails the build if it exceeds a defined limit. I always use this step in my own projects:

name: Mobile App Size Check

on:
  pull_request:
    branches: [ main, develop ]

jobs:
  size-check:
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v4

    - name: Set up JDK 17
      uses: actions/setup-java@v4
      with:
        java-version: '17'
        distribution: 'temurin'

    - name: Build Release Bundle (AAB)
      run: ./gradlew :app:bundleRelease

    - name: Check Bundle Size
      run: |
        # Define the path to the generated AAB file
        BUNDLE_PATH="app/build/outputs/bundle/release/app-release.aab"

        # Get file size in bytes
        SIZE_BYTES=$(stat -c%s "$BUNDLE_PATH")
        SIZE_MB=$(echo "scale=2; $SIZE_BYTES / 1048576" | bc)

        # Maximum limit: 20 MB (20971520 Bytes)
        MAX_SIZE_BYTES=20971520

        echo "Generated Package Size: $SIZE_MB MB ($SIZE_BYTES bytes)"

        if [ $SIZE_BYTES -gt $MAX_SIZE_BYTES ]; then
          echo "ERROR: Package size has exceeded the limit (20 MB)!"
          exit 1
        fi
        echo "Size check successful. Within limit."
Enter fullscreen mode Exit fullscreen mode

With this control mechanism, you automatically prevent size-bloating changes before they even reach the code review stage. Similarly, for iOS projects, you can set up automations using Fastlane integration to measure the .ipa size and send notifications to a Slack channel.


Conclusion: Decision Matrix and Process Tracking

When optimizing mobile app size, there is always a trade-off. Obfuscating code too much makes reading error logs (crash reports) difficult (you need to save deobfuscation mapping files for every build). Using Dynamic Delivery increases the architectural complexity of the codebase.

My clear position on this matter is as follows: In the initial phase, implement R8/ProGuard and WebP/Vector conversions without compromise. These are the "low-hanging fruits" that provide the highest gains (up to 40% reduction) without disrupting the application's architecture. If you are still above the limits, resort to more advanced methods requiring architectural changes, such as Dynamic Feature Delivery.

As a next step, use Android Studio's Analyze APK tool to analyze your application's current package size and start by seeing which folder takes up the most space.

Top comments (0)