← Back to list

30 Steps to Reduce APK Size: The Complete Guide: From 85MB to 15MB, ProGuard/R8, Resource…

Your APK is 85MB. Users on slow networks abandon downloads over 40MB. Google Play warns you about bloated APKs. Your CI pipeline takes 10…

Ramadan Sayed · 2026-04-18 15:43 · 6 claps · 13.6 min read paywalled
#apk-size #reduce-apk-size #proguard #r8 #resource-shrinking
Open on Medium ↗

30 Steps to Reduce APK Size: The Complete Guide: From 85MB to 15MB, ProGuard/R8, Resource Shrinking, WebP, Vector Drawables, App Bundles, Native Library Stripping, Font Subsetting, Dynamic Feature Modules, Baseline Profiles, CI Monitoring, and Every Optimization a Production Android App Needs

Your APK is 85MB. Users on slow networks abandon downloads over 40MB. Google Play warns you about bloated APKs. Your CI pipeline takes 10 minutes to upload. Users in emerging markets with 16GB storage phones skip your app entirely because it’s too big.

APK size directly impacts install conversion. Google’s internal data shows that for every 6MB increase in APK size, install conversion drops by 1%. An 85MB app loses roughly 10% of potential installs compared to a 25MB app.

This article covers every technique to reduce your APK size — from 5-minute quick wins (enable R8, switch image formats) to advanced optimization (dynamic feature modules, baseline profiles, native library stripping). Each technique shows the exact impact in MB saved, so you can prioritize what matters most for your app.

Part 1: Understanding What’s Inside Your APK

APK Anatomy

Before optimizing, you need to know what’s taking space. Use Android Studio’s APK Analyzer (Build → Analyze APK):

TYPICAL 85MB APK BREAKDOWN:

├── classes.dex         →  12 MB  (14%)  → Your Kotlin/Java code + libraries
├── res/                →  25 MB  (29%)  → Drawables, layouts, strings, animations
├── lib/                →  30 MB  (35%)  → Native .so libraries (arm64, armeabi-v7a, x86)
├── assets/             →   8 MB  (10%)  → Fonts, ML models, JSON, HTML
├── resources.arsc      →   4 MB  ( 5%)  → Compiled resource table
├── META-INF/           →   2 MB  ( 2%)  → Signatures, certificates
├── kotlin/             →   2 MB  ( 2%)  → Kotlin metadata
└── AndroidManifest.xml →  <1 MB  ( 1%)  → Manifest

TOP 3 SIZE OFFENDERS (usually):
  1. Native libraries (.so files)     → 30-50% of APK
  2. Drawables (images)               → 15-30% of APK
  3. Code (DEX)                       → 10-20% of APK

How to Analyze

# Command line — detailed size breakdown
./gradlew app:assembleRelease
# Then: Build → Analyze APK → select the .apk file

# Or use bundletool for AAB analysis:
java -jar bundletool.jar build-apks \
  --bundle=app-release.aab \
  --output=output.apks \
  --connected-device
# APK size by architecture:
# arm64-v8a:    typically 40-60% of universal APK
# armeabi-v7a:  typically 30-50% of universal APK
# x86_64:       rarely needed (emulators only)

Part 2: Quick Wins (5 Minutes Each)

1. Enable R8 (Code Shrinking + Obfuscation)

Impact: 15–30% reduction in DEX size

// build.gradle.kts (app module)
android {
    buildTypes {
        release {
            isMinifyEnabled = true       // ← Enable R8 code shrinking
            isShrinkResources = true     // ← Enable resource shrinking
            proguardFiles(
                getDefaultProguardFile("proguard-android-optimize.txt"),
                "proguard-rules.pro"
            )
        }
    }
}

What R8 does:

WITHOUT R8 (debug):
  Your code:           5 MB
  Library code:        20 MB (Retrofit, OkHttp, Room, Compose, Koin...)
  Total DEX:           25 MB

WITH R8 (release):
  Your code (minified): 3 MB   → Removed unused methods, shortened names
  Library code:         7 MB   → Most library code is unused
  Total DEX:           10 MB   → 60% smaller
R8 does 4 things:
  1. TREE SHAKING    → Removes classes/methods that are never called
  2. OBFUSCATION     → Renames com.myapp.TransferViewModel → a.b.c
  3. OPTIMIZATION    → Inlines small methods, removes dead branches
  4. DESUGARING      → Converts Java 8+ features for older devices

