Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/AmolPardeshi99/android-performance-skills/llms.txt

Use this file to discover all available pages before exploring further.

Catching ANR-prone code before it reaches production requires a layered detection strategy: StrictMode surfaces main-thread violations in development, Macrobenchmark measures startup and frame timing in CI, Perfetto enables deep profiling, and the FrameMetricsApi monitors frozen frames in production.

StrictMode — catch violations in development and CI

StrictMode instruments the main thread at runtime and logs (or kills) the process when it performs disk reads, disk writes, or network calls on the main thread. Configure it in Application.onCreate() so it is active from the very first frame:
class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        if (BuildConfig.DEBUG) {
            StrictMode.setThreadPolicy(
                StrictMode.ThreadPolicy.Builder()
                    .detectDiskReads()
                    .detectDiskWrites()
                    .detectNetwork()
                    .detectCustomSlowCalls()
                    .penaltyLog()
                    // For CI: .penaltyDeath() to hard-fail on first violation
                    .build()
            )
            StrictMode.setVmPolicy(
                StrictMode.VmPolicy.Builder()
                    .detectLeakedSqlLiteObjects()
                    .detectLeakedClosableObjects()
                    .detectActivityLeaks()
                    .penaltyLog()
                    .build()
            )
        }
    }
}
Use penaltyLog() during local development to see violations in Logcat without crashing. Switch to penaltyDeath() in CI runs to turn any violation into a hard test failure.

Annotating known-slow calls

When a function is intentionally slow but must run on a specific thread, annotate it with noteSlowCall so StrictMode surfaces it in violation reports:
fun parseConfig(raw: String): Config {
    StrictMode.noteSlowCall("parseConfig")
    return gson.fromJson(raw, Config::class.java)
}
detectCustomSlowCalls() must be enabled in the ThreadPolicy for noteSlowCall annotations to appear in StrictMode output.

Macrobenchmark — measure startup and frame timing in CI

The Jetpack Macrobenchmark library runs on a real or emulated device in the androidTest module and produces reproducible measurements of startup time and frame timing. Run this in CI to catch regressions before they reach production:
// androidTest module
@LargeTest
@RunWith(AndroidJUnit4::class)
class AppStartupBenchmark {
    @get:Rule val benchmarkRule = MacrobenchmarkRule()

    @Test
    fun coldStartup() = benchmarkRule.measureRepeated(
        packageName = "com.example.app",
        metrics     = listOf(StartupTimingMetric(), FrameTimingMetric()),
        iterations  = 5,
        startupMode = StartupMode.COLD
    ) {
        pressHome()
        startActivityAndWait()
    }
}
StartupTimingMetric captures time-to-first-frame and time-to-fully-drawn. FrameTimingMetric captures P50, P90, P95, and P99 frame durations. Both metrics are written to a JSON output file that CI can parse and compare against baseline thresholds.

Perfetto — main thread hotspot analysis

Perfetto traces capture a full timeline of every thread slice, Binder transaction, and scheduling event. Use these SQL queries in the Perfetto UI or the trace_processor CLI to find main-thread work exceeding one frame budget and Binder calls that block the main thread:
-- Main thread slices taking >16ms (one frame budget)
SELECT
    s.name,
    s.dur / 1e6 AS dur_ms,
    s.ts / 1e6  AS start_ms
FROM slice s
JOIN thread_track tt ON s.track_id = tt.id
JOIN thread t ON tt.utid = t.utid
WHERE t.name = 'main'
  AND s.dur > 16000000  -- 16ms in nanoseconds
ORDER BY s.dur DESC
LIMIT 50;
-- Binder transactions on main thread
SELECT s.name, s.dur / 1e6 AS dur_ms
FROM slice s
JOIN thread_track tt ON s.track_id = tt.id
JOIN thread t ON tt.utid = t.utid
WHERE t.name = 'main'
  AND s.name LIKE 'binder%'
ORDER BY s.dur DESC;
Duration values in Perfetto are stored in nanoseconds. Divide by 1e6 to convert to milliseconds in query output. The threshold 16000000 in the first query equals 16 ms expressed in nanoseconds.

FrameMetricsApi — production frame monitoring

