Skip to main content

Android Performance Optimization

Key Concepts

Android performance optimization requires a measurement-first approach: profile your app with Android Studio's CPU, Memory, and Network profilers to identify actual bottlenecks before making optimizations. Performance issues typically fall into three categories: memory problems (leaks, excessive allocations, inefficient data structures), UI rendering issues (unnecessary Compose recompositions, inefficient LazyColumn usage, dropped frames), and computational bottlenecks (heavy work on the main thread, inefficient algorithms, blocking I/O). The Android profiler reveals exactly where your app spends time and allocates memory, allowing you to optimize hot paths that actually impact user experience rather than guessing at potential issues. Key performance targets: maintain 60fps for smooth animations, load screens in under 1 second, respond to user interactions within 100ms, and prevent ANRs (Application Not Responding) by keeping the main thread responsive. For architectural approaches to performance, see Android Architecture for efficient state management patterns, and Android Testing for performance testing strategies.

Overview

This guide covers Android performance optimization strategies including memory management, LazyColumn optimization, Compose recomposition analysis, R8/ProGuard configuration, APK size reduction, profiling with Android Studio tools, and ANR (Application Not Responding) prevention. Performance optimization is essential for providing a smooth, responsive user experience that meets modern user expectations.


Core Principles

  1. Measure First: Use profiling tools before optimizing - don't guess where bottlenecks are
  2. Memory Efficiency: Prevent memory leaks, reduce allocations, use appropriate data structures
  3. LazyColumn Optimization: Use keys, avoid nested scrolling, implement proper item reuse
  4. Minimize Recomposition: Stable parameters, remember callbacks, smart state management
  5. R8/ProGuard: Enable code shrinking, obfuscation, and optimization for release builds
  6. APK Size: Reduce binary size through resource optimization, dependency management, and splits
  7. Proactive Profiling: Profile regularly during development to catch regressions early
  8. ANR Prevention: Keep main thread responsive, use coroutines for background work with appropriate dispatchers

Performance Optimization Flow


Memory Management

Preventing Memory Leaks

Memory leaks in Android occur when objects are held in memory longer than necessary, preventing garbage collection. A memory leak happens when an object that should be garbage collected still has a reference path to a GC root (static fields, active threads, or system services), keeping it alive indefinitely. In Android, the most common leaks involve Activities and Fragments being retained after destruction due to long-lived references (ViewModels holding Activity context, static callbacks, inner classes, handlers). Each leaked Activity instance retains its entire view hierarchy (hundreds of view objects) and associated resources (bitmaps, drawables), consuming megabytes of memory. Over time, repeated leaks cause OutOfMemoryErrors and app crashes. The solution is breaking reference chains: use Application context instead of Activity context in long-lived objects, remove callbacks in lifecycle methods, use static inner classes with WeakReferences, and leverage lifecycle-aware components that automatically clean up.

Common Memory Leak Causes

** BAD: Context Leak in ViewModel**

// BAD: Holding Activity context in ViewModel causes memory leak
class PaymentViewModel(
private val context: Context // Activity context leaks!
) : ViewModel() {

fun showToast() {
Toast.makeText(context, "Payment successful", Toast.LENGTH_SHORT).show()
}
}

** GOOD: Use Application Context**

// GOOD: Use Application context which is safe
@HiltViewModel
class PaymentViewModel @Inject constructor(
@ApplicationContext private val context: Context
) : ViewModel() {

fun showToast() {
Toast.makeText(context, "Payment successful", Toast.LENGTH_SHORT).show()
}
}

** BAD: Non-Static Inner Class Leak**

// BAD: Inner class holds reference to Activity
class PaymentActivity : AppCompatActivity() {

private val handler = Handler(Looper.getMainLooper())

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

// Inner class holds implicit reference to Activity
handler.postDelayed(object : Runnable {
override fun run() {
// Activity may be destroyed but still referenced
updateUI()
}
}, 60000)
}
}

** GOOD: Static Inner Class or Remove Callback**

