Android Performance Interview Questions
Performance questions for senior roles: memory leaks, startup time, jank and frame drops, Compose performance, garbage collection, and profiling with Perfetto and Android Studio. Most are scenarios: something is slow, how do you find out why and fix it?
By Vivek Choudhary, Android engineer · Updated October 2026 · How answers are checked
All 50 questions
- What is a memory leak in Android? What are the most common causes? Most asked
- How do you detect and fix memory leaks using LeakCanary and Android Studio Memory Profiler? Most asked
- What is overdraw? How do you detect and reduce it? Most asked
- What is ANR (Application Not Responding)? What causes it and how do you prevent it? Most asked
- Scenario: Your app's cold startup takes 4 seconds. Walk through how you diagnose and fix it.
- What is the 16ms frame budget? How does Android render frames and what causes jank? Most asked
- Scenario: Your RecyclerView scrolls janky. How do you systematically diagnose and fix the performance?
- How do you use Android Studio's CPU Profiler? What are the different recording modes? Most asked
- Scenario: Your app keeps crashing with OutOfMemoryError in production. How do you diagnose and fix OOM errors in Android?
- What is the difference between cold start, warm start, and hot start? How does each affect perceived performance? Most asked
- Scenario: How do you implement a splash screen correctly in 2025 without the white flash problem?
- What is StrictMode and how do you use it to catch performance violations? Most asked
- Scenario: How do you measure and improve Jetpack Compose rendering performance?
- What is the Android Performance class? How does it help you write adaptive performance code? Most asked
- Scenario: How do you use the Microbenchmark library to measure the performance of a specific function?
- How do you reduce layout inflation time? What makes some layouts slower to inflate than others? Most asked
- Scenario: How do you profile and fix excessive garbage collection causing frame drops?
- What is App Startup time on Play Console? How do you monitor and improve it using production data?
- Scenario: How do you detect and fix thread contention causing jank or ANR?
- How do you optimise battery usage in Android? What APIs help you reduce power consumption? Most asked
- Scenario: How do you use Perfetto to diagnose a complex jank issue that doesn't show up in simpler tools?
- What is the Android Vitals dashboard in Play Console? Which metrics matter most? Most asked
- Scenario: Your app's memory grows over time during a long session — a slow memory leak. How do you find it?
- What is the Tracing library and how does it integrate with system profiling tools?
- Scenario: Conduct a performance code review. What 10 things do you check?
- What is the difference between memory pressure, low memory, and OOM? How does Android handle each? Most asked
- Scenario: How do you implement efficient image loading and caching in a photo gallery with thousands of images?
- What is the RenderThread? How does it affect UI performance? Most asked
- Scenario: How do you implement a high-performance custom View that draws 60fps animations?
- What is the difference between invalidate() and requestLayout()? When do you call each? Most asked
- Scenario: How do you use FrameMetrics API to monitor production frame rendering performance?
- How do you optimise Compose LazyColumn for large lists? What are the key performance patterns? Most asked
- Scenario: Your app's CPU usage stays high even when idle. How do you find and fix the runaway work?
- What is the App History API and how do you use process death to improve startup UX?
- Scenario: How do you implement efficient background work that doesn't drain battery or get killed by the OS?
- What is hardware acceleration in Android Views? When does it help and when does it break things? Most asked
- Scenario: How do you optimise a complex Compose screen that has expensive recomposition?
- What is the Network Profiler? How do you use it to optimise API calls? Most asked
- Scenario: How do you profile and fix excessive work happening during app startup that the user can't see yet?
- What is App Size and how does it affect performance? What is the difference between download size, install size, and memory footprint? Most asked
- Scenario: How do you implement performance monitoring that automatically alerts you to regressions before users notice?
- What is lazy loading and how do you implement it in Android lists and navigation? Most asked
- Scenario: How do you optimise a screen that shows real-time data (e.g. live scores, stock prices) updating every second?
- What is the Android GPU Inspector? How does it complement Android Studio's profiling tools?
- Scenario: Design a comprehensive performance strategy for a new Android app from day one.
- What is a foreground service? When must you use one and what are the restrictions in Android 14+? Most asked
- Scenario: How do you diagnose and fix a complex multi-thread synchronisation bug causing intermittent crashes?
- How do you handle performance on low-end devices? What adaptations should your app make? Most asked
- Scenario: Your app uses a lot of Coroutines. How do you diagnose and fix coroutine performance issues?
- Scenario: Write a complete performance improvement plan for an app that has 3-second startup, frequent jank, 2% crash rate, and growing memory.
1. What is a memory leak in Android? What are the most common causes?
A memory leak occurs when objects are kept in memory after they're no longer needed — the garbage collector can't reclaim them because something still holds a reference. On Android, leaking a Context or Activity is particularly expensive because it drags the entire view hierarchy into memory.
// The most common Android memory leaks: // 1. Static reference to Context or Activity object ImageCache { var context: Context? = null // ❌ static holds Activity forever } // Fix: use Application context, never Activity in static fields object ImageCache { lateinit var appContext: Context // ✅ Application lives as long as the app } // 2. Fragment View Binding not cleared in onDestroyView class HomeFragment : Fragment() { private var binding: FragmentHomeBinding? = null override fun onDestroyView() { super.onDestroyView () binding = null // ✅ must clear — Fragment outlives its View } } // 3. Non-static inner class holding outer class reference class MyActivity : Activity() { inner class MyTask : AsyncTask<...>() { // ❌ inner class holds Activity ref override fun doInBackground(...) { /* long running */ } } } // Fix: use static class + WeakReference, or better: coroutines in ViewModel // 4. Listener registered but never unregistered override fun onResume() { super.onResume () locationManager.requestUpdates (listener) // ❌ never removed } // Fix: unregister in onPause() override fun onPause() { super.onPause () locationManager.removeUpdates (listener) // ✅ } // 5. Coroutine launched in wrong scope class HomeFragment : Fragment() { fun loadData() { GlobalScope.launch { api.fetchData () } // ❌ never cancelled on Fragment destroy } } // Fix: viewLifecycleOwner.lifecycleScope or viewModelScope
The most common memory leak pattern in Android is an Activity or Fragment reference held by a longer-lived object. A classic example: registering a listener on a singleton or ViewModel using an anonymous inner class or lambda that implicitly captures this (the Activity). When the Activity is destroyed and recreated on rotation, the old instance is never garbage collected because the singleton still holds a reference. Over several rotations, you accumulate multiple dead Activity instances — each carrying its entire view hierarchy, bitmaps loaded into ImageViews, and any objects those views reference. A single leaked Activity can retain 20–50MB of memory.
Static fields are the most severe leak category because they live for the entire process lifetime. A companion object { var context: Context? = null } set to an Activity context will hold that Activity in memory forever — no rotation, no back navigation, no system pressure will free it. The fix is always to use applicationContext for singletons that need a Context, and to either avoid static View references entirely or use WeakReference for cases where a View reference in a static scope is unavoidable. The Android View hierarchy is a tree of cross-references, so leaking any single View in that tree effectively leaks the entire hierarchy.
Handler and coroutine leaks are subtler. A Handler message posted with a delay holds a reference to the Runnable, which often captures an outer class reference. If the Activity is destroyed before the message fires, the Activity is leaked for the duration of the delay. The modern fix is to use coroutines with lifecycleScope — the scope is automatically cancelled when the lifecycle owner is destroyed. For background work, viewModelScope outlives the Activity through rotations but cancels when the ViewModel is cleared. The single rule: never use GlobalScope in production Android code — it creates coroutines that are never automatically cancelled and frequently cause both leaks and crashes on post-destroy callbacks.
- Static Context: leaks the entire Activity view hierarchy — use Application context for singletons
- Fragment binding: Fragment outlives its View — must null the binding in onDestroyView()
- Inner class: non-static inner classes hold an implicit reference to the outer class
- Unregistered listeners: always pair register with unregister in matching lifecycle callbacks
- GlobalScope: coroutines launched in GlobalScope run forever — always use structured scopes
"When an Activity leaks, you're not just leaking the Activity object — you're leaking everything it references: views, bitmaps, adapters, the entire view hierarchy. A single leaked Activity can retain 10-50MB of memory. On rotation-heavy apps, each rotation creates a new Activity and leaks the old one — heap grows until OOM."
2. How do you detect and fix memory leaks using LeakCanary and Android Studio Memory Profiler?
LeakCanary detects leaks automatically by watching objects that should be garbage collected -- if they're still alive after 5 GC cycles, it captures a heap dump and traces the shortest reference path. The Memory Profiler gives you a live heap graph and lets you capture heap dumps on demand for manual investigation.
// LeakCanary -- zero config, add to debugImplementation // debugImplementation("com.squareup.leakcanary:leakcanary-android:2.13") // LeakCanary trace output (notification + logcat): // HomeFragment ↓ binding (strong ref) // FragmentHomeBinding → View tree (LEAK) // Fix: binding = null in onDestroyView() override fun onDestroyView() { super.onDestroyView() binding = null // Fragment outlives its View -- must release binding reference } // Memory Profiler heap dump workflow // 1. Navigate to screen → rotate 3x → press GC button → Capture Heap Dump // 2. Filter by class: search "Activity" -- 4 instances instead of 1 = leak // 3. Click instance → Retention path shows who is holding the reference
LeakCanary works by watching objects that should be garbage collected — specifically Activity and Fragment instances after they are destroyed. It uses ObjectWatcher, which registers a WeakReference to each watched object and a ReferenceQueue. After a grace period of 5 seconds, LeakCanary checks whether the reference has been enqueued (meaning the object was GC'd). If not, it triggers a heap dump and analyses the reference chain to find what is preventing collection. The result is a human-readable leak trace showing the exact reference path from a GC root to the leaked object — far more actionable than a raw heap dump. LeakCanary adds significant overhead and must only be included in debug builds via debugImplementation.
The Android Studio Memory Profiler is complementary to LeakCanary: it shows the live heap allocation in real time and lets you capture heap dumps on demand. The most useful workflow is the "allocation tracking" mode — record allocations while performing the suspect action (opening a screen, running a search, loading images), then stop recording and inspect which objects were allocated but not released. The "Shallow size" column shows how much memory the object itself uses; the "Retained size" column shows how much would be freed if the object were GC'd, including all objects it holds exclusively. A large retained size on an Activity or Bitmap is the primary signal that something is wrong.
When a heap dump analysis points to a specific leak but the cause is not obvious from the reference chain, the next step is to look at the shortest reference path from a GC root. GC roots in Android are: static fields, active threads, JNI references, and objects on the current call stack. A chain that passes through a static field is a static leak. A chain passing through a HandlerThread or a thread pool is a threading leak. A chain passing through a system service callback (a registered BroadcastReceiver, SensorEventListener, or NetworkCallback) means you registered but never unregistered. Each pattern has a standard fix, and recognising the pattern from the reference chain is the core skill that separates developers who fix leaks quickly from those who chase them for days.
- LeakCanary: zero config -- add to debugImplementation, it hooks in automatically via ContentProvider
- Rotation test: navigate to a screen, rotate 3 times, force GC, capture heap dump -- more instances than expected = leak
- LeakCanary trace: shows the exact reference chain keeping the object alive -- usually 2-3 hops to the root cause
- Memory Profiler: use for leaks LeakCanary misses (non-Activity/Fragment leaks, slow growth leaks)
- Retention path: clicking an instance in the heap dump shows which object is holding a reference -- the fix is always at the nearest strong reference
"The rotation test is the fastest manual leak check: open a screen, rotate the device 5 times, press GC in the profiler, capture a heap dump. Search for your Activity class — if you see 6 instances instead of 1, you have a leak. LeakCanary automates this exact check and shows you exactly which reference chain is keeping the old instances alive."
3. What is overdraw? How do you detect and reduce it?
Overdraw happens when a pixel is drawn more than once in the same frame — a background behind a background behind a view. Each overdraw wastes GPU time and can cause frame drops on low-end devices. The GPU has to colour the same pixel multiple times for no visible benefit.
// Enable overdraw visualiser: // Developer Options → Debug GPU Overdraw → Show overdraw areas // Colors: // True color (white) = no overdraw (drawn once) ✅ // Blue = 1x overdraw // Green = 2x overdraw // Pink = 3x overdraw // Red = 4x+ overdraw ❌ problem area // Common overdraw causes and fixes: // 1. Window background + View background + child background // Fix: remove redundant backgrounds // styles.xml: <item name="android:windowBackground">@null</item> // OR remove background from root layout if it matches window bg // 2. Nested layouts each with their own background // <LinearLayout android:background="@color/white"> ← outer bg // <RelativeLayout android:background="@color/white"> ← duplicate // Fix: keep background only on the outermost needed container // 3. Custom View drawing opaque content over previous draws class MyCustomView : View(...) { override fun onDraw(canvas: Canvas) { // Tell the system this View is fully opaque — skip drawing underneath if (isOpaque ()) { canvas.clipRect (left, top, right, bottom) // clip to view bounds } // draw content } } // 4. Jetpack Compose — overdraw less common but still possible // Compose avoids many overdraw issues by default (no XML view hierarchy) // Still watch for: modifier.background() on multiple nested composables Box(modifier = Modifier.background (Color.White)) { // outer bg Box(modifier = Modifier.background (Color.White)) { // ❌ redundant inner bg Text("Hello") } }
Overdraw happens when the GPU draws the same pixel more than once in a single frame. Every layer of your view hierarchy that covers a pixel costs GPU time even if the result is invisible. A common source is setting a background on both a parent layout and its children — the parent's background is painted, then the child's background paints over it, and finally the content paints on top. Three draws for one visible pixel. On modern hardware this rarely causes visible jank by itself, but it contributes to frame budget overrun when combined with complex layouts and heavy compositing. Enable GPU Overdraw visualisation in Developer Options to see your app colour-coded: blue is good (1x overdraw), green is 2x, red is 4x or more.
The primary fix is removing redundant backgrounds. The Window background set by your theme is always drawn first — if your root layout has its own background colour that covers the window, the window background is pure overdraw. Remove it by setting android:windowBackground="@null" or @color/transparent in your theme. Similarly, ViewGroups like RecyclerView that are fully covered by their children don't need a background. In Compose, avoid nesting Surface composables with backgrounds when the inner surface fully covers the outer one — each Surface layer adds GPU compositing work.
The GPU Rendering profile (visible as coloured bars in Developer Options) is the companion tool to the overdraw visualisation. Each bar segment represents a phase of the rendering pipeline: input handling, animation, measurement, layout, draw, sync, execute. Bars that consistently exceed the 16ms green line indicate frame drops. A tall orange "draw" segment typically points to overdraw or complex onDraw() implementations. A tall purple "measure/layout" segment indicates an inefficient view hierarchy — often deep nesting or overuse of wrap_content in constrained contexts. In Compose, the Layout Inspector's "Recomposition counts" overlay shows which composables are recomposing every frame — unnecessary recomposition is the Compose equivalent of overdraw.
- Overdraw: same pixel drawn multiple times per frame — wastes GPU cycles, causes jank
- Debug GPU Overdraw: Developer Options visualiser — blue=1x, green=2x, red=4x+ (bad)
- Window background: set to @null in theme if your root view has its own background
- Nested backgrounds: only the outermost visible background needed — remove inner duplicates
- Custom Views: use clipRect to tell the system what's opaque — skip drawing underneath
"The window background is the invisible overdraw culprit. Every app has a window background (usually white or the theme color). If your root layout also has a white background, every pixel is drawn twice before a single View is rendered. Set android:windowBackground=@null in your theme when your layout covers the entire window — instant 1-layer overdraw reduction everywhere."
4. What is ANR (Application Not Responding)? What causes it and how do you prevent it?
ANR occurs when the main thread is blocked for too long — 5 seconds for user input events, 10 seconds for broadcast receivers, 20 seconds for service operations. Android kills the app with a dialog. The fix: never block the main thread.
// ANR triggers: // • 5 seconds: no response to input event (user tap blocked) // • 10 seconds: BroadcastReceiver.onReceive() takes too long // • 20 seconds: Service operations on main thread // Common ANR causes: // 1. Network/DB on main thread override fun onCreate(...) { val data = OkHttpClient().newCall (request).execute () // ❌ blocks main thread val user = db.userDao().getUserBlocking (id) // ❌ Room on main thread } // Fix: coroutines, suspend functions // 2. Holding a lock that another thread holds @Synchronized fun processOnMain() { // ❌ deadlock risk heavyWork() } // 3. Long broadcast receiver class MyReceiver : BroadcastReceiver() { override fun onReceive(ctx: Context, intent: Intent) { doLongWork() // ❌ must complete in 10 seconds } } // Fix: start a foreground service or use goAsync() override fun onReceive(ctx: Context, intent: Intent) { val result =goAsync () // extends time budget CoroutineScope(Dispatchers.IO).launch {doLongWork () result.finish () // must call finish() or ANR still occurs } } // Detect ANR-prone code with StrictMode StrictMode.setThreadPolicy ( StrictMode.ThreadPolicy.Builder() .detectAll () // detect all violations .penaltyDeath () // crash in debug — can't ignore .build () ) // Analyse ANR traces: // adb bugreport → extract /data/anr/anr_*.txt // Look for main thread stack trace — shows exactly where it was blocked
ANR (Application Not Responding) is triggered when the main thread is blocked for more than 5 seconds during a user-visible input event, or when a BroadcastReceiver does not return from onReceive() within 10 seconds. Android monitors the main thread via a watchdog that posts a sentinel message to the main looper; if it is not processed within the timeout, the system logs an ANR and shows the dialog. The ANR trace file written to /data/anr/traces.txt contains a stack dump of all threads at the moment of the ANR, showing exactly which call was blocking the main thread. Reading this trace is the first step in every ANR investigation.
The most common ANR causes, in order of frequency: (1) network or database calls on the main thread — a developer forgot withContext(Dispatchers.IO), or a synchronous SharedPreferences.commit() blocks on a slow disk. (2) Lock contention — two threads competing for the same mutex, one of which is the main thread. (3) A BroadcastReceiver.onReceive() starting a long-running operation synchronously instead of delegating to a coroutine or a Service. (4) Complex layout inflation — a deeply nested layout inflated on the main thread during the first frame of a cold start. Enable StrictMode.ThreadPolicy in debug builds to catch disk and network access on the main thread before they cause production ANRs.
Prevention is more effective than detection. The architectural rule is that the main thread should only do work that takes under 1ms: updating UI state, handling touch events, starting animations. Everything else — disk I/O, network, image decoding, JSON parsing, database queries — belongs on a background dispatcher. In practice, this means every DAO function is suspend, every network call is suspend, and every data transformation that takes non-trivial time is wrapped in withContext(Dispatchers.Default). The Play Console's Android Vitals "ANR rate" metric breaks down ANRs by type and activity, letting you prioritise the most impactful ones. A poor ANR rate triggers Play Store badging that reduces your app's visibility in search results.
- 5-second rule: any input event blocked for 5s triggers ANR — user sees the "Wait/Close" dialog
- Never block main thread: no network, no database, no file I/O, no long computation
- StrictMode: crash the debug build on any main-thread violation — the best prevention tool
- goAsync(): extends broadcast receiver time budget — still must complete and call finish()
- ANR traces: /data/anr/ contains thread dumps — main thread stack shows the blocking call
"ANR traces are the most useful debugging artefact. When an ANR occurs, Android writes the full thread dump to /data/anr/. The main thread's stack trace shows exactly where it was blocked — 'at java.net.SocketInputStream.read' means a network call on main thread. adb bugreport extracts these traces. Play Console shows ANRs from production users with their traces."
5. Scenario: Your app's cold startup takes 4 seconds. Walk through how you diagnose and fix it.
Slow startup is almost always caused by too much work on the main thread during Application.onCreate() or Activity.onCreate(). The fix is a combination of deferring initialisation, lazy loading, and generating a Baseline Profile.
// Step 1: Measure — get the real number // adb shell am start -W com.example.app/.MainActivity // Output: // TotalTime: 3847ms ← cold start duration // WaitTime: 3849ms // Step 2: Profile — Android Studio CPU Profiler // Run → Profile → Method Trace → start app → see flame chart // Identify: which methods take the most time in onCreate? // Step 3: Common culprits and fixes // ❌ Synchronous SDK initialisation in Application.onCreate() class MyApp : Application() { override fun onCreate() { FirebaseApp.initializeApp (this) // 200ms Timber.plant (Timber.DebugTree()) // fast, OK MapsInitializer.initialize (this) // 400ms ❌ analytics.initialize () // 300ms ❌ doesn't need to be eager } } // ✅ Fix: defer non-critical init to background thread override fun onCreate() { Timber.plant (Timber.DebugTree()) // fast — keep synchronous ProcessLifecycleOwner.get ().lifecycle .addObserver (LifecycleEventObserver { _, event -> if (event == Lifecycle.Event.ON_START) { MainScope().launch (Dispatchers.IO) { analytics.initialize () // defer to after first frame } } }) } // ✅ App Startup library — order-aware, dependency-tracking // implementation("androidx.startup:startup-runtime:1.1.1") class AnalyticsInitializer : Initializer<Analytics> { override fun create(context: Context): Analytics = Analytics.init (context) override fun dependencies() =emptyList () } // ✅ Baseline Profile — pre-compile critical startup code // Generates: src/main/baseline-prof.txt // Result: JIT warm-up cost eliminated → 30-40% faster cold start
Cold start time is measured from process creation to the first frame drawn. On Android, this involves: the system forking a new process, loading and verifying DEX bytecode (partially mitigated by ART ahead-of-time compilation), executing Application.onCreate(), creating the initial Activity, inflating its layout, performing the first measure and layout pass, and drawing the first frame. A 4-second cold start almost always means Application.onCreate() is doing too much work synchronously. Common culprits: initialising every third-party SDK eagerly, loading large configuration files, building dependency injection graphs synchronously, or running database migrations on the main thread.
The Jetpack App Startup library is the standard solution for expensive initialisation. It defers component initialisation to when it is first actually needed rather than at application launch. Define each component as an Initializer<T>, declare dependencies between initialisers, and let App Startup build the initialisation DAG. Components that are not needed for the first screen are never initialised at cold start. For DI frameworks, Hilt's component initialisation is lazy by default — modules are not instantiated until their scope is first requested. Move all non-essential initialisation (analytics, crash reporting configuration, feature flag fetching) to a coroutine launched in the background after the first frame is drawn using ProcessLifecycleOwner.get().lifecycleScope.launch.
Baseline Profiles are the highest-leverage optimisation for cold start on new installs and first launches after an OS update, when ART has not yet compiled the app's hot paths. A Baseline Profile is a text file specifying which classes and methods to AOT-compile at install time. It is generated by running the Macrobenchmark library's BaselineProfileRule, which records the code paths exercised by your critical user journeys (launch, first screen, navigation). Google reports that Baseline Profiles reduce cold start time by 30–40% on average. Generate one, commit it to your repository, and ship it with your release build — the Play Store handles distributing it to devices. Combine with ProfileInstaller for sideloaded or direct-installed APKs.
- Measure first: adb shell am start -W gives you the exact cold/warm/hot start times
- CPU Profiler method trace: flame chart shows which methods consume time in onCreate()
- Defer SDK init: move non-critical SDKs to background thread or after first frame
- App Startup library: replaces per-SDK ContentProviders with a single ordered initializer
- Baseline Profile: eliminates JIT warm-up — the single biggest startup improvement available
"The App Startup library solves an invisible problem: every library that needs early initialization registers a ContentProvider in its AAR. An app with 10 SDKs has 10 ContentProviders all initializing at startup before your Application.onCreate() even runs. App Startup consolidates all of them into one — reducing ContentProvider overhead significantly."
6. What is the 16ms frame budget? How does Android render frames and what causes jank?
Android must complete a measure → layout → draw → GPU render cycle within 16.67ms to hit 60fps. Any frame exceeding this budget is dropped and the user sees a stutter (jank). The frame is rendered by two threads working together: the main thread records drawing commands, and RenderThread executes them on the GPU.
// 60fps = 16.67ms per frame. 90fps = 11.11ms. 120fps = 8.33ms // Detect jank: Developer Options → Profile GPU Rendering → On screen as bars // Green line = 16ms threshold. Bars above it = dropped frames. // Most common jank causes: // 1. Allocating objects in onDraw() triggers GC pauses private val paint = Paint() // ✅ allocate once as field override fun onDraw(canvas: Canvas) { canvas.drawRect(bounds, paint) // no allocation here } // 2. Expensive work in RecyclerView.onBindViewHolder() override fun onBindViewHolder(holder: VH, pos: Int) { holder.price.text = items[pos].priceFormatted // ✅ pre-formatted in ViewModel }
Android's rendering pipeline works in two phases: the UI thread records drawing commands into a DisplayList, and the RenderThread replays those commands on the GPU. The 16ms budget (60fps) is split across both threads — if either takes more than 16ms, the frame is dropped and the user sees a jank. The Choreographer drives this: it receives a VSYNC signal from the display hardware every 16ms and uses it to trigger the next frame's traversal. If your UI thread is still in its previous frame's work when the next VSYNC fires, the frame is skipped. This is why 17ms and 32ms are the two common frame times you see in the GPU rendering profile — one missed VSYNC means the next frame renders in two VSYNC periods.
The most expensive operations in the measure/layout phase are chains of dependent measurements. wrap_content on a child inside a LinearLayout that is itself wrap_content causes two full measurement passes for every level of nesting — this is exponential. ConstraintLayout solves this by resolving all constraints in a single pass. In Compose, the equivalent issue is excessive nesting of composables that each trigger re-measurement of their parent. Compose's layout model is inherently single-pass, but incorrect use of BoxWithConstraints or intrinsic measurements re-introduces multi-pass measurement cost. Use the Layout Inspector's "Recomposition highlights" to verify that composables only remeasure when their inputs actually change.
Hardware acceleration, enabled by default since API 14, moves the DisplayList replay to the GPU via the RenderThread. This decouples rendering from the UI thread — simple animations (translation, alpha, scale) can run at full frame rate even when the UI thread is moderately busy, because they only require RenderThread work. View.animate() and Compose's animate*AsState() leverage this automatically. The operations that still require UI thread involvement are those that invalidate the DisplayList: anything that calls View.invalidate(), any layout change, and any custom onDraw() implementation. Minimise these during animations to keep the UI thread free and let the RenderThread do the work.
- 16ms budget: measure + layout + draw + GPU render must all complete within 16.67ms -- exceed it and the frame is dropped
- Profile GPU Rendering: the fastest jank diagnostic -- enable in Developer Options, look for bars above the green 16ms line
- Never allocate in onDraw(): allocating Paint, RectF, Path on every frame triggers GC which pauses all threads
- Pre-format data in ViewModel: date formatting, currency formatting, string building -- do it once on a background thread, not on every bind
- ConstraintLayout over nested LinearLayouts: reduces layout hierarchy depth, fewer measure passes per frame
"The most common jank cause in RecyclerView: onBindViewHolder doing synchronous image loading, string formatting, or complex calculations. Everything in onBindViewHolder runs on the main thread during scroll — it must complete in a fraction of the 16ms frame budget. Prepare all data before binding, use async image loading (Coil), and pre-format strings."
7. Scenario: Your RecyclerView scrolls janky. How do you systematically diagnose and fix the performance?
Janky RecyclerView scrolling usually comes from expensive onBindViewHolder, deep view hierarchies, or rebinding identical data. The fix combines profiling, ViewHolder optimisation, DiffUtil, and prefetching.
// Step 1: Diagnose — Profile GPU Rendering overlay // Developer Options → Profile GPU Rendering → On screen as bars // Scroll the RecyclerView — watch for bars above green 16ms line // Step 2: CPU Profiler — Method Trace during scroll // Profiler → CPU → Record → scroll RecyclerView → stop // Find: onBindViewHolder() taking > 2ms? That's the problem. // Common fixes: // FIX 1: Move computation out of onBindViewHolder // ❌ Formatting in bind override fun onBindViewHolder(holder: VH, position: Int) { val item = items[position] holder.price.text = NumberFormat.getCurrencyInstance(). format(item.price) // ❌ slow } // ✅ Pre-format in the data class or ViewModel data class ProductUiModel(val priceFormatted: String) // formatted once // FIX 2: DiffUtil — only rebind changed items class ProductDiffCallback : DiffUtil.ItemCallback<ProductUiModel>() { override fun areItemsTheSame(o: ProductUiModel, n: ProductUiModel) = o.id == n.id override fun areContentsTheSame(o: ProductUiModel, n: ProductUiModel) = o == n } // ListAdapter uses DiffUtil automatically — only calls onBind for changed items // FIX 3: setHasStableIds — skip full rebind when data source same adapter.setHasStableIds(true) // tell RecyclerView items have unique stable IDs override fun getItemId(position: Int) = items[position].id. hashCode ().toLong () // FIX 4: RecycledViewPool — share pool across multiple RecyclerViews val sharedPool = RecyclerView.RecycledViewPool() horizontalRv.setRecycledViewPool(sharedPool) verticalRv.setRecycledViewPool(sharedPool) // FIX 5: Prefetch — pre-bind items before they scroll into view val llm = LinearLayoutManager(context) llm.initialPrefetchItemCount = 4 // pre-bind 4 items ahead of scroll recyclerView.layoutManager = llm
RecyclerView jank is almost always caused by one of three things: expensive onBindViewHolder() calls, layout inflation happening on the main thread during fast scrolling (when the recycler pool is exhausted), or image loading triggered synchronously on bind. Profile the issue before fixing it — attach the CPU Profiler with Method Tracing while scrolling and look for which method is consuming the most time per frame. onBindViewHolder() should complete in under 1ms: it should only set pre-computed values onto views, never compute string formatting, parse dates, make database calls, or start network requests.
Pre-computing bind data in the ViewModel is the standard fix for expensive bind operations. Format dates, concatenate strings, map enums to display strings, and compute visibility flags before the data reaches the adapter. The adapter should receive a List<UiModel> where every field is already a ready-to-display value — no transformation needed on the main thread. For DiffUtil, always run the diff calculation on a background dispatcher and submit the result via submitList(): DiffUtil.calculateDiff() on the main thread with a large list causes a multi-millisecond freeze on every data update.
Prefetching and RecycledViewPool tuning address the pool exhaustion problem. RecyclerView.RecycledViewPool.setMaxRecycledViews(type, count) increases the pool size for item types that appear frequently, preventing inflation spikes during fast scrolling. RecyclerView.setItemViewCacheSize(n) increases the off-screen view cache — views just scrolled off screen are held in this cache rather than immediately returning to the pool, so scrolling back reuses them without rebinding. For image loading, configure Coil or Glide to preload items ahead of the scroll position using RecyclerView.addOnScrollListener and imageLoader.enqueue() with the next page's image URLs — images arrive in memory before the item is bound.
- Profile GPU Rendering + CPU Profiler: find whether jank is in layout, bind, or draw phase
- Pre-format data: never format numbers or dates in onBindViewHolder — do it in ViewModel
- ListAdapter + DiffUtil: only rebinds changed items — avoids full notifyDataSetChanged()
- setHasStableIds: RecyclerView skips full rebind when it can match items by ID
- initialPrefetchItemCount: pre-binds items on a background thread before they scroll into view
"The biggest RecyclerView win: replace notifyDataSetChanged() with ListAdapter. notifyDataSetChanged() forces RecyclerView to rebind every visible item every time — even if only one item changed. ListAdapter with DiffUtil only calls onBindViewHolder for items that actually changed. The difference on a list of 50 items: 50 bind calls vs 1."
8. How do you use Android Studio's CPU Profiler? What are the different recording modes?
Android Studio's CPU Profiler has four recording modes, each answering a different question. Instrumented trace tells you exactly which methods were called. Sampled trace tells you how long each method took without much overhead. System Trace reveals thread scheduling, locks, and frame timing. Callstack Sample is for native code.
// Open: View → Tool Windows → Profiler → CPU → Record // Add custom sections visible in all trace types import androidx.tracing.trace trace("UserRepository.load") { dao.getUsers() // appears as a labelled block in the timeline } // System Trace -- best for jank diagnosis // Shows: vsync signal, frame boundaries, thread scheduling, lock waits // Capture via adb for production-like conditions: // adb shell perfetto -o /data/misc/perfetto-traces/trace -t 5s gfx view sched // adb pull /data/misc/perfetto-traces/trace → open in https://ui.perfetto.dev
The CPU Profiler has three recording modes with distinct tradeoffs. System Trace uses Linux's ftrace mechanism — very low overhead (under 5% CPU), captures thread scheduling, lock contention, and system calls alongside app code. Use this first; it shows the full picture of what every thread is doing during a problematic interaction without distorting timing. Java/Kotlin Method Tracing records every method entry and exit with timestamps — high overhead (2–3x slowdown), but gives exact call counts and durations for every method. Use it to identify which specific method in a slow path is the bottleneck after System Trace narrows down the suspect area. Sampled Recording periodically samples the call stack — lower overhead than Method Tracing, useful for profiling longer sessions.
Reading a System Trace requires understanding the thread lanes. The main thread lane shows each frame's work as a coloured block — green is good (under budget), yellow is borderline, red is a dropped frame. Click any block to see what method was running at that point. The RenderThread lane shows GPU command submission time — a long RenderThread block while the main thread is idle means the GPU is the bottleneck, not the CPU. Other thread lanes (IO thread, OkHttp dispatcher, Glide worker threads) reveal background work that might be competing for CPU time with the main thread during critical rendering windows. Thread state colouring shows "Runnable" (ready but waiting for CPU), "Sleeping" (blocked on I/O or sleep), and "Running" (actively executing).
The Flame Chart view in Method Tracing is the most efficient way to identify hot paths. It shows the call stack as nested horizontal bars — wider bars consume more time. The chart is oriented so the bottom is the entry point (e.g. Choreographer.doFrame()) and each layer up is a callee. A wide bar deep in the stack that you don't recognise is usually the smoking gun: an unexpectedly expensive library call, a serialisation step, or a recursive traversal. Cross-reference with the Top Down and Bottom Up views to confirm: Bottom Up shows methods sorted by self-time (the time spent in that method excluding callees), which isolates leaf-level hot spots like tight loops or repeated string allocations.
- Instrumented trace: records every method entry/exit -- 100% coverage but 2-10x overhead, timing is inaccurate, use for call count analysis
- Sampled trace: captures call stack every 1ms -- low overhead, accurate timing, may miss very short methods -- use to find slow methods
- System Trace: kernel-level -- shows vsync, frame pipeline, thread scheduling, lock waits -- use for jank and startup diagnosis
- Callstack Sample: for native C/C++ code -- samples the native call stack
- Choose based on your question: 'What runs?' → Instrumented. 'How long does X take?' → Sampled. 'Why is this frame slow?' → System Trace
"System Trace is the most powerful and underused profiling mode. It shows: which CPU core each thread ran on, when threads were blocked waiting for locks, the vsync signal and each frame's render timeline, and the green 16ms line. When you see a janky scroll in System Trace, you can see exactly which work pushed the frame over 16ms and on which thread."
9. Scenario: Your app keeps crashing with OutOfMemoryError in production. How do you diagnose and fix OOM errors in Android?
OOM errors mean the app asked the OS for more heap than is available. Android caps each app's heap — typically 256–512MB on modern devices, as low as 64MB on older ones. The crash is rarely caused by a single allocation; it's almost always accumulated memory that was never released.
// Step 1 — Read the OOM stacktrace. It tells you WHERE it crashed, not WHY. // java.lang.OutOfMemoryError: Failed to allocate X bytes // Look at the allocation site, then ask: what's holding memory that shouldn't be? // Step 2 — Check heap with Android Studio Memory Profiler // Profile → Memory → Record heap dump // Sort by "Retained size" — largest retained = biggest leak suspects // Step 3 — Common OOM sources and fixes // ❌ #1: Bitmaps decoded at full resolution val bitmap = BitmapFactory.decodeFile (path) // 12MP = 48MB! // ✅ Fix: use Coil/Glide — they sample down to display size automatically AsyncImage(model = url, contentDescription = null) // ❌ #2: Static references holding Context or Views companion object { var ctx: Context? = null } // entire Activity leaked! // ✅ Fix: use applicationContext for singletons, WeakReference for Views // ❌ #3: Uncancelled coroutines or callbacks holding references // ✅ Fix: always use viewModelScope / lifecycleScope, never GlobalScope // ❌ #4: Unbounded in-memory caches val cache = HashMap<String, ByteArray>() // grows forever // ✅ Fix: LruCache with explicit maxSize based on available heap val maxMem = (Runtime.getRuntime ().maxMemory () / 1024).toInt() val lruCache = LruCache<String, Bitmap>(maxMem / 8) // 1/8 of heap // ❌ #5: Large lists fully loaded into memory // ✅ Fix: Paging 3 — loads only what's on screen // Step 4 — Add OOM monitoring in production fun isLowMemory (): Boolean { val info = ActivityManager.MemoryInfo() (getSystemService(ACTIVITY_SERVICE) as ActivityManager).getMemoryInfo (info) return info.lowMemory }
The heap dump is the most powerful diagnostic tool. Capture it in Android Studio's Memory Profiler while the app is in the state that precedes the crash — after navigating through several screens, after a long session, or after the action that reliably triggers the OOM. Sort the heap dump by retained size: retained size is the memory that would be freed if that object were garbage collected. A single Activity instance with a large retained size (tens of megabytes) is almost always a leak — something is holding a reference to it after onDestroy(). The reference chain view shows exactly which object is the leak root: a static field, a lambda capturing this, a Handler message in the queue, or a callback registered with a system service and never unregistered.
Bitmaps deserve special attention because they are the single largest source of heap pressure in image-heavy apps. A 12-megapixel photo decoded at full resolution into ARGB_8888 format occupies 48MB — loading five such images simultaneously uses 240MB, which exceeds the heap limit on many devices. The fix is always to use a mature image loading library (Coil or Glide) and never call BitmapFactory.decodeFile() directly on production code paths. These libraries decode to the display size, not the file size, maintain an LRU memory cache sized to a fraction of available heap, and handle lifecycle cancellation. If you must decode manually — for offline processing, for example — use BitmapFactory.Options.inSampleSize to subsample the image before decoding, and use inBitmap to reuse an existing allocation rather than creating a new one.
For production monitoring, integrate Firebase Crashlytics and look for the OutOfMemoryError crash cluster. The stacktrace tells you the allocation site, but the real cause is the accumulated unreleased memory. Supplement this with ComponentCallbacks2.onTrimMemory(): the system calls this before killing your process, passing levels like TRIM_MEMORY_RUNNING_LOW and TRIM_MEMORY_MODERATE. React to these callbacks by releasing caches, cancelling non-essential background work, and downgrading image quality. Apps that respond to onTrimMemory() gracefully are far less likely to be killed by the LMK (Low Memory Killer) and far less likely to crash with OOM. Implementing it is a signal to interviewers that you understand Android's memory model at the system level, not just the app level.
The structural fix is preventing the problem through disciplined scoping. Every long-lived object — ViewModel, Repository, singleton — must use applicationContext, never Activity context. Coroutines launched in viewModelScope cancel automatically when the ViewModel is cleared; those in lifecycleScope cancel when the lifecycle owner is destroyed. Any callback registered with a system service (SensorManager, LocationManager, NetworkCallback) must be unregistered in onStop() or onDestroyView(). In Compose, remember that lambdas passed to composables capture their outer scope — a lambda referencing a ViewModel that captures a Context is a leak waiting to happen. LeakCanary catches all of these at development time; the goal is to never ship a build where LeakCanary fires.
"When I get an OOM report, I do three things: (1) take a heap dump in the state before the crash and sort by retained size — the top entries are suspects. (2) Look for Activity or Fragment instances with large retained sizes — that's a context leak. (3) Check for unbounded collections and full-resolution bitmap decoding. Nine out of ten OOMs I've seen come from one of these three sources. LeakCanary during development catches the leaks; onTrimMemory() in production makes the app resilient to low-memory pressure."
10. What is the difference between cold start, warm start, and hot start? How does each affect perceived performance?
Cold start creates everything from scratch -- a new process, Application object, and Activity. Warm start skips process and Application creation. Hot start just resumes an existing Activity. Cold start is the hardest to optimise and the one that matters most for user perception. TTID is when the first frame is drawn; TTFD is when content is actually visible.
// Measure cold start time from command line // adb shell am start -W com.example.app/.MainActivity // Output: TotalTime: 2840ms (cold start duration) // Signal when content is ready (feeds TTFD metric in Play Console) override fun onResume() { super.onResume() viewModel.contentReady.observe(this) { ready -> if (ready) reportFullyDrawn() // Play Console records this as TTFD } } // SplashScreen API -- eliminates white flash, zero extra startup cost val splash = installSplashScreen() // must be called before super.onCreate() splash.setKeepOnScreenCondition { !viewModel.isReady }
Cold start, warm start, and hot start differ in how much work Android must do before the first frame is displayed. Cold start is the most expensive: the system creates a new process, loads the Application class, initialises the DI graph, and creates the Activity from scratch. Warm start occurs when the process already exists in memory (the user recently used the app) but the Activity was destroyed — the system skips process creation and Application initialisation, jumping straight to Activity creation and layout inflation. Hot start is the cheapest: the process and Activity both exist in memory, and the system only needs to bring the Activity to the foreground and call onStart()/onResume().
The practical implication for optimisation is that cold start and warm start have different bottlenecks. Cold start is dominated by Application.onCreate() time and DEX loading — the targets for Baseline Profiles and lazy initialisation. Warm start is dominated by Activity creation time: layout inflation, onCreate() database reads, and ViewModel initialisation. For warm start, the key optimisation is caching the first-screen data in the ViewModel — since the ViewModel survives configuration changes and even the warm start scenario (the process survived, only the Activity was recreated), the data is often already available and the screen can render without a database query.
The reportFullyDrawn() API tells the system — and therefore Play Console's startup metrics — when your app considers itself truly ready, not just when the first frame was drawn. A first frame with a loading spinner followed by a 2-second data fetch is measured as "fast" by the system but is perceived as slow by users. Calling reportFullyDrawn() after the meaningful content is displayed gives you accurate data in Play Console and allows the system to use this timing information for future optimisations. Track the time between process start and reportFullyDrawn() as your true "time to meaningful content" metric, not just the first frame time shown in Logcat's Displayed tag.
- Cold start: new process + Application.onCreate() + Activity.onCreate() + first frame -- typically 1-5 seconds, where optimisation matters most
- Warm start: process alive, Activity recreated -- skips Application.onCreate(), typically 300-700ms
- Hot start: Activity resumes from back stack -- just lifecycle callbacks, feels instant at < 100ms
- TTID vs TTFD: TTID is the first frame drawn (layout visible), TTFD is when content is actually loaded -- users perceive TTFD as startup time
- reportFullyDrawn(): tells Play Console when your content is genuinely ready -- without it Play measures TTID which hides slow data loading
"reportFullyDrawn() is underused but important for Play Console startup metrics. Without it, Android measures TTID — when the layout is first visible. But your layout may show a spinner for 2 more seconds while data loads. reportFullyDrawn() marks when the content is actually usable — that's the number users perceive as 'startup time'."
11. Scenario: How do you implement a splash screen correctly in 2025 without the white flash problem?
The white flash on app launch comes from the window background being rendered before your first Activity frame. The modern solution is the SplashScreen API (Android 12+) with the androidx.core SplashScreen compat library for older devices.
// implementation("androidx.core:core-splashscreen:1.0.1") // Step 1: Define splash screen theme // res/values/themes.xml <style name="Theme.App.Starting" parent="Theme.SplashScreen"> <item name="windowSplashScreenBackground">@color/brand_green</item> <item name="windowSplashScreenAnimatedIcon">@drawable/ic_logo</item> <item name="windowSplashScreenAnimationDuration">500</item> <item name="postSplashScreenTheme">@style/Theme.App</item> </style> // Step 2: Set as app theme in AndroidManifest.xml <activity android:theme="@style/Theme.App.Starting"> // Step 3: Install in MainActivity.onCreate() BEFORE super/setContent class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { val splashScreen =installSplashScreen () // ← must be first super.onCreate (savedInstanceState) // Keep splash visible while loading data splashScreen.setKeepOnScreenCondition { !viewModel.isDataReady // returns true = keep showing splash }setContent { AppTheme { AppNavigation() } } } } // How it solves the white flash: // Old way: window background = white → user sees white flash // SplashScreen API: windowSplashScreenBackground = brand color // → user sees brand color immediately (zero flash) // → smooth transition to app content // Custom exit animation (optional) splashScreen.setOnExitAnimationListener { splashScreenView -> ObjectAnimator.ofFloat (splashScreenView, View.ALPHA, 1f, 0f).apply { duration = 300doOnEnd { splashScreenView.remove () }start () } }
The SplashScreen API (available from Android 12+ natively, backported via the androidx.core:core-splashscreen library to API 23) replaces the old workaround of setting a theme with a windowBackground drawable. The old approach caused a "cold start white flash" between the splash screen and the first frame of the app, because the theme background was drawn before the Activity was ready, then replaced. The SplashScreen API eliminates this: it draws the splash screen in the system process before your app's process starts, ensuring a seamless transition with no flash. The splash screen is dismissed automatically when your Activity's first frame is drawn.
The SplashScreen.installSplashScreen() call in Activity.onCreate() (before super.onCreate()) gives you control over the splash duration and exit animation. Call splashScreen.setKeepOnScreenCondition { !viewModel.isDataReady } to hold the splash screen until your initial data load is complete — this prevents showing a blank or partially-loaded first screen. The condition is checked before every frame draw; when it returns false, the system begins the exit animation. Keeping the splash screen visible during a network call is acceptable if the call is fast (under 500ms); for slower operations, show the splash, dismiss it, then show a loading skeleton rather than holding the user on the splash indefinitely.
The exit animation customisation via splashScreen.setOnExitAnimationListener() is optional but powerful for brand consistency. The listener receives a SplashScreenView containing the icon and background, which you can animate with any Animator — scale it out, fade it, slide it. The key constraint is that you must call splashScreenView.remove() when the animation completes; if you don't, the splash view remains on screen indefinitely. One common anti-pattern to avoid: using the splash screen as a loading screen that stays visible for several seconds while the app initialises. The Android UI guidelines recommend a maximum splash duration of the time needed for the first content frame — not a fixed timer, not a full data load.
- White flash cause: window background renders before first Activity frame — visible gap
- SplashScreen API: sets the brand color as window background — shown instantly before any Java runs
- installSplashScreen: must be called before super.onCreate() — sets up the compat shim
- setKeepOnScreenCondition: hold the splash until async data is ready — prevents content flash
- androidx.core compat: works back to API 23 — same API on all supported versions
"The old DIY splash screen pattern (a dedicated SplashActivity that sleeps for 2 seconds, then starts MainActivity) adds 2 seconds to cold start for zero reason. The SplashScreen API shows the brand logo at ZERO extra cost — it's displayed during the window creation phase that was previously showing a white flash. No extra Activity, no artificial delay."
12. What is StrictMode and how do you use it to catch performance violations?
StrictMode monitors your app at runtime for performance violations — disk reads on the main thread, network calls, memory leaks, and more. It's the easiest way to catch performance anti-patterns before they reach users.
// Enable in Application.onCreate() — debug builds only! class MyApp : Application() { override fun onCreate() { super.onCreate () if (BuildConfig.DEBUG) { // Thread policy — main thread violations StrictMode.setThreadPolicy ( StrictMode.ThreadPolicy.Builder() .detectDiskReads () // file read on main thread .detectDiskWrites () // file write on main thread .detectNetwork() // network on main thread . detectCustomSlowCalls () // calls to beginSection() .penaltyLog () // log to Logcat .penaltyDeath() // crash app — can't ignore . build () ) // VM policy — object/resource violations StrictMode.setVmPolicy ( StrictMode.VmPolicy.Builder() .detectLeakedSqlLiteObjects () // unclosed Cursors .detectLeakedClosableObjects() // unclosed streams . detectActivityLeaks() // leaked Activities . detectLeakedRegistrationObjects() // unregistered receivers . penaltyLog() . penaltyDeath() . build () ) } } } // Custom slow call detection: StrictMode.noteSlowCall("expensiveOperation") // flags this as a slow call violation // Temporarily suppress for intentional main-thread operations: val old = StrictMode. allowThreadDiskReads () try { prefs.getString ("key", null) } // unavoidable SharedPrefs read finally { StrictMode.setThreadPolicy (old) }
StrictMode operates at the thread and VM level. ThreadPolicy detects violations on specific threads — primarily the main thread — including disk reads, disk writes, network access, slow calls, and custom slow code paths you annotate with StrictMode.noteSlowCall(). VmPolicy detects VM-wide issues regardless of thread: leaked SQLiteConnection, Cursor, InputStream, CloseableReference, and Activity instances; cleared but not closed CloseGuards; and untagged network sockets. Together they catch the two most common sources of production ANRs and memory leaks at development time, when they are cheap to fix.
The penalty configuration determines what StrictMode does when a violation is detected. In development, use penaltyDialog() to show an alert on screen (impossible to miss) or penaltyDeath() to crash immediately (forces the violation to be fixed). In CI builds, use penaltyLog() to write to Logcat and penaltyDropBox() to store in the system drop box for automated analysis. Never enable StrictMode in release builds — the overhead is significant and some policies (like detecting cleared references) are intrusive. The canonical pattern is if (BuildConfig.DEBUG) { StrictMode.setThreadPolicy(...); StrictMode.setVmPolicy(...) } at the start of Application.onCreate().
The most valuable StrictMode violation to fix is disk access on the main thread. Every SharedPreferences commit(), every synchronous Room query, every file read on the main thread shows up as a ThreadPolicy violation. Some are obvious — a developer forgot withContext(Dispatchers.IO). Others are hidden: the first access to SharedPreferences loads the entire XML file synchronously, which triggers a disk-read violation even though no explicit disk call was written. The fix is to initialise SharedPreferences (or DataStore) on a background thread before the main thread first reads from it. Similarly, TypedArray.recycle() not being called after obtainStyledAttributes() triggers a VmPolicy leak violation — a common oversight in custom View implementations.
- ThreadPolicy: catches main-thread disk/network/custom slow calls — the ANR prevention tool
- VmPolicy: catches leaked Cursors, streams, Activities, and unregistered receivers
- penaltyDeath: crashes the debug build — violations can't be ignored or forgotten
- Debug only: StrictMode has overhead — wrap in BuildConfig.DEBUG, never ship to production
- allowThreadDiskReads: temporary bypass for intentional main-thread operations — always restore
"StrictMode with penaltyDeath is the single most effective performance tool for development. It turns invisible performance anti-patterns into crashes that block you from moving on. Every new Android project should have StrictMode enabled from day one — finding a disk read on the main thread on day 100 is much harder than on day 1."
13. Scenario: How do you measure and improve Jetpack Compose rendering performance?
Compose performance issues come from unnecessary recomposition — composables recomposing when their inputs haven't changed. The tools are the Recomposition Counter in Layout Inspector, stability annotations, and derivedStateOf.
// Tool 1: Layout Inspector — Recomposition Counter // Android Studio → Layout Inspector → Recomposition tab // Shows: how many times each composable recomposed // Red numbers = recomposing too frequently // Tool 2: Composition Tracing (Android 12+) // implementation("androidx.compose.runtime:runtime-tracing:1.0.0") // Enables Compose function names in Perfetto traces // COMMON FIX 1: Unstable lambdas causing recomposition // ❌ New lambda created on every recomposition of parent ParentComposable { ChildComposable(onClick = { doSomething() }) // new lambda each time! } // ✅ Remember the lambda val onClick =remember { {doSomething () } } ChildComposable(onClick = onClick) // COMMON FIX 2: Unstable class — mark @Stable // ❌ Regular class — Compose doesn't know if it's changed class User(val name: String, val score: Int) // ✅ data class — Compose infers stability via equals() data class User(val name: String, val score: Int) // ✅ @Stable annotation — tell Compose this is stable manually @Stable class User(val name: String, val score: Int) // COMMON FIX 3: derivedStateOf — avoid unnecessary recomposition val listState =rememberLazyListState () // ❌ Recomposes on EVERY scroll event val showFab = listState.firstVisibleItemIndex > 0 // ✅ Only recomposes when the boolean VALUE changes val showFab byremember {derivedStateOf { listState.firstVisibleItemIndex > 0 } } // COMMON FIX 4: key() in LazyColumn — stable recomposition LazyColumn { items(products, key = { it.id }) { product -> // ✅ stable key ProductCard(product) } }
Compose's recomposition model means performance profiling requires different tools than the View system. The primary indicator of a performance problem is unnecessary recomposition — a composable re-running its body when its inputs haven't meaningfully changed. Enable the Layout Inspector's "Show recomposition counts" in Android Studio to see a live overlay of recomposition counts on each composable. A composable showing recomposition counts in the hundreds during a simple scroll is a red flag. The fix is almost always one of: making the state reads more granular, using derivedStateOf to memoise computed values, or marking a class as @Stable so the compiler knows it can skip recomposition when instances compare equal.
Lambda stability is the most common source of unexpected recomposition. When you pass a lambda to a composable — onClick = { viewModel.doSomething() } — Compose checks whether the lambda has changed since the last composition. Lambdas that capture unstable values (a non-stable class, a non-val reference) are considered changed on every recomposition of the parent, which forces every child composable receiving that lambda to recompose even if nothing else changed. The fix is to hoist the lambda creation: define it once in the ViewModel or pass a function reference rather than a lambda literal at the call site. The Compose Compiler Report (generated with -P plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=...) lists every unstable class and every lambda that causes instability — read it before optimising.
The Macrobenchmark library with CompilationMode.None gives you reproducible baseline measurements for Compose startup and frame timing. Run FrameTimingMetric during a scrolling interaction to capture the 50th, 90th, and 99th percentile frame times on a real device under realistic conditions. The 99th percentile is the most important for user experience — it represents the worst frames the user sees, not the average. Profile on a mid-range device (Pixel 5 or equivalent) rather than a flagship — the performance gap between your development device and the device your median user has is typically 3–5x, and issues invisible on a Pixel 8 are severe jank on a mid-range phone.
- Recomposition Counter: Layout Inspector shows how often each composable recomposes — find hotspots
- Unstable lambdas: new lambda on each recomposition = child always recomposes — use remember
- data class stability: Compose uses equals() for data classes — stable by default
- derivedStateOf: converts frequently-changing state into a derived value — only recomposes on value change
- LazyColumn key: stable keys prevent full-list recomposition when one item changes
"derivedStateOf is the most impactful Compose performance fix for scrolling UIs. A LazyColumn with 1000 items firing a recomposition on every scroll pixel is common — listState.firstVisibleItemIndex changes 60 times per second. Wrapping the FAB visibility in derivedStateOf means the FAB composable only recomposes when it actually needs to show or hide — twice per interaction instead of 60 times per second."
14. What is the Android Performance class? How does it help you write adaptive performance code?
Performance class (PerformanceClass) is an API introduced in Android 12 that categorises devices into tiers. You can query the tier and adapt your app's feature set — higher quality effects on flagship phones, simpler UI on low-end devices.
// Query device performance class val perfClass = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { Build.VERSION.MEDIA_PERFORMANCE_CLASS // API 31+ } else { 0 // not available — treat as low-end } // Performance classes: // 0 = no class (older/low-end devices) // 30 = Android 12 baseline performance (API 30 requirements) // 31 = Android 12 L performance // 33 = Android 13 performance // Adapt features based on performance class when { perfClass >= 33 -> { // Flagship — enable all effectsenableBlurEffects ()enableParticleAnimations ()useHighResAssets () videoPlayer.setQuality (Quality.HD_1080p) } perfClass >= 30 -> { // Mid-range — balancedenableBasicAnimations () videoPlayer.setQuality(Quality.HD_720p) } else -> { // Low-end — minimal effects disableAnimations () videoPlayer.setQuality (Quality.SD_480p) } } // Alternative: check RAM and CPU cores directly val ram = ActivityManager.MemoryInfo() val am = context.getSystemService (ActivityManager::class.java) am.getMemoryInfo (ram) val isLowRam = ram.totalMem < 2L * 1024 * 1024 * 1024 // < 2GB RAM val cpuCores = Runtime.getRuntime ().availableProcessors () // isLowRamDevice() — system-level flag val isLowRam = am.isLowRamDevice() // Go edition devices and old phones
The Android Performance class (defined in androidx.core:core-performance) is a standardised rating system that assigns devices a score based on their hardware capabilities. Performance class 12 (Android 12) guarantees a minimum set of hardware capabilities — camera resolution, codec support, memory — that apps can rely on without querying individual device properties. Performance class 13 adds further guarantees for Android 13. The DevicePerformance API returns a score of 10 (baseline), 11, 12, or 13, letting you branch your code to deliver a richer experience on capable devices without degrading the experience on lower-end hardware.
Adaptive performance is the practical application: deliver different quality tiers based on the device's performance class. A camera app might enable 4K video recording only on class 12+ devices. A game might enable high-quality shaders on class 13 and fall back to simplified rendering on class 10. A social app might load higher-resolution images or enable video autoplay on class 12+ while defaulting to static thumbnails on class 10. This approach is preferable to querying ActivityManager.isLowRamDevice(), which only identifies the extreme low end, or relying on manufacturer/model strings, which are fragile and incomplete.
Beyond the Performance class API, adaptive performance also means responding dynamically to current device state rather than just capability. The PowerManager.THERMAL_STATUS_* API (available from Android 10) notifies your app when the device is throttling due to heat — a common issue during sustained GPU-intensive work on mid-range devices. On THERMAL_STATUS_SEVERE, reduce frame rate, lower rendering quality, or pause background work to prevent the OS from more aggressively throttling your process. Similarly, BatteryManager.EXTRA_LEVEL and PowerManager.isPowerSaveMode() let you reduce polling frequency and background sync when the user is in battery saver mode — showing awareness of system state rather than blindly consuming resources.
- PerformanceClass: standardised device tier — no more RAM/CPU heuristics
- Adaptive features: blur effects, particle animations, video quality scaled to device capability
- isLowRamDevice(): system-set flag for Go and very low-end devices — disable heavy features entirely
- Memory check: totalMem < 2GB → reduce texture quality, limit concurrent loads
- Available processors: more cores → more background parallelism is safe
"PerformanceClass is the right way to segment devices in 2025 — better than RAM thresholds or brand heuristics. A MediaPerformanceClass of 33 guarantees specific camera, codec, and RAM capabilities. Use it for media-heavy features. Use isLowRamDevice() for the most aggressive low-end optimisation — those devices have 512MB-1GB RAM and can't run many things concurrently."
15. Scenario: How do you use the Microbenchmark library to measure the performance of a specific function?
Microbenchmark measures the performance of small units of code — JSON parsing, sorting algorithms, regex matching, custom view drawing. Unlike System.currentTimeMillis(), it handles JIT warmup, garbage collection, and clock accuracy automatically.
// androidTestImplementation("androidx.benchmark:benchmark-junit4:1.2.3") // Benchmark module build.gradle.kts — CRITICAL config android { defaultConfig { testInstrumentationRunnerArguments["androidx.benchmark.suppressErrors"] = "EMULATOR" } } // The benchmark test @RunWith(AndroidJUnit4::class) class JsonParsingBenchmark { @get:Rule val benchmarkRule = BenchmarkRule() private val json = """{"id":"1","name":"Alice","email":"alice@example.com"}""" @Test fun gsonParsing() { benchmarkRule.measureRepeated { Gson().fromJson (json, User::class.java) } } @Test fun kotlinSerializationParsing() { benchmarkRule.measureRepeated { Json. decodeFromString <User>(json) } } // Results (example): // gsonParsing: 42,310 ns/op // kotlinSerializationParsing: 18,240 ns/op ← 2.3x faster } // BenchmarkRule.measureRepeated handles: // ✅ JIT warmup: runs code until JIT-compiled (stable timing) // ✅ Multiple iterations: statistical accuracy // ✅ GC pauses: excluded from timing // ✅ Clock precision: nanosecond accuracy // Run benchmarks: // ./gradlew :benchmark:connectedAndroidTest // Results in: build/outputs/connected_android_test_additional_output/ // JSON report with min/median/max times // runWithTimingDisabled — exclude setup from timing benchmarkRule.measureRepeated { val data =runWithTimingDisabled {loadTestData () } // setup not countedprocessData (data) // only this is measured }
Microbenchmark (the androidx.benchmark:benchmark-macro library) runs your code in isolation on a real device with full ahead-of-time compilation, measuring wall-clock time with nanosecond precision. Unlike JUnit tests run on an emulator or in a Robolectric environment, Microbenchmark accounts for real hardware behaviour: CPU frequency scaling, cache effects, JIT warmup. The library automatically handles warmup iterations (discarded) and measurement iterations (averaged), and outputs median, minimum, and maximum timing. It also blocks the benchmark thread from being preempted by the OS scheduler during measurement, producing stable results that are comparable between runs and between devices.
The typical use cases are measuring a specific algorithm (JSON parsing, database query, image decoding), comparing two implementations before choosing one, and adding a performance regression test to CI. For a database query benchmark: set up the Room database with seed data in @Before, then inside the benchmarkRule.measureRepeated { } block call the DAO and collect the result. The benchmark reports timing for just the measured block, excluding setup. Commit benchmark results as baseline files — if a code change makes the benchmark more than 10% slower, CI fails. This catches performance regressions before they reach production rather than discovering them in user reviews.
Macrobenchmark (benchmark-macro) is the higher-level counterpart for measuring user-journey performance: startup time, scroll frame timing, and interaction latency. It drives the app through a UiAutomator script and measures system-level metrics using Perfetto under the hood. The key advantage over manual profiling is reproducibility: the same test on the same device produces stable results you can track over time. Use StartupTimingMetric for cold/warm start benchmarks, FrameTimingMetric for scroll and animation benchmarks, and TraceSectionMetric to measure custom-labelled code sections added with trace("my_section") { }. Run Macrobenchmarks on a physical device in release build configuration — debug builds are slower by definition and produce misleading results.
- BenchmarkRule: handles JIT warmup, GC exclusion, and statistical accuracy automatically
- nanosecond precision: far more accurate than System.currentTimeMillis() for small operations
- runWithTimingDisabled: exclude test setup from measurements — only measure the target code
- Real device required: benchmarks on emulators are unreliable — run on physical device
- Use case: compare implementations, verify performance doesn't regress across releases
"Microbenchmark is how you make data-driven performance decisions. 'Kotlin Serialization is faster than Gson' is a claim — measuring both with BenchmarkRule turns it into a fact: '2.3x faster, 42µs vs 18µs per parse'. Use it when choosing between implementations, and add benchmark tests for performance-critical code so regressions are caught automatically in CI."
16. How do you reduce layout inflation time? What makes some layouts slower to inflate than others?
Layout inflation parses XML and instantiates View objects reflectively — it's one of the most expensive operations in the UI lifecycle. Reducing hierarchy depth, using ViewStub for conditional layouts, and async inflation all reduce its cost.
// What makes layout inflation slow: // 1. Deep nested hierarchy — each level multiplies measure passes // 2. Many Views — each requires reflection-based instantiation // 3. Large drawables resolved during inflation // 4. Complex ConstraintLayout with many constraints // FIX 1: Flatten hierarchy with ConstraintLayout // ❌ 4 nested LinearLayouts (each forces a measure pass) // ✅ Single ConstraintLayout with all views (one measure pass) // FIX 2: ViewStub — defer inflation of rarely-shown views // <ViewStub android:id="@+id/error_stub" // android:layout="@layout/error_view" // android:inflatedId="@+id/error_view" /> // Inflated only when needed: val stub =findViewById <ViewStub>(R.id.error_stub) stub.inflate () // ← only called when error occurs // ✅ error_view XML is NOT parsed during activity startup // FIX 3: AsyncLayoutInflater — inflate on background thread // implementation("androidx.asynclayoutinflater:asynclayoutinflater:1.0.0") AsyncLayoutInflater(context).inflate ( R.layout.fragment_home, parentView ) { view, resId, parent -> parent?.addView(view) // callback on main thread } // ✅ XML parsing happens on background thread // ✅ main thread not blocked during inflation // ❌ Views still added to hierarchy on main thread // FIX 4: Jetpack Compose — no XML inflation at all // Compose compiles to direct function calls — no reflection, no XML parsing // Composables are faster to "inflate" than equivalent XML views // Measure inflation time: Trace. beginSection ("HomeFragment.inflate") val view =layoutInflater .inflate (R.layout.fragment_home, parent, false) Trace.endSection () // Visible in Perfetto trace — compare before/after optimisations
Layout inflation is the process of parsing an XML file, creating a View object for each node, and setting attributes from the XML values. The cost scales with the number of nodes in the hierarchy — a flat layout with 10 Views inflates much faster than a deeply nested layout with 30 Views. The worst pattern is deep nesting of LinearLayout with weights, which causes multiple measurement passes per level. The three techniques to reduce inflation time are: (1) flatten with ConstraintLayout — a single level with constraints replaces three levels of nested layouts, (2) defer inflation with ViewStub — the stub is a zero-size placeholder that inflates its referenced layout only when explicitly triggered, perfect for error states and optional UI, (3) use Compose — Compose's compiler-generated code skips XML parsing entirely.
The AsyncLayoutInflater from androidx.asynclayoutinflater moves inflation off the main thread. This is valuable for RecyclerView items — you can pre-inflate item views during idle time and place them in the RecycledViewPool, eliminating inflation delay during fast scrolling. The constraint is that views inflated off the main thread cannot reference the View hierarchy (no parent access) and cannot use system services that require a main-thread context. Glide and Coil handle this correctly; custom Views that access the main thread in their constructor will crash. In practice, AsyncLayoutInflater is most useful for first-screen inflation — pre-inflating above-the-fold content while Application.onCreate() is still running.
In Compose, the equivalent of "expensive inflation" is expensive initial composition — a composable that does heavy work during its first composition. The fix is the same: move expensive work off the composition thread. Data fetching and transformations go in the ViewModel and arrive via StateFlow. Image loading goes through Coil's AsyncImage which handles everything on background threads. Inline computations that depend on large datasets should use remember { computeExpensiveValue() } to avoid recomputation on every recomposition. For lists, LazyColumn's item factory only composes items as they scroll into view, making the first composition cost proportional to the viewport size rather than the total list size.
- Nested hierarchies: each level multiplies layout measurement passes — flatten with ConstraintLayout
- ViewStub: placeholder that defers inflation until needed — error views, empty states
- AsyncLayoutInflater: parse XML on background thread — main thread unblocked during inflation
- Compose: no XML parsing or reflection — compiled directly to function calls, faster to start
- Trace.beginSection: label inflation in Perfetto — measure improvement before/after changes
"ViewStub is underused. An Activity with an error state, an empty state, and a loading state — all three layouts inflated at startup even though only one is ever shown. With ViewStub: only the initial layout inflates — error and empty stubs inflate on demand. For a complex error screen with 10 views, that's 10 View objects not created until actually needed."
17. Scenario: How do you profile and fix excessive garbage collection causing frame drops?
Excessive object allocation triggers the garbage collector frequently, which pauses all threads and causes frame drops. The solution is to identify allocation hotspots in the profiler and eliminate allocations in performance-critical paths.
// Detect GC pressure: // Logcat filter: "GC_" or "Dalvik" or "art" // "GC freed 1523K in 12ms" → GC is running frequently → allocation hotspot // Memory Profiler — Allocation Recording // Profiler → Memory → Record (Java/Kotlin allocations) → scroll list → stop // Sort by: "Count" → find which objects are created most // Sort by: "Size" → find which objects consume most memory // Common allocation hotspots: // 1. Boxing primitives in hot paths val scores: List<Int> =listOf (1, 2, 3) // boxes ints to Integer objects val scores: IntArray =intArrayOf (1, 2, 3) // ✅ no boxing, stack-friendly // 2. String concatenation in loops var result = "" for (item in items) { result += item.name // ❌ creates new String object every iteration } val sb = StringBuilder() for (item in items) { sb.append (item.name) // ✅ single object, append in place } // 3. Lambda closures creating objects fun doForEach(items: List<Int>) { items.forEach { value -> process(value) } // ❌ closure object per call } // In extremely hot paths: use for loop instead of forEach // 4. Object pools for frequently-created objects object RectPool { private val pool =mutableListOf <RectF>() fun acquire(): RectF = if (pool.isNotEmpty ()) pool.removeAt (0) else RectF() fun release(rect: RectF) { rect.setEmpty (); pool.add (rect) } } // Android itself uses Pools.SimplePool for this pattern // In custom View onDraw — reuse objects private val tempRect = RectF() // ✅ allocated once, reused every frame private val paint = Paint() // ✅ allocated once
Excessive garbage collection manifests as periodic frame drops with a regular cadence — not random jank but predictable pauses every few seconds during heavy activity. ART's concurrent garbage collector runs mostly in background threads, but it still requires a brief stop-the-world pause to scan roots and remap references. When the allocation rate is high enough to trigger GC several times per second, these pauses accumulate into visible stutter. The Memory Profiler's timeline shows GC events as downward spikes in the heap size graph — a saw-tooth pattern (heap grows, GC fires, heap shrinks, repeat) indicates allocation pressure that can be reduced.
The root cause is almost always object allocation inside a hot loop — a method called hundreds of times per frame. Common offenders: creating a new Paint object in onDraw() instead of caching it as a field, allocating a new lambda in a frequently-called function (each lambda invocation can create an object if it captures state), calling String.format() or string concatenation in a tight loop (each creates at least one intermediate String), and creating ArrayList instances as return values from methods called per frame. The Method Tracing profiler with allocation tracking on shows allocation counts per method — methods with high allocation counts in the hot path are the targets.
Object pooling is the primary fix for allocation pressure in performance-critical paths. The Android framework itself uses pools extensively: Message.obtain() reuses Message objects from a pool instead of allocating, MotionEvent.obtain() does the same. For custom heavy objects used in drawing code, maintain a small pool manually: a fixed-size array of pre-allocated objects with a free-list pointer. For less extreme cases, restructure the code to eliminate the allocation: pass a result object as a parameter rather than returning a newly-allocated one, use primitive arrays instead of boxed collections, and cache the results of computations that are pure functions of their inputs using remember in Compose or a hand-written cache field in a View's drawing code.
- GC log detection: "GC freed Xms in Yms" in Logcat — frequent entries = allocation hotspot
- Allocation recording: Memory Profiler shows which object types are created most frequently
- IntArray vs List<Int>: avoids boxing primitives to Integer objects in performance-critical code
- StringBuilder: reuse a single buffer for string concatenation — avoid N string objects
- Object reuse: allocate once as class fields; reset and reuse in onDraw, tight loops, game loops
"The golden rule for custom View performance: allocate ZERO objects in onDraw(). No Paint(), no RectF(), no String formatting. Allocate all objects as class-level fields in the View constructor and reuse them on every draw call. The garbage collector runs on the main thread — every GC pause is a potential dropped frame."
18. What is App Startup time on Play Console? How do you monitor and improve it using production data?
Play Console's Android Vitals tracks real user startup times across your entire user base — broken down by cold/warm/hot start, device tier, and country. This gives you production data that no local profiling can match.
// Android Vitals → Core Vitals → App Startup // Metrics shown: // • Slow cold start rate: % of cold starts taking > 5 seconds // • Slow warm start rate: % of warm starts taking > 2 seconds // • Slow hot start rate: % of hot starts taking > 1.5 seconds // Google's "bad behaviour" thresholds — exceeding them affects Play Store ranking // Segment data by: // • Device tier (low-end vs flagship) // • Android version // • Country // • App version (before/after a release) // Custom startup metrics via Firebase Performance // implementation("com.google.firebase:firebase-perf-ktx") class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate (savedInstanceState) val trace = Firebase.performance.newTrace ("app_startup_custom") trace.start ()setContent { AppTheme { AppContent() } } // Stop trace when content is actually ready viewModel.contentReady.observe (this) { ready -> if (ready) { trace.stop () // custom TTFD in Firebase dashboard } } } } // reportFullyDrawn() — feeds data to Play Console TTFD metric override fun onResume() { super.onResume () viewModel.isContentReady.collectLatest { ready -> if (ready)reportFullyDrawn () // signals Play Console: content is visible } } // Track improvement after baseline profile deploy: // Play Console → App Startup → filter by version // Before v2.1.0: slow cold start rate = 15% // After v2.1.0: slow cold start rate = 4% ← baseline profile effect
App Startup time in Play Console is measured from process creation to the first drawn frame, captured from real user devices via the Android Vitals framework. Unlike local profiling where you control the device and conditions, Play Console data represents the actual distribution across your entire user base — including users on 3-year-old mid-range phones, low-RAM devices, and phones where your app has been sitting cold in memory for days. The metric is broken down by percentile (p50, p75, p95) and by start type (cold, warm, hot). The p95 cold start time is the most actionable metric: it represents the worst experience for 5% of your users, typically on the slowest devices in your install base.
The Play Console flags apps as having "slow startup" when the p75 cold start exceeds 5 seconds or the p75 warm start exceeds 2 seconds. Crossing these thresholds triggers a "Slow app startup" warning in Android Vitals and can influence Play Store ranking and user sentiment. The metric correlates strongly with day-1 retention: users who experience a slow first launch are significantly more likely to uninstall immediately. The benchmark to aim for is p75 cold start under 2 seconds on mid-range hardware — achievable with lazy initialisation, Baseline Profiles, and moving Application.onCreate() work to background threads.
The reportFullyDrawn() API closes the gap between "first frame drawn" and "meaningful content visible". Without it, Play Console measures startup as complete when the first frame is drawn — even if that frame is a blank white screen or a loading spinner. Call reportFullyDrawn() after your critical content is on screen and interactive. This gives Play Console a more accurate signal for your actual user experience and gives you a meaningful metric to optimise. In Compose, this is typically called after the ViewModel's state transitions from Loading to Success and the content composable has been composed and drawn — use LaunchedEffect(isContentReady) { if (isContentReady) activity.reportFullyDrawn() }.
- Play Console Android Vitals: real user startup data — thousands of devices, real conditions
- Bad behaviour thresholds: >5s cold start affects Play Store ranking — monitor actively
- Segment by version: compare before/after a release — validate that your fixes actually helped users
- Firebase Performance custom traces: measure TTFD with content-ready granularity
- reportFullyDrawn(): signals Play Console when content is genuinely ready — not just first frame
"Production startup data from Play Console often reveals surprises. Low-end device users (which may be your largest segment in India and SE Asia) can have 3x worse startup times than your test device. The Android Vitals data segmented by device tier shows the real picture — where baseline profiles and deferred initialisation matter most."
19. Scenario: How do you detect and fix thread contention causing jank or ANR?
Thread contention happens when the main thread waits to acquire a lock held by a background thread — or when background threads compete for shared resources. It's invisible in normal profiling but shows up in System Trace as "lock wait" blocks.
// Detect in System Trace (Perfetto): // Main thread shows orange "sleeping" blocks → waiting for lock // Expand thread row → see "monitor-lock" events // Common contention scenarios: // 1. Main thread calling @Synchronized function object Cache { @Synchronized fun get(key: String): String? { /* expensive lookup */ return data[key] } } // Main thread calls Cache.get() → IO thread holds lock → main thread waits → jank // Fix: use concurrent data structures instead of synchronized private val cache = ConcurrentHashMap<String, String>() fun get(key: String) = cache[key] // ✅ lock-free reads with ConcurrentHashMap // 2. Mutex in coroutines — avoid holding across suspensions private val mutex = Mutex() // ❌ Holds mutex across a suspend point mutex.withLock {expensiveNetworkCall () // ❌ other coroutines blocked during network call } // ✅ Hold mutex only for the state update, not the network call val result =expensiveNetworkCall () // outside mutex mutex.withLock { cache[key] = result } // ✅ mutex only for state write // 3. Actor pattern — serialise access without locks val counterActor =actor <Int>(Dispatchers.Default) { var count = 0 for (delta in channel) { count += delta } } // Single coroutine owns the state — no lock needed // 4. StateFlow replaces synchronized shared mutable state private val _state = MutableStateFlow(AppState()) // All writes to _state go through update {} which is thread-safe _state.update { it.copy (isLoading = true) } // ✅ atomic CAS, no locks
Thread contention is one of the harder performance issues to diagnose because it doesn't show up as expensive CPU work — it shows up as threads waiting. In the Perfetto or System Trace timeline, a thread in the "Sleeping" state (shown in orange/brown depending on the tool) while holding a lock that the main thread is waiting on will block the main thread even though neither thread is doing heavy computation. The main thread appears idle, but it is actually blocked on a mutex. This manifests as random jank that doesn't correlate with any specific method's CPU cost — the smoking gun in the trace is the main thread blocked in a synchronized block or a Mutex.lock() call while another thread holds that lock.
The fix is to either eliminate the shared mutable state or move all mutation off the main thread. Kotlin's coroutine-based approach to this is the single-threaded ViewModel pattern: all state mutations happen on the main dispatcher (effectively single-threaded), so there is never contention for UI state. Background work produces immutable results that are posted to the main dispatcher via withContext(Dispatchers.Main). For truly shared mutable state between threads — a cache accessed by both network callbacks and UI reads — use Mutex with withLock { } in a suspend function rather than Java's synchronized: the coroutine suspends rather than blocking the thread, releasing the underlying thread to do other work while waiting.
Dispatcher pool exhaustion is a subtler form of contention. Dispatchers.IO has a thread pool capped at 64 threads by default. An app that launches 100 concurrent network requests will queue 36 of them, causing apparent latency that looks like network slowness but is actually scheduling delay. Diagnose this by looking at thread counts in the Memory Profiler — if you see 60+ IO threads during a burst, you are saturating the pool. The fix is to limit concurrency explicitly: use a semaphore, use Flow.flatMapMerge(concurrency = 4), or structure the work as a queue processed by a fixed-size dispatcher built with Dispatchers.IO.limitedParallelism(n).
- System Trace detection: orange "sleeping" blocks on main thread = waiting for a lock
- ConcurrentHashMap: lock-free reads in most cases — replace synchronized HashMap for caches
- Mutex scope: hold Mutex only for state mutation, not during the work that produces the value
- Actor pattern: single coroutine owns mutable state — no locks needed for sequential access
- StateFlow.update: atomic compare-and-set — thread-safe state updates without manual locking
"Thread contention is the hardest performance bug to diagnose — it doesn't show up as slow code in method traces, it shows as 'sleeping' on the main thread. Perfetto/System Trace is the only tool that makes this visible. Once identified, the fix is usually: (1) use lock-free data structures, (2) shrink the critical section, or (3) move the lock out of the main thread's call path entirely."
20. How do you optimise battery usage in Android? What APIs help you reduce power consumption?
Battery drain on mobile is primarily caused by radio wakeups (network and GPS), CPU wake locks, and frequent sensor polling. Writing battery-efficient code means batching network requests, using JobScheduler/WorkManager, and avoiding persistent wake locks.
// Battery drain causes (in order of impact): // 1. Cellular radio wakeups (expensive — 20-second tail time) // 2. GPS polling // 3. Partial wake locks held too long // 4. Frequent alarm firing // 5. Excessive CPU usage in background // FIX 1: Batch network requests — reduce radio wakeups // Instead of: analytics event per user action (10 radio wakeups) // Use: WorkManager batched upload every 30 min (1 radio wakeup) val workRequest = PeriodicWorkRequestBuilder<AnalyticsWorker>( repeatInterval = 30, TimeUnit.MINUTES ).setConstraints (Constraints(requiresNetwork = true)).build () // FIX 2: WorkManager constraints — run only when conditions met val constraints = Constraints.Builder() .setRequiredNetworkType (NetworkType.UNMETERED) // WiFi only .setRequiresCharging (true) // only when charging .setRequiresBatteryNotLow(true) // not when battery < 15% . build () // FIX 3: Doze mode compatibility // Android 6+ puts device in Doze when screen off + stationary + unplugged // Doze blocks: network, wake locks, alarms (except AlarmManager.setExactAndAllowWhileIdle) // WorkManager handles Doze automatically — don't use raw AlarmManager for periodic work // FIX 4: GPS — use lowest acceptable accuracy val request = LocationRequest.create ().apply { priority = Priority.PRIORITY_BALANCED_POWER_ACCURACY // city-level, uses WiFi interval = 60_000 // check every 60s, not every second } // PRIORITY_HIGH_ACCURACY uses GPS chip — 10x more power than BALANCED // FIX 5: Battery Health API — query device battery status val batteryStatus = IntentFilter(Intent.ACTION_BATTERY_CHANGED).let { filter -> context.registerReceiver (null, filter) } val isCharging = batteryStatus?.getIntExtra (BatteryManager.EXTRA_PLUGGED, -1) != 0 // Defer heavy work when not charging
Battery drain comes from four sources: CPU wakeups, network radio usage, GPS/sensor polling, and screen-on time. Your app contributes to all four through background work, polling loops, frequent network calls, and foreground services that prevent the screen from going off. The Android battery historian tool (available via adb bugreport) shows a timeline of which apps caused which wakeups — it is the primary diagnostic tool for battery issues identified in Play Console's Android Vitals "excessive wakeups" or "excessive background Wi-Fi scans" alerts.
WorkManager is the correct API for all deferrable background work. It respects Doze mode and App Standby — the system defers non-urgent WorkManager tasks during Doze, batching them into maintenance windows. Mark work as requiring network by adding a NetworkType constraint so the task only runs when connectivity is already active — it doesn't wake the radio. Use PeriodicWorkRequest with a 15-minute minimum interval rather than a foreground service with a polling loop; WorkManager batches tasks from multiple apps into the same execution window, dramatically reducing overall system wakeups. Set setExpedited(true) only for work that genuinely cannot wait — overusing expedited tasks defeats the batching optimisation.
Location is the highest-cost sensor by far. GPS keeps the radio powered and the CPU partially awake for the duration of a location fix. Use the minimum accuracy that your use case requires: Priority.PRIORITY_BALANCED_POWER_ACCURACY uses network and cell tower triangulation (50m accuracy) at a fraction of the power cost of GPS, and is sufficient for most consumer use cases. Request location updates with the longest interval you can tolerate — LocationRequest.Builder(10_000) (10 seconds) rather than 1 second. Set setMinUpdateDistanceMeters() to suppress updates when the device hasn't moved. In the background, use Geofencing instead of polling — it delivers a callback when the device crosses a boundary, consuming power only at transition time rather than continuously.
- Radio tail time: cellular radio stays awake 20s after a network call — batch to minimise wakeups
- WorkManager constraints: declare when work should run — system defers to optimal time
- Doze mode: system blocks background activity — WorkManager is Doze-compatible, raw alarms are not
- GPS accuracy tiers: BALANCED uses WiFi/cell triangulation — 10x less power than GPS chip
- isCharging check: defer heavy sync/backup operations until the device is plugged in
"Battery optimization interview: every network call wakes the cellular radio and it stays awake for ~20 seconds (the 'tail time'). 100 single-event analytics requests = 100 radio wakeups = ~33 minutes of radio-on time. Batch them into 5 requests = 5 wakeups = ~1.7 minutes. That's the difference between your app draining the battery and being a good citizen."
21. Scenario: How do you use Perfetto to diagnose a complex jank issue that doesn't show up in simpler tools?
Perfetto is the most powerful Android performance tool -- it captures kernel-level events, CPU scheduling, all thread activity, and custom trace events in a unified timeline. Use it when Profiler shows a slow frame but CPU method traces don't explain why -- the answer is usually thread preemption, lock contention, or a slow binder call to a system service.
// Capture via adb (production-like conditions, no Studio overhead) // adb shell perfetto -o /data/misc/perfetto-traces/trace.perfetto-trace \ // -t 10s sched freq idle am wm gfx view dalvik binder_driver // adb pull /data/misc/perfetto-traces/trace.perfetto-trace // Open in: https://ui.perfetto.dev // Add custom trace events -- visible in Perfetto timeline import androidx.tracing.trace trace("ProductList.loadFromDb") { dao.getProducts() } // Async trace -- for operations that span threads Trace.beginAsyncSection("ImageLoad", cookie) // ... work on another thread ... Trace.endAsyncSection("ImageLoad", cookie)
Perfetto is Google's system-level tracing tool that replaced Systrace for Android 10+. It captures traces from multiple sources simultaneously: the kernel scheduler, hardware counters, atrace events, and custom app trace points — all in a single timeline with nanosecond resolution. The Perfetto UI (ui.perfetto.dev) renders the trace interactively: you can zoom into individual frames, see every thread's state at any point in time, and search for specific trace events by name. For complex jank that isn't reproducible in Android Studio (multi-process interactions, system service delays, Binder call latency), Perfetto is the only tool with enough visibility.
The most powerful Perfetto workflow for jank investigation is: (1) identify a janked frame in the "Actual Timeline" lane — it shows red for dropped frames, green for on-time frames, (2) click the red frame to see its duration and expand the frame's slice in all thread lanes, (3) look at the main thread and RenderThread slices for that frame — the longest slice is where time was spent. If the main thread was idle and the RenderThread was long, the bottleneck is GPU command complexity (overdraw, complex shaders). If the main thread was long, expand its slices to find the expensive method. (4) Use the "Flow" arrows to follow Binder calls across process boundaries — a slow system service call often appears as the main thread waiting for an IPC response.
Custom trace sections make your app's code visible in Perfetto. Wrap any code section you want to measure with trace("descriptive_name") { } from the Jetpack Tracing library — this emits an atrace event visible in Perfetto's timeline. Add trace sections around your critical paths: ViewModel data loading, repository cache checks, custom View drawing operations, and heavy computations. When a Perfetto trace shows a jank frame but the system-level calls don't explain the full duration, your custom trace sections will show exactly which application code consumed the remaining budget. This technique is how Google's internal performance teams identify regressions in first-party apps before they ship.
- Perfetto reveals what CPU Profiler misses: thread preemption (UI thread de-scheduled by another thread), lock contention, binder IPC latency
- Frame analysis workflow: find a red frame bar → zoom into its time range → check main thread and RenderThread activity → find what pushed it over 16ms
- Custom trace events: trace{} or Trace.beginSection() make your own code visible in the timeline alongside framework events
- Async traces: Trace.beginAsyncSection/endAsyncSection for operations that cross thread boundaries -- matched by a cookie ID
- Compose runtime tracing: implementation('androidx.compose.runtime:runtime-tracing') makes composable function names appear in Perfetto automatically
"The scenario where Perfetto is essential: 'scroll is janky but CPU profiler shows nothing slow.' Perfetto reveals: the main thread is actually idle during the jank, but RenderThread is waiting for a binder call to SurfaceFlinger. Or the main thread gets preempted by a high-priority background thread. These are impossible to diagnose without a system-level tracer."
22. What is the Android Vitals dashboard in Play Console? Which metrics matter most?
Android Vitals in Play Console tracks real user performance data -- crashes, ANRs, startup times, and frame rates -- across your entire user base. Google defines 'bad behaviour' thresholds; apps that exceed them get a lower search ranking and may show a warning label before install.
// Android Vitals thresholds (exceeding = "bad behaviour") // Crash rate: > 1.09% of daily active users // ANR rate: > 0.47% of daily active users // Slow cold start: > 25% of cold starts taking > 5 seconds // Slow frames: > 50% of frames taking > 16ms // Frozen frames: > 0.1% of frames taking > 700ms // Report fully drawn -- feeds TTFD into Play Console startup metrics reportFullyDrawn() // Firebase Performance -- custom traces for your own flow timing val trace = Firebase.performance.newTrace("checkout_flow") trace.start() // ... checkout completes ... trace.stop()
Android Vitals tracks six core stability and performance metrics. ANR rate: the percentage of daily active users who experience at least one ANR. Crash rate: the percentage experiencing at least one crash. Slow rendering: frames delivered slower than 16ms. Frozen frames: frames taking longer than 700ms — the user experiences a visible freeze. Slow startup: p75 cold start over 5 seconds or warm start over 2 seconds. Excessive wakeups: more than 10 wakeups per hour in the background. Play Console highlights when any metric crosses the "bad behaviour" threshold, which can trigger a rating demotion in search results.
The most impactful metric to track closely is the ANR rate, because ANRs are directly visible to users and Google Play's algorithm weighs them heavily. The Play Console ANR dashboard breaks down ANRs by type (Input Dispatching, Service, Broadcast Receiver), affected Android version, device model, and OS version. Filtering by "top affected clusters" groups ANRs by their stack trace, showing you the ten most impactful unique ANR causes rather than individual reports. A single widespread ANR cause — such as a synchronous database call triggered by a push notification handler on a specific Android version — can account for 80% of all ANR reports and is the highest-leverage thing to fix.
Frozen frames (over 700ms) deserve special attention beyond slow rendering because they correlate most strongly with user-perceived quality. A user can tolerate occasional 20–30ms frames — they notice a slight hitch. A 700ms freeze feels like the app is broken and triggers uninstalls. Frozen frames are almost always caused by: lock contention between the main thread and a background thread, a very large Binder transaction that blocks the main thread waiting for a system service response, or an unexpectedly expensive onDraw() triggered by a layout change mid-animation. The Perfetto trace for a frozen frame typically shows the main thread in the "Sleeping" state for the entire duration — it is not doing work, it is waiting.
- Crash rate bad threshold: > 1.09% of daily users -- exceeding this hurts Play Store ranking
- ANR rate bad threshold: > 0.47% -- any thread blocking the main thread for > 5s triggers this
- Slow cold start bad threshold: > 25% of cold starts taking > 5 seconds -- most impactful on low-end devices
- Frozen frames: > 700ms frames -- these are complete UI hangs, not just jank -- Play tolerates almost zero
- Segment by version: compare vitals before/after each release to pinpoint which version caused a regression
"Android Vitals data is segmented by device tier — your premium device metrics look fine, but 60% of your users are on low-end devices where startup takes 4 seconds and crashes happen 5% of the time. Filter by 'Low RAM devices' in Vitals. In markets like India, this segmentation is where the most impactful user experience issues hide."
23. Scenario: Your app's memory grows over time during a long session — a slow memory leak. How do you find it?
A slow leak doesn't crash immediately — memory grows gradually until the system kills the app. Detecting it requires a heap dump comparison workflow — capture at start, use the app for 30 minutes, capture again, compare the two dumps.
// Slow leak symptoms: // Memory Profiler shows steadily growing heap over time // App eventually gets OOM killed (no crash dialog — just closes) // LeakCanary may not catch it (leak source may not be an Activity/Fragment) // Workflow: Heap Dump Comparison // 1. Launch app → capture heap dump A (baseline) // 2. Use the app for 30 min — navigate all screens, scroll lists // 3. Force GC (press GC button in profiler) // 4. Capture heap dump B // 5. Compare A vs B — look for classes with growing instance count // Android Studio Heap Dump Analysis: // Open dump → filter by "Allocations per class" // Sort by count descending // Look for: growing Bitmap/byte[] counts → image cache leak // growing custom class counts → model object leak // growing Handler/Runnable → message queue leak // Common slow leak: unbounded cache object UserCache { private val cache = HashMap<String, User>() // ❌ grows forever fun put(id: String, user: User) = cache.put (id, user) } // Fix: LruCache or WeakReference cache private val lruCache = LruCache<String, User>(100) // ✅ max 100 entries // Handler leak (classic slow leak) class MyActivity : Activity() { private val handler = object : Handler(Looper.getMainLooper ()) { override fun handleMessage(msg: Message) { /* uses Activity fields */ } } override fun onDestroy() { handler.removeCallbacksAndMessages (null) // ✅ remove pending messages super.onDestroy () } }
Gradual memory growth (a "memory slope" rather than a sudden spike) is the signature of a slow leak — something accumulates with each user action or each new screen opened. The diagnosis starts with the Memory Profiler: trigger the suspect action 10–20 times while watching the heap size graph. If the heap grows by roughly the same amount each time and does not return to baseline after GC, you have a collection-based leak. Force a GC in the profiler, capture a heap dump, and look for Collections (Lists, Maps, Sets) or arrays with unexpectedly large sizes. A HashMap that grows with each screen navigation and is never cleared is the most common pattern.
Retained Fragment and ViewModel instances are a common source of gradual growth in apps with complex navigation. If a ViewModel is scoped to the Activity rather than to its Fragment, it survives Fragment back-stack pops and accumulates data for every destination the user visited. Check the heap dump for multiple instances of the same ViewModel class — there should only be one live instance per scope. Similarly, a LiveData or Flow that observes a data source and caches all emissions in a list without a size limit will grow monotonically. Add .take(n) or replace with a bounded data structure.
The LeakCanary heap dump analysis and the Memory Profiler's "Comparison" feature (comparing two heap dumps taken before and after the suspect action) are complementary tools. The comparison view shows exactly which object types increased — if MessageData went from 50 instances to 150 instances, you know 100 MessageData objects accumulated. Cross-reference with their retained size: if those 100 extra instances retain 10MB total, that explains the memory slope. Track heap size over time in production using Firebase Performance's custom metrics: FirebasePerformance.getInstance().newTrace("heap_check").apply { putMetric("heap_mb", Runtime.getRuntime().totalMemory() / 1_048_576); stop() } logged periodically gives you a fleet-wide memory trend chart.
- Heap dump comparison: baseline vs 30-minute-later dump — growing class counts reveal leaks
- Unbounded cache: HashMap with no eviction policy grows forever — use LruCache
- Handler leak: pending messages in MessageQueue hold references — always removeCallbacksAndMessages in onDestroy
- Memory Profiler timeline: steady upward slope = slow leak — spiky but returning = just GC pressure
- LeakCanary limitation: primarily catches Activity/Fragment leaks — custom slow leaks need manual investigation
"The heap dump comparison technique is the only reliable way to find slow leaks. The key: capture after forcing GC (not before) — this removes all short-lived objects so only truly retained objects remain. Compare instance counts between dumps. A class growing from 50 to 800 instances over 30 minutes is your culprit, even if each instance is small."
24. What is the Tracing library and how does it integrate with system profiling tools?
The AndroidX Tracing library (androidx.tracing) provides a Kotlin-idiomatic API to add custom trace events to your code. These appear as labelled blocks in Perfetto, Android Studio's System Trace, and Macrobenchmark outputs — making your own code sections visible alongside framework events.
// implementation("androidx.tracing:tracing:1.1.0") // implementation("androidx.tracing:tracing-ktx:1.1.0") import androidx.tracing.trace import androidx.tracing.Trace // Kotlin DSL — cleanest API trace("UserRepository.fetchUsers") { dao.getUsers () // appears in Perfetto as "UserRepository.fetchUsers" block } // Manual begin/end (for Java or complex async cases) Trace.beginSection ("parseProductList") try {parseJson (jsonString) } finally { Trace.endSection() // must always call endSection — even on exception } // Async tracing — for operations that span threads val cookie = 42 // unique ID for this trace event Trace. beginAsyncSection ("ImageLoad", cookie) // ... work happens on another thread ... Trace.endAsyncSection ("ImageLoad", cookie) // matches on cookie, not thread // Track values over time Trace.setCounter ("active_coroutines", activeCount) // visible as graph in Perfetto // Compose function tracing — automatic with runtime-tracing // implementation("androidx.compose.runtime:runtime-tracing:1.0.0") // All @Composable functions appear by name in Perfetto traces // No code changes needed — the compiler plugin adds trace calls // Overhead: trace events have ~microsecond cost // Keep trace names short — they're stored as strings // Use in performance-critical paths sparingly // Remove from production if tracing extremely hot paths
The Jetpack Tracing library (androidx.tracing:tracing-ktx) is a thin wrapper around Android's Trace.beginSection()/endSection() API, adding Kotlin-idiomatic syntax and ensuring sections are always properly closed even if an exception is thrown. The trace("section_name") { block } extension function wraps any code block in a named trace section visible in Perfetto, Android Studio Profiler, and Systrace. Unlike adding log statements, trace sections have near-zero overhead when tracing is not active — the check for an active trace session is a single volatile read. This makes it safe to add trace sections in production code, not just debug builds.
The integration with Perfetto is what makes the library powerful. When you record a Perfetto trace — either via the adb command line, the Android Studio Profiler, or the developer options in-app tracing — your custom sections appear in the app's main thread lane (or whichever thread the code runs on) as labelled slices alongside system events. You can see your trace("load_feed_from_cache") slice sitting inside a frame, compare its duration against the frame budget, and immediately know if cache loading is consuming more than its allowable share of the 16ms window. This precision is impossible with log timestamps, which don't align to frame boundaries.
Compose's Recomposer and the Compose runtime already emit internal trace sections visible in Perfetto — you can see individual composable functions labelled by name in traces when the Compose tracing library (androidx.compose.runtime:runtime-tracing) is included. This means a Perfetto trace of a Compose screen shows which composable functions ran, how long each took, and whether they recomposed unexpectedly. Combine this with your own trace() sections around ViewModel state computations and repository calls to get end-to-end visibility from data fetch to pixel on screen. This level of instrumentation is what separates teams that fix performance issues quickly from teams that guess.
- trace{}: Kotlin lambda DSL — automatically handles beginSection/endSection even on exception
- beginAsyncSection: trace operations that cross thread boundaries — matched by cookie not thread
- setCounter: plot a value over time in Perfetto — track active coroutines, cache size, queue depth
- Compose runtime-tracing: zero-code composable function names in traces — compiler plugin adds them
- Microsecond cost: trace overhead is tiny — safe in most code, but skip in innermost tight loops
"Custom trace events are what separate professional performance debugging from guesswork. Without them, Perfetto shows framework calls but your code is a black box. With trace{} around your repository calls, database queries, and parsing operations, you see exactly which of YOUR code is responsible for a slow frame. Add them once, they pay off every time you profile."
25. Scenario: Conduct a performance code review. What 10 things do you check?
A systematic performance code review catches the anti-patterns that accumulate into user-visible slowness — before they reach production. Each item maps to a real performance failure mode.
// 1. ❌ Network/DB/File IO on main thread fun onCreate() { db.userDao().getUserBlocking () } // ❌ ANR risk // ✅ suspend functions + coroutines // 2. ❌ View Binding not cleared in Fragment.onDestroyView private var binding: FragmentHomeBinding? = null // ❌ missing null in onDestroyView // ✅ binding = null in onDestroyView() // 3. ❌ Object allocation inside onDraw() override fun onDraw(canvas: Canvas) { val p = Paint() } // ❌ GC every frame // ✅ Paint as class field, allocated once // 4. ❌ notifyDataSetChanged() instead of DiffUtil adapter.notifyDataSetChanged() // ❌ rebinds everything even if nothing changed // ✅ ListAdapter with DiffUtil.ItemCallback // 5. ❌ Static reference to Activity or Context companion object { var ctx: Context? = null } // ❌ Activity leak // ✅ Use ApplicationContext for singletons // 6. ❌ GlobalScope coroutine in Fragment/Activity GlobalScope. launch { api.getData () } // ❌ never cancelled // ✅ viewLifecycleOwner.lifecycleScope or viewModelScope // 7. ❌ Unnecessary recomposition (Compose) LazyColumn { items(list) { ItemCard(it) } } // ❌ no key — unstable // ✅ items(list, key = { it.id }) { ItemCard(it) } // 8. ❌ Bitmap decoded at full resolution BitmapFactory.decodeFile (path) // ❌ 48MB for a 12MP photo // ✅ Use Coil/Glide with automatic downsampling // 9. ❌ Registered listener never unregistered locationManager.requestUpdates (listener) // ❌ in onResume with no onPause removal // ✅ Remove in corresponding lifecycle callback // 10. ❌ Heavy computation on main thread during startup class MyApp : Application() { override fun onCreate() { analytics.initialize () } // ❌ 400ms synchronous init } // ✅ Defer non-critical SDKs to background thread or after first frame
A performance code review starts with the UI thread boundary. Every line of code that runs on the main thread is a candidate for scrutiny: is this operation bounded in time? Database queries must be suspend functions dispatched on IO. Network calls must never appear without a coroutine context. SharedPreferences.commit() is a red flag — change to apply() or migrate to DataStore. BitmapFactory.decodeFile() in any UI-related code path is a critical finding — replace with Coil or Glide. Any use of runBlocking in production code (outside tests) is an automatic rejection — it blocks the calling thread, which on the main thread causes ANRs.
The second pass covers allocations in hot paths. onDraw() must never instantiate objects — no new Paint(), no string formatting, no collection creation. RecyclerView's onBindViewHolder() should contain no computation, only assignment. Any lambda defined inside a method that is called per-frame deserves inspection — if the lambda captures state, it allocates an object on every call. Custom View implementations that override onMeasure() or onLayout() should not allocate or call methods that allocate. These checks prevent GC pressure that accumulates into periodic frame drops that are difficult to reproduce on a development device.
The third pass is architectural: check for polling, check for unbounded caches, and check for missing lifecycle cancellation. A while(true) { delay(1000); fetchData() } loop launched in GlobalScope runs forever, wakes the CPU every second, and is never cancelled. A HashMap used as a cache with no eviction policy grows until OOM. A BroadcastReceiver registered in onStart() without a corresponding unregister in onStop() leaks the receiver and delivers callbacks to a destroyed Activity. A Flow.collect { } called directly in a composable without collectAsStateWithLifecycle() keeps the flow active in the background even when the app is not visible, preventing battery optimisations. Each of these is a systematic, repeatable checklist item — not an edge case.
- IO on main thread: ANR risk — everything DB/network/file must be on a background thread
- Binding leaks: Fragment outlives its View — null binding in onDestroyView always
- onDraw allocations: GC pauses on every frame — allocate zero objects in onDraw
- notifyDataSetChanged: rebinds entire list — DiffUtil only rebinds changed items
- Static Context: Activity memory leak — use Application context in long-lived singletons
"In a performance code review I check stability (leaks, IO on main thread, unregistered listeners) before rendering performance (onDraw allocations, DiffUtil, recomposition). A memory leak causes an eventual crash — more user impact than a janky scroll. Then rendering, then startup. Triage by user impact."
26. What is the difference between memory pressure, low memory, and OOM? How does Android handle each?
Android has a tiered memory management system. As RAM fills up, the OS progressively terminates background processes before eventually sending low-memory callbacks to foreground apps. Understanding these tiers helps you write apps that survive memory pressure without crashing.
// Android memory pressure tiers (low → critical): // 1. Normal — plenty of RAM, all processes running // 2. Moderate — OS starts killing cached processes (user won't notice) // 3. Low — OS killing background processes aggressively // 4. Critical — only foreground app + critical services alive // 5. OOM — foreground app itself killed (rare) // ComponentCallbacks2 — respond to memory pressure class MyApp : Application() { override fun onTrimMemory(level: Int) { super.onTrimMemory (level) when (level) { ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN -> { // App went to background — release UI caches imageCache.evictAll () } ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW, ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> { // Foreground but system is low — release non-essential memory thumbnailCache.evictAll () prefetchedData.clear () } ComponentCallbacks2.TRIM_MEMORY_COMPLETE -> { // Background, about to be killed — release everythingreleaseAllCaches () } } } } // ActivityManager — query current memory state val info = ActivityManager.MemoryInfo() (context.getSystemService (ActivityManager::class.java)).getMemoryInfo (info) // info.availMem — currently available RAM in bytes // info.totalMem — total RAM on device // info.lowMemory — true if system is in low memory state // info.threshold — RAM level that triggers low memory callbacks
Memory pressure is a continuum, not a binary state. When available RAM on the device drops, Android's Low Memory Killer (LMK) terminates background processes in order of their OOM adjustment score — cached processes die first, then background services, then the current app if nothing else frees enough memory. Your app receives onTrimMemory() callbacks as the device descends through pressure levels: TRIM_MEMORY_UI_HIDDEN when your UI goes to background, TRIM_MEMORY_RUNNING_MODERATE when the system is under moderate pressure, TRIM_MEMORY_RUNNING_CRITICAL when only a few background processes remain, and TRIM_MEMORY_COMPLETE when your process is about to be killed. Each level demands progressively more aggressive memory release.
Low memory is the system-wide state where available RAM has dropped below a threshold and the LMK is actively killing processes. Your app can detect this via ActivityManager.getMemoryInfo() — the MemoryInfo.lowMemory boolean flips to true and availMem drops below threshold. In this state, any new allocation your app makes competes with the kernel's need to keep essential system processes alive. OOM (OutOfMemoryError) is what happens when your app tries to allocate memory that Android cannot provide — the allocator throws rather than returning null. Unlike desktop JVMs where OOM is truly "no memory left", on Android it typically means your app's heap has hit its per-process cap (set by the manufacturer, typically 256–512MB) even though the device itself has RAM available.
The right response to each level differs. For TRIM_MEMORY_UI_HIDDEN, release UI caches — decoded bitmaps for views that are no longer visible, view-level caches that can be rebuilt when the UI returns. For TRIM_MEMORY_RUNNING_CRITICAL, drop non-essential data caches, cancel speculative prefetch work, and reduce in-memory data to the absolute minimum needed to restore state if the process survives. For TRIM_MEMORY_COMPLETE, you have milliseconds — release everything you can to give the LMK a reason to spare your process. Apps that respond aggressively to these callbacks are more likely to be retained in the background, improving warm-start performance and the user's perception of the app as fast and reliable.
- onTrimMemory: called when system needs memory — your chance to release caches before being killed
- TRIM_MEMORY_UI_HIDDEN: app backgrounded — safe to release all UI-related caches
- TRIM_MEMORY_COMPLETE: about to be killed — release everything, you may not get another chance
- MemoryInfo.lowMemory: check if system is currently in low memory state before doing heavy work
- Cached processes: Android kills these first — apps in background are always at risk when RAM is low
"onTrimMemory(TRIM_MEMORY_UI_HIDDEN) is the signal your app just went to the background. This is the best time to release image caches, decoded bitmaps, and prefetched data — the user can't see them anyway. Responding correctly here means your app is less likely to be killed when in the background, preserving the user's place when they return."
27. Scenario: How do you implement efficient image loading and caching in a photo gallery with thousands of images?
A photo gallery is the hardest image loading challenge on Android — thousands of high-resolution images, fast scroll, limited RAM. The solution combines Coil with a LazyVerticalGrid, thumbnail-first loading, and memory/disk cache tuning.
// Coil with custom cache configuration // val imageLoader = ImageLoader.Builder(context) // .memoryCache { // MemoryCache.Builder(context) // .maxSizePercent(0.25) // use 25% of available RAM for image cache // .build() // } // .diskCache { // DiskCache.Builder() // .directory(context.cacheDir.resolve("image_cache")) // .maxSizeBytes(512L * 1024 * 1024) // 512MB disk cache // .build() // } // .build() // Gallery grid — LazyVerticalGrid val photos by viewModel.photos.collectAsStateWithLifecycle () LazyVerticalGrid(columns = GridCells.Fixed(3)) { items(photos, key = { it.id }) { photo -> AsyncImage( model = ImageRequest.Builder(context) .data(photo.thumbnailUrl) // ✅ thumbnail first, not full-res .size (300, 300) // ✅ decode at display size .precision (Precision.INEXACT) // ✅ allow cached size reuse .memoryCachePolicy (CachePolicy.ENABLED) .diskCachePolicy (CachePolicy.ENABLED) .crossfade (200) .placeholder (R.drawable.placeholder_grey) .build (), contentDescription = null, modifier = Modifier.aspectRatio (1f), contentScale = ContentScale.Crop ) } } // Prefetch for smoother scrolling val gridState =rememberLazyGridState () LaunchedEffect(gridState) {snapshotFlow { gridState.firstVisibleItemIndex } .collect { firstVisible -> // Prefetch thumbnails 12 items ahead of visible area val prefetchRange = firstVisible + 12..firstVisible + 24 photos.getOrNull () ?.subList (minOf (prefetchRange.first, photos.size),minOf (prefetchRange.last, photos.size)) ?.forEach { imageLoader.enqueue (ImageRequest.Builder(context).data(it.thumbnailUrl).build ()) } } }
Efficient image loading in a photo gallery requires three layers of caching working together: memory cache, disk cache, and request deduplication. Coil and Glide implement all three. The memory cache holds decoded bitmaps keyed by URL and target size — a cache hit returns a bitmap in under 1ms with zero IO. The disk cache holds the compressed network response (JPEG/WebP bytes) — a cache hit decodes the image without a network round-trip, typically under 20ms. Request deduplication ensures that if 10 list items all request the same image URL simultaneously, only one network request is made and all 10 receive the same decoded bitmap. Without deduplication, a grid of 20 identical profile photos makes 20 parallel network requests.
Sizing requests to the display target is the most impactful optimisation. A 4000×3000 server image displayed in a 200×200dp thumbnail should be decoded at 200×200dp (adjusted for screen density), not at 4000×3000. Coil does this automatically when you pass a Size to ImageRequest — it uses BitmapFactory's inSampleSize to subsample during decoding, never allocating more memory than needed. For a photo gallery with 100 thumbnails, the difference between proper sizing and full-resolution decoding is the difference between 50MB of heap and 5GB — obviously the latter causes OOM. Always set explicit sizes: .size(200, 200) or let Coil read the View's dimensions by binding the request to a specific ImageView or AsyncImage.
Placeholder, error, and transition states complete the user experience. A placeholder drawable displays immediately while the image loads — prevents layout jank from a zero-height image slot suddenly gaining height when the image arrives. Use a colour placeholder matching your image's dominant colour for a visually smooth transition. The crossfade(true) transition fades from placeholder to loaded image rather than snapping, which feels more polished. For the gallery grid's scroll performance, pre-warm the image pipeline by calling imageLoader.enqueue() with the next page's URLs during RecyclerView.onScrollStateChanged(IDLE) — images that arrive in memory cache before their ViewHolder is bound eliminate all visible loading delay during scrolling.
- Thumbnails not full-res: load small previews for the grid — full-res only when user opens the photo
- size(300,300): Coil decodes at display size — never loads a 12MP image for a 100dp thumbnail
- Precision.INEXACT: allows cache hits from slightly different sizes — better cache utilisation
- maxSizePercent(0.25): 25% of RAM for image cache — adjust based on available device memory
- Prefetch ahead: enqueue thumbnail loads before they scroll into view — eliminates loading placeholders
"The most impactful gallery optimisation: never load full-resolution images in the grid. A 12MP photo is 48MB decoded. 9 visible at once = 432MB. Thumbnails at 300×300 pixels = 0.36MB each. 9 thumbnails = 3.24MB. That's a 133x memory reduction. Only load full-res when the user taps to open an individual photo."
28. What is the RenderThread? How does it affect UI performance?
RenderThread was introduced in Android 5.0 to offload GPU command execution from the main thread. The main thread records drawing commands into a RenderNode display list; RenderThread replays them on the GPU. This means property animations and scroll physics can continue smoothly even if the main thread briefly spikes.
// Property animations run on RenderThread -- main thread not involved ObjectAnimator.ofFloat(view, "translationX", 0f, 200f).start() // Compose graphicsLayer -- transforms executed on RenderThread Box(modifier = Modifier.graphicsLayer { alpha = animatedAlpha // RenderThread only -- no recomposition scaleX = animatedScale // RenderThread only translationX = offset // RenderThread only }) // Hardware layer -- promote View to its own GPU texture view.setLayerType(View.LAYER_TYPE_HARDWARE, null) // Animations on this layer run entirely on RenderThread -- main thread not involved // Use sparingly: each layer consumes GPU memory
The RenderThread is a dedicated thread, separate from the main UI thread, that handles the final stage of Android's rendering pipeline. The main thread records drawing commands into a DisplayList (a serialised list of GPU operations) and hands it to the RenderThread. The RenderThread then submits these commands to the GPU asynchronously. This architecture means simple animations — those that only require changes to GPU-composited properties like translation, rotation, scale, and alpha — can run at full frame rate entirely on the RenderThread even when the main thread is moderately busy. The main thread doesn't need to be involved in every frame of a translation animation once it starts.
The operations that break RenderThread independence are those that require the main thread to regenerate the DisplayList. Any View.invalidate() call, any layout change, and any custom onDraw() that produces different output triggers a main-thread traversal to regenerate the DisplayList — the RenderThread cannot proceed until the main thread finishes. This is why animating translationX is free but animating a custom-drawn progress bar that calls invalidate() each frame is expensive: the progress bar requires main thread participation every frame. In Compose, the equivalent distinction is between animations that only modify graphicsLayer properties (free) versus those that trigger recomposition (expensive).
In the GPU rendering profile bars (visible in Developer Options), the purple "sync" segment represents the time the main thread waits for the RenderThread to finish the previous frame before starting the next one. A consistently long sync segment means the RenderThread is the bottleneck — it cannot keep up with the DisplayLists the main thread is generating. This typically indicates too many draw calls (overdraw), complex shader operations, or very large textures being uploaded to the GPU. Reduce draw call count by flattening the view hierarchy, reduce overdraw by removing redundant backgrounds, and reduce texture upload time by downsampling images to their display size before uploading. In Perfetto, the RenderThread lane shows exactly which GPU submission is taking longest.
- RenderThread: executes GPU commands asynchronously -- main thread records, RenderThread draws, they run in parallel
- Property animations: ObjectAnimator and Compose animations backed by graphicsLayer run on RenderThread -- smooth even during main thread work
- graphicsLayer{}: the Compose way to leverage RenderThread -- alpha, scale, rotation, translation execute without recomposition
- Hardware layers: LAYER_TYPE_HARDWARE promotes a View to its own GPU texture -- ideal for complex Views being animated
- Perfetto: look for the RenderThread row -- if it's consistently backed up, you're GPU-bound; if it's waiting for main thread, you're CPU-bound
"graphicsLayer{} in Compose is how you animate without triggering recomposition. alpha, scale, rotation, and translation inside graphicsLayer run entirely on RenderThread — the Composable function is never called again during the animation. This is why Compose animations are smooth even during complex UI updates: the rendering and the business logic are decoupled."
29. Scenario: How do you implement a high-performance custom View that draws 60fps animations?
High-performance custom Views require zero object allocation in onDraw, hardware acceleration, efficient invalidation, and using ValueAnimator for smooth frame-synced updates.
class WaveformView(context: Context, attrs: AttributeSet?) : View(context, attrs) { // ✅ All objects allocated ONCE — never inside onDraw private val wavePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { color = ContextCompat.getColor (context, R.color.accent) style = Paint.Style.STROKE strokeWidth = 4f } private val path = Path() // reused every frame private val bounds = RectF() // reused every frame private var animPhase = 0f // current animation state // ✅ ValueAnimator — synced to display vsync, runs on main thread private val animator = ValueAnimator.ofFloat (0f, 1f).apply { duration = 2000 repeatCount = ValueAnimator.INFINITE interpolator = LinearInterpolator()addUpdateListener { anim -> animPhase = anim.animatedValue as Floatinvalidate () // ← triggers onDraw, synced to vsync } } override fun onAttachedToWindow() { super.onAttachedToWindow (); animator.start () } override fun onDetachedFromWindow() { animator.cancel (); super.onDetachedFromWindow () } override fun onDraw(canvas: Canvas) { // ✅ Zero allocations here — reuse path, bounds, paint path.reset () val amplitude = height / 4f val frequency = 2f * Math.PI.toFloat() / width path.moveTo (0f, height / 2f) for (x in 0..width) { val y = height / 2f + amplitude * Math.sin ((x * frequency + animPhase * 2 * Math.PI).toDouble()).toFloat () path.lineTo (x.toFloat (), y) } canvas.drawPath (path, wavePaint) } }
A custom View that must maintain 60fps has a strict budget: the onDraw() method must complete in under 4–5ms (leaving room for measure, layout, and GPU submission within the 16ms frame budget). The primary rule is no object allocation in onDraw() — declare all Paint, Path, Rect, and RectF objects as fields and initialise them once in init or a dedicated setup method. The Android framework enforces this implicitly through StrictMode's allocation tracking, but allocations in onDraw() are not caught automatically — you must profile with Method Tracing and filter by allocation count to find them.
Pre-computation is the second pillar of high-performance drawing. Any value that can be computed outside onDraw() should be. Measure text width with paint.measureText() in onSizeChanged(), not on every draw call. Compute the bounding rectangles for all drawn elements when the data changes, not on every frame. Build Path objects representing your view's geometry in onSizeChanged() and reuse them — constructing a complex path with Bezier curves on every frame is expensive. For animated values, use ValueAnimator or Compose's animation APIs to produce the interpolated values and store them in a field; onDraw() reads the field value and calls invalidate() for the next frame.
Hardware layer caching (view.setLayerType(LAYER_TYPE_HARDWARE, null)) composites the View into a GPU texture that is reused across frames as long as the View doesn't change. During an animation where only the View's position changes (translation), the GPU reuses the cached texture and just repositions it — avoiding a full redraw every frame. However, hardware layers are a double-edged sword: they consume GPU memory proportional to the View's size, and invalidating the layer (triggering a re-draw) is more expensive than a normal invalidate because the texture must be re-uploaded to the GPU. Use them only for Views that are animated without content changes, and call setLayerType(LAYER_TYPE_NONE, null) when the animation ends to free the texture memory.
- Pre-allocate everything: Paint, Path, RectF as class fields — zero allocations in onDraw
- ValueAnimator: vsync-synced callbacks — smoother than posting Runnables manually
- invalidate() in animator: triggers onDraw exactly once per frame — no over-drawing
- onAttached/onDetached: start animator when view enters window, cancel when it leaves
- path.reset(): clears path contents without allocating a new Path object
"The onAttachedToWindow/onDetachedFromWindow pair is the correct lifecycle for View animations — not onResume/onPause. A View can be attached to a window while the Activity is paused (e.g., in a dialog). Stopping the animator in onDetachedFromWindow guarantees it stops when the View actually leaves the screen, regardless of the Activity lifecycle."
30. What is the difference between invalidate() and requestLayout()? When do you call each?
These two View methods trigger different parts of the rendering pipeline. Calling the wrong one is either too expensive (requestLayout when invalidate would do) or incorrect (invalidate when the size changed).
// invalidate() — triggers onDraw() only // Use when: visual appearance changed, size/position unchanged // Cost: just onDraw() — no measure, no layout class ColorView(...) : View(...) { private var fillColor = Color.RED fun setColor(color: Int) { fillColor = colorinvalidate () // ✅ color changed, size same → just redraw } } // requestLayout() — triggers measure → layout → draw // Use when: size or position needs to change // Cost: full measure + layout pass for this view and potentially parents class ExpandableView(...) : View(...) { private var expanded = false fun toggle() { expanded = !expandedrequestLayout () // ✅ height changes → must remeasure } override fun onMeasure(wms: Int, hms: Int) {val h =if (expanded) 200.dp else 50.dp setMeasuredDimension (MeasureSpec .getSize (wms), h) } } // Both together — when size AND appearance change fun updateState(newText: String) { text = newTextrequestLayout () // size may change with new textinvalidate () // appearance also changed } // Note: requestLayout() does NOT automatically call onDraw() // You need both if you want immediate visual update + size update // postInvalidate() — call invalidate from a background thread Thread {updateData ()postInvalidate () // ✅ safe from background thread // invalidate() directly from background thread = crash }.start()
invalidate() and requestLayout() trigger different parts of the rendering pipeline. invalidate() marks the View's area as dirty and schedules a redraw — only onDraw() is called again, with no measurement or layout pass. Use it when the View's visual appearance changes but its size remains the same: a progress bar advancing, a colour changing, a custom animation updating. requestLayout() triggers a full traversal starting from the root: onMeasure() is called on the View and potentially its entire parent chain, then onLayout(), then onDraw(). Use it only when the View's size or its children's sizes may have changed — when text content changes length, when a child View is added or removed, or when padding changes.
Calling requestLayout() unnecessarily is a common performance mistake. Every requestLayout() call on a View in a complex hierarchy propagates up to the root ViewGroup, triggering re-measurement of potentially hundreds of views. In a RecyclerView cell, calling requestLayout() on a text view that receives the same content it already displays triggers a full measure/layout pass for the entire cell, even though nothing visually changes. The fix is to call setText() conditionally — check whether the new value differs from the current value before setting it — or use a data binding approach that only triggers layout when the value actually changes.
In Compose, the equivalent of invalidate() is triggering recomposition by writing to a State that a composable reads. The equivalent of requestLayout() is changing a measured dimension — which in Compose forces the parent to remeasure. Compose's rendering model eliminates the explicit requestLayout() call but introduces its own gotcha: reading state at the wrong phase (in composition when it should be in layout, or in layout when it should be in drawing) causes the wrong type of update. For example, reading an animated Float inside a composable body triggers recomposition on every frame; reading it inside a Modifier.graphicsLayer block triggers only a draw update, which is handled by the RenderThread without main thread involvement.
- invalidate(): schedules onDraw() only — no measure/layout — use for appearance-only changes
- requestLayout(): schedules full measure + layout + draw — use when size or position changes
- Both together: requestLayout() does not trigger onDraw() — call both when size AND appearance change
- postInvalidate(): thread-safe version of invalidate() — use when calling from background threads
- Performance: prefer invalidate() over requestLayout() — layout passes are expensive, especially with deep hierarchies
"requestLayout() is 10-100x more expensive than invalidate() because it triggers measure and layout for the view and propagates up the hierarchy. A common mistake: calling requestLayout() when only a color changed — that triggers a full layout pass for nothing. Always ask: 'Did the SIZE change?' If yes → requestLayout(). If only the visual changed → invalidate()."
31. Scenario: How do you use FrameMetrics API to monitor production frame rendering performance?
FrameMetrics gives you per-frame timing data programmatically — no developer tools needed. You can collect it in production to detect jank on real user devices and report to your analytics system.
// FrameMetrics — available API 24+ @RequiresApi(24) class FrameMetricsMonitor(private val activity: Activity) { private val handler = Handler(HandlerThread("FrameMetrics").also { it.start () }.looper) private val listener = Window.OnFrameMetricsAvailableListener { _, metrics, _ -> val totalMs = metrics.getMetric (FrameMetrics.TOTAL_DURATION) / 1_000_000L val inputMs = metrics.getMetric (FrameMetrics.INPUT_HANDLING_DURATION) / 1_000_000L val drawMs = metrics.getMetric (FrameMetrics.DRAW_DURATION) / 1_000_000L val syncMs = metrics.getMetric (FrameMetrics.SYNC_DURATION) / 1_000_000L val gpuMs = metrics.getMetric (FrameMetrics.GPU_DURATION) / 1_000_000L when { totalMs > 700 -> reportFrozenFrame(totalMs) // > 700ms = frozen totalMs > 16 -> reportSlowFrame(totalMs) // > 16ms = janky } // Report breakdown to analytics for P95/P99 analysis analytics.logFrameTiming ( screen = activity.localClassName, totalMs = totalMs, drawMs = drawMs, gpuMs = gpuMs ) } fun start() { activity.window.addOnFrameMetricsAvailableListener (listener, handler) } fun stop() { activity.window.removeOnFrameMetricsAvailableListener (listener) } } // FrameMetrics breakdown: // INPUT_HANDLING_DURATION — touch event processing time // ANIMATION_DURATION — ValueAnimator callbacks // LAYOUT_MEASURE_DURATION — measure + layout pass // DRAW_DURATION — onDraw() recording time // SYNC_DURATION — main→RenderThread sync // GPU_DURATION — GPU rasterisation time // TOTAL_DURATION — sum of all phases
The FrameMetrics API (available from API 26) provides per-frame timing data from a running app, broken down into the same phases visible in the GPU rendering profile: unknown, input, animation, layout measure, draw, sync, command issue, swap buffers, and total duration. Unlike the GPU rendering profile which requires Developer Options and visual inspection, FrameMetrics delivers this data programmatically via a callback — you can log it to Firebase Performance, aggregate it into a histogram, or alert when the 99th percentile exceeds a threshold. This makes it suitable for production monitoring, not just development profiling.
Set it up by registering an OnFrameMetricsAvailableListener on your Activity's window: window.addOnFrameMetricsAvailableListener(listener, handler). The listener fires on a background handler thread for each frame, passing a FrameMetrics object. Extract the total duration with frameMetrics.getMetric(FrameMetrics.TOTAL_DURATION) (in nanoseconds). Filter for frames over 16ms to measure your jank rate, or over 700ms for frozen frames. Unregister the listener in onStop() — leaving it registered in the background wastes resources since there are no visible frames to measure. In Compose, wrap critical navigation transitions with FrameMetrics collection to measure the actual frame timing of your most important interactions, not just average performance.
Integrating FrameMetrics data with your analytics pipeline lets you build a continuous performance regression detector. Log the p99 frame duration per screen name to Firebase Analytics as a custom event. Set up a BigQuery alert that fires when p99 exceeds 100ms on any screen for more than 0.5% of sessions. This gives you automatic notification of regressions introduced by any code change — far more sensitive than manual profiling, which can only catch regressions you happen to test for. Combine FrameMetrics data with your crash-free sessions rate and ANR rate for a complete picture of app quality: a release that improves crash rate but worsens frame timing is not a quality improvement.
- FrameMetrics: per-frame timing in production — no developer tools, real user data
- Per-phase breakdown: INPUT, ANIMATION, LAYOUT_MEASURE, DRAW, SYNC, GPU — pinpoints which phase causes jank
- Background handler: FrameMetrics callback on a HandlerThread — never process on main thread
- Frozen frame threshold: > 700ms is a frozen frame — much worse than a slow frame
- P95/P99 analytics: track frame time percentiles — P50 hides outlier jank that P99 reveals
"FrameMetrics is how you catch production jank that never appears during development. Your test device is a flagship — 60fps always. Your users have mid-range phones where GPU_DURATION exceeds 16ms on complex screens. With FrameMetrics reporting to your analytics, you see 'P95 frame time on screen X is 42ms on Android 11 devices' — a specific, actionable performance bug from real users."
32. How do you optimise Compose LazyColumn for large lists? What are the key performance patterns?
Compose LazyColumn performance is driven by stable keys, avoiding unnecessary recomposition inside items, and keeping item composables lean. Getting these right means smooth 60fps scrolling on mid-range devices.
// ✅ Pattern 1: Stable keys — prevent full list recomposition LazyColumn { items( items = products, key = { it.id }, // ✅ stable String key — Compose tracks by ID contentType = { it.type } // ✅ ViewHolder reuse equivalent ) { product -> ProductCard(product = product) } } // ✅ Pattern 2: Stable item composable — use @Stable data class @Stable data class ProductUiModel( val id: String, val name: String, val priceFormatted: String // ✅ pre-formatted — no formatting in composable ) // ✅ Pattern 3: Avoid lambda capture causing recomposition val onFavClick: (String) -> Unit =remember { { id -> viewModel.toggleFav (id) } } LazyColumn { items(products, key = { it.id }) { product -> ProductCard( product = product, onFavClick = onFavClick // ✅ same lambda instance — no recomposition ) } } // ✅ Pattern 4: Pagination with Paging 3 val products = viewModel.products.collectAsLazyPagingItems () LazyColumn { items(products, key = { it.id }) { product -> product?.let { ProductCard(it) } ?: PlaceholderCard() } item { when (products.loadState.append) { is LoadState.Loading -> LoadingSpinner() is LoadState.Error -> ErrorRow { products.retry () } else -> {} } } } // ✅ Pattern 5: rememberUpdatedState for callbacks @Composable fun ProductCard(product: ProductUiModel, onFavClick: (String) -> Unit) { val currentOnFavClick byrememberUpdatedState (onFavClick) val onClick =remember { { currentOnFavClick(product.id) } } // onClick stable reference → ProductCard won't recompose on parent lambda change }
LazyColumn only composes items that are visible on screen plus a small prefetch buffer. The main performance lever is ensuring each item's composition is cheap and stable. Unstable item data causes unnecessary recomposition: if the list items are data classes with all val properties, Compose treats them as stable and skips recomposition when the reference hasn't changed. If items contain mutable or unstable fields, annotate the class with @Immutable or @Stable to tell the compiler it can trust your equality implementation. Use items(list, key = { it.id }) to provide stable keys — without keys, Compose recomposes every visible item on any list change, even if only one item was added at the top.
The key parameter is the single most impactful optimisation for lists that change frequently. When items are reordered, added, or removed, Compose uses keys to match old and new items. Without keys, a list of 100 items where one is inserted at position 0 will recompose all 100 visible items — Compose sees that position 0 now has different content and recomposes everything below it. With stable keys, Compose recognises that the existing items just moved and only composes the new item. This is the difference between a smooth insert animation and 100ms of janky recomposition when a notification arrives while the user is scrolling a feed.
For very large lists (thousands of items) or items with expensive composition, rememberLazyListState() combined with prefetch configuration controls how many items are composed ahead of the scroll direction. The default prefetch is one viewport ahead; for data-heavy items, reduce this with LazyListPrefetchStrategy to avoid composing too many items during a fast fling. For items containing images, Coil's prefetching can be triggered by the scroll listener to start loading images that will be needed before they scroll into view. Avoid Modifier.fillMaxWidth() on every item if the width is always the screen width — use Modifier.wrapContentWidth() with a fixed width calculation done once, as fillMaxWidth() triggers an additional constraint measurement on each item.
- key parameter: Compose tracks items by key — inserts/removes animate correctly, no full recomposition
- contentType: equivalent to RecyclerView viewType — allows composable reuse across items
- @Stable data class: Compose skips recomposition when data class equals() returns true
- remembered lambdas: unstable lambdas cause item recomposition on every parent update
- rememberUpdatedState: capture latest callback value without breaking stability
"The most impactful LazyColumn optimisation is often the simplest: add key = { it.id }. Without it, Compose treats the list as positional — insert an item at position 0 and every item recomposes. With a stable key, only the new item composes and existing items are matched by ID. This is the Compose equivalent of DiffUtil, and it's one line."
33. Scenario: Your app's CPU usage stays high even when idle. How do you find and fix the runaway work?
High idle CPU means something is running that shouldn't be — a polling loop, a stuck animation, a runaway coroutine, or a repeating alarm. The CPU Profiler in sampled mode shows what's running even when the app appears idle.
// Step 1: Profile — CPU Profiler (Sampled) while app is "idle" // Leave app on home screen but don't interact // If CPU > 5% while idle → something is running it shouldn't be // Common causes of idle CPU: // 1. Animation not stopped val animator = ObjectAnimator.ofFloat (view, "rotation", 0f, 360f).apply { repeatCount = ValueAnimator.INFINITE } animator.start () // ❌ If view goes offscreen or fragment detaches — animator still runs! // Fix: cancel in onPause() or onDestroyView() override fun onPause() { super.onPause (); animator.cancel () } // 2. Polling with while(true) or Handler.postDelayed loop val handler = Handler(Looper.getMainLooper ()) val pollRunnable = object : Runnable { override fun run() {checkForUpdates () handler.postDelayed (this, 1000) // ❌ runs forever, even in background } } // Fix: stop the loop in onStop(), restart in onStart() override fun onStop() { handler.removeCallbacks(pollRunnable) } // 3. Flow collected without cancellation class MyActivity : Activity() { override fun onCreate(...) { GlobalScope. launch { // ❌ never cancelled locationFlow.collect { updateMap(it) } } } } // Fix: use lifecycleScope.launch or repeatOnLifecycle lifecycleScope.launch {repeatOnLifecycle (Lifecycle.State.STARTED) { locationFlow.collect {updateMap (it) } // ✅ auto-cancels when STOPPED } } // 4. WakeLock held too long val wl = powerManager.newWakeLock (PowerManager.PARTIAL_WAKE_LOCK, "app:sync") wl.acquire (10 * 60 * 1000L) // ✅ always set timeout — prevents infinite hold
High CPU usage when idle is almost always caused by one of three patterns: a polling loop that never stops, a Flow or LiveData observer that triggers on every emission from a high-frequency source, or a coroutine that re-schedules itself in a tight loop. The CPU Profiler with System Trace is the first tool — record for 10–15 seconds while the app appears idle and look for threads that are consistently "Running" (green) rather than "Sleeping". A thread that never sleeps is doing continuous work. Click its slices to see what method is executing. If it's your code, the trace will show the method name; if it's a library, the call stack above it will show what your code is doing to drive it.
The most common hidden polling source is a while(true) { delay(interval); doWork() } loop launched without a cancellation hook. If launched in GlobalScope, it runs for the process lifetime. If the interval is very short (under 100ms), the CPU is woken up constantly. Audit every place in the codebase where a coroutine is launched with a loop — replace polling with push-based alternatives wherever possible. Location updates should use FusedLocationProviderClient callbacks, not a polling loop. Network data should use WebSockets or Server-Sent Events for real-time updates, not repeated HTTP polling. Sensor data should use SensorManager.registerListener() with the lowest sampling rate that meets your requirements.
A StateFlow or SharedFlow emitting at high frequency (e.g., every frame from a sensor) with a collector that does expensive work per emission is another source of idle CPU usage. Apply conflate() to drop intermediate emissions when the collector is slower than the producer — the collector always processes the latest value without queuing up a backlog of unprocessed updates. Use distinctUntilChanged() to suppress emissions where the value hasn't changed — a location flow that emits the same coordinates because the device hasn't moved should not trigger a database write or UI update. Combine conflate() and distinctUntilChanged() on any high-frequency flow whose downstream processing is heavier than a trivial assignment.
- Sampled CPU Profiler while idle: shows what's running — method names reveal the culprit
- Infinite animations: ObjectAnimator with INFINITE must be cancelled on lifecycle events
- Handler polling loops: postDelayed loops run forever — remove callbacks in onStop
- GlobalScope coroutines: never cancelled — use lifecycleScope with repeatOnLifecycle
- WakeLock timeout: always set acquire(timeout) — prevents accidental infinite CPU activity
"repeatOnLifecycle is the modern fix for the runaway coroutine problem. It automatically cancels the inner coroutine when the lifecycle drops below the specified state and restarts it when it rises above. A Flow collected with repeatOnLifecycle(STARTED) stops running when the app backgrounds — exactly what you want for location, sensors, or network polling."
34. What is the App History API and how do you use process death to improve startup UX?
Android can kill your app's process at any time when it's in the background. Understanding process death — and designing for it with SavedStateHandle — turns what could be a broken UX into a seamless restoration experience.
// Process death: Android kills your process (not the app) when RAM is needed // User doesn't see a crash — they just see a "cold start" on next open // But: the OS restores the back stack, navigation state, and saved instance state // Simulate process death for testing: // 1. Launch app, navigate to a deep screen // 2. Press Home // 3. adb shell am kill com.example.app ← kills process // 4. Switch back to app — should restore seamlessly // SavedStateHandle — survives process death @HiltViewModel class SearchViewModel @Inject constructor( private val savedState: SavedStateHandle // persists across process death ) : ViewModel() { var searchQuery by savedState.saveable {mutableStateOf ("") } private set fun updateQuery(q: String) { searchQuery = q } } // searchQuery restored after process death — user sees their search query still there // What survives process death (automatic): // ✅ Navigation back stack (Compose Navigation) // ✅ SavedStateHandle values // ✅ onSaveInstanceState() Bundle // ✅ Room database (persisted to disk) // ✅ DataStore (persisted to disk) // What does NOT survive process death: // ❌ ViewModel state (non-saved) // ❌ In-memory variables // ❌ Pending coroutines / queued work // Design principle: treat process death as a normal event // Any UI state the user cares about → SavedStateHandle // Any data the user created → Room/DataStore immediately
Process death is a normal part of Android's lifecycle: the OS kills background app processes to reclaim memory, and users experience this as cold starts that feel like warm starts — the app appears to relaunch but the navigation stack and user state are gone. SavedStateHandle is the primary mechanism for surviving process death: it persists a Bundle through the Activity recreation that follows process death, allowing ViewModels to restore critical UI state (search query, scroll position, selected tab) without a network round-trip. The key insight is that SavedStateHandle is not a general-purpose persistence layer — it is limited to small, serialisable values (up to ~50KB) and should only hold the minimum state needed to restore the user's exact context.
The "App History" mental model is that the most recently visible screen's critical state should always be recoverable without a network call. Combine SavedStateHandle for ephemeral UI state (which item is expanded, what text is in the search box) with Room or DataStore for durable content (the actual list items, user preferences). When the app restarts after process death, the ViewModel reads the saved UI state from SavedStateHandle and simultaneously starts loading content from Room's cache — the user sees their previous screen's content rendered immediately from cache, with their scroll position and selections restored, before any network refresh completes. This pattern eliminates the perception of a cold start entirely.
Testing process death resilience is underrated and frequently skipped. Use adb shell am kill <package-name> while the app is backgrounded, then return to it — this simulates exactly what the OS does during memory pressure. If your app restores correctly (correct screen, correct state, content from cache), your architecture is robust. If it shows a blank screen, crashes, or returns to the home screen, you have state restoration gaps. Automate this in your Macrobenchmark tests with a killProcess() call between sessions and assert that the app reaches the content state within a target time. Teams that test process death regularly ship more reliable apps because they discover state restoration bugs during development rather than from user reviews.
- Process death: normal Android behaviour — not a crash, OS reclaims RAM while app is backgrounded
- SavedStateHandle: ViewModel property store that survives process death — use for UI state
- savedState.saveable: Compose State delegate on SavedStateHandle — state survives rotation AND death
- Test with adb kill: simulate process death manually — verify seamless restoration
- Room/DataStore: persist immediately on user action — don't wait for onSaveInstanceState
"Process death is the most under-tested scenario in Android development. Most developers never test it. The fix: after every navigation, run 'adb shell am kill your.package' then switch back. If the app restores perfectly — you're done. If it shows a blank screen or crashes — you have SavedStateHandle work to do. Make this part of your QA checklist."
35. Scenario: How do you implement efficient background work that doesn't drain battery or get killed by the OS?
Android increasingly restricts background work to protect battery life. WorkManager is the only guaranteed way to run background work — it handles Doze mode, App Standby, Background Execution Limits, and process death automatically.
// Background execution restrictions timeline: // Android 6: Doze mode — blocks network/alarms when screen off + stationary // Android 7: Background network restrictions // Android 8: Background Service Limits — no background services without foreground notification // Android 12: Foreground service restrictions tightened // Android 14: Foreground service type declaration required // Solution: WorkManager — designed for all these restrictions // One-time sync work — runs when constraints met, survives process death val syncWork = OneTimeWorkRequestBuilder<SyncWorker>() .setConstraints (Constraints(requiresNetwork = true)) .setBackoffCriteria(BackoffPolicy.EXPONENTIAL, 15, TimeUnit.MINUTES) . setExpedited (OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .build() WorkManager. getInstance (context).enqueueUniqueWork ( "sync", ExistingWorkPolicy.KEEP, syncWork ) // Periodic work — runs ~every 6 hours, battery-efficient val periodicSync = PeriodicWorkRequestBuilder<SyncWorker>(6, TimeUnit.HOURS) .setConstraints (Constraints.Builder() .setRequiredNetworkType (NetworkType.CONNECTED) .setRequiresBatteryNotLow(true) . build ()) .build () // The Worker itself — Doze-safe, runs when constraints met @HiltWorker class SyncWorker @AssistedInject constructor( @Assisted appContext: Context, @Assisted params: WorkerParameters, private val repo: SyncRepository ) : CoroutineWorker(appContext, params) { override suspend fun doWork(): Result { return try { repo.sync () Result.success () } catch (e: Exception) { if (runAttemptCount < 3) Result.retry () else Result.failure () } } }
WorkManager is the correct API for all background work that must survive process death and should respect system constraints. It integrates with the system's job scheduling (JobScheduler on API 23+, AlarmManager on older APIs) to batch tasks from multiple apps into shared execution windows — dramatically reducing the number of times the CPU is woken from a low-power state. Constraints prevent unnecessary work: NetworkType.CONNECTED ensures a sync task only runs when connectivity is already active (not waking the radio), requiresCharging(true) defers heavy batch processing to charging time, and requiresStorageNotLow(true) prevents writing to a nearly-full disk. These constraints eliminate the most common causes of background battery drain.
The periodic work interval has a hard minimum of 15 minutes — the system enforces this to prevent apps from circumventing Doze mode with rapid-fire scheduling. For truly real-time background updates (a messaging app, a sports scores app), a foreground service is required — but foreground services must display a persistent notification and face strict OS restrictions in Android 12+. Most background sync scenarios don't actually require real-time updates: a news app syncing headlines, a social app prefetching the feed, an email app checking for new messages — all of these can tolerate 15–30 minute delays. Design background work with the minimum frequency that meets user expectations, not the maximum frequency that is technically possible.
Expedited work is WorkManager's mechanism for tasks that are time-sensitive but don't justify a foreground service. setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) requests that the task run immediately without Doze or App Standby deferral, subject to a per-app quota. The quota prevents abuse — an app cannot mark every task as expedited. Use expedited work for user-initiated actions that have a background component: uploading a photo the user just took, processing a payment, sending a message that was composed offline. The OutOfQuotaPolicy determines what happens when the quota is exhausted: run as a regular (deferrable) task, or drop the task entirely. Choose based on whether a delayed execution is acceptable or a missed execution is a correctness bug.
- WorkManager: the only API that works across all Android background restrictions — Doze, App Standby, limits
- setConstraints: declare what conditions must be met — OS schedules at the best time
- setBackoffCriteria: exponential retry with cap — resilient to transient failures
- enqueueUniqueWork: prevent duplicate work — KEEP or REPLACE depending on semantics
- setRequiresBatteryNotLow: don't drain a user's already-low battery — responsible scheduling
"The Android background work API history is a graveyard of deprecated approaches: AsyncTask, IntentService, raw AlarmManager, JobScheduler, Firebase JobDispatcher. WorkManager wraps the best available mechanism per Android version and handles all the restrictions automatically. In 2025, WorkManager is the correct answer for any background work that needs to be reliable."
36. What is hardware acceleration in Android Views? When does it help and when does it break things?
Hardware acceleration routes drawing through the GPU via OpenGL ES / Vulkan instead of the CPU. It's enabled by default since Android 4.0 and dramatically improves rendering performance — but some Canvas operations are unsupported or produce different visual results.
// Hardware acceleration: enabled by default at application level // AndroidManifest.xml: android:hardwareAccelerated="true" (default) // Per-View override for problematic views: view.setLayerType (View.LAYER_TYPE_SOFTWARE, null) // force software for this view view.setLayerType (View.LAYER_TYPE_HARDWARE, null) // force hardware layer view.setLayerType (View.LAYER_TYPE_NONE, null) // default (inherit from window) // ✅ GPU-accelerated (fast): canvas.drawBitmap (...) // texture upload → GPU draw canvas.drawRect(...) // direct GPU primitive canvas. drawText(...) // GPU text rendering canvas. drawRoundRect(...) // GPU // ❌ Not hardware-accelerated (falls back to software): canvas. drawBitmapMesh(...) // complex mesh deformation canvas. drawPicture(...) // Picture objects // Paint.setXfermode(PorterDuffXfermode) — some modes not GPU accelerated // canvas.clipPath() with non-rectangular clips — software on older APIs // Canvas.clipPath() issue — visual difference // Software: anti-aliased edges // Hardware: jagged edges (fixed in API 26 with outline clipping) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { view.outlineProvider = ViewOutlineProvider.BOUNDS view.clipToOutline = true // ✅ GPU-accelerated rounded corners } // Detect if hardware accelerated: canvas.isHardwareAccelerated // true in onDraw when using GPU path if (!canvas.isHardwareAccelerated) { // fall back to software-compatible drawing }
Hardware acceleration renders Views using the GPU via OpenGL ES (or Vulkan on API 29+) instead of the CPU's Skia software renderer. It is enabled by default at the Application level since API 14, but individual Views or Windows can opt out. The GPU excels at compositing — blending layers, applying transforms, drawing rectangles and rounded rectangles. Hardware acceleration dramatically speeds up animations that involve alpha, translation, scale, and rotation, because these properties are handled by the GPU's compositing engine without touching the CPU's drawing code. A View.animate().alpha(0f) call on a hardware-accelerated View runs entirely on the GPU after the initial DisplayList is recorded.
Hardware acceleration hurts performance in two specific scenarios. First, certain Canvas drawing operations are unsupported or fall back to software rendering: some Paint modes, certain PorterDuff compositing operations, and drawing to a Picture. When unsupported operations are encountered, Android silently falls back to a slower software path for that View subtree, and may log a warning in Logcat. Check the hardware acceleration documentation for your target API level before relying on advanced canvas operations in performance-critical Views. Second, hardware layers (LAYER_TYPE_HARDWARE) consume GPU memory proportional to the View's size in pixels. A large View with a hardware layer on a 2K display takes 8MB+ of GPU memory — acceptable for a brief animation, problematic if left permanently enabled.
Disabling hardware acceleration selectively is sometimes the right choice. A View that does complex software canvas operations — a custom chart using PorterDuff.Mode.XOR, a custom text rendering path — may actually be faster with LAYER_TYPE_SOFTWARE if the GPU's overhead (texture upload, compositing setup) exceeds the CPU's drawing time. Measure before deciding: use the GPU rendering profile to compare the "draw" segment (CPU drawing) and the "execute" segment (GPU execution) with and without hardware acceleration. The rarer but real case where software rendering wins is small, frequently-invalidated Views where the GPU texture upload cost dominates the rendering budget.
- Enabled by default: API 14+ all apps hardware accelerated — check manifest if turned off
- LAYER_TYPE_SOFTWARE: force software rendering for specific views with unsupported operations
- LAYER_TYPE_HARDWARE: explicit GPU texture — use for complex animated views
- clipPath on older APIs: anti-aliasing differs between software and hardware — use clipToOutline instead
- canvas.isHardwareAccelerated: check in onDraw to adapt drawing code per render path
"The most common hardware acceleration bug: a custom View uses canvas.clipPath() for rounded corners, looks perfect in Android Studio preview (software), but has jagged edges on device (hardware). The fix: use view.clipToOutline = true with a ViewOutlineProvider — this uses GPU-accelerated outline clipping available from API 21, which is smooth on hardware."
37. Scenario: How do you optimise a complex Compose screen that has expensive recomposition?
Expensive recomposition is the most common Compose performance issue. The fix involves identifying which composables recompose unnecessarily (Layout Inspector) and applying targeted optimisations: state hoisting, derivedStateOf, and composable function scoping.
// Problem: ProductListScreen recomposes whenever ANY state changes // Even state that only affects the FAB triggers full screen recomposition // ❌ BAD — all state in one composable @Composable fun ProductListScreen(vm: ProductViewModel) { val state by vm.state.collectAsStateWithLifecycle () // state.products + state.isLoading + state.scrollPosition + state.fabVisible // Any change → entire screen recomposes! ProductList(state.products) // recomposes even on scrollPosition change Fab(state.fabVisible) // recomposes even on products change } // ✅ GOOD — separate state reads into separate composables @Composable fun ProductListScreen(vm: ProductViewModel) { ProductListContent(vm) // reads products + loading FabSection(vm) // reads only fabVisible } @Composable fun ProductListContent(vm: ProductViewModel) { val products by vm.products.collectAsStateWithLifecycle () val isLoading by vm.isLoading.collectAsStateWithLifecycle () // Only recomposes when products or isLoading changes } @Composable fun FabSection(vm: ProductViewModel) { val fabVisible by vm.fabVisible.collectAsStateWithLifecycle () // Only recomposes when fabVisible changes } // derivedStateOf — convert frequent scroll state to rare boolean val listState =rememberLazyListState () val fabVisible byremember {derivedStateOf { listState.firstVisibleItemIndex > 3 } } // FAB only recomposes when boolean changes (2x per interaction) // NOT on every scroll pixel (60x per second) // Composition Local — avoid prop drilling that causes recomposition val LocalTheme =compositionLocalOf { AppTheme() } // Reads from LocalTheme don't cause recomposition unless LocalTheme changes
An expensive recomposition in Compose is one where either the composition itself takes long (computing a derived value, running a loop) or where too many composables recompose when only one piece of state changes. The Compose Compiler Report identifies both. Generate it by adding compiler arguments to your build.gradle: freeCompilerArgs += ["-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=${buildDir}/compose_reports"]. The report lists every composable annotated as "restartable but not skippable" — these are composables that can potentially restart (recompose) when their inputs change but cannot be skipped when they haven't. A composable receiving an unstable parameter is always marked as non-skippable.
The most effective structural fix for expensive Compose screens is state hoisting with surgical precision. If a Text composable displaying a counter updates 60 times per second but is nested inside a complex parent composable, every counter update recomposes the parent and all its children. The fix is to hoist only the counter state and pass it to a minimal Text composable that is the only thing that recomposes. The parent composable, which renders the expensive static UI, is recomposed zero times. This pattern — move state reads as far down the composable tree as possible — is the single most impactful structural change for Compose performance. Read state at the leaf, not the root.
derivedStateOf prevents unnecessary recomposition when a composable depends on a computation over state, not the state directly. val isScrolled by remember { derivedStateOf { listState.firstVisibleItemIndex > 0 } } only triggers recomposition when the boolean result changes — not on every scroll event, even though firstVisibleItemIndex changes on every pixel of scroll. Without derivedStateOf, any composable reading listState.firstVisibleItemIndex recomposes on every scroll frame. The rule: if you derive a value from state and only care about when the derived value changes (not when the underlying state changes), wrap the derivation in derivedStateOf.
- State isolation: split big state objects into separate flows — each composable reads only what it needs
- Composable scoping: smaller composables recompose independently — isolate expensive sections
- derivedStateOf: convert high-frequency state (scroll position) to low-frequency derived value
- Layout Inspector: use recomposition count to identify which composables recompose most
- CompositionLocal: share theme/config without prop drilling — doesn't trigger recomposition unless changed
"The state scoping principle: a composable should only read state it actually renders. If ProductListContent reads a fabVisible boolean it never uses, any fab state change triggers ProductListContent recomposition — wasted work. Split state into separate StateFlows in the ViewModel, each composable collects only its own slice. One state change = one composable recomposes, not the whole screen."
38. What is the Network Profiler? How do you use it to optimise API calls?
The Network Profiler in Android Studio visualises all HTTP requests your app makes -- their timing, payload size, and response codes. It's invaluable for finding duplicate requests triggered by rotation, oversized API payloads, and serial requests that could run in parallel.
// Open: Profiler → + → Network → see live request timeline // OkHttp logging interceptor -- detailed request/response in Logcat (debug only) val logging = HttpLoggingInterceptor().apply { level = if (BuildConfig.DEBUG) Level.BODY else Level.NONE } val client = OkHttpClient.Builder().addInterceptor(logging).build() // Chucker -- in-app network inspector, shareable with QA (debug builds only) // debugImplementation("com.github.chuckerteam.chucker:library:4.0.0") val chucker = ChuckerInterceptor.Builder(context).build() val client = OkHttpClient.Builder().addInterceptor(chucker).build()
The Network Profiler in Android Studio shows all HTTP/HTTPS requests made by your app in a timeline, with each request's timing broken down into send, waiting (TTFB — time to first byte), and receive phases. The waiting phase is the most revealing: a long waiting phase means the server is slow to respond (backend performance issue or high latency) rather than a network bandwidth problem. Sort requests by duration to find the slowest endpoints. Look for requests that fire sequentially when they could be parallel — a screen that makes three independent API calls in sequence adds their latencies, while making them in parallel with async { }.await() takes only the longest one's time.
Request redundancy is the most actionable finding in the Network Profiler. Scroll through the timeline during typical user interactions and count how many times the same endpoint is called. A user opening a profile screen that calls /users/{id} three times (once for the header, once for the posts list, once for a widget) is a backend or architecture issue — consolidate into a single call or cache the response in the repository layer. HTTP response caching via OkHttp's Cache is the structural fix: add .cache(Cache(cacheDir, maxSizeMb)) to OkHttpClient and ensure your API returns proper Cache-Control headers. A response with Cache-Control: max-age=300 is served from disk for 5 minutes without a network request.
Payload size is the third dimension to optimise after latency and redundancy. The Network Profiler shows request and response sizes — look for JSON responses over 100KB for what should be lightweight list endpoints. Large payloads indicate that the API is returning more fields than the client uses (over-fetching). Work with your backend team to add field filtering (?fields=id,name,thumbnail) or adopt GraphQL for precise field selection. On the client side, enable GZIP compression by adding an OkHttp interceptor that sets Accept-Encoding: gzip — this typically reduces JSON payload size by 60–80%, directly reducing the "receive" phase duration. Compress request bodies (POST/PUT) with GzipRequestInterceptor for large uploads like bulk data sync.
- Waterfall pattern: serial requests that could run in parallel show as sequential bars -- parallelise with async { } + awaitAll()
- Duplicate requests: same URL called multiple times -- usually caused by ViewModel recreating on rotation, fix with viewModelScope.launch in init{}
- Payload inspection: click a request → Body tab → see the full JSON -- look for fields you're fetching but not displaying
- Chucker: in-app network log accessible via notification -- share with QA team without requiring Android Studio
- Size matters: a 200KB response for a list screen is normal; a 2MB response for a settings screen is a backend API design issue
"The most common finding in Network Profiler: duplicate requests. A screen loads, user rotates, ViewModel recreates, same 3 API calls fire again. Fix: move the API call to viewModelScope.launch in init{} — only runs once per ViewModel lifetime. Or use Paging 3's cachedIn(viewModelScope) — pages cached in ViewModel survive rotation."
39. Scenario: How do you profile and fix excessive work happening during app startup that the user can't see yet?
Work during startup that the user can't see is waste — it delays the first frame without any visible benefit. Startup tracing with Perfetto reveals exactly what's running before the first frame and which of it is actually necessary.
// Capture startup trace: // adb shell am start -W --start-profiler /data/misc/perfetto-traces/startup.trace \ // -P /data/misc/perfetto-traces/startup.trace com.example.app/.MainActivity // OR: Android Studio → Profiler → CPU → Start recording → launch app // Startup trace shows timeline of Application.onCreate() through first frame // Common unnecessary startup work: // 1. SharedPreferences read on main thread (blocks) // Fix: migrate to DataStore (async) or read in background // 2. SDK initialization that isn't needed for first frame class MyApp : Application() { override fun onCreate() { // ❌ Analytics SDK: user can't see analytics before first frame // ❌ Push notification setup: no notification received during startup // ❌ Crash reporting: crashes during startup handled differently // ✅ Only what's needed for first frame: Timber.plant (Timber.DebugTree()) // needed for debug logging DaggerAppComponent.create ().inject (this) // DI graph (may be fast) } } // App Startup library — ordered, traceable initialization class AnalyticsInitializer : Initializer<Unit> { override fun create(context: Context) { Trace.beginSection ("AnalyticsInitializer") analytics.initialize (context) Trace.endSection () } override fun dependencies() =listOf (FirebaseInitializer::class.java) } // Lazy initialization — only when first used val analyticsClient bylazy { Analytics.create (context) } // Initialized on first access — not during startup // PostDelayed initialization — after first frame rendered override fun onResume() { super.onResume () Handler(Looper.getMainLooper ()).postDelayed ({initNonCriticalSdks () // after first frame is drawn }, 500) }
Excessive startup work is diagnosed by recording a System Trace that starts before the app's process is created — use adb shell am start-activity --start-profiler or the Macrobenchmark library's StartupTimingMetric. The trace will show Application.onCreate() as a single long slice on the main thread. Expand it to see which method calls within it are consuming the most time. Common findings: a dependency injection framework (Hilt, Dagger) performing expensive reflection-based component graph construction; a crash reporting SDK initialising its upload queue and reading from disk; a feature flag SDK making a synchronous network call; an analytics SDK flushing a pending event queue from the previous session.
The fix strategy is always the same: move work off the main thread and defer it past the first meaningful frame. Use ProcessLifecycleOwner.get().lifecycleScope.launch(Dispatchers.IO) { } to run non-essential initialisation after the process starts. Use the Jetpack App Startup library to convert eager SDK initialisation into lazy initialisation — SDKs that implement Initializer<T> are only initialised when first accessed, not at process creation. For SDKs that cannot be made lazy, move their initialisation into a WorkManager one-time task that runs immediately but off the main thread — they initialise concurrently with the UI rendering its first frame.
Baseline Profiles are the compile-time complement to lazy initialisation. While lazy initialisation defers work, Baseline Profiles ensure that the work that must happen synchronously at startup (class loading, DEX verification, JIT compilation of hot paths) is pre-compiled at install time via ART's ahead-of-time compilation. The effect is most dramatic on first launch after install or after an OS update, when ART's code cache is cold. Google reports 30–40% cold start improvement from Baseline Profiles alone on first launch. Generate the profile by running the Macrobenchmark BaselineProfileRule test that covers your startup journey, check the generated baseline-prof.txt into the app module, and verify it is included in release builds via the ProfileInstaller library.
- Startup trace: Perfetto shows every method between process start and first frame
- First-frame critical path: only init what's needed to draw the first visible frame
- App Startup tracing: Trace.beginSection in Initializer.create() — each SDK's startup cost visible
- Lazy: initialize expensive singletons on first use, not at startup
- postDelayed(500ms): defer non-critical work until after first frame renders and user perceives app is ready
"Ask 'does the user need this for the first frame?' for every line in Application.onCreate(). Analytics: no — user can't see it. Crash reporting: maybe — depends on SDK. Navigation graph: yes — needed to display the first screen. DI graph: yes — needed to inject the ViewModel. Everything else: defer. This single question can cut 500ms from cold startup."
40. What is App Size and how does it affect performance? What is the difference between download size, install size, and memory footprint?
App size has three distinct meanings with different optimisation strategies. Download size is what users see on Play Store before installing -- reduce with AAB and R8. Install size is the storage footprint after installation -- typically 2-3x the download size due to ART compilation. Memory footprint is RAM used while running -- affects how often the OS kills your process.
// Analyze APK -- Android Studio → Build → Analyze APK // Shows: res/ classes.dex lib/ assets/ -- identify largest contributors // Check memory footprint (heap limit per device) val am = context.getSystemService(ActivityManager::class.java) val heapLimitMb = am.memoryClass // device-recommended heap limit in MB val largeHeapMb = am.largeMemoryClass // if android:largeHeap="true" in manifest // Runtime memory breakdown (from adb) // adb shell dumpsys meminfo com.example.app // Shows: Java Heap, Native Heap, Code, Graphics, Stack, System
APK size and AAB (Android App Bundle) size affect performance in two distinct ways. Large install size correlates with slower cold start because more DEX bytecode must be loaded and verified at first launch — a 100MB app has more code to JIT-compile than a 20MB app. Large download size affects conversion rate: Play Store data shows that for every 6MB increase in APK size, install conversion rate drops by approximately 1%. The switch from APK to AAB (mandatory since August 2021) enables Play to deliver device-specific APKs — only the ABIs, screen densities, and languages needed for the target device are downloaded, reducing delivered size by 15–20% on average without any code changes.
R8 (the replacement for ProGuard, enabled by default in release builds) performs three optimisations: shrinking (removing unused code), obfuscation (renaming classes and methods to short names), and optimisation (inlining methods, removing dead code paths). The size reduction from R8 shrinking is typically 20–40% of the final DEX size. Verify R8 is enabled in your release build config: minifyEnabled = true and shrinkResources = true. Resource shrinking removes drawable, layout, and string resources that are not referenced anywhere in the code — a common source of bloat in apps that have accumulated years of unused assets. Check the R8 mapping file after each release build to understand what was removed and add -keep rules for any classes incorrectly removed by shrinking.
Image asset format choice is the highest-leverage size optimisation for most apps. PNG files should be replaced with WebP — lossless WebP is 26% smaller than PNG, and lossy WebP at quality 80 is typically 70% smaller than the equivalent JPEG with comparable visual quality. Convert all PNG assets using Android Studio's "Convert to WebP" action. For vector assets, use VectorDrawable (SVG-like XML) for icons and simple illustrations — a 24dp icon as a VectorDrawable is typically under 1KB versus 3–10KB for equivalent PNG assets at all densities. Audit your assets directory with the APK Analyzer (Build → Analyze APK) to see the actual size contribution of each resource file before optimising.
- Download size: what users see before installing -- optimise with AAB (Play generates per-device APKs) + R8 + WebP images
- Install size: 2-3x the download size -- ART compiles DEX to native code at install, occupying additional storage
- Memory footprint: RAM while running -- affects process kill priority; exceed memoryClass and you'll see frequent OOM kills
- Analyze APK: Build → Analyze APK in Android Studio -- shows which section (res, dex, lib) is the largest
- adb shell dumpsys meminfo: breaks down RSS into Java heap, native heap, graphics, code -- find where memory is going at runtime
"These three sizes have different optimisation strategies. Download size → AAB + R8 + WebP. Install size → fewer native libs, fewer resources. Memory footprint → image cache limits, fewer retained objects, release caches in onTrimMemory. An app can have a 10MB download size but a 300MB memory footprint — small to install, but killed constantly on low-end devices."
41. Scenario: How do you implement performance monitoring that automatically alerts you to regressions before users notice?
Performance regressions are caught by combining automated benchmarks in CI (catches code-level regressions), Firebase Performance Monitoring (catches production regressions), and Android Vitals alerts (Play Console alerting).
// LAYER 1: Microbenchmarks in CI — catch code-level regressions // Add benchmark tests for critical paths: @Test fun productListJsonParsing() { benchmarkRule.measureRepeated { Json.decodeFromString <List<Product>>(sampleJson) } // CI fails if median > 5ms — regression detected before merge } // LAYER 2: Macrobenchmark in CI — catch startup/scroll regressions // Run on a dedicated physical device in CI @Test fun coldStartupRegression() { benchmarkRule.measureRepeated ( packageName = "com.example.app", metrics =listOf (StartupTimingMetric()), startupMode = StartupMode.COLD, iterations = 5 ) { pressHome(); startActivityAndWait() } // Assert median < 1500ms — fail PR if startup regressed } // LAYER 3: Firebase Performance — production regression detection // Custom traces for critical user flows val checkoutTrace = Firebase.performance.newTrace ("checkout_flow") checkoutTrace.start () // ... user completes checkout ... checkoutTrace.stop () // Firebase dashboard: alert if P95 checkout_flow > 3s // Set up alerts: Performance → Traces → Add alert // LAYER 4: Play Console Vitals alerts // Android Vitals → Set threshold alerts for: // • Crash rate spike > 2% // • ANR rate spike > 0.5% // • Slow cold start rate > 30% // Email alert sent when threshold crossed // LAYER 5: Version-based comparison // Android Vitals → Filter by app version // Compare v2.1.0 vs v2.0.0 on the same metric // Slow start rate: v2.0.0 = 8%, v2.1.0 = 22% → regression from v2.1.0
Automatic performance alerting requires instrumenting your app with metrics that can be compared against a baseline. Firebase Performance Monitoring's custom traces are the building block: wrap each critical user journey (home screen load, search result return, checkout completion) with a trace and add custom attributes (network type, user tier, device class). Firebase Performance aggregates these traces across your user base and shows p50/p75/p95 breakdowns by attribute. Set up alerts in the Firebase console to notify your team when any trace's p75 exceeds a threshold — a "search_results_load" trace whose p75 jumps from 800ms to 2000ms after a backend deployment is the alert that triggers a rollback.
FrameMetrics data (from the API discussed in Q31) can be routed to Firebase Analytics as custom events, creating a per-screen frame timing dashboard. Log the p99 frame duration per screen name whenever the user leaves a screen: firebaseAnalytics.logEvent("frame_p99") { param("screen", screenName); param("p99_ms", p99Duration) }. Build a BigQuery report that surfaces any screen whose p99 worsened by more than 20% compared to the previous 7-day baseline. This gives you regression detection at the frame level, which is more sensitive than crash-free sessions rate — a performance regression that doesn't crash is invisible to Crashlytics but visible to your frame timing metrics.
Synthetic performance tests in CI complement production monitoring by catching regressions before they reach users. Use Macrobenchmark in your CI pipeline on a physical device (not an emulator — emulator performance is too variable) to measure cold start time, scroll frame timing, and interaction latency on every pull request. Set a budget: if cold start p75 increases by more than 200ms or scroll p99 increases by more than 50ms compared to the main branch baseline, fail the CI build. Teams that implement this catch performance regressions within minutes of the offending commit, when fixing them is trivial, rather than after they have been shipped to millions of users and appear as "app feels slow" reviews.
- Microbenchmark CI: catch parsing/algorithm regressions in PRs — before merge
- Macrobenchmark CI: catch startup/scroll regressions on physical device — before release
- Firebase Performance alerts: P95 custom trace threshold — production regression notification
- Play Vitals alerts: crash/ANR/slow start threshold email — before users leave bad reviews
- Version comparison: filter Vitals by version to pinpoint which release caused a regression
"The four-layer approach: Microbenchmark (code) → Macrobenchmark (device) → Firebase (production) → Play Vitals (users). Each layer catches different types of regressions. A performance regression that passes all four layers either doesn't exist or is too small to matter. The most common gap in teams: they have none of these layers and only discover regressions from 1-star reviews."
42. What is lazy loading and how do you implement it in Android lists and navigation?
Lazy loading defers work until it's actually needed — don't load data you're not showing, don't initialise objects you might not use. In lists it means loading on demand; in navigation it means loading screens only when navigated to.
// LAZY LOADING IN LISTS — Paging 3 // Load 20 items → user scrolls near end → load next 20 val flow = Pager( config = PagingConfig( pageSize = 20, prefetchDistance = 5 // load next page when 5 items from end ), pagingSourceFactory = { dao.paginate () } ).flow.cachedIn (viewModelScope) // LAZY LOADING IMAGES — Coil loads only visible items LazyColumn { items(products, key = { it.id }) { product -> AsyncImage(model = product.imageUrl, ...) // loads when scrolled into view // Coil automatically cancels loads for items that scroll away } } // LAZY INITIALISATION — object created on first use class AnalyticsManager { private val heavyClient bylazy { HeavyAnalyticsClient.create () // created only when first method is called } fun track(event: String) { heavyClient.log (event) } } // LAZY NAVIGATION — Compose destination loaded on navigate NavHost(navController, startDestination = "home") {composable ("home") { HomeScreen() } composable("detail/{id}") { DetailScreen() } // not instantiated until navigated to } // LAZY DEPENDENCY INJECTION — Hilt Provider class HomeViewModel @Inject constructor( private val repo: Provider<HeavyRepository> // not created until repo.get() called ) : ViewModel() { fun loadIfNeeded() { if (shouldLoad) repo.get ().load () // HeavyRepository created on first call } }
Lazy loading in Android lists is handled automatically by RecyclerView and Compose's LazyColumn — they only inflate and bind items near the viewport, deferring the cost of items far outside the visible area. The explicit lazy loading work falls to the data layer: Paging 3 fetches the next page of data from the network or database only when the user scrolls close to the end of the loaded content, with a configurable prefetch distance. Set PagingConfig(pageSize = 20, prefetchDistance = 5) to load the next page when 5 items before the end of the loaded content are visible — balanced between loading early enough to feel seamless and not over-fetching data the user may never scroll to.
Lazy loading in navigation is about deferring the cost of screen initialisation to when the screen is actually opened. In a bottom navigation app, don't pre-create all destination Fragments at startup — lazy-create them on first selection. Jetpack Navigation handles this correctly by default: destinations are only created when first navigated to. For heavyweight screens that have expensive ViewModel initialisation (loading large datasets, connecting to real-time sources), consider adding a loading skeleton that displays immediately while the ViewModel populates, rather than blocking navigation with a loading spinner in the previous screen. The user sees the new screen appear instantly, then content replaces the skeleton within 500ms.
Image lazy loading is one of the most visible forms of lazy loading because it directly affects perceived responsiveness. The correct pattern is to load only the image at the display size, not the full-resolution original — Coil and Glide do this automatically. For very large grids (a photo gallery with thousands of items), implement two-level thumbnails: a 50×50px blurHash placeholder decoded from a tiny base64-encoded string (loaded instantly from the API response JSON), followed by the full thumbnail at display resolution loaded from Coil's disk cache. The blurHash gives immediate visual feedback before the image loads, eliminating the jarring grey placeholder. Libraries like coil-compose support blurHash out of the box via the BlurHashDecoder component.
- Paging 3: load data in pages — only fetches what the user scrolls to see
- Coil: automatically cancels image loads for off-screen items — never loads what's not visible
- Kotlin lazy: delegate initialises on first access — ideal for expensive singletons
- Compose NavHost: destination composables not instantiated until navigated to
- Hilt Provider: inject a factory instead of an instance — create the object only when needed
"Lazy loading's most impactful application: don't load data for screens the user hasn't visited. A common mistake is loading all data for all tabs on launch — even tabs the user may never open. Use ViewModel lifecycle scoped to the NavBackStackEntry: the ViewModel (and its data loading) is only created when the user navigates to that screen."
43. Scenario: How do you optimise a screen that shows real-time data (e.g. live scores, stock prices) updating every second?
Real-time UIs require careful optimisation — updating 50 list items every second on the main thread will cause jank. The solution is surgical UI updates via DiffUtil (Views) or stable keys with derivedStateOf (Compose), combined with smart update throttling.
// ❌ Naive approach — full list update every second viewModel.liveData.collect { items -> adapter.submitList (items) // DiffUtil runs on every update — OK but can miss frames // If items is 1000 entries updating every 100ms → DiffUtil on main thread → jank } // ✅ Throttle updates to prevent overloading main thread viewModel.liveScores .sample (100) // emit at most once per 100ms — cap at 10fps .flowOn(Dispatchers.Default) // process updates on background thread . collect { scores -> adapter.submitList (scores.toList ()) } // ✅ DiffUtil in background — for large lists // ListAdapter already does this automatically on a background thread // But: if updates come faster than DiffUtil processes, it queues // Solution: deduplicate with conflate() viewModel.liveData .conflate () // drop intermediate updates — only process latest .collect { adapter.submitList (it) } // ✅ Compose — surgical updates with stable keys + derivedStateOf val scores by vm.scores.collectAsStateWithLifecycle () LazyColumn { items(scores, key = { it.matchId }) { score -> // Each ScoreRow recomposes only if ITS score changed // Other rows are skipped by Compose's smart recomposition ScoreRow(score = score) } } // Highlight changes briefly @Composable fun ScoreRow(score: LiveScore) { val bg byanimateColorAsState ( targetValue =if (score.justUpdated) Color.Yellowelse Color.Transparent, animationSpec =tween (500) ) Box(modifier = Modifier.background (bg)) { ScoreContent(score) } }
Real-time data screens face two conflicting requirements: the data must update frequently to feel "live," and the UI must remain smooth at 60fps. The key architectural principle is to decouple the data update rate from the UI refresh rate. A WebSocket stream delivering price updates 20 times per second should not trigger 20 recompositions per second — the UI cannot keep up, and the user cannot read data changing that fast anyway. Use a Flow with .conflate() to drop intermediate updates when the collector is busy, and throttleLatest(16) (or sample(16)) to cap the emission rate at one per frame (16ms). The UI always shows the latest value without dropping frames due to update pressure.
Diffing large real-time datasets efficiently requires the right data structure. A live leaderboard updating 100 entries is expensive to re-render if you replace the entire list on every update. Instead, compute and apply a diff: compare the new list to the previous list, identify the changed items by ID, and emit only those changes to the UI. In Compose, use LazyColumn with stable keys and update only the affected items' state — the unchanged items are not recomposed. In RecyclerView, submit the new list to ListAdapter (which uses DiffUtil on a background thread) rather than calling notifyDataSetChanged(), which redraws the entire visible viewport even for a single item change.
Battery and network efficiency are as important as rendering performance for real-time screens. A WebSocket that keeps the connection alive indefinitely drains both battery and data. Implement connection lifecycle management: open the WebSocket in onStart() and close it in onStop() — there's no point receiving updates when the screen is not visible. On foreground loss, switch from WebSocket to periodic WorkManager polling (every 15 minutes) so the user sees stale-but-cached data when they return rather than a loading state, and the live connection resumes immediately when the screen comes back. This pattern reduces background battery usage while maintaining the "live" feel during active use.
- sample(100ms): cap update rate — 10 UI updates per second is plenty for live scores
- conflate(): drop skipped frames — only process the latest update, skip intermediates
- Compose stable keys: only the changed score row recomposes — other rows stay intact
- flowOn(Dispatchers.Default): process diff on background thread — main thread just receives result
- animateColorAsState: flash highlight on update — tells users which item just changed
"The update rate question: 'How fast does the UI really need to update?' Stock prices at 60fps means rendering 60 new frames per second with identical-looking data. Users can't perceive changes faster than 200ms. Use sample(200) to cap at 5 updates/second — indistinguishable from real-time to users, but 12x less work for the rendering pipeline."
44. What is the Android GPU Inspector? How does it complement Android Studio's profiling tools?
Android GPU Inspector (AGI) is a deep-GPU profiling tool -- it captures a single frame and breaks it down to individual draw calls, shader execution times, texture memory, and fill rate. It complements Android Studio's Profiler which is CPU-focused. For most apps, CPU Profiler + Perfetto is sufficient; reach for AGI only when FrameMetrics shows high GPU_DURATION.
// AGI: download from developer.android.com/agi // Requires physical device with ARM Mali, Qualcomm Adreno, or Imagination GPU // Does NOT work on emulator // Instrument your app for AGI frame captures // AGI uses the GPU debugger API -- no code changes needed for frame capture // What AGI shows per draw call: // GPU time, vertex count, texture samples, shader invocations // Use FrameMetrics to find high-GPU frames first, then investigate with AGI window.addOnFrameMetricsAvailableListener({ _, metrics, _ -> val gpuMs = metrics.getMetric(FrameMetrics.GPU_DURATION) / 1_000_000L if (gpuMs > 8) log("High GPU frame: ${gpuMs}ms") // flag for AGI investigation }, handler)
The Android GPU Inspector (AGI) is Google's standalone GPU profiling tool that provides visibility into the GPU pipeline that Android Studio's profiler cannot. It captures frame-level GPU data: draw call counts per frame, shader execution time, vertex throughput, texture binding operations, and GPU memory bandwidth usage. AGI is designed for games and GPU-intensive apps where the GPU is the bottleneck, not the CPU. If Android Studio's GPU rendering profile shows a consistently tall "execute" segment (GPU submission time) while the main thread and RenderThread are idle, AGI is the next tool to reach for — it will identify whether the bottleneck is shader complexity, overdraw, texture upload bandwidth, or draw call count.
The most actionable insight from AGI for standard Android apps is draw call count. Each individual drawXxx() operation on a Canvas or each shader program execution on the GPU has a fixed overhead cost, regardless of how many pixels it draws. An app with 500 draw calls per frame is significantly slower than one with 50 draw calls drawing the same visual result. View hierarchy flattening directly reduces draw calls — a ConstraintLayout with 10 Views in a single level has fewer GPU draw calls than the equivalent nested LinearLayout hierarchy. In Compose, Modifier.graphicsLayer with renderEffect or drawBehind batches drawing operations more efficiently than equivalent imperative canvas calls.
AGI's shader profiler identifies which GLSL shader programs are the most expensive. Custom RenderEffect shaders, blur effects, and complex gradient rendering all execute as shader programs on the GPU. The shader profiler shows execution cycles per fragment — a shader that runs 1000 cycles per fragment on a high-end GPU runs 3000+ cycles on the same shader on a mid-range GPU's smaller shader cores. Optimise high-cycle shaders by reducing mathematical operations, using lower-precision floats (mediump instead of highp where visual quality allows), and pre-computing constants outside the fragment shader in the vertex shader. For blur effects specifically, use Android 12's RenderEffect.createBlurEffect() which uses the platform's optimised blur implementation rather than a custom shader.
- AGI frame capture: one frozen frame broken down to individual GPU draw calls -- see which draw call costs the most GPU time
- Draw call count: too many draw calls stall the GPU pipeline -- AGI shows the count per frame, target < 200 for UI apps
- Shader profiling: identifies expensive GLSL/SPIR-V shader code -- relevant for apps with custom OpenGL effects
- Use FrameMetrics first: GPU_DURATION > 8ms signals a GPU-bound frame -- that's when to open AGI for the frame details
- Most apps don't need AGI: standard View/Compose apps are CPU-bound, not GPU-bound -- AGI is for game-level GPU debugging
"For standard Android apps with Views or Compose, Android Studio Profiler + Perfetto covers 95% of performance issues. AGI is the specialist tool — reach for it when Perfetto shows high GPU_DURATION in FrameMetrics and you need to know exactly which draw operations are expensive. Game developers and apps with heavy custom rendering use AGI routinely; typical app developers rarely need it."
45. Scenario: Design a comprehensive performance strategy for a new Android app from day one.
A performance strategy built from day one is exponentially cheaper than retrofitting it. Establish four things before writing feature code: preventive tools (StrictMode, LeakCanary), architectural patterns that are inherently efficient (offline-first, Paging 3), performance budgets with numbers, and monitoring that alerts before users notice.
// Day 1: preventive tools -- crash on violations in debug if (BuildConfig.DEBUG) { StrictMode.setThreadPolicy( StrictMode.ThreadPolicy.Builder().detectAll().penaltyDeath().build() ) } // debugImplementation("com.squareup.leakcanary:leakcanary-android") // gradle.properties -- performance budgets enforced in CI // Macrobenchmark: fail if cold start > 1.5s // AAB size check: fail if download size > 20MB // Lint: abortOnError=true // Baseline Profile -- generate before v1.0 ships // src/main/baseline-prof.txt covers startup + main navigation // 30-40% cold start improvement at zero runtime cost
A comprehensive performance strategy starts before the first line of code is written. Establish a performance budget: define maximum acceptable values for cold start p75 (target: under 2 seconds on mid-range device), scroll frame p99 (target: under 50ms), ANR rate (target: under 0.1%), and crash-free session rate (target: above 99.5%). These budgets inform architectural decisions — choosing between a synchronous or async initialisation approach, selecting a DI framework with known startup overhead, deciding whether to use Compose or Views for a specific screen. Without a budget, performance is an afterthought addressed reactively; with a budget, it is a first-class constraint evaluated alongside functional requirements.
The toolchain setup establishes measurement infrastructure before optimisation begins. Integrate Macrobenchmark tests for cold start and critical user journeys in CI from week one — before there are performance regressions to catch, while the tests are still fast to write. Add Firebase Performance custom traces around every screen load and user action. Enable Android Vitals in Play Console and configure email alerts for any metric crossing the "bad" threshold. Set up a weekly performance review ritual: look at the Android Vitals dashboard every Monday, compare the previous 7 days against the 7 days before that, and file bugs for any metric that worsened. This cadence catches gradual regressions (a slow SDK update, an accumulation of small inefficiencies) that would be invisible in a single-release comparison.
The ongoing discipline is treating performance regressions with the same urgency as functional regressions. A CI build that introduces a 300ms cold start regression should block the PR, just as a failing unit test does. A release that worsens the ANR rate from 0.1% to 0.3% should trigger the same response as a crash spike. This cultural shift — performance as a correctness property, not a nice-to-have — is what separates apps that remain fast as they grow from apps that gradually become sluggish with each release. In code reviews, ask "what is the performance cost of this change?" with the same frequency as "is this correct?" and "is this tested?" The answers to all three questions determine whether the change ships.
- Day 1 preventive tools: StrictMode (crash on main-thread violations) + LeakCanary (auto-detect leaks) -- these cost zero effort and catch issues immediately
- Architectural performance: offline-first (Room as source of truth = fast loads), Paging 3 (never load full list), Coil (correct image loading)
- Performance budgets before features: cold start < 1.5s, scroll P90 > 55fps, APK < 20MB -- objective CI pass/fail criteria
- Baseline Profile before v1.0: generate once, commit to source, 30-40% startup improvement for every user from the first install
- Monitoring setup: Firebase Performance custom traces + Play Vitals alerts + Macrobenchmark in CI -- four independent early-warning layers
"The ROI calculation: fixing a memory leak takes 1 hour on day 1 (StrictMode crashes immediately). It takes 5 hours on day 30 (debug production crash reports, reproduce, fix). On day 300, it's a production incident affecting users. Performance debt compounds like financial debt — the interest rate is very high. Day 1 investment in StrictMode + LeakCanary pays for itself within the first week."
46. What is a foreground service? When must you use one and what are the restrictions in Android 14+?
A foreground service is a Service that shows a persistent notification — it tells the user (and the OS) that the app is doing important ongoing work. The OS gives it much higher priority than background services. Android 14 requires declaring the foreground service type explicitly.
// Use foreground service for: media playback, navigation, ongoing calls, file downloads // Must show: persistent notification in the notification shade // Priority: not killed by OS (unlike background services) // AndroidManifest.xml — declare permission and service type (Android 14 required) // <uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> // <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> // <service android:name=".MusicService" android:foregroundServiceType="mediaPlayback" /> // Modern foreground service with notification class MusicService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { val notification = NotificationCompat.Builder(this, "music_channel") .setContentTitle ("Now Playing") .setSmallIcon (R.drawable.ic_music) .setOngoing(true) // user can't dismiss . build ()if (Build.VERSION.SDK_INT >= 29) {startForeground (NOTIF_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK) }else {startForeground (NOTIF_ID, notification) }return START_STICKY } } // Android 14 foreground service types: // camera: camera capture // connectedDevice: Bluetooth, USB // dataSync: uploading/downloading // health: fitness tracking // location: ongoing navigation // mediaPlayback: music/video playback // mediaProjection: screen recording // microphone: voice recording // phoneCall: ongoing calls // remoteMessaging: messaging apps // specialUse: other (requires Play approval) // WorkManager expedited work — alternative to foreground service for tasks OneTimeWorkRequestBuilder<UploadWorker>() .setExpedited (OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) .build ()
A foreground service is an Android component that performs a long-running operation visible to the user via a persistent notification. The key distinction from a background service is that foreground services are user-aware — the notification tells the user that the app is actively doing work. Android prioritises foreground service processes against the LMK, making them significantly less likely to be killed for memory reclamation. Foreground services are the only correct mechanism for work that must continue while the app's UI is in the background and cannot be deferred: music playback, turn-by-turn navigation, a workout tracker recording GPS coordinates, a file upload that must not be interrupted.
Android 12 introduced strict restrictions on starting foreground services from the background — apps can no longer call startForegroundService() while not visible to the user unless specific exemptions apply (high-priority FCM message, precise alarm, or certain system-granted permissions). Android 14 added foreground service types that must be declared in the manifest: mediaPlayback, location, camera, microphone, dataSync, connectedDevice, mediaProjection, and phoneCall. Declaring the wrong type or no type causes a crash on Android 14+. Each type requires specific permissions — location requires ACCESS_FINE_LOCATION or ACCESS_COARSE_LOCATION, camera requires CAMERA.
WorkManager's expedited tasks are the preferred alternative to foreground services for data sync and upload work that is user-initiated but doesn't require a persistent notification. Expedited tasks run with high priority, bypass Doze deferral (subject to quota), and handle process death recovery automatically — WorkManager re-enqueues the task if the process is killed mid-execution. The practical rule: if the work requires a visible notification and must run indefinitely (music player, navigation), use a foreground service. If the work is bounded in time (under a few minutes), is triggered by a user action, and should not show a persistent notification, use an expedited WorkManager task. Getting this distinction right keeps the notification shade clean and avoids the Play Store policy violations that come from unnecessary foreground service usage.
- Foreground service: persistent notification + high OS priority — for user-visible ongoing work
- foregroundServiceType: required in manifest for Android 14+ — must match the actual work done
- startForeground: must be called within 5 seconds of service start — else ANR-like behaviour
- START_STICKY: OS restarts service after being killed — appropriate for media playback
- WorkManager expedited: alternative to foreground service for tasks — WorkManager handles type declaration
"Android 14's foreground service type requirement closes a common abuse pattern: apps that declared a vague foreground service to avoid background limits. Now you must declare 'mediaPlayback' or 'dataSync' — and Play Store reviewers can verify your service actually does what the type says. Using 'specialUse' requires justification in your Play Store listing."
47. Scenario: How do you diagnose and fix a complex multi-thread synchronisation bug causing intermittent crashes?
Race conditions and synchronisation bugs are the hardest category of Android bugs — they're intermittent, hard to reproduce, and don't leave obvious stack traces. The fix is eliminating shared mutable state by using coroutine-native patterns.
// Common race condition: two threads modify the same list class CartRepository { private val items =mutableListOf <CartItem>() // ❌ not thread-safe fun add(item: CartItem) { items.add (item) } // called from any thread fun remove(id: String) { items.removeIf { it.id == id } } } // ConcurrentModificationException when add + remove run simultaneously // FIX 1: Mutex — serialise access class CartRepository { private val mutex = Mutex() private val items =mutableListOf <CartItem>() suspend fun add(item: CartItem) = mutex.withLock { items.add (item) } suspend fun remove(id: String) = mutex.withLock { items.removeIf { it.id == id } } } // FIX 2: Single-thread dispatcher — all access on one thread class CartRepository { private val dispatcher = Dispatchers.IO.limitedParallelism (1) // single thread private val items =mutableListOf <CartItem>() suspend fun add(item: CartItem) = withContext(dispatcher) { items.add (item) } } // FIX 3: StateFlow — immutable snapshots, thread-safe updates class CartRepository { private val _items = MutableStateFlow(emptyList <CartItem>()) val items: StateFlow<List<CartItem>> = _items fun add(item: CartItem) { _items.update { current -> current + item } // ✅ atomic CAS — thread-safe } fun remove(id: String) { _items.update { current -> current.filter { it.id != id } } // ✅ atomic } } // Detect race conditions: // ThreadSanitizer (TSan) — catches data races at runtime // ./gradlew assembleDebug -PenableTsan=true (NDK apps) // For JVM: use -ea -Djava.util.concurrent.ThreadSanitizer
Multi-thread synchronisation bugs manifest as data races, deadlocks, or priority inversion — each with a distinct diagnostic signature. A data race (two threads reading and writing shared mutable state without synchronisation) shows up as corrupted data or ConcurrentModificationException — inconsistent behaviour that varies run-to-run. A deadlock shows up in an ANR trace as two threads each waiting for a lock the other holds — thread A holds lock X and waits for lock Y, thread B holds lock Y and waits for lock X. Priority inversion shows up as the main thread waiting unusually long for a low-priority background thread that holds a lock the main thread needs.
The Kotlin coroutine approach to synchronisation eliminates most lock-based concurrency bugs by design. Confine all mutations of shared state to a single coroutine dispatcher — effectively single-threaded access without a mutex. Use a dedicated Mutex for cases where concurrent access is unavoidable: mutex.withLock { sharedState.update() } suspends the coroutine rather than blocking the thread, keeping the underlying thread available for other work. For high-contention read-heavy state, use StateFlow with atomic updates — _state.update { currentState -> currentState.copy(field = newValue) } is thread-safe without explicit synchronisation because update() uses a CAS (compare-and-swap) loop internally.
Diagnosing a live synchronisation bug requires Perfetto's thread state visualisation. A deadlock appears as two threads permanently in the "Sleeping" state, each showing a lock wait in the Linux kernel's futex call. The Perfetto trace shows the exact timestamp when each thread acquired its first lock and when it began waiting for the second — the chronological sequence proves the deadlock. For data races, Thread Sanitizer (TSan) is the definitive tool: enable it in a debug build with -fsanitize=thread in the C++ build flags for NDK code, or use Kotlin's @Volatile checker and coroutine analysis tools for JVM code. TSan instruments memory accesses at the JNI/NDK boundary and reports every unsynchronised access with the full stack trace of both conflicting threads.
- Mutex.withLock: serialises concurrent access — only one coroutine executes the block at a time
- limitedParallelism(1): single-threaded dispatcher — all accesses sequential without explicit locks
- StateFlow.update: atomic compare-and-set — thread-safe without locks for simple state mutations
- Immutable lists in StateFlow: emit new list reference — readers always see a consistent snapshot
- ThreadSanitizer: detects data races at runtime — requires NDK, but finds races that testing misses
"The best solution to race conditions is eliminating shared mutable state entirely. StateFlow.update() uses atomic CAS (Compare-And-Swap) — no lock needed, no blocking. The pattern: state is immutable List, update creates a new List, StateFlow atomically swaps. Readers always see a complete, consistent list. No race, no mutex, no complexity."
48. How do you handle performance on low-end devices? What adaptations should your app make?
Low-end devices (< 2GB RAM, old CPUs) are a significant portion of Android users, especially in emerging markets. An app that's smooth on a Pixel may be unusable on a Redmi 9. Adaptive performance degrades gracefully based on device capability.
// Detect low-end device val am = context.getSystemService (ActivityManager::class.java) val isLowRam = am.isLowRamDevice () // system flag — Go/budget devices val memInfo = ActivityManager.MemoryInfo() am.getMemoryInfo(memInfo) val ramGb = memInfo.totalMem / (1024 * 1024 * 1024) // Adaptive strategy data class PerformanceTier(val level: Int) // 1=low, 2=mid, 3=high fun getPerformanceTier(context: Context): PerformanceTier { val am = context. getSystemService (ActivityManager::class.java) val info = ActivityManager.MemoryInfo() am.getMemoryInfo (info) return when { am.isLowRamDevice () -> PerformanceTier(1) info.totalMem < 3L * 1024 * 1024 * 1024 -> PerformanceTier(2) else -> PerformanceTier(3) } } // Apply tier-based adaptations when (performanceTier.level) { 1 -> { // Low-end: maximum savingsdisableAllAnimations () imageLoader.memoryCache ?.resize (20) // tiny image cache videoQuality = Quality.SD_360p prefetchDistance = 0 // no prefetching maxConcurrentTasks = 1 } 2 -> { // Mid-range: balancedenableBasicAnimations () imageLoader.memoryCache ?.resize (30) videoQuality = Quality.HD_720p } 3 -> { // Flagship: everything onenableAllAnimations ()enableBlur () videoQuality = Quality.HD_1080p } }
Low-end devices — defined as having under 2GB RAM, a processor below the Snapdragon 600 tier, and eMMC 5.0 storage — represent a significant portion of the global Android install base, particularly in emerging markets. The performance characteristics differ from flagship phones in every dimension: CPU is 3–5x slower, RAM fills up with 5–6 apps rather than 15+, storage I/O is 2–3x slower, and the GPU has half to a quarter of the shader throughput. An app that runs at 60fps on a Pixel 8 may drop to 20fps on a Galaxy A14 — and the Galaxy A14 represents a larger share of your user base in most markets than the Pixel 8.
Adaptive quality tiers are the implementation pattern for low-end support. Read the Android Performance class score at startup and cache it for the session. On performance class 10 (baseline), reduce image resolution by 50%, disable animations longer than 200ms, disable background image loading prefetch, use smaller RecyclerView page sizes, and skip speculative preloading. On performance class 12+, enable all features at full quality. Gate specific expensive features (video autoplay, real-time effects, 3D transitions) behind a performance class check. This is preferable to a global "low performance mode" toggle because it is automatic and calibrated to actual device capability rather than a user's potentially inaccurate self-assessment.
Testing on low-end hardware is non-negotiable. The Android emulator with restricted CPU cores and RAM (-cores 2 -ram 1024) approximates low-end performance for quick checks, but real hardware testing is irreplaceable for identifying GPU-related issues, storage I/O bottlenecks, and memory pressure behaviour. Maintain at least one low-end physical device in your test device farm — a sub-$150 Android One device works well. Run your Macrobenchmark suite on both high-end and low-end devices and track both sets of results in CI. A performance budget that only passes on the flagship device is not a real performance budget; it is a performance guarantee for the 5% of users who can afford the best hardware.
- isLowRamDevice(): system flag for Go edition and budget devices — disable heavy features
- totalMem check: RAM-based tier — < 3GB = mid-range, >= 3GB = flagship
- Animation reduction: disable complex animations on low-end — CPU/GPU are the bottleneck
- Image cache sizing: reduce memory cache size on low-RAM — prevents OOM kills
- Prefetch distance = 0: no eager loading on low-end — save RAM for visible content
"Test on a Redmi 9 or similar budget device before every release if your market includes India. What looks smooth on your Pixel dev device can be a slideshow on a 2GB RAM phone. The isLowRamDevice() flag covers the most extreme cases — devices that report themselves as constrained. For mid-range, use the totalMem check. Profile on actual budget hardware, not just emulator."
49. Scenario: Your app uses a lot of Coroutines. How do you diagnose and fix coroutine performance issues?
Coroutine performance issues are subtle — wrong dispatcher, too many coroutines, blocking calls inside suspend functions, and structured concurrency violations. The tools are coroutine debugging in Android Studio and Perfetto with coroutine tracing.
// Enable coroutine debugging (debug builds) // In Application.onCreate(): System.setProperty ("kotlinx.coroutines.debug", "on") // Coroutine stack traces become readable in debugger and crash reports // ISSUE 1: Blocking call inside suspend function suspend fun loadData(): Data { return OkHttpClient().newCall (request).execute () // ❌ blocks coroutine thread } // Fix: use Retrofit suspend functions (non-blocking) or wrap with withContext(IO) suspend fun loadData(): Data = withContext(Dispatchers.IO) { blockingClient.fetchSync () // ✅ runs on IO thread pool, main thread free } // ISSUE 2: Wrong dispatcher — CPU-intensive on Main suspend fun processImage(bitmap: Bitmap): Bitmap { returnapplyFilter (bitmap) // ❌ heavy CPU work — if called from Main, causes jank } // Fix: always specify dispatcher for CPU-heavy work suspend fun processImage(bitmap: Bitmap) = withContext(Dispatchers.Default) {applyFilter (bitmap) // ✅ Default = CPU-optimised thread pool } // ISSUE 3: Coroutine leak — not cancelled on lifecycle end // Already covered: use viewModelScope / lifecycleScope // ISSUE 4: Too many coroutines — coroutine overhead // Each coroutine: ~100 bytes memory + scheduling overhead // 10,000 concurrent coroutines: fine // 100,000 concurrent coroutines: memory pressure // Fix: use Flow operators instead of launching a coroutine per item // ❌ items.forEach { launch { process(it) } } // ✅ items.asFlow().flatMapMerge(concurrency = 8) { process(it) } // ISSUE 5: Dispatcher.Main misuse // Dispatchers.Main.immediate vs Dispatchers.Main: // .immediate: runs immediately if already on Main (no coroutine dispatch overhead) // Use for: UI updates that are latency-sensitive viewModelScope.launch (Dispatchers.Main.immediate) { _state.value = UiState.Loading // immediate → no frame delay for UI update }
Coroutine performance problems are often invisible in normal profiling because coroutines use shared thread pools — a leak or contention problem in coroutine scheduling doesn't show up as a single hot method but as global performance degradation. The primary diagnostic tool is the coroutine debugger in Android Studio (Run → Attach Debugger → Coroutines tab) which shows all active coroutines, their state (Active, Suspended, Created), their dispatcher, and their stack trace at the point of suspension. A large number of suspended coroutines all waiting on the same resource (a Mutex, a Channel, a database connection) reveals contention. Thousands of active coroutines where you expect dozens reveals a coroutine leak — typically a launch { } in a loop without a cancellation mechanism.
Dispatcher pool saturation is diagnosed by looking at thread counts in the Memory Profiler. Dispatchers.IO is backed by a thread pool with a default maximum of 64 threads. An app that launches 100 parallel network requests saturates this pool, causing the excess requests to queue. The queuing delay is invisible in per-request timing (it appears as network latency) but shows up as a discrepancy between the time a coroutine was launched and the time it first executed. Fix pool saturation by limiting concurrency explicitly: Dispatchers.IO.limitedParallelism(n) creates a view of the IO dispatcher that caps concurrent usage, ensuring your feature doesn't consume the entire pool and starve other components.
Structured concurrency violations — using GlobalScope, failing to cancel child coroutines, leaking CoroutineScope — are the root cause of most coroutine-related memory and performance issues. Every launch { } or async { } call should be in a scope that is cancelled when the associated lifecycle ends. Audit your codebase for GlobalScope usage with a lint rule or a simple grep — any occurrence in production code is a bug. For custom scopes, ensure scope.cancel() is called in the appropriate lifecycle callback (onDestroy(), onCleared()). The Kotlin coroutines debug artifact (kotlinx-coroutines-debug) adds coroutine lifecycle tracking that reports leaked coroutines to Logcat — include it in debug builds to catch violations during development.
- kotlinx.coroutines.debug: readable coroutine names in stack traces — essential for debugging
- Blocking in suspend: wrapping blocking calls without withContext(IO) starves the coroutine thread
- Dispatchers.Default: CPU-intensive work — separate thread pool from IO, right tool for computation
- flatMapMerge(concurrency): bounded parallelism — process N items concurrently, not all at once
- Dispatchers.Main.immediate: skip dispatch overhead when already on main thread — latency-sensitive UI updates
"The dispatcher choice rule: Dispatchers.IO for I/O-bound work (network, disk), Dispatchers.Default for CPU-bound work (sorting, image processing, JSON parsing of large files). Using IO for CPU work wastes the IO thread pool. Using Default for blocking I/O blocks the CPU threads. The distinction matters at scale: a 60-thread IO pool being used for CPU work stalls all network calls."
50. Scenario: Write a complete performance improvement plan for an app that has 3-second startup, frequent jank, 2% crash rate, and growing memory.
A performance recovery plan is prioritised by user impact: crashes affect more users than jank, jank affects more users than slow startup. Fix stability first, then perceived speed, then memory. Each phase delivers a measurable metric improvement -- quantify before and after to justify the investment to stakeholders.
// Week 1-2: fix crashes first (2% crash rate = 1 in 50 sessions) // Play Console → Android Vitals → Crashes → sort by affected users → fix top 3 // Enable StrictMode + LeakCanary → fix all violations before moving on // Week 3-4: startup (3s → target 1.5s) // CPU Profiler method trace of Application.onCreate() → defer non-critical SDKs class MyApp : Application() { override fun onCreate() { Timber.plant(Timber.DebugTree()) // fast -- keep // defer analytics, maps, push -- move to background after first frame } } // Week 5-6: jank → ListAdapter + DiffUtil, remove onDraw() allocations // Week 7-8: memory → LruCache, onTrimMemory(), heap dump comparison
A complete performance improvement plan begins with measurement, not optimisation. Before touching a line of code, establish the current baseline across all key metrics: capture cold start p75/p95 with Macrobenchmark on two devices (flagship and mid-range), record the ANR rate and crash-free sessions from the last 30 days in Play Console, measure scroll frame p99 on the three most-used screens, and log heap usage at steady state after 10 minutes of typical usage. Document these numbers. Every subsequent optimisation is evaluated against this baseline — if an optimisation doesn't move a number, it wasn't the bottleneck, and effort should be redirected elsewhere.
Prioritise by user impact, not by engineering interest. A 500ms cold start improvement is invisible if 90% of launches are warm starts. An ANR rate of 1% affects every user who encounters it but is a lower priority than a crash rate of 2% that hard-closes the app. Use Play Console's Android Vitals data to rank issues by the percentage of daily active users affected — fix the issue affecting 10% of users before the one affecting 0.1%. Assign each identified issue to one of four buckets: Quick wins (under 2 hours each — remove redundant backgrounds, add missing indexes, fix uncancelled coroutines), Architecture changes (1–3 days each — add Baseline Profiles, implement Paging 3, add lazy initialisation), Infrastructure (1 week — integrate Macrobenchmark in CI, set up Firebase Performance alerting), and Long-term (ongoing — establish performance review cadence, enforce budgets in PR reviews).
The plan's success criterion is not "performance improved" but "performance stays improved." Regressions are inevitable without infrastructure to prevent them — every new SDK integration risks startup time, every new feature risks frame timing, every new background task risks battery drain. The infrastructure that prevents regressions: Macrobenchmark in CI with budget enforcement, Firebase Performance alerts for production metric spikes, FrameMetrics logging per screen, and a weekly 30-minute review of Android Vitals trends. With this infrastructure in place, the team receives regression signals within hours of a bad commit merging, not after users leave one-star reviews. A performance improvement plan that doesn't end with "and here is how we will never regress to this state again" is incomplete.
- Crashes first: 2% crash rate means 1 in 50 sessions ends in a crash -- fix top 3 crash types from Play Console before touching performance
- Startup second: 3s cold start exceeds Play's 'bad behaviour' threshold (5s) but is still user-visible -- defer non-critical Application.onCreate() work
- Jank third: replace notifyDataSetChanged() with ListAdapter + DiffUtil, zero-allocation onDraw(), remove overdraw
- Memory last: slow leak is invisible until OOM kill -- heap dump comparison (start vs 30-min session), LruCache for unbounded HashMaps
- Measure every phase: record build time, crash rate, startup, and frame times before and after -- quantify the ROI for stakeholders
"The prioritisation principle: fix what prevents users from using the app before fixing what makes it slow. A crash during checkout is worse than checkout taking 2 extra seconds. A 3-second startup is worse than occasional scroll jank. Memory growth is worst because it's invisible until the OOM kill. Always lead with crash fix, then startup, then rendering, then memory."