Android Performance & Memory
Irbisa · cheatsheetSeptember 13, 2026

Android Performance & Memory

Profilers, leaks, ANRs, baseline profiles, R8, startup, recomposition perf

Senior Developer20 itemscompressed for a skim
  1. 01

    What is an ANR? How do you avoid them?

    Medium

    An ANR fires when the main thread fails to respond in time — roughly five seconds for an input event.

    // ❌ Sync I/O on main
    fun loadConfig(): String = File("/sdcard/config.json").readText()
    
    // ✅ Off-main with coroutines
    suspend fun loadConfig(): String = withContext(Dispatchers.IO) {
      File(filesDir, "config.json").readText()
    …
  2. 02

    How do you find and fix memory leaks on Android?

    Medium

    A leak on Android is almost always something long-lived holding on to a destroyed Activity, Fragment or View.

    object Cache { var ctx: Context? = null }        // any Activity stored here leaks
    
    class MainActivity : ComponentActivity() {
      override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Cache.ctx = this                             // ❌ leaks the Activity and its view tree
    …
  3. 03

    What is jank? How do you measure and fix dropped frames?

    Medium

    Jank is a frame that misses its deadline, so the display shows the previous one again and the user sees a stutter.

    class MainActivity : ComponentActivity() {
      private lateinit var jankStats: JankStats
    
      override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        // Production jank telemetry — per-frame durations from real devices
    …
  4. 04

    What does R8 do to a release build, and what breaks when its keep rules are wrong?

    Medium

    R8 shrinks, optimizes and obfuscates your code in a single pass over the bytecode when you build a release variant.

    // app/build.gradle.kts
    android {
      buildTypes {
        release {
          isMinifyEnabled = true          // R8: shrink + optimize + obfuscate
          isShrinkResources = true        // requires isMinifyEnabled
    …
  5. 05

    What are baseline profiles and what do they buy you?

    Medium

    A baseline profile lists the methods ART should compile ahead of time at install, instead of interpreting them on first run.

    @RunWith(AndroidJUnit4::class)
    class BaselineProfileGenerator {
      @get:Rule val rule = BaselineProfileRule()
    
      @Test
      fun generate() = rule.collect(
    …
  6. 06

    How do you optimize app startup time on Android?

    Medium

    Cold start is the case that matters: the process, the Application object and the first Activity all have to be created before anything reaches the screen.

    // App Startup — one ContentProvider for every library initializer, in a declared order
    class AnalyticsInitializer : Initializer<AnalyticsClient> {
      override fun create(context: Context): AnalyticsClient =
        AnalyticsClient.init(context).also { it.start() }
    
      override fun dependencies(): List<Class<out Initializer<*>>> = emptyList()
    …
  7. 07

    Users report the app eats their battery. How do you find out what it is doing and fix it?

    Medium

    Battery complaints are almost always work the app does while nobody is looking — wakeups, polling, location, or a wake lock that was never released.

    val sync = PeriodicWorkRequestBuilder<SyncWorker>(6, TimeUnit.HOURS)
      .setConstraints(
        Constraints.Builder()
          .setRequiredNetworkType(NetworkType.UNMETERED)
          .setRequiresBatteryNotLow(true)
          .build()
    …
  8. 08

    Why is a photo grid the fastest way to run an Android app out of memory?

    Medium

    A decoded bitmap costs width times height times bytes per pixel, and nothing about the file size on disk predicts that number.

    // Manual decode, scaled to the size actually displayed
    fun decodeScaled(path: String, reqW: Int, reqH: Int): Bitmap {
      val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
      BitmapFactory.decodeFile(path, bounds)
    
      var sample = 1
    …
  9. 09

    The download size of your app has crept up to 90 MB and install conversion is falling. Where does the weight go and what do you cut?

    Medium

    Measure before cutting: the APK Analyzer in Android Studio breaks a build down by DEX, resources, assets and native libraries, and the answer is usually native code, images, or one oversized dependency.

    android {
      buildTypes {
        release {
          isMinifyEnabled = true       // R8: shrink, optimize, obfuscate
          isShrinkResources = true     // drop resources no surviving code references
          proguardFiles(
    …
  10. 10

    Microbenchmark, Macrobenchmark and JankStats — which question does each one answer?

    Medium

    They measure three different scales: one function, one user journey, and one real user's session.

    @RunWith(AndroidJUnit4::class)
    class StartupBenchmark {
      @get:Rule val rule = MacrobenchmarkRule()
    
      @Test fun coldStartNoProfile() = rule.measureRepeated(
        packageName = "com.example.app",
    …
  11. 11

    Testers keep saying the app "restarts itself" when they switch back to it, and there is no crash in Crashlytics. How do you find out what happened?

    Hard

    The system records how your previous process ended, and ApplicationExitInfo is how you read that record on the next launch.

    class MyApp : Application() {
      override fun onCreate() {
        super.onCreate()
        reportLastExit()
      }
    …
  12. 12

    A Perfetto trace shows the framework's work but none of yours. How do you get your own code onto the timeline, and how do you trace a release build?

    Hard

    Named trace sections put your methods on the timeline as slices, and a profileable manifest flag is what makes a non-debuggable release build traceable at all.

    import androidx.tracing.trace
    
    suspend fun loadFeed(): List<Post> = trace("FeedRepo.load") {
      val json = trace("FeedRepo.network") { api.feed() }
      trace("FeedRepo.parse") { parse(json) }
    }
    …
  13. 13

    Scrolling stutters, LeakCanary is silent, and the memory graph is a perfect sawtooth. What is going on?

    Medium

    That sawtooth is allocation churn — you are not leaking, you are creating and discarding objects fast enough that the collector runs during the scroll.

    class Sparkline(ctx: Context) : View(ctx) {
      // ❌ three objects every frame, times 120 fps, times the whole fling
      override fun onDraw(canvas: Canvas) {
        val paint = Paint().apply { color = Color.BLUE; strokeWidth = 4f }
        val rect = RectF(0f, 0f, width.toFloat(), height.toFloat())
        canvas.drawRoundRect(rect, 8f, 8f, paint)
    …
  14. 14

    How do you catch a disk read on the main thread, or a Cursor nobody closed, before a tester does?

    Medium

    StrictMode — it watches the main thread for blocking work and the VM for objects you failed to close, and you turn it on yourself in Application.onCreate of a debug build.

    class MyApp : Application() {
      override fun onCreate() {
        if (BuildConfig.DEBUG) enableStrictMode()   // debug only — never ship this
        super.onCreate()
      }
    …
  15. 15

    Debug GPU overdraw paints your whole feed dark red. What is that costing you, and what do you actually remove?

    Medium

    Every extra layer is the GPU filling the same pixels again, and on a budget phone with a 1080p screen that wasted fillrate is where the frame time goes.

    // ❌ three opaque layers over the same pixels: window background, screen
    // background, and the card's own surface
    Surface(color = MaterialTheme.colorScheme.background) {
      Box(Modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
        Card(Modifier.padding(16.dp)) { Text("Hi") }
      }
    …
  16. 16

    A feed of headers, text rows and video rows janks on a fast fling even though every item has a stable key. What else does a lazy list need?

    Medium

    A contentType — keys tell Compose which item is which, contentType tells it which item a retired slot can be reused for.

    sealed interface Row { val id: String }
    data class Header(override val id: String, val title: String) : Row
    data class TextPost(override val id: String, val body: String, val date: Instant) : Row
    data class VideoPost(override val id: String, val url: String) : Row
    
    // ❌ keys but no content type: every retired slot looks interchangeable, so a
    …
  17. 17

    Your main thread finishes its frame work in 9 ms and users still see stutter at 120 Hz. What else is inside the frame budget?

    Hard

    The budget covers the main thread, the RenderThread and the GPU together, and at 120 Hz the whole pipeline has 8.3 ms — so 9 ms of main-thread work has already missed it on its own.

    class MainActivity : ComponentActivity() {
      private val frameThread = HandlerThread("frames").apply { start() }
    
      override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        setContent { AppTheme { Home() } }
    …
  18. 18

    Play Vitals shows the app being killed for memory, but the Java heap in the profiler never goes above 40 MB. Where is the rest of it?

    Hard

    The low-memory killer weighs the whole process footprint, and graphics, native allocations, code and stacks all count toward it while none of them appear on the Java heap graph.

    // The same accounting as `adb shell dumpsys meminfo <package>`, from in-process
    fun logFootprint(context: Context) {
      val am = context.getSystemService(ActivityManager::class.java)
      val info = am.getProcessMemoryInfo(intArrayOf(Process.myPid())).first()
    
      val kb = listOf("java-heap", "native-heap", "graphics", "code", "stack", "total-pss")
    …
  19. 19

    An image decode that benchmarks at 30 ms takes 300 ms inside the real app on the same phone. What is different?

    Hard

    Thread priority.

    // ❌ a pool created lazily by whichever thread got there first inherits its
    // priority — one background-priority caller and every decode is demoted
    private val decoders = Executors.newFixedThreadPool(4)
    
    // ✅ say out loud what each pool is for
    private fun pool(name: String, priority: Int, size: Int): ExecutorService =
    …
  20. 20

    The Layout Inspector says nothing is recomposing, and the screen still drops frames. Where does a Compose frame spend its time then?

    Hard

    Recomposition is only the first of three phases — a frame can be entirely layout-bound or draw-bound, and none of that shows up as a recomposition count.

    // ❌ BoxWithConstraints is a SubcomposeLayout: it composes its children during
    // the measure pass, once per item, on the frame that scrolls the item in
    LazyColumn {
      items(rows, key = { it.id }) { row ->
        BoxWithConstraints {
          if (maxWidth > 600.dp) WideRow(row) else NarrowRow(row)
    …