Performance Optimization
A slow app feels broken. A janky scroll feels broken. A 2-second startup feels broken. This module teaches you to measure first, then optimize the things that matter — using the same tools the Android team uses internally.
Topic 1 · Profiling
Memory management & LeakCanary
The JVM garbage collector frees objects no longer reachable. A leak occurs when something keeps a reference longer than it should — preventing GC. Common culprits on Android:
- Inner classes / lambdas holding the outer Activity
- Static references to Context
- Listeners not unregistered
- Coroutines launched on the wrong scope
LeakCanary catches leaks automatically in debug builds:
// app/build.gradle.kts
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14")
When LeakCanary detects a leaked Activity/Fragment/View, it dumps the heap, shows the leak trace, and points at the exact reference holding the object. Fix leaks immediately — they compound and cause OOM crashes in the wild.
Layout performance
In Compose, the main perf risks are:
- Excessive recomposition — composables recomposing when nothing visible changed
- Lazy lists without keys — items recompose on reorder
- Heavy work in composition — read DB, do network, parse JSON in a
@Composable
// BAD — recomposes on EVERY parent recomposition because mutableListOf creates a new list
@Composable
fun BadList() {
val items = mutableListOf(1, 2, 3) // ← new instance every recomposition
LazyColumn { items(items) { Text("$it") } }
}
// GOOD — remember keeps the same instance, with a stable key
@Composable
fun GoodList() {
val items = remember { listOf(1, 2, 3) }
LazyColumn { items(items, key = { it }) { Text("$it") } }
}
Enable Layout Inspector → Recomposition counts in Android Studio to spot hot composables.
For XML layouts: prefer ConstraintLayout (flat hierarchies), avoid
nested RelativeLayout/LinearLayout, and watch the GPU profiler for
overdraw.
App startup optimization
Startup has three phases the platform measures:
| Phase | What happens |
|---|---|
| Cold start | Process create, Application.onCreate, first frame |
| Warm start | Activity recreated, no process create |
| Hot start | Activity already in memory, brought to front |
To improve cold start:
- Defer initialization with App Startup library instead of running everything in
Application.onCreate. - Avoid heavy DI graph eager init (Hilt is already lazy).
- Use SplashScreen API (Android 12+) — the system shows the splash for free.
- Move first-frame work to Compose's
LaunchedEffectso it doesn't block layout.
// App Startup — declarative, lazy initialization
class AnalyticsInitializer : Initializer<AnalyticsClient> {
override fun create(ctx: Context): AnalyticsClient = AnalyticsClient(ctx)
override fun dependencies() = emptyList<Class<out Initializer<*>>>()
}
<!-- AndroidManifest.xml -->
<provider
android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data
android:name="com.example.AnalyticsInitializer"
android:value="androidx.startup" />
</provider>
Topic 2 · Optimization
R8 / ProGuard — code shrinking, obfuscation, optimization
R8 (Google's replacement for ProGuard) does three things at release build:
- Shrinking — removes unused classes, methods, fields.
- Optimization — inlines methods, removes dead code, unboxes primitives.
- Obfuscation — renames classes/methods to short names (a, b, c) to shrink and obscure.
// app/build.gradle.kts
android {
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}
You'll need keep rules for code accessed via reflection (e.g., Retrofit
interfaces, Moshi-generated adapters, custom Compose @Stable types):
# proguard-rules.pro
-keepattributes *Annotation*, Signature, InnerClasses
# Retrofit
-keep,allowobfuscation,allowshrinking interface retrofit2.Call
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
# Moshi
-keep class **JsonAdapter { *; }
APK size reduction
| Technique | Typical savings |
|---|---|
| Enable R8 with shrinkResources | 30–50% |
| Use App Bundles (.aab) | 15–25% per device download |
| WebP / AVIF for images | 25–35% vs PNG |
| Vector drawables for icons | 100x vs raster at all densities |
Remove unused languages with resConfigs | a few hundred KB |
android {
defaultConfig {
resourceConfigurations += listOf("en", "hi", "es", "fr") // ship only these locales
}
bundle {
language { enableSplit = true } // per-language splits
density { enableSplit = true }
abi { enableSplit = true }
}
}
Baseline profiles
Baseline profiles ship with your APK telling ART which methods to AOT-compile at install time. The result: 20–40% faster startup and smoother first launches of critical screens.
// macrobenchmark module — generate the profile
@RunWith(AndroidJUnit4::class)
class StartupBenchmark {
@get:Rule val rule = MacrobenchmarkRule()
@Test fun startup() = rule.measureRepeated(
packageName = "com.example.app",
metrics = listOf(StartupTimingMetric()),
iterations = 5,
startupMode = StartupMode.COLD
) {
pressHome()
startActivityAndWait()
}
}
@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {
@get:Rule val rule = BaselineProfileRule()
@Test fun generate() = rule.collect(packageName = "com.example.app") {
pressHome()
startActivityAndWait()
// walk through critical user flows here
}
}
The generated baseline-prof.txt ships in src/main/ and is consumed
automatically by the Android build system.
Other tools you'll use
Android Studio CPU Profiler
Sample method traces, see where time is spent on the main thread.
Memory Profiler
Track allocations, find retained objects, force GC, dump heaps.
Energy Profiler
See CPU, network, and location wakelocks combined into a battery score.
Perfetto traces
Microsecond-level system traces — the same tool Google uses internally.
Macrobenchmark
Measure startup time, frame timing, and scrolling on real devices in CI.
Firebase Performance
Production trace collection — see real-user metrics from real devices.
Key takeaways
Practice exercises
- 01
Add LeakCanary
Install LeakCanary in your debug build and intentionally create a leak (static reference to an Activity). Observe the leak trace.
- 02
Measure recomposition
Use Layout Inspector in Compose mode to find a composable recomposing too often, then fix it with derivedStateOf or remember.
- 03
Generate baseline profile
Set up the macrobenchmark module and generate a baseline profile for your app's startup flow.
Next module
Continue to Module 11 — Google Play Store & Publishing to ship the optimized app to users.