Window.OnFrameMetricsAvailableListener delivers per-frame timing data in production without requiring a profiler or adb connection. Register it in onStart and unregister in onStop to track frozen frames (>700 ms) and jank frames (>16 ms):
class PerformanceActivity : AppCompatActivity() {
    private val frameListener = Window.OnFrameMetricsAvailableListener { _, frameMetrics, _ ->
        val totalDuration = frameMetrics.getMetric(FrameMetrics.TOTAL_DURATION) / 1_000_000.0
        if (totalDuration > 700.0) {
            analyticsTracker.logFrozenFrame(totalDuration)
        } else if (totalDuration > 16.0) {
            analyticsTracker.logJankFrame(totalDuration)
        }
    }

    override fun onStart()  { super.onStart(); window.addOnFrameMetricsAvailableListener(frameListener, Handler(Looper.getMainLooper())) }
    override fun onStop()   { super.onStop();  window.removeOnFrameMetricsAvailableListener(frameListener) }
}
The 700 ms threshold matches Android Vitals’ definition of a frozen frame. Google Play tracks the percentage of daily active users who experience at least one frozen frame, so reporting these events lets you correlate your internal metrics with Play Console data.

Compose compiler metrics — detect unstable parameters

The Compose compiler can emit a report listing which composables are restartable and skippable. A composable is skippable only when all its parameters are stable — meaning Compose can skip recomposition when the parent recomposes but the parameters have not changed. A composable that is restartable but not skippable will recompose on every parent recomposition regardless of whether its inputs changed. Enable the reports in your app module’s build.gradle.kts:
// build.gradle.kts (app module)
android {
    kotlinOptions {
        freeCompilerArgs += listOf(
            "-P", "plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=${project.buildDir}/compose-metrics",
            "-P", "plugin:androidx.compose.compiler.plugins.kotlin:reportsDestination=${project.buildDir}/compose-reports"
        )
    }
}
// After build: check build/compose-reports/<module>-composables.txt
// Look for: "restartable skippable" (good) vs "restartable" without "skippable" (unstable parameters)
After building, open build/compose-reports/<module>-composables.txt. Each composable is listed with its stability classification. Any composable marked restartable without skippable has at least one unstable parameter — fix it by annotating the parameter type with @Immutable or @Stable, or by replacing List<T> with ImmutableList<T> from the kotlinx-collections-immutable library.
Add this check to CI by parsing the composables report and failing the build if any public-facing composable is not skippable. This prevents performance regressions from sneaking in through parameter type changes.

ApplicationExitInfo — post-mortem ANR analysis

ActivityManager.getHistoricalProcessExitReasons() is available from Android 11 (API 30). It returns a list of ApplicationExitInfo objects describing why the process was last terminated, including full ANR tombstone traces. Query this at app startup to detect whether the previous session ended in an ANR and to upload the trace to your observability backend:
// Query at app startup, e.g. in Application.onCreate() or a startup ViewModel
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
    val am = getSystemService(ActivityManager::class.java)
    am.getHistoricalProcessExitReasons(null, 0, 10).forEach { info ->
        if (info.reason == ApplicationExitInfo.REASON_ANR) {
            // info.traceInputStream contains the ANR trace (equivalent to /data/anr/anr_* content)
            info.traceInputStream?.use { stream ->
                // Parse and upload tombstone trace to your observability backend
                crashReporter.uploadAnrTrace(
                    stream.readBytes(),
                    extras = mapOf(
                        "timestamp"   to info.timestamp,
                        "description" to info.description,
                        "importance"  to info.importance  // foreground vs background at time of ANR
                    )
                )
            }
        }
    }
}
traceInputStream contains the same thread stack and CPU data as the files in /data/anr/, but is accessible without root or a userdebug build. The importance field tells you whether the process was in the foreground or background at the time of the ANR, which helps triage user-perceived vs background ANRs.

Pulling ANR traces with adb

For local debugging on rooted or userdebug devices:
# Pull all ANR traces from device (requires root or userdebug build)
adb pull /data/anr/ ./anr_traces/

