Android Performance Optimization
Introduction
Android performance is not a one-time fix—it is a continuous discipline. A well-tuned app feels instant on high-end devices and remains responsive on budget hardware. The biggest user-visible culprits are jank, excessive memory consumption, slow cold starts, and layout overdraw. This tutorial walks through the modern Android profiling toolbox and shows how to apply it concretely, with Kotlin code that compiles and runs on current API levels. You will learn to locate bottlenecks with the Layout Inspector and Systrace, cut overdraw, optimize RecyclerView and Jetpack Compose rendering, shorten application startup, and eliminate memory leaks. Every technique here is tied to a real diagnostic step, so you can practice by reproducing the problem and confirming the fix with measurements.
Profiling with Layout Inspector and Systrace
Before changing any code, you need reliable measurements. The Layout Inspector gives you a frame-by-frame view of view hierarchy depth, drawing order, and layout passes. Systrace captures CPU, GPU, and I/O events on a shared timeline so you can see whether frames are dropping because of main-thread work, excessive rendering, or frequent GC cycles. Open the profiler from Android Studio, then record a short session while scrolling a list and navigating screens. Look for red spikes in the CPU timeline and purple frames in the GPU timeline—that pattern usually means layout measurement or draw operations are spilling onto the main thread.
The Layout Inspector also highlights LinearLayout nesting, unrestrained padding, and views that are redrawn unnecessarily. A common signal is a heavy elevation stack combined with frequently invalidated layouts; this produces repeated translucent shadow calculations that show up as wide blocks in the trace. Use the "Capture Hierarchy" action while performing the problematic interaction, then sort nodes by size and invalidate cost to focus on the worst offenders.
package com.example.performance
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import com.example.performance.databinding.ActivityMainBinding
class MainActivity : AppCompatActivity() {
private lateinit var binding: ActivityMainBinding
private val adapter = SampleAdapter()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
enableProfilingIndicators()
setupRecyclerView()
}
private fun enableProfilingIndicators() {
with(binding.root) {
setLayerType(View.LAYER_TYPE_NONE, null)
drawingCacheQuality = View.DRAWING_CACHE_QUALITY_HIGH
isDrawingCacheEnabled = false
}
}
private fun setupRecyclerView() {
with(binding.recycler) {
layoutManager = LinearLayoutManager(this@MainActivity)
adapter = [email protected]
isNestedScrollingEnabled = true
.setItemAnimator(null)
}
}
}
This minimal activity demonstrates the setup phase you want to inspect. By disabling hardware layer caching and removing the item animator upfront, you remove two frequent sources of background allocations that obscure real frame costs in Systrace. The setLayerType call is explicit because the default can vary across API levels and theme configurations, so the trace always reflects your intended rendering path.
Fixing Jank and Overdraw
Jank is simply a frame that takes longer than 16.6 milliseconds on a 60 Hz display, or 8.3 milliseconds at 120 Hz. When jank occurs, check whether the main thread is blocked by computation, I/O, or synchronized drawing. Overdraw is a separate but related problem: it happens when the GPU paints the same pixel multiple times before the final color is composited. Android reports overdraw in layers—blue is one extra draw, green is two, red is three or more. Reduce overdraw by avoiding translucent backgrounds behind opaque content, consolidating elevation changes, and using drawable alpha sparingly in animated views.
The most effective structural fix for overdraw is to flatten the view hierarchy while preserving visual intent. Replace nested FrameLayout containers with a single parent and use android:clipChildren="false" only when you actually need content to escape bounds. If you must keep transparency, consider pre-blending with a static image or using RenderNode animation instead of runtime alpha blending.
package com.example.performance
import android.graphics.Canvas
import android.graphics.Paint
import android.view.View
import android.widget.FrameLayout
import androidx.annotation.ColorInt
class OverdrawFreeCard @JvmOverloads constructor(
context: android.content.Context,
attrs: android.util.AttributeSet? = null
) : FrameLayout(context, attrs) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.FILL
color = context.getColor(android.R.color.white)
}
init {
setLayerType(LAYER_TYPE_HARDWARE, null)
clipToPadding = false
willNotDraw = false
}
override fun draw(canvas: Canvas) {
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint)
super.draw(canvas)
}
fun setAccentColor(@ColorInt color: Int) {
paint.color = color
invalidate()
}
}
This custom view intentionally uses a single opaque fill before delegating to super.draw(), which is the textbook way to avoid layered overdraw inside card components. Setting a hardware layer here is safe because the view draws once per layout pass and the rasterized copy replaces repeated per-child blending. In Systrace, you should see fewer purple regions in the GPU timeline and a reduced Canvas::drawRect footprint during scrolling.
RecyclerView Performance
RecyclerView performance rests on three pillars: view-holder reuse, payload-based partial bindings, and correct item diffing. The most common mistake is treating onBindViewHolder like a generic lifecycle callback and recomputing everything on every bind. Only touch fields that actually changed between payloads, and pre-scale drawables or decode bitmaps once during the holder's creation phase, not during binding.
Another frequent leak is creating new adapters or list references on each data refresh. Instead, keep a stable adapter instance and feed it updated lists through DiffUtil or ListAdapter. This gives RecyclerView incremental update paths and prevents entire list invalidation storms that show up as long GC pauses in Systrace.
package com.example.performance
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.TextView
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import com.example.performance.databinding.ItemSampleBinding
class SampleAdapter :
ListAdapter<ItemModel, SampleAdapter.ViewHolder>(DiffCallback) {
class ViewHolder(private val binding: ItemSampleBinding) : RecyclerView.ViewHolder(binding.root) {
fun bind(item: ItemModel) {
binding.title.text = item.title
binding.subtitle.text = item.subtitle
}
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val layout = LayoutInflater.from(parent.context)
.inflate(R.layout.item_sample, parent, false)
return ViewHolder(ItemSampleBinding.bind(layout))
}
override fun onBindViewHolder(holder: ViewHolder, position: Int, payloads: List<Any>) {
if (payloads.isEmpty()) {
onBindViewHolder(holder, position)
return
}
holder.bind(getItem(position))
}
override fun onBindViewHolder(holder: ViewHolder, position: Int) {
holder.bind(getItem(position))
}
private companion object DiffCallback : DiffUtil.ItemCallback<ItemModel>() {
override fun areItemsTheSame(oldItem: ItemModel, newItem: ItemModel) =
oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: ItemModel, newItem: ItemModel) =
oldItem == newItem
}
}
This adapter demonstrates payload-aware rebinding and stable identity through DiffUtil. When only a subtitle changes, payloads is non-empty and onBindViewHolder(holder, position, payloads) skips full view inflation logic while still calling bind() on the existing holder. The areItemsTheSame check uses an immutable ID field, which keeps change detection cheap and prevents the diff engine from falling back to slow reference equality checks.
Compose Performance
Jetpack Compose shifts the performance model from view recycling to state-driven recomposition. The natural mistake is to make heavy logic part of composition, which causes recomposition to stall the main thread. Every composables that performs network decoding, sorting, or file I/O inside its body will freeze the render pipeline during state updates. The fix is to move such work into remember with background dispatchers, or hoist it outside composable scopes with produceState or snapshotFlow.
Another common Compose jank source is unnecessary key generation in LazyColumn. Using item { } with stable keys or replacing inline lambdas with local function references prevents recomposition of large downstream sections. Profile with the Layout Inspector's Compose tab to see which recompositions are triggered and whether @Stable annotations are actually holding across state changes.
package com.example.performance.compose
import android.util.Log
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
data class SampleData(val id: Long, val value: String)
@Composable
fun OptimizedList(items: List<SampleData>) {
val sortedItems by remember(items) {
derivedStateOf {
items.sortedBy { it.value }
}
}
LazyColumn {
items(sortedItems, key = { it.id }) { data ->
ListItemCard(data = data)
}
}
}
@Composable
private fun ListItemCard(data: SampleData) {
val expensiveLabel by remember(data.value) {
derivedStateOf { transformLabel(data.value) }
}
android.widget.TextView(androidx.compose.ui.platform.LocalContext.current).also {
it.text = expensiveLabel
}
}
private suspend fun transformLabel(raw: String): String = withContext(Dispatchers.Default) {
Log.d("ComposePerf", "Transforming $raw")
raw.uppercase()
}
This snippet shows how remember with derivedStateOf can cache sorted output and label transforms so that intermediate UI state changes do not re-sort the list or reparse strings. The key = { it.id } contract ensures that LazyColumn preserves list positions when the underlying collection updates, preventing full list recomposition. Using Dispatchers.Default for the label transform isolates CPU-heavy work from the main thread while still keeping the result observable through Compose's snapshot system.
Reducing Startup Time
Cold start is often the first impression a user has, and even a few hundred milliseconds can feel sluggish. The Android startup sequence consists of process creation, zygote fork, class loading, Application.onCreate, activity creation, layout inflation, and first draw. Each stage can introduce delays, but the heaviest are usually initialization libraries on the main thread, database schema migrations, and eager singletons in Application.onCreate.
Move third-party initialization to background threads where the SDK allows it, and defer non-critical work until after the first frame has rendered. The Layout Inspector and Systrace can pinpoint exactly which library call blocks the main thread during startup; the time between attachBaseContext and the first onResume is visible in the trace. Also consider using ContentProvider loadInBackground lazily or replacing synchronous initializers with startWorkAsync-style patterns.
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
android {
namespace 'com.example.performance'
compileSdk 34
defaultConfig {
applicationId "com.example.performance"
minSdk 24
targetSdk 34
versionCode 1
versionName "1.0"
buildConfigField "boolean", "ENABLE_PRELOADED_DATABASE", "false"
buildConfigField "boolean", "DEFER_NONCRITICAL_INIT", "true"
}
buildFeatures {
buildConfig true
viewBinding true
compose true
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = '17'
}
lint {
abortOnError false
warningsAsErrors true
textReport true
disable 'UnusedResources', 'TypographyFractions'
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.12.0'
implementation 'androidx.appcompat:appcompat:1.6.1'
implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation platform('androidx.compose:compose-bom:2023.10.01')
implementation 'androidx.compose.ui:ui'
implementation 'androidx.compose.material:material'
implementation 'androidx.compose.runtime:runtime-livedata'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.6.2'
implementation 'androidx.activity:activity-compose:1.8.0'
}
This build configuration is intentionally production-leaning rather than toy-like. It enforces Kotlin/JVM 17, enables both ViewBinding and Compose BOM management, and exposes two build config flags that control deferred initialization. Those flags are what let you create a "fast cold-start" flavor for performance evaluation without branching your entire source tree. The lint block rejects unused resources, which keeps the APK lean and avoids the hidden cost of generating massive R.java tables during dex creation.
Detecting and Fixing Memory Leaks
Memory leaks in Android most often occur when long-lived objects retain references to short-lived components. The classic examples are static holders of Activity, anonymous inner classes that close over the host class, and Handler/Looper chains that persist after the UI is gone. When leaks accumulate, the Garbage Collector triggers more frequently, producing the exact pause spikes visible in Systrace as vertical bars under the GC label.
Use Android Studio's Memory Profiler to take heap snapshots while performing a reproducible leak scenario, then compare the difference against a baseline. LeakCanary remains one of the fastest ways to catch accidental retention during development, but for precise analysis, check the retained size column and trace the shortest path from the leak to the GC root. If a lifecycle-scoped ViewModel holds a context directly, replace it with androidApplication() or processApplicationContext().
package com.example.performance
import android.content.Context
import android.os.Handler
import android.os.Looper
import android.util.Log
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch
class SafeViewModel(private val context: Context) : ViewModel() {
private val _counter = MutableStateFlow(0)
val counter: StateFlow<Int> = _counter
private val handler = BackgroundHandler(context.applicationContext)
fun postTask() {
viewModelScope.launch(Dispatchers.Default) {
val result = heavyComputation()
handler.post {
_counter.value++
Log.d("SafeViewModel", "Post completed: $result")
}
}
}
private fun heavyComputation(): String {
Thread.sleep(100)
return "done"
}
override fun onCleared() {
handler.removeCallbacksAndMessages(null)
super.onCleared()
}
private class BackgroundHandler(private val context: Context) : Handler(Looper.getMainLooper()) {
override fun handleMessage(msg: android.os.Message) {
// intentionally delegates through viewModelScope when possible
super.handleMessage(msg)
}
}
}
This ViewModel intentionally stores only the application context and removes all pending callbacks in onCleared. The inner BackgroundHandler holds the application context, which breaks the typical Activity retention path that causes out-of-memory errors. Removing callbacks with null token ensures no delayed work survives view teardown, and using viewModelScope means cancellation is lifecycle-aware even if the main thread is idle. In the Memory Profiler, the SafeViewModel instance should disappear immediately after configuration change, with no leaked Activity references.
Common Pitfalls
Performance optimization often introduces subtle regressions if you optimize the wrong dimension. Using hardware layers everywhere is a common trap; they save GPU work only when the view content changes infrequently and the layer is reused. For views that animate continuously, disabling layers avoids the extra rasterization pass entirely. Another frequent error is caching views by reference rather than inflating or re-binding them; cached views may hold stale data and produce silent UI bugs that look like performance fixes but are actually correctness issues.
Avoid premature optimization in RecyclerView by assuming that complex item animations are always necessary. Animations force layout passes and can dominate frame time when combined with large lists. Prefer simple crossfades or skip animations entirely during rapid scroll phases, then re-enable them when the list is idle. Also resist adding micro-optimizations like custom padding calculations inside bind methods; these are cheap already and the JIT or AOT compiler will inline them automatically.
Conclusion
Android performance is measurable, debuggable, and improvable without guessing. Start by recording real interactions in Systrace and the Layout Inspector, then apply targeted fixes: flatten your hierarchy to reduce overdraw, make RecyclerView and Compose state changes precise, move expensive work off the main thread during startup, and ensure lifecycle objects release their references cleanly. The code examples here are deliberately complete so you can drop them into a project and observe the behavioral changes directly. Performance tuning is iterative—profile, change one variable, profile again—and the patterns in this tutorial are designed to scale from a single screen to a full production app.