Android Performance & Memory
Profilers, leaks, ANRs, baseline profiles, R8, startup, recomposition perf
- 01
What is an ANR? How do you avoid them?
MediumAn 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() … - 02
How do you find and fix memory leaks on Android?
MediumA 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 … - 03
What is jank? How do you measure and fix dropped frames?
MediumJank 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 … - 04
What does R8 do to a release build, and what breaks when its keep rules are wrong?
MediumR8 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 … - 05
What are baseline profiles and what do they buy you?
MediumA 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( … - 06
How do you optimize app startup time on Android?
MediumCold 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() … - 07
Users report the app eats their battery. How do you find out what it is doing and fix it?
MediumBattery 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() … - 08
Why is a photo grid the fastest way to run an Android app out of memory?
MediumA 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 … - 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?
MediumMeasure 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
Microbenchmark, Macrobenchmark and JankStats — which question does each one answer?
MediumThey 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
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?
HardThe system records how your previous process ended, and
ApplicationExitInfois how you read that record on the next launch.class MyApp : Application() { override fun onCreate() { super.onCreate() reportLastExit() } … - 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?
HardNamed trace sections put your methods on the timeline as slices, and a
profileablemanifest 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
Scrolling stutters, LeakCanary is silent, and the memory graph is a perfect sawtooth. What is going on?
MediumThat 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
How do you catch a disk read on the main thread, or a Cursor nobody closed, before a tester does?
MediumStrictMode — 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.onCreateof a debug build.class MyApp : Application() { override fun onCreate() { if (BuildConfig.DEBUG) enableStrictMode() // debug only — never ship this super.onCreate() } … - 15
Debug GPU overdraw paints your whole feed dark red. What is that costing you, and what do you actually remove?
MediumEvery 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
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?
MediumA
contentType— keys tell Compose which item is which,contentTypetells 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
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?
HardThe 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
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?
HardThe 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
An image decode that benchmarks at 30 ms takes 300 ms inside the real app on the same phone. What is different?
HardThread 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
The Layout Inspector says nothing is recomposing, and the screen still drops frames. Where does a Compose frame spend its time then?
HardRecomposition 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) …