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
- Measure First: Use profiling tools before optimizing - don't guess where bottlenecks are
- Memory Efficiency: Prevent memory leaks, reduce allocations, use appropriate data structures
- LazyColumn Optimization: Use keys, avoid nested scrolling, implement proper item reuse
- Minimize Recomposition: Stable parameters, remember callbacks, smart state management
- R8/ProGuard: Enable code shrinking, obfuscation, and optimization for release builds
- APK Size: Reduce binary size through resource optimization, dependency management, and splits
- Proactive Profiling: Profile regularly during development to catch regressions early
- 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:
- Run app in debug mode
- Tools → Layout Inspector
- Enable "Show Recomposition Counts"
- 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:
- Build → Analyze APK
- Select release APK
- 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:
- Run app in profile mode (Profile icon in toolbar)
- Open Profiler window (View → Tool Windows → Profiler)
- Click CPU timeline
- Start recording (choose "Java/Kotlin Method Trace")
- Perform action (e.g., load payment list)
- Stop recording
- 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:
- Open Profiler → Memory
- Perform action multiple times (e.g., open/close screen 5 times)
- Force GC (garbage icon)
- Dump heap
- 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:
- Open Profiler → Network
- Perform network operations
- 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
- Network operations on main thread
- Database queries on main thread
- Heavy computation on main thread
- Deadlocks
- 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
- Android Overview - Build configuration and ProGuard/R8
- Android UI - Compose recomposition and rendering performance
- Android Data - Database and network performance
- Android Security - Performance impact of encryption
- Android Architecture - Architecture for performance
- Android Testing - Performance testing and benchmarking
Performance Guidelines
- Performance Overview - Performance strategy
- Performance Optimization - Cross-platform optimization
- Performance Testing - Load and performance testing
Mobile Guidelines
- Mobile Overview - Mobile performance patterns
- Mobile Performance - Cross-platform mobile optimization
Language Performance
- Kotlin Performance - Kotlin-specific optimizations
External Resources
- Android Performance Patterns - Google's video series
- Jetpack Compose Performance - Official Compose performance guide
- R8 Documentation - Code shrinking with R8
- Android Profiler - Profiling tools guide
- Baseline Profiles - App startup optimization
Summary
Key Takeaways
- Measure first - Use profilers to identify actual bottlenecks before optimizing
- Prevent memory leaks - Avoid context leaks, clean up callbacks, use Application context
- LazyColumn optimization - Stable keys, no nesting, proper content types
- Minimize recomposition - Stable parameters, remember callbacks, derivedStateOf
- R8 configuration - Enable minification, proper ProGuard rules, test release builds
- APK size - Shrink resources, use vector drawables, App Bundle splits
- Profile regularly - CPU/Memory/Network profilers during development
- ANR prevention - Never block main thread, use coroutines with proper dispatchers
- User expectations - Users expect instant responses and smooth interactions
- 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.