// GOOD: Clean up callbacks in onDestroy
class PaymentActivity : AppCompatActivity() {

private val handler = Handler(Looper.getMainLooper())
private val updateRunnable = Runnable { updateUI() }

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
handler.postDelayed(updateRunnable, 60000)
}

override fun onDestroy() {
super.onDestroy()
handler.removeCallbacks(updateRunnable) // Prevent leak
}

private fun updateUI() {
// Update UI
}
}

Detecting Memory Leaks with LeakCanary

// build.gradle (Module :app)
dependencies {
debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.12'
}

LeakCanary is a memory leak detection library that automatically monitors your app for leaks during development. It works by watching objects that should be garbage collected (Activities, Fragments, ViewModels) and triggering a heap dump when they remain in memory 5 seconds after they should have been destroyed. The library then analyzes the heap dump using an efficient traversal algorithm to find the reference chain (GC path) from the leaked object to a GC root, showing you exactly why the object wasn't garbage collected. LeakCanary displays leak notifications with a detailed trace showing the reference path, making it trivial to identify the root cause. Install LeakCanary in debug builds only - it has performance overhead unsuitable for production. When a leak is detected, follow the reference chain from bottom to top to find where the long-lived reference is held and break that reference in the appropriate lifecycle method.

Reducing Memory Allocations

** BAD: Creating Objects in onDraw/Recomposition**

// BAD: Creating new Paint object on every draw
@Composable
fun PaymentChart(data: List<Float>) {
Canvas(modifier = Modifier.fillMaxSize()) {
val paint = Paint() // ALLOCATES EVERY FRAME!
paint.color = Color.Blue

data.forEachIndexed { index, value ->
drawLine(/* ... */, paint)
}
}
}

** GOOD: Reuse Objects**

// GOOD: Remember objects across recompositions
@Composable
fun PaymentChart(data: List<Float>) {
val paint = remember {
Paint().apply {
color = Color.Blue
}
}

Canvas(modifier = Modifier.fillMaxSize()) {
data.forEachIndexed { index, value ->
drawLine(/* ... */, paint)
}
}
}

Using Appropriate Data Structures

// GOOD: Choose appropriate collections

// For lookups by key - use HashMap (O(1))
val paymentCache = HashMap<String, Payment>()

// For ordered iteration - use ArrayList (O(1) access)
val paymentList = ArrayList<Payment>()

// For unique items - use HashSet (O(1) contains)
val processedIds = HashSet<String>()

// For large lists with frequent additions/removals - use LinkedList
val pendingQueue = LinkedList<Payment>()

// For thread-safe access - use ConcurrentHashMap
val sharedCache = ConcurrentHashMap<String, Payment>()

Managing Bitmap Memory

// GOOD: Optimize bitmap loading
@Composable
fun UserAvatar(imageUrl: String) {
AsyncImage(
model = ImageRequest.Builder(LocalContext.current)
.data(imageUrl)
.memoryCachePolicy(CachePolicy.ENABLED)
.diskCachePolicy(CachePolicy.ENABLED)
.size(200) // Resize to target size
.transformations(CircleCropTransformation())
.build(),
contentDescription = "User avatar",
modifier = Modifier.size(48.dp)
)
}

// Coil automatically:
// - Downsamples large images
// - Uses memory and disk caching
// - Releases bitmaps when not needed

LazyColumn Optimization

LazyColumn is Compose's lazy list component that renders only visible items, similar to RecyclerView but declarative. However, LazyColumn requires careful configuration to achieve optimal performance. Without proper keys, Compose can't efficiently track which items changed, causing unnecessary recomposition of the entire list when data updates. Nested scrolling (LazyColumn inside LazyColumn) forces all inner items to be composed immediately, defeating lazy composition and causing performance degradation. Item keys enable Compose to reuse compositions when items move or update, dramatically improving scroll performance and preventing flickering during data changes. For detailed architectural patterns, see Android Architecture for state management approaches that work efficiently with LazyColumn.

Use Keys for Item Identity

** BAD: No keys**