2. Enable Resource Shrinking

Impact: 5–15% reduction in res/ folder

release {
    isShrinkResources = true  // ← Removes unused resources
}

This removes resources that are never referenced in code. If your app includes a library with 500 icons but you only use 10, the other 490 are removed.

Custom resource shrinking — keep/discard specific resources:

<!-- res/raw/keep.xml -->
<resources xmlns:tools="http://schemas.android.com/tools"
    tools:shrinkMode="strict"
    tools:keep="@layout/used_*,@drawable/important_*"
    tools:discard="@layout/unused_*,@drawable/test_*" />

3. Use Android App Bundle (AAB) Instead of APK

Impact: 35–50% reduction in download size

UNIVERSAL APK (what you build):
  Contains: ALL architectures (arm64 + arm32 + x86)
           ALL screen densities (mdpi + hdpi + xhdpi + xxhdpi + xxxhdpi)
           ALL languages (50+ string files)
  Size: 85 MB

APP BUNDLE (what Google Play delivers):
  User on Pixel 8 (arm64, xxhdpi, English) gets:
    arm64 only + xxhdpi only + English only
  Size: 35 MB  (59% smaller!)
  User on old Samsung (arm32, hdpi, Arabic) gets:
    arm32 only + hdpi only + Arabic only
  Size: 28 MB

You don’t need to change any code. Just upload AAB instead of APK:

# Build AAB instead of APK
./gradlew app:bundleRelease
# Output: app/build/outputs/bundle/release/app-release.aab

# Test locally with bundletool:
java -jar bundletool.jar build-apks \
  --bundle=app-release.aab \
  --output=output.apks \
  --connected-device
java -jar bundletool.jar install-apks --apks=output.apks
# See size per device:
java -jar bundletool.jar get-size total --apks=output.apks

4. Split APKs by ABI (if not using AAB)

Impact: 50–65% reduction per APK

android {
    splits {
        abi {
            isEnable = true
            reset()
            include("arm64-v8a", "armeabi-v7a")
            // Exclude x86, x86_64 (emulator only)
            isUniversalApk = false  // Don't build universal APK
        }
    }
}

5. Remove Unused Libraries

Impact: 1–10 MB per removed library

// ❌ Adding the entire Google Play Services suite
implementation("com.google.android.gms:play-services:21.0.0")  // ~20 MB!

// ✅ Only the services you actually use
implementation("com.google.android.gms:play-services-auth:21.0.0")     // ~2 MB
implementation("com.google.android.gms:play-services-location:21.2.0") // ~1.5 MB
// Check which libraries contribute most to APK size:
// Build → Analyze APK → Click on classes.dex → Sort by size

Part 3: Image Optimization (Biggest Visual Win)

6. Convert PNGs to WebP

Impact: 50–80% reduction in image sizes

PNG:   hero_image.png       →  2.4 MB
WebP:  hero_image.webp      →  0.5 MB  (79% smaller, same quality!)

PNG:   background.png       →  1.8 MB
WebP:  background.webp      →  0.3 MB  (83% smaller)

Batch convert in Android Studio: Right-click res/drawableConvert to WebP → Select all PNGs → Convert

WebP advantages:
  ✅ 25-34% smaller than PNG for lossless
  ✅ 25-34% smaller than JPEG for lossy
  ✅ Supports transparency (unlike JPEG)
  ✅ Supports animation (unlike PNG, replaces GIF)
  ✅ Supported on Android 4.0+ (lossless on 4.2+)
  ❌ Slightly slower to decode than PNG (negligible on modern devices)

7. Use Vector Drawables Instead of PNGs

Impact: 80–95% reduction for icons

PNG icons (need mdpi + hdpi + xhdpi + xxhdpi + xxxhdpi):
  ic_settings.png (mdpi):     2 KB
  ic_settings.png (hdpi):     3 KB
  ic_settings.png (xhdpi):    5 KB
  ic_settings.png (xxhdpi):   8 KB
  ic_settings.png (xxxhdpi): 12 KB
  Total: 30 KB × 200 icons = 6 MB

Vector drawables (ONE file, all densities):
  ic_settings.xml:  1 KB
  Total: 1 KB × 200 icons = 200 KB  (97% smaller!)
