The Android UI toolkit renders frames on a 16ms budget at 60fps (8ms at 120Hz). Exceeding this budget causes dropped frames. The primary causes are overdraw, deep view hierarchies, and work done on the main thread.
Every additional level in a view hierarchy adds measurement and layout passes. Use ConstraintLayout to express complex layouts in a single flat level. Avoid nested LinearLayout chains.
// Avoid: nested LinearLayouts create O(n²) measure passes
LinearLayout (vertical)
LinearLayout (horizontal)
TextView
ImageView
LinearLayout (horizontal)
TextView
Button
// Prefer: flat ConstraintLayout, single measure pass
ConstraintLayout
TextView (constrained to parent start, top)
ImageView (constrained to TextView end)
Button (constrained to bottom barrier)XML
Overdraw occurs when a pixel is drawn more than once per frame. Enable GPU Overdraw visualization in Developer Options. Blue = 1x overdraw (acceptable), Red = 4x+ (problematic). Remove redundant backgrounds set on parent views when children cover them entirely.
// Remove window background when your root layout sets its own
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
window.setBackgroundDrawableResource(android.R.color.transparent)
}Kotlin
In Compose, recomposition is triggered on state reads. Minimize the scope of state reads to prevent recomposing large subtrees.
// Bad: entire composable recomposes when scrollOffset changes
@Composable
fun Header(scrollOffset: Float) {
Box(modifier = Modifier.offset(y = scrollOffset.dp)) {
ExpensiveContent() // recomposes on every scroll tick
}
}
// Good: lambda defers read to layout phase, ExpensiveContent skips recompose
@Composable
fun Header(scrollOffset: () -> Float) {
Box(modifier = Modifier.offset { IntOffset(0, scrollOffset().toInt()) }) {
ExpensiveContent()
}
}Kotlin
@Stable and @Immutable annotations on data classes passed to composables. Without them, Compose cannot determine whether parameters have changed, and will recompose unconditionally.
RecyclerView performance depends on correct use of setHasFixedSize(true) when item changes don't affect the RecyclerView's dimensions, using DiffUtil instead of notifyDataSetChanged(), and prefetching with LinearLayoutManager.setInitialPrefetchItemCount().
recyclerView.setHasFixedSize(true)
(recyclerView.layoutManager as LinearLayoutManager)
.initialPrefetchItemCount = 4
// Use ListAdapter (wraps DiffUtil) instead of RecyclerView.Adapter directly
class ItemAdapter : ListAdapter<Item, ItemViewHolder>(ItemDiffCallback()) {
...
}Kotlin
Android kills processes under memory pressure. Apps that leak memory increase their RSS (Resident Set Size) over time, leading to OutOfMemoryError crashes or system-initiated kills. The heap is limited per process (typically 256–512 MB on modern devices, configurable via largeHeap).
android:largeHeap="true" increases GC pause times and signals poor memory hygiene. Avoid it except for specific use cases like image editors or 3D apps.
| Source | Cause | Fix |
|---|---|---|
| Static Context | Holding Activity reference in a static field |
Use ApplicationContext for long-lived objects |
| Listeners | Registering callbacks without unregistering on lifecycle end | Unregister in onStop() / onDestroy() |
| Inner Classes | Non-static inner class holds implicit outer class reference | Use WeakReference or static inner class |
| Coroutines | Job launched in global scope outliving the component | Use viewModelScope or lifecycleScope |
| Bitmaps | Loading full-resolution images into memory | Decode at target dimensions using BitmapFactory.Options |
fun decodeSampledBitmap(res: Resources, resId: Int, reqW: Int, reqH: Int): Bitmap {
val opts = BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
BitmapFactory.decodeResource(res, resId, opts)
opts.inSampleSize = calculateInSampleSize(opts, reqW, reqH)
opts.inJustDecodeBounds = false
return BitmapFactory.decodeResource(res, resId, opts)
}
fun calculateInSampleSize(opts: BitmapFactory.Options, reqW: Int, reqH: Int): Int {
var inSampleSize = 1
if (opts.outHeight > reqH || opts.outWidth > reqW) {
val halfH = opts.outHeight / 2
val halfW = opts.outWidth / 2
while (halfH / inSampleSize >= reqH && halfW / inSampleSize >= reqW) {
inSampleSize *= 2
}
}
return inSampleSize
}Kotlin
In practice, delegate image loading to Coil or Glide, which handle sampling, caching, and lifecycle binding automatically.
The main thread handles UI events and draws frames. Any blocking call on the main thread that takes longer than ~5ms can cause jank. Blocking calls include disk I/O, network requests, locks, and long CPU-bound computations.
| Dispatcher | Use Case | Thread Pool Size |
|---|---|---|
| Dispatchers.Main | UI updates, View operations | 1 (main thread) |
| Dispatchers.IO | Network, disk, database | Up to 64 |
| Dispatchers.Default | CPU-intensive work, JSON parsing, sorting | CPU core count |
| Dispatchers.Unconfined | Testing only; not for production | — |
class UserRepository constructor(
private val dao: UserDao,
private val api: UserApi,
private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO
) {
suspend fun fetchUser(id: String): User = withContext(ioDispatcher) {
val cached = dao.getUser(id)
if (cached != null) return@withContext cached
api.fetchUser(id).also { dao.insert(it) }
}
}Kotlin
Always launch coroutines in a scope tied to a lifecycle. Avoid GlobalScope — it has no cancellation and leaks coroutines.
// ViewModel — cancelled when ViewModel is cleared
class UserViewModel : ViewModel() {
fun loadUser(id: String) {
viewModelScope.launch {
_uiState.value = UiState.Loading
_uiState.value = try {
UiState.Success(repository.fetchUser(id))
} catch (e: Exception) {
UiState.Error(e.message)
}
}
}
}Kotlin
Use async/await to run independent tasks concurrently instead of sequentially.
// Sequential: total time = T(user) + T(posts)
val user = repository.fetchUser(id)
val posts = repository.fetchPosts(id)
// Parallel: total time = max(T(user), T(posts))
val userDeferred = async { repository.fetchUser(id) }
val postsDeferred = async { repository.fetchPosts(id) }
val user = userDeferred.await()
val posts = postsDeferred.await()Kotlin
Network calls have variable latency and consume battery via radio wake-ups. Optimize for fewer requests, smaller payloads, and aggressive caching.
val cacheDir = File(context.cacheDir, "http_cache")
val cache = Cache(cacheDir, maxSize = 50L * 1024 * 1024) // 50 MB
val client = OkHttpClient.Builder()
.cache(cache)
.addNetworkInterceptor { chain ->
chain.proceed(chain.request()).newBuilder()
.header("Cache-Control", "public, max-age=300")
.build()
}
.build()Kotlin
Enable gzip compression on the server. OkHttp adds Accept-Encoding: gzip automatically and decompresses transparently. For custom binary protocols, consider Protocol Buffers over JSON — typically 3–10× smaller payloads.
Use a single OkHttpClient instance across the app. Each instance maintains its own connection pool and cache. Multiple instances fragment these resources.
The radio (LTE/5G) is the largest battery consumer after the screen. Every network request wakes the radio for ~20 seconds. Batch and defer non-urgent background work using WorkManager.
val constraints = Constraints.Builder()
.setRequiredNetworkType(NetworkType.UNMETERED)
.setRequiresBatteryNotLow(true)
.setRequiresStorageNotLow(true)
.build()
val syncRequest = PeriodicWorkRequestBuilder<SyncWorker>(
repeatInterval = 1, TimeUnit.HOURS
)
.setConstraints(constraints)
.setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 30, TimeUnit.SECONDS)
.build()
WorkManager.getInstance(context)
.enqueueUniquePeriodicWork("sync", ExistingPeriodicWorkPolicy.KEEP, syncRequest)Kotlin
Android restricts background activity in Doze mode (device stationary, screen off, unplugged) and when an app enters the App Standby bucket. Maintenance windows are provided for essential operations. Do not use AlarmManager.setExact() for deferrable work — it bypasses Doze and drains battery. Use WorkManager which is Doze-aware.
Use the least accurate location that serves the use case. Each accuracy tier maps to a different sensor pipeline with different power costs.
| Priority | Accuracy | Power Cost |
|---|---|---|
| PRIORITY_HIGH_ACCURACY | ~5m (GPS) | High |
| PRIORITY_BALANCED_POWER_ACCURACY | ~100m (Cell/WiFi) | Medium |
| PRIORITY_LOW_POWER | ~10km (Cell) | Low |
| PRIORITY_PASSIVE | Varies | None (piggybacks other apps) |
// gradle.properties
org.gradle.jvmargs=-Xmx4g -XX:MaxMetaspaceSize=512m
org.gradle.parallel=true
org.gradle.caching=true
org.gradle.configuration-cache=true
android.enableR8.fullMode=trueProperties
R8 is the default shrinker since AGP 3.4. Full mode enables additional optimizations beyond ProGuard compatibility mode. It performs class merging, argument removal, and more aggressive inlining. Always test release builds — R8 can cause issues with reflection-heavy libraries.
// build.gradle.kts
android {
buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
}Kotlin DSL
resConfigs to exclude unused locale resources from third-party libraries.android {
defaultConfig {
resourceConfigurations += listOf("en", "es", "fr")
}
}Kotlin DSL
Kotlin's incremental compilation is enabled by default. Ensure annotation processors also support incremental mode — check each processor's documentation. kapt is slower than KSP (Kotlin Symbol Processing); migrate annotation processors that have KSP support.
Room is the standard local database abstraction. Raw SQLite is appropriate for cases requiring complex query control not expressible in Room.
// Add indices for columns used in WHERE / ORDER BY clauses
@Entity(
tableName = "messages",
indices = [Index(value = ["thread_id", "timestamp"])]
)
data class Message(
@PrimaryKey val id: String,
val threadId: String,
val timestamp: Long,
val content: String
)
// Fetch only columns you need
@Query("SELECT id, timestamp FROM messages WHERE thread_id = :tid ORDER BY timestamp DESC LIMIT 50")
fun getMessageHeaders(tid: String): Flow<List<MessageHeader>>Kotlin
Wrap multiple write operations in a single transaction. Each uncommitted write flushes to the WAL (Write-Ahead Log) and involves a fsync. Grouping 100 inserts into one transaction can be 50–100× faster than 100 individual inserts.
@Transaction
suspend fun replaceAllMessages(messages: List<Message>) {
deleteAll()
insertAll(messages)
}Kotlin
SharedPreferences performs synchronous I/O on commit() and has inconsistent behavior across process restarts. Use Jetpack DataStore (Proto or Preferences) as the replacement — it uses Kotlin Flow, is fully asynchronous, and handles data consistency correctly.
Measure before optimizing. Android Studio and the platform provide instrumentation for every major subsystem.
| Tool | What It Measures | When to Use |
|---|---|---|
| CPU Profiler | Thread activity, method traces, system traces | Jank, slow startup, excessive CPU usage |
| Memory Profiler | Heap allocations, GC events, object counts | Memory leaks, OOM crashes, heap growth |
| Network Profiler | Request/response timing, payload sizes | Slow API calls, unnecessary requests |
| Energy Profiler | CPU, radio, GPS usage over time | Battery drain investigation |
| Layout Inspector | Live view hierarchy and recomposition counts | Overdraw, deep hierarchies, Compose issues |
| Macrobenchmark | App startup, scrolling, transitions (on-device) | Regression testing in CI |
Baseline Profiles pre-compile critical code paths at install time, bypassing JIT interpretation on first run. This reduces startup time by 20–40% on cold starts. Generate profiles using the Macrobenchmark library and commit the baseline-prof.txt to source.
@ExperimentalBaselineProfilesApi
class BaselineProfileGenerator {
@get:Rule
val rule = BaselineProfileRule()
@Test
fun generateBaselineProfile() = rule.collect(
packageName = "com.example.app"
) {
pressHome()
startActivityAndWait()
// navigate critical user journeys here
}
}Kotlin
For low-level frame pipeline analysis, use Perfetto (replaces Systrace). It provides nanosecond-resolution traces of the entire system: Choreographer frame times, RenderThread, GPU composition, and binder transactions. Available via adb shell perfetto or the web UI at ui.perfetto.dev.
Trace.beginSection() / Trace.endSection() calls around application code to make custom labels visible in Perfetto traces. This is low overhead and survives in release builds.