Slide 1

Slide 1 text

ADVANCED ANDROID APP SHRINKING TECHNIQUES In Search of Lightness: Christophe Beyls [email protected]

Slide 2

Slide 2 text

Hello! I am Christophe Beyls Freelance Android developer from Belgium. ▸ [email protected] ▸ bladecoder.medium.com ▸ github.com/cbeyls 2 Now available for hire!

Slide 3

Slide 3 text

3 Medium-sized app Large app FOSDEM Companion https://github.com/cbeyls/fosdem-companion-android Tusky https://github.com/tuskyapp/Tusky

Slide 4

Slide 4 text

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

Slide 5

Slide 5 text

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.

Slide 6

Slide 6 text

6 Android Studio APK Analyzer Browse and explore all file types.

Slide 7

Slide 7 text

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

Slide 8

Slide 8 text

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

Slide 9

Slide 9 text

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

Slide 10

Slide 10 text

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

Slide 11

Slide 11 text

Optimize images Not all pixels are created equal 1

Slide 12

Slide 12 text

(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

Slide 13

Slide 13 text

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

Slide 14

Slide 14 text

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

Slide 15

Slide 15 text

▸ 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

Slide 16

Slide 16 text

https://github.com/alexjlockwood/avocado 16 Avocado: optimize vector drawables

Slide 17

Slide 17 text

Optimize resources Remove, remove, remove 2

Slide 18

Slide 18 text

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

Slide 19

Slide 19 text

Remove unsupported locales: Example case study 19

Slide 20

Slide 20 text

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

Slide 21

Slide 21 text

APK meta data 21

Slide 22

Slide 22 text

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

Slide 23

Slide 23 text

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

Slide 24

Slide 24 text

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

Slide 25

Slide 25 text

APK split per CPU architecture android { splits { abi { isEnable = true reset() include ("x86", "armeabi-v7a", "arm64-v8a") isUniversalApk = false } } } 25 build.gradle.kts

Slide 26

Slide 26 text

Optimize dependencies Select and deduplicate 3

Slide 27

Slide 27 text

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

Slide 28

Slide 28 text

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

Slide 29

Slide 29 text

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

Slide 30

Slide 30 text

Optimize Proguard rules Beyond the defaults 4

Slide 31

Slide 31 text

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

Slide 32

Slide 32 text

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

Slide 33

Slide 33 text

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

Slide 34

Slide 34 text

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

Slide 35

Slide 35 text

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)

Slide 36

Slide 36 text

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)

Slide 37

Slide 37 text

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

Slide 38

Slide 38 text

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

Slide 39

Slide 39 text

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(bundle.getString("type")!!)

Slide 40

Slide 40 text

-allowaccessmodification # Preserve some attributes that may be required for reflection. -keepattributes RuntimeVisible*Annotations, AnnotationDefault # For native methods -keepclasseswithmembernames class * { native ; } -keepclassmembers class * implements android.os.Parcelable { public static final ** CREATOR; } # Preserve annotated Javascript interface methods. -keepclassmembers class * { @android.webkit.JavascriptInterface ; } # 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

Slide 41

Slide 41 text

41 Impact of optimizing default Proguard rules

Slide 42

Slide 42 text

42 Impact of optimizing default Proguard rules

Slide 43

Slide 43 text

https://jakewharton.com/blog/ 43 More about R8 optimizations

Slide 44

Slide 44 text

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

Slide 45

Slide 45 text

android { buildTypes { release { kotlinOptions { freeCompilerArgs += listOf( "-Xno-param-assertions", "-Xno-call-assertions", "-Xno-receiver-assertions" ) } } } } 45 build.gradle.kts Remove some Kotlin assertions

Slide 46

Slide 46 text

# 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

Slide 47

Slide 47 text

47 Impact of removing all Kotlin assertions

Slide 48

Slide 48 text

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

Slide 49

Slide 49 text

Examples of bad third-party library rules -keep class com.mylibrary.** { *; } -keep class * extends android.view.View { *; } -dontobfuscate -dontoptimize 49

Slide 50

Slide 50 text

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

Slide 51

Slide 51 text

Excluding Proguard rules of a specific library android { buildTypes { release { optimization.keepRules { ignoreFrom("com.badlibrary:badlibrary") } } } } 51 build.gradle.kts

Slide 52

Slide 52 text

fun patchDesugarConfig(config: Property) { val defaultConfig = config as org.gradle.api.internal.provider.DefaultProperty 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

Slide 53

Slide 53 text

53 Impact of desugaring optimization

Slide 54

Slide 54 text

54

Slide 55

Slide 55 text

XML resources vs. shrinking Jetpack libraries using XML to load code 5

Slide 56

Slide 56 text

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

Slide 57

Slide 57 text

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

Slide 58

Slide 58 text

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

Slide 59

Slide 59 text

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

Slide 60

Slide 60 text

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

Slide 61

Slide 61 text

android { sourceSets { getByName("main") { res.srcDir("src/main/res-override") } } } 61 A workaround build.gradle.kts

Slide 62

Slide 62 text

Library resources overriding: Example case study 62

Slide 63

Slide 63 text

Down the Rabbit hole. 63

Slide 64

Slide 64 text

▸ The AppCompat and Material Components libraries use a custom layout inflater to replace 14 system widgets: Button, TextView, CheckBox, ImageView, … ▸ These widgets and their resources are always included. 64 Shrinking limitations of XML-based Jetpack libraries com.google.android.material .button.MaterialButton inflate

Slide 65

Slide 65 text

▸ The CoordinatorLayout library adds a Proguard rule to keep every CoordinatorLayout.Behavior. They are created via reflection: 65 Shrinking limitations of XML-based Jetpack libraries

Slide 66

Slide 66 text

▸ The RecyclerView library adds a Proguard rule to keep all LayoutManagers. They may be created via reflection: 66 Shrinking limitations of XML-based Jetpack libraries

Slide 67

Slide 67 text

▸ 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

Slide 68

Slide 68 text

68

Slide 69

Slide 69 text

“ How do we move on from the code + XML duo? 69

Slide 70

Slide 70 text

Jetpack Compose 70

Slide 71

Slide 71 text

Compose Everywhere Going 100% Compose 6

Slide 72

Slide 72 text

▸ 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

Slide 73

Slide 73 text

▸ 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)

Slide 74

Slide 74 text

(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

Slide 75

Slide 75 text

▸ 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

Slide 76

Slide 76 text

76 Better Fix: Send a pull request

Slide 77

Slide 77 text

77 Case study: Migrating an entire app to Compose T.B.D. 😅

Slide 78

Slide 78 text

78 A Compose migration example: Tivi https:/ /github.com/chrisbanes/tivi

Slide 79

Slide 79 text

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