<!-- res/drawable/ic_settings.xml -->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
    android:width="24dp"
    android:height="24dp"
    android:viewportWidth="24"
    android:viewportHeight="24">
    <path
        android:fillColor="@android:color/white"
        android:pathData="M19.14,12.94c0.04,-0.31 0.06,-0.63 ..." />
</vector>

Rules for vectors vs bitmaps:

USE VECTOR DRAWABLES FOR:
  ✅ Icons (simple shapes, < 200 path commands)
  ✅ Logos (flat design)
  ✅ Simple illustrations
  ✅ Anything < 200dp × 200dp

USE WebP/PNG FOR:
  ❌ Photos (vectors can't represent photos)
  ❌ Complex illustrations (> 500 path commands → slow rendering)
  ❌ Large images (> 200dp - vector rendering becomes expensive)

8. Use AVIF for Photos (Android 12+)

Impact: 50% smaller than WebP, 60% smaller than JPEG

// For Coil (image loading library):
implementation("io.coil-kt:coil-compose:2.7.0")

// Coil automatically handles AVIF on Android 12+
// Fallback to WebP/JPEG on older devices
// For local resources, keep WebP (broadest compatibility)
// For network images, let your CDN serve AVIF with content negotiation

9. Remove Unused Density Folders

Impact: 20–40% reduction in res/ size

android {
    defaultConfig {
        // Only include the densities you need
        resourceConfigurations += listOf("en", "ar")  // Languages
    }

// If not using AAB, manually limit densities
    buildTypes {
        release {
            // Keep only hdpi and xxhdpi - covers 90% of devices
            // mdpi and xxxhdpi are interpolated from these
        }
    }
}

10. Compress Resources at Build Time

// build.gradle.kts
android {
    packaging {
        resources {
            // Don't compress these (already compressed or needs raw access)
            noCompress += listOf(
                "*.mp4", "*.mp3", "*.ogg",  // Media
                "*.pdf",                      // Documents
                "*.db", "*.sqlite"            // Databases
            )
        }
    }
}

Part 4: Code Optimization

11. ProGuard/R8 Rules — Keep What You Need

# proguard-rules.pro

# ═══════════ KEEP your data models (Retrofit/Moshi/kotlinx.serialization) ═══════════
-keep class com.myapp.data.remote.dto.** { *; }
# ═══════════ Keep @Serializable classes ═══════════
-keepclassmembers class * {
    @kotlinx.serialization.Serializable <fields>;
}
# ═══════════ Hilt ═══════════
-keep class dagger.hilt.** { *; }
# ═══════════ Remove logging in release ═══════════
-assumenosideeffects class android.util.Log {
    public static int v(...);
    public static int d(...);
    public static int i(...);
    public static int w(...);
    public static int e(...);
}
# This REMOVES all Log.v/d/i/w/e calls from the bytecode → smaller DEX
# ═══════════ Remove Kotlin assertions ═══════════
-assumenosideeffects class kotlin.jvm.internal.Intrinsics {
    public static void check*(...);
    public static void throw*(...);
}
# ═══════════ Optimize aggressively ═══════════
-optimizationpasses 5
-allowaccessmodification
-repackageclasses ''

12. Remove Unused Kotlin Metadata

Impact: 1–3 MB reduction

// build.gradle.kts
android {
    packaging {
        resources {
            excludes += listOf(
                "META-INF/LICENSE.md",
                "META-INF/LICENSE-notice.md",
                "META-INF/NOTICE.md",
                "META-INF/*.kotlin_module",
                "kotlin/**",
                "DebugProbesKt.bin",
                "kotlin-tooling-metadata.json"
            )
        }
    }
}

13. Use R8 Full Mode

Impact: 5–10% additional DEX reduction

// gradle.properties
android.enableR8.fullMode=true
R8 COMPATIBILITY MODE (default):
  - Keeps more reflection-based code
  - Safer, less likely to break things
  - Less aggressive optimization

R8 FULL MODE:
  - More aggressive tree shaking
  - Removes more unused code
  - May break reflection-heavy libraries
  - Requires testing ALL library features
  - 5-10% smaller DEX

14. Remove Kotlin Parcelize Runtime If Not Using It

// If you don't use @Parcelize, remove the plugin:
// plugins {
//     id("kotlin-parcelize")  // Remove if unused → saves ~50KB
// }

Part 5: Native Library Optimization

15. Strip Debug Symbols from Native Libraries

Impact: 50–80% reduction in lib/ folder

android {
    buildTypes {
        release {
            ndk {
                debugSymbolLevel = "SYMBOL_TABLE"
                // Options: "FULL", "SYMBOL_TABLE", "NONE"
                // NONE = smallest APK, no crash symbolication
                // SYMBOL_TABLE = good balance (crashes readable)
            }
        }
    }

packaging {
        jniLibs {
            // Strip debug info from .so files
            useLegacyPackaging = false  // Use compressed .so (Android 6.0+)
        }
    }
}

16. Only Include Needed ABIs

Impact: 50–70% reduction in lib/ folder

android {
    defaultConfig {
        ndk {
            // Only include ABIs you need
            abiFilters += listOf("arm64-v8a", "armeabi-v7a")
            // Exclude: "x86", "x86_64" (emulators only — not needed in production)
        }
    }
}

// Size comparison for a typical native library:
// arm64-v8a:     8 MB
// armeabi-v7a:   6 MB
// x86_64:        9 MB  ← Remove this
// x86:           7 MB  ← Remove this
// Total before: 30 MB
// Total after:  14 MB (53% smaller)

17. Audit Native Library Dependencies

COMMON HEAVY NATIVE LIBRARIES:
  SQLCipher:         ~15 MB  → Do you really need encrypted SQLite?
  FFmpeg:            ~20 MB  → Use Media3 ExoPlayer instead
  TensorFlow Lite:   ~10 MB  → Can you use a smaller model?
  OpenCV:            ~30 MB  → Do you need full OpenCV?
  WebRTC:            ~15 MB  → Use only the modules you need

ASK FOR EACH .so FILE:
  1. What library does this belong to?
  2. Do we actually use the feature that needs native code?
  3. Is there a pure-Kotlin/Java alternative?
  4. Can we load this dynamically instead of bundling?

Part 6: Font Optimization

18. Use System Fonts or Google Fonts (Downloadable)

Impact: 2–5 MB reduction per font family

BUNDLED FONTS (in assets/):
  Poppins-Regular.ttf     →  280 KB
  Poppins-Medium.ttf      →  285 KB
  Poppins-SemiBold.ttf    →  282 KB
  Poppins-Bold.ttf        →  283 KB
  Poppins-Light.ttf       →  275 KB
  Poppins-Italic.ttf      →  290 KB
  Total: 1.7 MB for ONE font family

DOWNLOADABLE FONTS (from Google Fonts API):
  No bundled files → 0 MB in APK
  Downloaded at runtime → cached on device
// Using Downloadable Fonts in Compose:
val fontFamily = FontFamily(
    Font(
        googleFont = GoogleFont("Poppins"),
        fontProvider = GoogleFont.Provider(
            providerAuthority = "com.google.android.gms.fonts",
            providerPackage = "com.google.android.gms",
            certificates = R.array.com_google_android_gms_fonts_certs
        )
    )
)

// Fallback for devices without Google Play Services:
val fallbackFontFamily = FontFamily(
    Font(R.font.poppins_regular),
    Font(R.font.poppins_bold, FontWeight.Bold)
)

19. Subset Fonts (Keep Only Characters You Need)

Impact: 60–80% reduction per font file

# Use pyftsubset (from fonttools) to keep only Latin + Arabic characters
pip install fonttools brotli

# Latin only (removes CJK, Cyrillic, etc.)
pyftsubset Poppins-Regular.ttf \
  --output-file=Poppins-Regular-subset.ttf \
  --unicodes="U+0000-007F,U+00A0-00FF,U+0100-024F,U+0600-06FF" \
  --layout-features='*'
# Before: 280 KB
# After:   65 KB (77% smaller)

20. Use Only Weights You Actually Need

# ❌ Including every weight (6 files × 280KB = 1.7 MB)
font_family = Poppins: Thin, ExtraLight, Light, Regular, Medium, SemiBold, Bold, ExtraBold, Black

# ✅ Include only used weights (3 files × 280KB = 840 KB)
font_family = Poppins: Regular, Medium, Bold

Part 7: Dynamic Feature Modules (Advanced)

21. Move Large Features to On-Demand Modules

Impact: 20–50% reduction in initial download

// settings.gradle.kts
include(":app")
include(":feature:scanner")     // QR/barcode scanner (includes ML Kit: ~5MB)
include(":feature:videochat")   // Video calling (includes WebRTC: ~15MB)
include(":feature:ar")          // AR features (includes ARCore: ~10MB)

// feature/scanner/build.gradle.kts
plugins {
    id("com.android.dynamic-feature")
}

android {
    namespace = "com.myapp.feature.scanner"
}
dependencies {
    implementation(project(":app"))
}
<!-- feature/scanner/src/main/AndroidManifest.xml -->
<manifest xmlns:dist="http://schemas.android.com/apk/dist">
    <dist:module
        dist:instant="false"
        dist:title="@string/scanner_module_title">
        <dist:delivery>
            <dist:on-demand />  <!-- Downloaded only when user needs it -->
        </dist:delivery>
        <dist:fusing dist:include="false" />
    </dist:module>
</manifest>
// In your main app — request the module when needed:
fun openScanner(context: Context) {
    val splitInstallManager = SplitInstallManagerFactory.create(context)

// Check if already installed
    if (splitInstallManager.installedModules.contains("scanner")) {
        // Module is already available - open it
        navigateToScanner()
        return
    }
    // Download the module
    val request = SplitInstallRequest.newBuilder()
        .addModule("scanner")
        .build()
    splitInstallManager.startInstall(request)
        .addOnSuccessListener { sessionId ->
            // Monitor download progress
        }
        .addOnFailureListener { exception ->
            // Handle failure
        }
}

Part 8: Baseline Profiles

22. Generate Baseline Profiles

Impact: 15–30% faster cold start, ~2% larger APK (worth it)

Baseline Profiles tell the ART runtime which methods to compile ahead of time. This isn’t about APK size — it’s about perceived performance. But it slightly increases APK size (~200KB), and the tradeoff is almost always worth it.

// Add dependencies
// build.gradle.kts (app)
dependencies {
    implementation("androidx.profileinstaller:profileinstaller:1.4.1")
}

// build.gradle.kts (benchmark module)
plugins {
    id("com.android.test")
    id("androidx.baselineprofile")
}
dependencies {
    implementation("androidx.benchmark:benchmark-macro-junit4:1.3.3")
}
// Generate the profile
@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {

@get:Rule
    val rule = BaselineProfileRule()
    @Test
    fun generateProfile() {
        rule.collect("com.myapp") {
            // Critical user journeys
            startActivityAndWait()
            // Scroll the main list
            device.findObject(By.scrollable(true))
                .scroll(Direction.DOWN, 3f)
            // Open a detail screen
            device.findObject(By.text("Transfers")).click()
            device.waitForIdle()
            // Navigate
            device.findObject(By.text("Settings")).click()
            device.waitForIdle()
        }
    }
}

Part 9: Advanced Techniques

23. Use Compose BOM to Avoid Duplicate Dependencies

dependencies {
    val composeBom = platform("androidx.compose:compose-bom:2025.01.00")
    implementation(composeBom)

// No version numbers - BOM ensures compatible, deduplicated versions
    implementation("androidx.compose.material3:material3")
    implementation("androidx.compose.ui:ui")
    implementation("androidx.compose.ui:ui-tooling-preview")
}

24. Replace Heavy Libraries with Lighter Alternatives

HEAVY → LIGHT ALTERNATIVES:

Gson (350KB)          → kotlinx.serialization (200KB) or Moshi (250KB)
Glide (3MB)           → Coil (500KB) - Kotlin-first, smaller
Joda-Time (1.8MB)     → java.time (built-in API 26+)
Apache Commons (1MB+) → Kotlin stdlib (already included)
OkHttp Logging (200KB) → Remove in release build
Timber (20KB)         → Remove logs with R8 rules (0 KB in release)
RxJava (2.5MB)        → Coroutines + Flow (already included with Kotlin)

25. Defer Large Library Initialization

// ❌ Loading ML model at app startup (adds to cold start + APK size)
class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        TensorFlowLite.initialize()  // 10MB model loaded immediately
    }
}