// BAD: No keys - entire list re-renders on updates
@Composable
fun PaymentList(payments: List<Payment>) {
LazyColumn {
items(payments) { payment ->
PaymentCard(payment)
}
}
}

** GOOD: Provide stable keys**

// GOOD: Keys enable efficient updates
@Composable
fun PaymentList(payments: List<Payment>) {
LazyColumn {
items(
items = payments,
key = { payment -> payment.id } // Stable key
) { payment ->
PaymentCard(payment)
}
}
}

Avoid Nested Scrolling

** BAD: Nested LazyColumn**

// BAD: Nested scrollable causes performance issues
@Composable
fun AccountScreen(accounts: List<Account>) {
LazyColumn {
items(accounts) { account ->
Text("Account: ${account.accountNumber}")

// NESTED LAZY COLUMN!
LazyColumn(modifier = Modifier.height(200.dp)) {
items(account.transactions) { transaction ->
TransactionItem(transaction)
}
}
}
}
}

** GOOD: Single LazyColumn with different item types**

// GOOD: Single LazyColumn with multiple item types
@Composable
fun AccountScreen(accounts: List<Account>) {
LazyColumn {
accounts.forEach { account ->
item(key = "header_${account.id}") {
AccountHeader(account)
}

items(
items = account.transactions,
key = { transaction -> transaction.id }
) { transaction ->
TransactionItem(transaction)
}
}
}
}

Content Padding and Spacing

// GOOD: Use contentPadding instead of wrapping modifier
@Composable
fun PaymentList(payments: List<Payment>) {
LazyColumn(
contentPadding = PaddingValues(16.dp), // Applied to content, not container
verticalArrangement = Arrangement.spacedBy(12.dp) // Efficient spacing
) {
items(
items = payments,
key = { it.id }
) { payment ->
PaymentCard(payment)
}
}
}

Prefetching and Item Configuration

// GOOD: Configure prefetch for smoother scrolling
@Composable
fun PaymentList(payments: List<Payment>) {
val state = rememberLazyListState()

LazyColumn(
state = state,
contentPadding = PaddingValues(16.dp),
// Prefetch items before they're visible
flingBehavior = ScrollableDefaults.flingBehavior()
) {
items(
items = payments,
key = { it.id },
contentType = { "payment_card" } // Group similar items
) { payment ->
PaymentCard(payment)
}
}
}

Compose Recomposition Optimization

Recomposition is Compose's mechanism for updating the UI when state changes, but unnecessary recomposition is a common performance bottleneck. When state changes, Compose intelligently recomposes only the composables that read that state, skipping composables whose inputs haven't changed - but only if those inputs are stable. Unstable parameters (mutable collections, lambdas recreated on each recomposition, data classes with var properties) force Compose to recompose defensively because it can't prove the values haven't changed. This causes excessive recomposition: instead of skipping unchanged composables, Compose re-executes them and their children, wasting CPU cycles and causing dropped frames. The solution is stability: use immutable data types, remember lambda instances, and leverage derivedStateOf to prevent cascading recompositions from expensive calculations. The Layout Inspector's recomposition counter visualizes hot spots, showing which composables recompose frequently so you can optimize the right places.

Understanding Recomposition

// Every state change triggers recomposition
@Composable
fun PaymentScreen() {
var count by remember { mutableStateOf(0) }

Column {
// This recomposes when count changes
Text("Count: $count")

// This also recomposes (unnecessarily)
StaticHeader() // No dependency on count!

Button(onClick = { count++ }) {
Text("Increment")
}
}
}

Stability and Skippability

** BAD: Unstable parameters**

// BAD: Mutable list is unstable - always recomposes
@Composable
fun PaymentList(payments: MutableList<Payment>) {
// Recomposes even if list content unchanged
LazyColumn {
items(payments) { payment ->
PaymentCard(payment)
}
}
}

** GOOD: Stable parameters**

// GOOD: Immutable list is stable - skips when unchanged
@Composable
fun PaymentList(payments: List<Payment>) {
LazyColumn {
items(payments, key = { it.id }) { payment ->
PaymentCard(payment)
}
}
}

