Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Advanced App Shrinking Techniques

Advanced App Shrinking Techniques

With the inner complexity of Android apps gradually increasing over the years, it has become crucial to use tools to reduce their footprint in production. But simply enabling code and resource shrinking is not enough to make your release builds as small as they could be!

In this talk, we're going to take a full tour of the available techniques to put apps on a diet, including undocumented ones. We will see how Jetpack Compose enables new minification opportunities compared to the classic Views system. We will discover the limitations of R8 and the resources shrinker and figure out how to work around them in order to eliminate the last unused bytes of APK files.

This will be illustrated with concrete file sizes and case studies of real applications.

Learn how to tweak your code, resources and configuration to make your apps lighter than ever!

Christophe Beyls

July 05, 2024
Tweet

More Decks by Christophe Beyls

Other Decks in Programming

Transcript

  1. Hello! I am Christophe Beyls Freelance Android developer from Belgium.

    [email protected] ▸ bladecoder.medium.com ▸ github.com/cbeyls 2 Now available for hire!
  2. Why smaller app packages? ▸ Faster app downloads and less

    consumed data. (slow connections, costly/limited data fees) ▸ Smaller install size on the device. Uncompressed compiled native code takes more space! ▸ Faster app initialization (less code and resources to load). ▸ Faster performance (code optimizations by R8). 4
  3. Inside an APK file 5 Resources resources.arsc res/ assets/ Bytecode

    classes.dex classes2.dex … Others AndroidManifest.xml META-INF/ lib/ resources.arsc: Compiled resources tables (strings, colors, dimens, styles, ids, …) res/: Folder containing resource files (images, layouts, menus, fonts, …) assets/ (optional): Folder containing other arbitrary resource files.
  4. The basics android { buildTypes { release { isMinifyEnabled =

    true proguardFiles( getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" ) isShrinkResources = true } } } 7 build.gradle.kts
  5. The basics android { buildTypes { release { isMinifyEnabled =

    true proguardFiles( getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" ) isShrinkResources = true } } } 8 Remove unused code build.gradle.kts
  6. The basics android { buildTypes { release { isMinifyEnabled =

    true proguardFiles( getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" ) isShrinkResources = true } } } 9 Remove unused resources build.gradle.kts
  7. Agenda 10 1 3 5 6 4 2 Optimize images

    Optimize dependencies XML resources vs. shrinking Optimize resources Optimize Proguard rules The future: Jetpack Compose
  8. (with comparable perceived image quality) ▸ Replace JPEG with lossy

    WebP: 20-30% smaller file sizes. ▸ Android 14+ Replace JPEG with AVIF: 50% smaller file sizes. 12 Better lossy image formats
  9. 13 640x640 transparent PNG 400,4 Kb 640x640 transparent WebP 106,1

    Kb Original Lossy WebP (drawing mode, q98)
  10. 14 640x640 transparent PNG 400,4 Kb 640x640 transparent WebP 106,1

    Kb Original Lossy WebP (drawing mode, q98)
  11. ▸ Replace PNG with lossless WebP: 20-40% smaller file sizes.

    ▸ For small icons, prefer density-independent Vector Drawables to sets of PNG/WebP images. 15 Better lossless image formats
  12. Remove unsupported locales ▸ Libraries like AppCompat, Material Components or

    Google Play Services include many strings in 75+ languages. ▸ Specify the locales you actually support and let the build tool remove the rest. 18 android { defaultConfig { ... resourceConfigurations += listOf("en", "fr", "nl") } } build.gradle.kts
  13. Remove unused custom fonts ▸ Each style/weight combination in a

    font family needs a separate font file. ▸ Not all font variants are used in an app. ▸ The resources shrinker is unable to detect unused variants within a font family. 20
  14. Remove APK metadata android { buildTypes { release { packaging

    { resources { excludes += listOf( "DebugProbesKt.bin", "kotlin-tooling-metadata.json", "/*.properties", "kotlin/**" ) } } vcsInfo.include = false } } dependenciesInfo { includeInApk = false includeInBundle = false } } 22 build.gradle.kts
  15. Remove APK metadata android { buildTypes { release { packaging

    { resources { excludes += listOf( "DebugProbesKt.bin", "kotlin-tooling-metadata.json", "/*.properties", "kotlin/**" ) } } vcsInfo.include = false } } dependenciesInfo { includeInApk = false includeInBundle = false } } 23 build.gradle.kts For security checks (Google Play only) Version control info
  16. A word about native binaries If ▹ You distribute your

    app as an APK file ▹ Your app includes large native binaries in the /lib folder Then ▹ You should build one APK file per CPU architecture. 24
  17. APK split per CPU architecture android { splits { abi

    { isEnable = true reset() include ("x86", "armeabi-v7a", "arm64-v8a") isUniversalApk = false } } } 25 build.gradle.kts
  18. Your actual dependencies ▸ ./gradlew app:dependencies --configuration releaseCompileClasspath > deps.txt

    +--- org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1 (*) +--- androidx.core:core-ktx:1.13.1 (*) +--- androidx.activity:activity:1.9.0 (*) +--- androidx.fragment:fragment-ktx:1.8.0 | +--- androidx.activity:activity-ktx:1.8.1 -> 1.9.0 | | +--- androidx.activity:activity:1.9.0 (*) | | +--- androidx.core:core-ktx:1.13.0 -> 1.13.1 (*) | | +--- androidx.lifecycle:lifecycle-runtime-ktx:2.6.1 -> 2.8.2 | | | \--- androidx.lifecycle:lifecycle-runtime-ktx-android:2.8.2 | | | +--- androidx.annotation:annotation:1.8.0 (*) | | | +--- androidx.lifecycle:lifecycle-runtime:2.8.2 (*) | | | +--- org.jetbrains.kotlin:kotlin-stdlib:1.8.22 -> 2.0.0 (*) | | | +--- org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3 -> 1.8.1 (*) | | | +--- androidx.lifecycle:lifecycle-common:2.8.2 (c) | | | +--- androidx.lifecycle:lifecycle-livedata-core:2.8.2 (c) | | | +--- androidx.lifecycle:lifecycle-livedata-core-ktx:2.8.2 (c) | | | +--- androidx.lifecycle:lifecycle-runtime:2.8.2 (c) | | | +--- androidx.lifecycle:lifecycle-viewmodel:2.8.2 (c) | | | +--- androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.2 (c) 27
  19. Choose your dependencies wisely ▸ Prefer libraries using the same

    dependencies as your app: ▹ Same JSON parser (Kotlinx serialization, Moshi) ▹ Same image loader (Coil, Glide) ▹ Same network stack (Retrofit/OkHttp, Ktor). ▸ Avoid libraries with too many dependencies. 28
  20. Choose your dependencies wisely ▸ Avoid heavy libraries (check size

    using APK Analyzer) ▸ Avoid libraries depending on kotlin-reflect (+ 1 MB dex!) (example: replace moshi-kotlin with moshi-kotlin-codegen) ▸ Replace RxJava with Kotlin coroutines. 29
  21. Optimize Proguard rules android { buildTypes { release { isMinifyEnabled

    = true proguardFiles( getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro" ) isShrinkResources = true } } } 31 build.gradle.kts -dontoptimize
  22. Optimize Proguard rules android { buildTypes { release { isMinifyEnabled

    = true proguardFiles( getDefaultProguardFile("proguard-android.txt"), getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro" ) isShrinkResources = true } } } 32 build.gradle.kts
  23. proguard-android-optimize.txt # Preserve some attributes that may be required for

    reflection. -keepattributes AnnotationDefault, EnclosingMethod, InnerClasses, RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations, RuntimeVisibleTypeAnnotations, Signature 33
  24. proguard-android-optimize.txt # Preserve some attributes that may be required for

    reflection. -keepattributes AnnotationDefault, EnclosingMethod, InnerClasses, RuntimeVisibleAnnotations, RuntimeVisibleParameterAnnotations, RuntimeVisibleTypeAnnotations, Signature # Preserve some attributes that may be required for reflection. -keepattributes RuntimeVisible*Annotations, AnnotationDefault 34
  25. proguard-android-optimize.txt # Keep setters in Views so that animations can

    still work. -keepclassmembers public class * extends android.view.View { void set*(***); *** get*(); } 35 ObjectAnimator.ofInt(myView, "counter", 1, 4)
  26. proguard-android-optimize.txt # Keep setters in Views so that animations can

    still work. -keepclassmembers public class * extends android.view.View { void set*(***); *** get*(); } 36 ObjectAnimator.ofInt(myView, "counter", 1, 4) ObjectAnimator.ofInt(myView, MyView.COUNTER, 1, 4)
  27. proguard-android-optimize.txt # We want to keep methods in Activity that

    could be used in the XML attribute onClick. -keepclassmembers class * extends android.app.Activity { public void *(android.view.View); } 37 <Button android:id="@+id/button" android:onClick="onButtonClick" android:text="@string/about_text" />
  28. proguard-android-optimize.txt # For enumeration classes -keepclassmembers enum * { public

    static **[] values(); public static ** valueOf(java.lang.String); } 38 bundle.putSerializable("type", AnimalType.CAT)
  29. proguard-android-optimize.txt # For enumeration classes -keepclassmembers enum * { public

    static **[] values(); public static ** valueOf(java.lang.String); } 39 bundle.putSerializable("type", AnimalType.CAT) bundle.putString("type", AnimalType.CAT.name) val type = enumValueOf<AnimalType>(bundle.getString("type")!!)
  30. -allowaccessmodification # Preserve some attributes that may be required for

    reflection. -keepattributes RuntimeVisible*Annotations, AnnotationDefault # For native methods -keepclasseswithmembernames class * { native <methods>; } -keepclassmembers class * implements android.os.Parcelable { public static final ** CREATOR; } # Preserve annotated Javascript interface methods. -keepclassmembers class * { @android.webkit.JavascriptInterface <methods>; } # The support libraries contains references to newer platform versions. # Don't warn about those in case this app is linking against an older # platform version. We know about them, and they are safe. -dontnote androidx.** -dontwarn androidx.** # This class is deprecated, but remains for backward compatibility. -dontwarn android.util.FloatMath # These classes are duplicated between android.jar and core-lambda-stubs.jar. -dontnote java.lang.invoke.** 40 Modern defaults
  31. Kotlin assertions import java.time.Instant class Demo { fun hello(name: String)

    { val now = Instant.now().toString() println("Hello $name at $now") } } 44 public final class Demo { public final void hello(@NotNull String name) { Intrinsics.checkNotNullParameter(name, "name"); String now = Instant.now().toString(); Intrinsics.checkNotNullExpressionValue(now, "Instant.now().toString()"); String var3 = "Hello " + name + " at " + now; System.out.println(var3); } } compiled into
  32. android { buildTypes { release { kotlinOptions { freeCompilerArgs +=

    listOf( "-Xno-param-assertions", "-Xno-call-assertions", "-Xno-receiver-assertions" ) } } } } 45 build.gradle.kts Remove some Kotlin assertions
  33. # Remove Kotlin assertions -assumenosideeffects class kotlin.jvm.internal.Intrinsics { public static

    void checkNotNull(...); public static void checkExpressionValueIsNotNull(...); public static void checkNotNullExpressionValue(...); public static void checkParameterIsNotNull(...); public static void checkNotNullParameter(...); public static void checkReturnedValueIsNotNull(...); public static void checkFieldIsNotNull(...); public static void throwUninitializedPropertyAccessException(...); public static void throwNpe(...); public static void throwJavaNpe(...); public static void throwAssert(...); public static void throwIllegalArgument(...); public static void throwIllegalState(...); } 46 proguard-rules.pro Remove all Kotlin assertions
  34. Debug your Proguard rules -dontobfuscate ▹ To be able to

    check which packages and classes are kept in dex files using the APK Analyzer. -printconfiguration proguard-config.txt ▹ Check the rules added by third-party libraries. 48 proguard-rules.pro
  35. Examples of bad third-party library rules -keep class com.mylibrary.** {

    *; } -keep class * extends android.view.View { *; } -dontobfuscate -dontoptimize 49
  36. Fixing bad third-party library rules ▸ Proguard rules are global

    to the app. ▸ A Proguard rule can not be overridden by another Proguard rule. ▸ Since AGP 7.3 (Sept 2022), Proguard rules can be excluded for a specific library, then can be copied and fixed in the app rules file. 50
  37. Excluding Proguard rules of a specific library android { buildTypes

    { release { optimization.keepRules { ignoreFrom("com.badlibrary:badlibrary") } } } } 51 build.gradle.kts
  38. fun patchDesugarConfig(config: Property<String>) { val defaultConfig = config as org.gradle.api.internal.provider.DefaultProperty<String>

    val patchedDesugarConfig = defaultConfig.provider.map { it.replace( "\"support_all_callbacks_from_library\":true", "\"support_all_callbacks_from_library\":false" ) } config.set(patchedDesugarConfig) } afterEvaluate { tasks.withType(com.android.build.gradle.internal.tasks.R8Task::class).configureEach { patchDesugarConfig(coreLibDesugarConfig) } tasks.withType(com.android.build.gradle.internal.tasks.L8DexDesugarLibTask::class).configureEach { patchDesugarConfig(libConfiguration) } } 52 build.gradle.kts Packaging Patching desugaring configuration
  39. 54

  40. The shrinking process 56 STEP 3: Resources shrinking The resources

    shrinker removes all resources (including layouts) not referenced in the remaining code STEP 2: Code shrinking R8 removes unused code using the concatenated Proguard rules of: app + libraries + step 1 STEP 1: Scan + Configuration Proguard rules are added to keep: - Entry points in AndroidManifest.xml - Custom Views from all XML layouts
  41. Shrinking process limitations 57 My App Material Components library Code

    Resources MainActivity.class (entry point) main_activity.xml CustomButton.class MaterialTimePicker.class TimePickerView.class material_timepicker_dialog.xml
  42. Shrinking process limitations 58 My App Material Components library Code

    Resources MainActivity.class (entry point) main_activity.xml CustomButton.class MaterialTimePicker.class TimePickerView.class material_timepicker_dialog.xml Step 1 + MainActivity.class + CustomButton.class + TimePickerView.class
  43. Shrinking process limitations 59 My App Material Components library Code

    Resources MainActivity.class (entry point) main_activity.xml CustomButton.class TimePickerView.class material_timepicker_dialog.xml Step 2
  44. Shrinking process limitations 60 My App Material Components library Code

    Resources MainActivity.class (entry point) main_activity.xml CustomButton.class TimePickerView.class Step 3 Unreachable code
  45. ▸ The AppCompat and Material Components libraries use a custom

    layout inflater to replace 14 system widgets: Button, TextView, CheckBox, ImageView, … <Button android:text="@string/button_text" android:layout_width="match_parent" android:layout_height="wrap_content" /> ▸ These widgets and their resources are always included. 64 Shrinking limitations of XML-based Jetpack libraries com.google.android.material .button.MaterialButton inflate
  46. ▸ The CoordinatorLayout library adds a Proguard rule to keep

    every CoordinatorLayout.Behavior. They are created via reflection: <androidx.coordinatorlayout.widget.CoordinatorLayout> <com.google.android.material.appbar.AppBarLayout /> <androidx.core.widget.NestedScrollView app:layout_behavior="@string/appbar_scrolling_view_behavior" /> </androidx.coordinatorlayout.widget.CoordinatorLayout> 65 Shrinking limitations of XML-based Jetpack libraries
  47. ▸ The RecyclerView library adds a Proguard rule to keep

    all LayoutManagers. They may be created via reflection: <androidx.recyclerview.widget.RecyclerView android:id="@android:id/list" android:layout_width="match_parent" android:layout_height="match_parent" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" /> 66 Shrinking limitations of XML-based Jetpack libraries
  48. ▸ The Preferences library adds a Proguard rule to keep

    all 11 preference types. They are created via reflection. ▸ The Transitions library includes the code of all 10 transition types when TransitionInflater is used. ▸ … 67 Shrinking limitations of XML-based Jetpack libraries
  49. 68

  50. ▸ XML resources are replaced with Kotlin DSL. ▸ No

    reflection or Proguard rules needed. But: ▸ The app needs to include the Compose runtime (~900 KB dex code). 72 Compose shrinks best
  51. ▸ Compose-only apps don’t need AppCompat. ▸ Check your library

    dependencies! ▹ ./gradlew app:dependencies > deps.txt +--- io.insert-koin:koin-androidx-compose:3.5.6 | +--- io.insert-koin:koin-android:3.5.6 | | +--- io.insert-koin:koin-core:3.5.6 | | +--- androidx.appcompat:appcompat:1.6.1 73 Compose UI does not depend on AppCompat (or Material Components)
  52. (after shrinking) + AppCompat 1.7.0 + AppCompat 1.7.0 + Material

    Components 1.12.0 /res + 195 KB + 288 KB *.dex + 426 KB + 929 KB Apk file (3 locales) + 479 KB + 1006 KB 74 Overhead of AppCompat & Material Components
  53. ▸ Double-check that the library doesn’t require it. dependencies {

    implementation(libs.koin.androidx.compose) { exclude(group = "androidx.appcompat", module = "appcompat") } } // Alternatively configurations.configureEach { exclude(group = "androidx.appcompat", module = "appcompat") } 75 build.gradle.kts Quick Fix: Exclude AppCompat
  54. 79 THANKS! Any questions? Find me at: ▸ [email protected]

    bladecoder.medium.com ▸ github.com/cbeyls Credits ▸ Presentation template by SlidesCarnival ▸ Illustrations from Pixabay ▸ Some images by Sergei Tikhonov ▸ Movie shot from “The Matrix”.