// ✅ Load only when needed
class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        // Don't initialize TF here
    }
}
class ScannerViewModel : ViewModel() {
    private val model by lazy {
        // Load ML model only when scanner screen opens
        TensorFlowLite.initialize(context)
    }
}

26. Remove Unused Locales

android {
    defaultConfig {
        resourceConfigurations += listOf("en", "ar", "fr")
        // Only include English, Arabic, French
        // Libraries like AppCompat include 80+ languages — this strips the rest
    }
}

// Impact: Each unused locale has string resources
// 80 unused locales × ~20KB each = ~1.6 MB saved

27. Use shrinkResources with Strict Mode

<!-- res/raw/keep.xml -->
<resources xmlns:tools="http://schemas.android.com/tools"
    tools:shrinkMode="strict">
    <!-- In strict mode, you must declare which resources to keep.
         Anything not referenced in code AND not listed here is removed.
         This catches resources loaded dynamically via getIdentifier(). -->
</resources>

28. Obfuscate with R8 and Check the Output

# After building release, inspect what R8 removed:
# app/build/outputs/mapping/release/

# mapping.txt - obfuscation mapping (upload to Play Console for crash reports)
# seeds.txt - classes/members that were kept (not shrunk)
# usage.txt - classes/members that were REMOVED
# configuration.txt - effective ProGuard rules
# Check usage.txt - if important classes are listed, your keep rules are wrong
# Check seeds.txt - if too many classes are kept, your rules are too broad