Remember Lambdas and Callbacks

** BAD: Lambda created on every recomposition**

// BAD: New lambda instance on every recomposition
@Composable
fun PaymentCard(payment: Payment) {
Card(
onClick = {
// New lambda instance created!
navigateToDetail(payment.id)
}
) {
Text(payment.recipientName)
}
}

** GOOD: Remember lambda**

// GOOD: Lambda remembered and reused
@Composable
fun PaymentCard(
payment: Payment,
onPaymentClick: (String) -> Unit
) {
val onClick = remember(payment.id) {
{ onPaymentClick(payment.id) }
}

Card(onClick = onClick) {
Text(payment.recipientName)
}
}

Derived State with derivedStateOf

** BAD: Derived value triggers extra recompositions**

// BAD: Every keystroke triggers recomposition of entire screen
@Composable
fun SearchScreen() {
var searchQuery by remember { mutableStateOf("") }

Column {
TextField(
value = searchQuery,
onValueChange = { searchQuery = it }
)

// Recomposes on EVERY character typed!
val filteredPayments = payments.filter {
it.recipientName.contains(searchQuery, ignoreCase = true)
}

PaymentList(filteredPayments)
}
}

** GOOD: Use derivedStateOf for expensive calculations**

// GOOD: Only recomputes when search query actually changes
@Composable
fun SearchScreen() {
var searchQuery by remember { mutableStateOf("") }

// Only recalculates when searchQuery changes
val filteredPayments by remember {
derivedStateOf {
payments.filter {
it.recipientName.contains(searchQuery, ignoreCase = true)
}
}
}

Column {
TextField(
value = searchQuery,
onValueChange = { searchQuery = it }
)

PaymentList(filteredPayments)
}
}

Layout Inspector for Recomposition Counts

Enable Live Layout Inspector in Android Studio:

  1. Run app in debug mode
  2. Tools → Layout Inspector
  3. Enable "Show Recomposition Counts"
  4. Red highlights = frequent recomposition (investigate!)

Immutable Collections for Stability

// build.gradle
dependencies {
implementation 'org.jetbrains.kotlinx:kotlinx-collections-immutable:0.3.6'
}

// GOOD: Immutable collections are stable
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList

@Composable
fun PaymentList(payments: ImmutableList<Payment>) {
// Compose knows this won't change unless reference changes
LazyColumn {
items(payments, key = { it.id }) { payment ->
PaymentCard(payment)
}
}
}

// Convert in ViewModel
val payments: StateFlow<ImmutableList<Payment>> =
paymentFlow
.map { it.toImmutableList() }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), persistentListOf())

R8/ProGuard Configuration

R8 is Android's code shrinker, obfuscator, and optimizer that runs during release builds to reduce APK size and improve performance. Code shrinking removes unused classes, fields, and methods from your app and its dependencies, often reducing APK size by 30-50%. Obfuscation renames classes and methods to short, meaningless names (a, b, c), making reverse engineering more difficult and further reducing APK size. Optimization performs bytecode-level improvements like method inlining, unused parameter removal, and dead code elimination. However, R8 can break code that relies on reflection or dynamic class loading (Gson serialization, Retrofit interfaces, dependency injection) because it can't detect these runtime usages through static analysis. ProGuard rules tell R8 which classes to keep, preventing it from removing or renaming code that must be preserved. Always test release builds thoroughly - R8 issues often manifest as ClassNotFoundExceptions or NoSuchMethodErrors at runtime that don't occur in debug builds.

Enable R8 in build.gradle