# On older devices, single aggregated file:
#   /data/anr/traces.txt
# On newer devices (Android 11+), per-event files:
#   /data/anr/anr_YYYY-MM-DD-HH-MM-SS-mmm_<pid>

# Full bugreport (captures traces, EventLog, MainLog, CPU info — preferred for production):
adb bugreport bugreport.zip

Code review checklist

Use this checklist when reviewing Android code for ANR risk or when auditing an existing codebase.
  • All three ANR types are understood: Component-class (AMS), Input-class (InputDispatcher), No-Focused-Window
  • Foreground service calls startForeground() within 3 s (API 35+) or 5 s (API 26–34) of onStartCommand
  • ApplicationExitInfo is queried at startup to capture ANR traces (Android 11+ / API 30+)
  • Production crash reporter uploads ANR trace bytes with timestamp, description, and importance fields
  • ANR monitoring dashboard tags OEM-freeze false positives by detecting unfreeze … reason: Signal in logs within 1–2 s of am_anr
  • No network, file, database, or ContentResolver calls run on Dispatchers.Main or Dispatchers.Default
  • All JSON/Protobuf parsing runs on Dispatchers.IO or Dispatchers.Default — never after observeOn(mainThread())
  • No runBlocking in Activity, Fragment, ViewModel, or Service code
  • No GlobalScope.launch in production — use an injected application-scoped CoroutineScope
  • Dispatchers.IO for all I/O; Dispatchers.Default for CPU work; Dispatchers.Main for UI only
  • lazy on Main-only UI properties uses LazyThreadSafetyMode.NONE
  • No synchronized {} block contains a suspend call — use Mutex.withLock instead
  • ReentrantLock always uses withLock {} or try/finally; never held across a suspend point
  • Nested lock acquisitions follow a consistent, documented ordering; no cross-thread lock inversion
  • Crash, logging, and analytics utilities do not hold their own locks while calling into other locked utilities
  • StrictMode enabled in debug builds; zero disk/network violations on the main thread
  • onBindViewHolder contains only view-assignment — no filtering, mapping, or I/O
  • DiffUtil calculated off Main (or ListAdapter used)
  • onDraw contains no object allocations
  • BroadcastReceiver.onReceive uses goAsync() only for work under 10 s, or delegates to WorkManager
  • goAsync() PendingResult.finish() is always called — including on the error path — before the timeout
  • JobService.onStartJob returns immediately and performs work in a coroutine
  • Services call stopSelf() promptly after work completes
  • SharedPreferences reads on Dispatchers.IO; writes use apply(), never commit(); consider DataStore migration
  • Cancellation checkpoint (ensureActive() or yield()) present in tight CPU loops
  • Binder calls to system services (PackageManager, AccountManager, ContentResolver) are off Main
  • Heavy startup work is deferred past the first frame using window.decorView.post { }
  • Bitmap decoding is scaled to display size before being handed to the RenderThread
  • No expensive computation directly in the composable body — use remember(key) or push to a ViewModel
  • No backwards state write (writing state after reading it in the same composition phase)
  • High-frequency state reads (scroll offset, animation value) deferred via lambda-based Modifiers or derivedStateOf
  • derivedStateOf used for state derived from a rapidly-changing source to avoid over-recomposition
  • All composable parameters are stable (@Immutable, @Stable, primitives, or Immutable Collections)
  • LazyColumn/LazyRow items have stable key = { item.id }
  • No runBlocking or blocking calls inside a composable body — use LaunchedEffect
  • Side effects (Toast, analytics, network) are inside LaunchedEffect, not the composable body
  • Modifier.offset { } (lambda form) used instead of Modifier.offset(value) for scroll-driven offsets
  • Compose compiler metrics checked in CI — all composables expected to be restartable skippable are
  • FrameMetrics or Android Vitals monitored for frozen frame rate at or below acceptable threshold
  • Debug builds capture and log ApplicationExitInfo ANR traces on next launch
  • Production observability backend receives ANR trace bytes, not just stack strings
  • Team can read thread state (Blocked vs Native vs TimedWaiting) in trace files
  • Team can interpret CPU load lines and page fault counts in "ANR in" log entries
  • Perfetto trace is captured for any ANR not fully explained by thread stack alone

Build docs developers (and LLMs) love