Part 10: Monitoring and CI Integration

29. Track APK Size in CI

# GitHub Actions — fail build if APK grows unexpectedly
- name: Check APK size
  run: |
    APK_SIZE=$(stat -f%z app/build/outputs/apk/release/app-release.apk 2>/dev/null || stat -c%s app/build/outputs/apk/release/app-release.apk)
    APK_SIZE_MB=$((APK_SIZE / 1048576))
    echo "APK size: ${APK_SIZE_MB}MB"

    MAX_SIZE_MB=30
    if [ $APK_SIZE_MB -gt $MAX_SIZE_MB ]; then
      echo "❌ APK size (${APK_SIZE_MB}MB) exceeds limit (${MAX_SIZE_MB}MB)"
      exit 1
    fi
    echo "✅ APK size is within limits"

30. APK Size Comparison Report

// Custom Gradle task to track size changes
tasks.register("reportApkSize") {
    dependsOn("assembleRelease")
    doLast {
        val apk = file("app/build/outputs/apk/release/app-release.apk")
        val sizeMB = apk.length() / 1_048_576.0
        println("═══════════════════════════════════")
        println("  APK Size Report")
        println("  Size: %.2f MB".format(sizeMB))
        println("═══════════════════════════════════")

// Save to file for CI comparison
        file("apk-size.txt").writeText("%.2f".format(sizeMB))
    }
}