// build.gradle (Module :app)
android {
buildTypes {
release {
minifyEnabled true
shrinkResources true
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
}

ProGuard Rules for Banking App

# proguard-rules.pro

# ===== GENERAL RULES =====

# Keep source file names and line numbers for crash reports
-keepattributes SourceFile,LineNumberTable

# Rename source file to "SourceFile" to prevent leaking project structure
-renamesourcefileattribute SourceFile

# ===== BANKING DOMAIN MODELS =====

# Keep domain models for serialization
-keep class com.bank.paymentapp.domain.model.** { *; }

# Keep data classes used in API responses
-keep class com.bank.paymentapp.data.remote.dto.** { *; }

# Keep Room entities
-keep class com.bank.paymentapp.data.local.entity.** { *; }

# ===== RETROFIT =====

# Keep Retrofit interfaces
-keep interface com.bank.paymentapp.data.remote.api.** { *; }

# Retrofit does reflection on generic parameters
-keepattributes Signature

# Retain service method parameters when optimizing
-keepclassmembernames,allowobfuscation interface * {
@retrofit2.http.* <methods>;
}

# Ignore warnings for OkHttp platform used only on Android
-dontwarn org.bouncycastle.jsse.**
-dontwarn org.conscrypt.*
-dontwarn org.openjsse.**

# ===== GSON =====

# Keep generic signature of Gson
-keepattributes Signature

# Keep Gson classes
-keep class com.google.gson.** { *; }

# ===== ROOM =====

# Keep Room generated classes
-keep class * extends androidx.room.RoomDatabase
-keep @androidx.room.Entity class *
-dontwarn androidx.room.paging.**

# ===== HILT =====

# Keep Hilt generated classes
-keep class dagger.hilt.** { *; }
-keep class javax.inject.** { *; }
-keep class * extends dagger.hilt.android.internal.managers.ViewComponentManager$FragmentContextWrapper

# ===== COROUTINES =====

# ServiceLoader support for Coroutines
-keepnames class kotlinx.coroutines.internal.MainDispatcherFactory {}
-keepnames class kotlinx.coroutines.CoroutineExceptionHandler {}

# ===== SECURITY =====

# Keep cryptography classes
-keep class androidx.security.crypto.** { *; }
-keep class javax.crypto.** { *; }

# ===== COMPOSE =====

# Keep Compose runtime
-keep class androidx.compose.runtime.** { *; }

# ===== CRASHLYTICS =====

# Keep crash reporting metadata
-keepattributes *Annotation*
-keep class com.google.firebase.crashlytics.** { *; }

# ===== OPTIMIZATION =====

# Aggressive optimization
-optimizationpasses 5
-dontpreverify

# Allow optimization
-optimizations !code/simplification/arithmetic,!code/simplification/cast,!field/*,!class/merging/*

# ===== WARNINGS =====

# Don't warn about missing classes
-dontwarn javax.annotation.**
-dontwarn org.jetbrains.annotations.**

Testing R8 Configuration

# Build release APK
./gradlew assembleRelease

# Check for R8 issues in build output
# Look for warnings about missing classes

# Test release build thoroughly!
# R8 can break reflection-based code

APK Size Optimization

Analyze APK Size

Android Studio APK Analyzer:

  1. Build → Analyze APK
  2. Select release APK
  3. Review size breakdown by category
# Command line analysis
./gradlew :app:assembleRelease

# Check APK size
ls -lh app/build/outputs/apk/release/

# Analyze with bundletool
bundletool build-apks --bundle=app.aab --output=app.apks
bundletool get-size total --apks=app.apks

Resource Optimization

Remove unused resources:

// build.gradle (Module :app)
android {
buildTypes {
release {
shrinkResources true // Remove unused resources
minifyEnabled true
}
}
}

Use vector drawables instead of PNGs:

<!-- res/drawable/ic_payment.xml -->
<!-- Vector drawable scales without quality loss, smaller size -->
<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="#000000"
android:pathData="M12,2C6.48,2 2,6.48 2,12s4.48,10 10,10 10,-4.48 10,-10S17.52,2 12,2z"/>
</vector>

Use WebP images:

# Convert PNG to WebP (lossy, 80% quality)
cwebp input.png -q 80 -o output.webp

# Convert PNG to WebP (lossless)
cwebp input.png -lossless -o output.webp

Dependency Optimization

** BAD: Including entire library for one feature**

// BAD: Entire Guava library (2.6 MB)
implementation 'com.google.guava:guava:31.1-android'

** GOOD: Use Android/Kotlin alternatives**

// GOOD: Use Kotlin standard library
// Instead of Guava's ImmutableList
val payments: List<Payment> = listOf(payment1, payment2)

// Instead of Guava's Preconditions
require(amount > 0) { "Amount must be positive" }

Exclude unused transitive dependencies:

// build.gradle (Module :app)
dependencies {
implementation('com.some.library:library:1.0') {
exclude group: 'com.unused', module: 'module'
}
}

App Bundle and Dynamic Features

// build.gradle (Module :app)
android {
bundle {
language {
enableSplit = true // Separate APKs per language
}
density {
enableSplit = true // Separate APKs per screen density
}
abi {
enableSplit = true // Separate APKs per CPU architecture
}
}
}

Build Android App Bundle (.aab):

./gradlew bundleRelease

# Upload .aab to Play Store
# Google Play generates optimized APKs for each device

Android Studio Profiler

CPU Profiler

Identify slow methods:

  1. Run app in profile mode (Profile icon in toolbar)
  2. Open Profiler window (View → Tool Windows → Profiler)
  3. Click CPU timeline
  4. Start recording (choose "Java/Kotlin Method Trace")
  5. Perform action (e.g., load payment list)
  6. Stop recording
  7. Analyze flame chart and top-down tree

Look for:

  • Methods taking >100ms
  • Excessive method calls (thousands of calls)
  • Main thread blocking
  • Unnecessary object creation
// Example: CPU profiling reveals slow JSON parsing on main thread

// FOUND BY PROFILER: JSON parsing on main thread (200ms)
@Composable
fun PaymentScreen() {
val payments = remember {
// SLOW! Blocks UI thread
Gson().fromJson(jsonString, PaymentList::class.java)
}
}

// FIX: Move to background thread
@Composable
fun PaymentScreen(viewModel: PaymentViewModel = hiltViewModel()) {
val payments by viewModel.payments.collectAsStateWithLifecycle()

// ViewModel loads data in background coroutine
}

Memory Profiler

Detect memory leaks:

  1. Open Profiler → Memory
  2. Perform action multiple times (e.g., open/close screen 5 times)
  3. Force GC (garbage icon)
  4. Dump heap
  5. Analyze heap dump for retained objects

Look for:

  • Activity/Fragment instances not released
  • Growing collections
  • Bitmap allocations
  • Large object graphs

Network Profiler

Optimize API calls:

  1. Open Profiler → Network
  2. Perform network operations
  3. Analyze request/response sizes, timing, failures

Look for:

  • Large response payloads (>100 KB)
  • Slow requests (>1s)
  • Redundant API calls
  • Missing caching
// GOOD: Add response caching
@Provides
@Singleton
fun provideOkHttpClient(): OkHttpClient {
return OkHttpClient.Builder()
.cache(
Cache(
directory = cacheDir,
maxSize = 10 * 1024 * 1024 // 10 MB
)
)
.addInterceptor { chain ->
val request = chain.request().newBuilder()
.header("Cache-Control", "public, max-age=300") // 5 min cache
.build()
chain.proceed(request)
}
.build()
}

ANR (Application Not Responding) Prevention

ANRs (Application Not Responding) occur when the main thread is blocked for more than 5 seconds, causing Android to show a dialog asking users to force close your app. The main thread (UI thread) must remain responsive to handle user input, draw frames, and dispatch lifecycle events. Blocking operations on the main thread - network requests, database queries, file I/O, complex calculations - prevent the event loop from processing, causing the UI to freeze and triggering ANR detection. When an ANR occurs, users typically force close your app, and Google Play tracks ANR rates as a quality metric that affects your app's ranking and eligibility for featuring. The solution is moving all slow work to background threads using Kotlin coroutines with appropriate dispatchers: Dispatchers.IO for I/O operations (network, database, files), Dispatchers.Default for CPU-intensive work, and Dispatchers.Main for UI updates. StrictMode helps catch violations during development by crashing or logging when main thread violations occur, preventing ANRs from reaching production.

Common ANR Causes

  1. Network operations on main thread
  2. Database queries on main thread
  3. Heavy computation on main thread
  4. Deadlocks
  5. Too many broadcast receivers

Detecting ANRs

Enable StrictMode in debug builds:

// PaymentApplication.kt
class PaymentApplication : Application() {

override fun onCreate() {
super.onCreate()

if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectNetwork() // Detect network on main thread
.penaltyLog()
.penaltyDeath() // Crash on violation (debug only!)
.build()
)

StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder()
.detectLeakedSqlLiteObjects()
.detectLeakedClosableObjects()
.penaltyLog()
.build()
)
}
}
}

Preventing ANRs with Coroutines

** BAD: Blocking main thread**

// BAD: Database query on main thread causes ANR
@Composable
fun PaymentScreen() {
val payments = remember {
// BLOCKS MAIN THREAD!
database.paymentDao().getAll()
}
}

** GOOD: Use coroutines with IO dispatcher**

// GOOD: Database query on background thread
@HiltViewModel
class PaymentViewModel @Inject constructor(
private val repository: PaymentRepository
) : ViewModel() {

val payments: StateFlow<List<Payment>> = repository.getPayments()
.stateIn(
scope = viewModelScope,
started = SharingStarted.WhileSubscribed(5000),
initialValue = emptyList()
)
}

// Repository uses IO dispatcher
override fun getPayments(): Flow<List<Payment>> {
return flow {
val payments = withContext(Dispatchers.IO) {
paymentDao.getAll() // Runs on background thread
}
emit(payments)
}
}

Monitoring ANRs in Production

// Firebase Crashlytics for ANR tracking
dependencies {
implementation 'com.google.firebase:firebase-crashlytics:18.5.1'
}
// Custom ANR watchdog
class AnrWatchdog(
private val timeoutMillis: Long = 5000
) {
private val handler = Handler(Looper.getMainLooper())
private var tick = 0
private var reported = 0

fun start() {
handler.post(object : Runnable {
override fun run() {
tick++

// Check if tick progressed (main thread responsive)
handler.postDelayed({
if (tick == reported) {
// Main thread blocked for >5s
reportAnr()
}
reported = tick
}, timeoutMillis)

handler.postDelayed(this, 500)
}
})
}

private fun reportAnr() {
val stackTrace = Looper.getMainLooper().thread.stackTrace
FirebaseCrashlytics.getInstance().log("ANR detected: ${stackTrace.joinToString()}")
}
}

Real-World Performance Examples

Optimizing Data List Screen

// GOOD: Optimized payment list with all techniques
@Composable
fun PaymentListScreen(
viewModel: PaymentListViewModel = hiltViewModel()
) {
val payments by viewModel.payments.collectAsStateWithLifecycle()
val isLoading by viewModel.isLoading.collectAsStateWithLifecycle()

Box(modifier = Modifier.fillMaxSize()) {
if (isLoading && payments.isEmpty()) {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
} else {
// Stable, immutable list
PaymentListContent(payments = payments.toImmutableList())
}
}
}

@Composable
private fun PaymentListContent(
payments: ImmutableList<Payment>,
onPaymentClick: (String) -> Unit = {}
) {
// Remember list state for scroll position retention
val listState = rememberLazyListState()

LazyColumn(
state = listState,
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(
items = payments,
key = { payment -> payment.id }, // Stable key
contentType = { "payment" } // Content type for prefetch
) { payment ->
// Remember click callback
val onClick = remember(payment.id) {
{ onPaymentClick(payment.id) }
}

PaymentCard(
payment = payment,
onClick = onClick
)
}
}
}

@Composable
private fun PaymentCard(
payment: Payment,
onClick: () -> Unit
) {
// Stable parameters prevent unnecessary recomposition
Card(
onClick = onClick,
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
// Use Text instead of complex layouts when possible
Text(
text = payment.recipientName,
style = MaterialTheme.typography.titleMedium
)

// Format currency efficiently
Text(
text = remember(payment.amount, payment.currency) {
formatCurrency(payment.amount, payment.currency)
},
style = MaterialTheme.typography.headlineSmall
)
}
}
}

Efficient Paginated List Loading

// GOOD: Pagination for large data lists
@HiltViewModel
class DataListViewModel @Inject constructor(
private val repository: DataRepository
) : ViewModel() {

private val _items = MutableStateFlow<List<DataItem>>(emptyList())
val items: StateFlow<List<DataItem>> = _items.asStateFlow()

private var currentPage = 0
private var isLoading = false
private var hasMorePages = true

init {
loadNextPage()
}

fun loadNextPage() {
if (isLoading || !hasMorePages) return

viewModelScope.launch {
isLoading = true

repository.getItems(
page = currentPage,
pageSize = 20 // Load 20 at a time
)
.onSuccess { newItems ->
_items.update { current ->
current + newItems // Append to existing
}

hasMorePages = newItems.size == 20
currentPage++
}
.onFailure { error ->
// Handle error
}

isLoading = false
}
}
}

@Composable
fun DataListScreen(
viewModel: DataListViewModel = hiltViewModel()
) {
val items by viewModel.items.collectAsStateWithLifecycle()
val listState = rememberLazyListState()

LazyColumn(state = listState) {
items(
items = items,
key = { it.id }
) { item ->
DataItemView(item)
}

// Load more when reaching end
item {
LaunchedEffect(Unit) {
viewModel.loadNextPage()
}
}
}
}

This pagination pattern prevents loading thousands of items at once, which would cause memory issues and slow initial screen load. Instead, it loads 20 items at a time and appends new pages as the user scrolls, providing smooth infinite scrolling. The hasMorePages check prevents unnecessary API calls when all data has been loaded.


Performance Checklist

Before Release

  • Enable R8/ProGuard with proper rules
  • Test release build thoroughly (R8 can break runtime code)
  • Profile app with CPU, Memory, and Network profilers
  • Check APK size (<50 MB ideal, <100 MB maximum)
  • Verify 60fps during animations (use GPU rendering profile)
  • Test on low-end device (2GB RAM, slow CPU)
  • Enable StrictMode in debug builds
  • Run ANR watchdog in production
  • Monitor Crashlytics for performance issues
  • Benchmark critical user flows (<1s screen load)

LazyColumn Performance

  • Provide stable keys for all items
  • Avoid nested LazyColumn/LazyRow
  • Use contentPadding instead of wrapper Modifier.padding
  • Implement proper item content types
  • Use Arrangement.spacedBy for spacing
  • Avoid heavy computations in item composables

Compose Recomposition

  • Use immutable/stable parameters
  • Remember lambdas and callbacks
  • Use derivedStateOf for expensive calculations
  • Check recomposition counts with Layout Inspector
  • Hoist state appropriately
  • Avoid reading state in composables that don't need it

Further Reading

Android Framework Guidelines

Performance Guidelines

Mobile Guidelines

Language Performance

External Resources


Summary

Key Takeaways

  1. Measure first - Use profilers to identify actual bottlenecks before optimizing
  2. Prevent memory leaks - Avoid context leaks, clean up callbacks, use Application context
  3. LazyColumn optimization - Stable keys, no nesting, proper content types
  4. Minimize recomposition - Stable parameters, remember callbacks, derivedStateOf
  5. R8 configuration - Enable minification, proper ProGuard rules, test release builds
  6. APK size - Shrink resources, use vector drawables, App Bundle splits
  7. Profile regularly - CPU/Memory/Network profilers during development
  8. ANR prevention - Never block main thread, use coroutines with proper dispatchers
  9. User expectations - Users expect instant responses and smooth interactions
  10. Monitor production - Track ANRs, performance metrics, and user experience in production

Next Steps: Review Android Architecture for efficient architectural patterns and Android Testing for performance testing strategies.