Android Development Optimization Techniques

01 /

UI Rendering & Layouts

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.

Flatten View Hierarchies

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

Reduce Overdraw

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

Jetpack Compose Performance

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
Note Use the @Stable and @Immutable annotations on data classes passed to composables. Without them, Compose cannot determine whether parameters have changed, and will recompose unconditionally.

RecyclerView

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

02 /

Memory Management

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).

Warning Enabling 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.

Common Leak Sources

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

Bitmap Sampling

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.


03 /

Threading & Coroutines

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 Selection

DispatcherUse CaseThread Pool Size
Dispatchers.MainUI updates, View operations1 (main thread)
Dispatchers.IONetwork, disk, databaseUp to 64
Dispatchers.DefaultCPU-intensive work, JSON parsing, sortingCPU core count
Dispatchers.UnconfinedTesting 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

Structured Concurrency

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

Parallel Decomposition

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

04 /

Network Efficiency

Network calls have variable latency and consume battery via radio wake-ups. Optimize for fewer requests, smaller payloads, and aggressive caching.

OkHttp 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

Request Batching & Pagination

Payload Compression

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.

Connection Reuse

Use a single OkHttpClient instance across the app. Each instance maintains its own connection pool and cache. Multiple instances fragment these resources.


05 /

Battery & Background Work

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.

WorkManager Constraints

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

Doze Mode & App Standby

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.

App Standby Buckets Apps are assigned to Active, Working Set, Frequent, Rare, or Restricted buckets. Background job frequency and network access are throttled per bucket. High-engagement apps remain in Active/Working Set and are less affected.

Location Updates

Use the least accurate location that serves the use case. Each accuracy tier maps to a different sensor pipeline with different power costs.

PriorityAccuracyPower Cost
PRIORITY_HIGH_ACCURACY~5m (GPS)High
PRIORITY_BALANCED_POWER_ACCURACY~100m (Cell/WiFi)Medium
PRIORITY_LOW_POWER~10km (Cell)Low
PRIORITY_PASSIVEVariesNone (piggybacks other apps)

06 /

Build Optimization

Gradle Configuration

// 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 / ProGuard

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

APK / AAB Size

android {
    defaultConfig {
        resourceConfigurations += listOf("en", "es", "fr")
    }
}Kotlin DSL

Build Cache & Incremental Compilation

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.


07 /

Database & Storage

Room is the standard local database abstraction. Raw SQLite is appropriate for cases requiring complex query control not expressible in Room.

Query Optimization

// 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

Transactions

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 vs DataStore

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.


08 /

Profiling Tools

Measure before optimizing. Android Studio and the platform provide instrumentation for every major subsystem.

ToolWhat It MeasuresWhen to Use
CPU ProfilerThread activity, method traces, system tracesJank, slow startup, excessive CPU usage
Memory ProfilerHeap allocations, GC events, object countsMemory leaks, OOM crashes, heap growth
Network ProfilerRequest/response timing, payload sizesSlow API calls, unnecessary requests
Energy ProfilerCPU, radio, GPS usage over timeBattery drain investigation
Layout InspectorLive view hierarchy and recomposition countsOverdraw, deep hierarchies, Compose issues
MacrobenchmarkApp startup, scrolling, transitions (on-device)Regression testing in CI

Baseline Profiles

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

Systrace / Perfetto

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.

Tip Add 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.