Part 11: The Complete Optimization Checklist

TECHNIQUE                          EFFORT      IMPACT       PRIORITY
──────────────────────────────────────────────────────────────────────
✅ Enable R8 (minifyEnabled)       5 min       15-30%       ★★★★★
✅ Enable resource shrinking       1 min       5-15%        ★★★★★
✅ Use AAB instead of APK          2 min       35-50%       ★★★★★
✅ Convert PNG → WebP              10 min      50-80%       ★★★★★
✅ Use vector drawables for icons  30 min      80-95%       ★★★★★
✅ Remove unused ABI (x86)         2 min       50-70%       ★★★★★
✅ Remove unused libraries         15 min      1-10 MB      ★★★★☆
✅ Strip native debug symbols      2 min       50-80%       ★★★★☆
✅ Limit language resources        2 min       1-2 MB       ★★★★☆
✅ Remove Kotlin metadata          2 min       1-3 MB       ★★★★☆
✅ Remove Log calls via R8 rules   5 min       0.5-1 MB     ★★★☆☆
✅ R8 full mode                    10 min      5-10%        ★★★☆☆
✅ Use downloadable fonts          15 min      2-5 MB       ★★★☆☆
✅ Subset fonts                    10 min      60-80%       ★★★☆☆
✅ Audit native libraries          30 min      5-20 MB      ★★★☆☆
✅ Replace heavy libraries         1 hour      3-10 MB      ★★☆☆☆
✅ Dynamic feature modules         4 hours     20-50%       ★★☆☆☆
✅ Baseline profiles               2 hours     +200KB       ★★☆☆☆
✅ CI size monitoring               1 hour      prevention   ★★☆☆☆

Part 12: Real-World Case Study

BANKING APP — BEFORE OPTIMIZATION:
  Total APK:        82 MB
  ├── classes.dex:  18 MB  (Retrofit, OkHttp, Room, Compose, Firebase, Analytics)
  ├── res/:         22 MB  (PNG icons, illustrations, density variants)
  ├── lib/:         28 MB  (SQLCipher, TFLite for ID scan, 4 ABIs)
  ├── assets/:       8 MB  (3 font families, lottie animations, country flags)
  ├── resources.arsc: 4 MB
  └── META-INF/:     2 MB

OPTIMIZATIONS APPLIED:
  1. Enable R8 + resource shrinking        →  -8 MB  (DEX 18→12, res 22→20)
  2. PNG → WebP                            →  -12 MB (res 20→8)
  3. PNG icons → Vector drawables          →  -3 MB
  4. Remove x86/x86_64 ABIs              →  -14 MB (lib 28→14)
  5. Strip native debug symbols           →  -4 MB  (lib 14→10)
  6. 3 font families → 1 + downloadable   →  -4 MB
  7. Remove unused locales (keep 3)        →  -1.5 MB
  8. R8 remove Log calls + Kotlin metadata →  -2 MB
  9. Dynamic feature: ID scanner module    →  -8 MB (TFLite moved to on-demand)
  10. Lottie → Compose animations          →  -2 MB
AFTER OPTIMIZATION:
  Total APK:   24 MB  (71% reduction!)
  AAB download: 15 MB (82% reduction from original!)
TIME SPENT: ~8 hours
INSTALL CONVERSION IMPROVEMENT: ~10% (Google's data)

Part 13: Common Pitfalls

Pitfall 1: R8 Breaks Reflection-Based Libraries

// ❌ Retrofit DTOs get stripped by R8
data class TransferDto(val id: String, val amount: Double)
// R8 renames fields → JSON parsing breaks: "No field 'a' in class 'b'"

// ✅ Keep DTO classes
// proguard-rules.pro
-keep class com.myapp.data.remote.dto.** { *; }

Pitfall 2: Forgetting to Test Release Builds

// ❌ "It works in debug" — debug doesn't have R8, different image loading, no obfuscation

// ✅ Test EVERY feature in release build before shipping
// Critical to test: API calls, navigation, deep links, background work, notifications

Pitfall 3: AAB Size vs Download Size Confusion

// ❌ "My AAB is 90MB!" — That's fine. AAB contains ALL variants.
// What matters is the DOWNLOAD size per device.

// Check actual download size:
java -jar bundletool.jar get-size total --apks=output.apks
// This shows what each user actually downloads

Pitfall 4: Removing Too Many ABIs

// ❌ Only keeping arm64-v8a
ndk { abiFilters += listOf("arm64-v8a") }
// Excludes older 32-bit devices (still ~15% of market in some regions)

// ✅ Keep arm64-v8a + armeabi-v7a (covers ~99.5% of devices)
ndk { abiFilters += listOf("arm64-v8a", "armeabi-v7a") }

Conclusion

APK size optimization is not a one-time task — it’s a continuous discipline. The three highest-impact changes are: enable R8 with resource shrinking (5 minutes, 20–30% reduction), convert images to WebP/vectors (30 minutes, 50–95% reduction for images), and use Android App Bundle (2 minutes, 35–50% reduction in user download).

For most apps, these three changes alone bring you from 80MB to 30MB. The advanced techniques (dynamic features, font subsetting, native library auditing, CI monitoring) get you from 30MB to 15MB.

The number to optimize for is not your AAB size — it’s what the user downloads. Use bundletool get-size total to measure what real users actually receive. That's the number that affects install conversion, storage complaints, and user satisfaction.

Connect with Me on LinkedIn

Follow me on LinkedIn

Tags: #Android #APKSize #R8 #ProGuard #WebP #AppBundle #Performance #Kotlin #JetpackCompose #Optimization #MobileDevelopment


메타데이터
post_id
e4cdff7ad9c0
slug
30-steps-to-reduce-apk-size-the-complete-guide-from-85mb-to-15mb-proguard-r8-resource-e4cdff7ad9c0
url
https://medium.com/@ramadan123sayed/30-steps-to-reduce-apk-size-the-complete-guide-from-85mb-to-15mb-proguard-r8-resource-e4cdff7ad9c0
canonical_url
https://medium.com/@ramadan123sayed/30-steps-to-reduce-apk-size-the-complete-guide-from-85mb-to-15mb-proguard-r8-resource-e4cdff7ad9c0
author_url
https://medium.com/@ramadan123sayed
status
ok
fetched_at
2026-07-11 04:18:25