Performance, accessibility and Play quality
Startup and jank measurement, baseline profiles, leak detection, TalkBack and contrast, and the Play vitals that affect distribution.
Startup and jank
// macrobenchmark: measure cold start on a real device
@RunWith(AndroidJUnit4::class)
class StartupBenchmark {
@get:Rule val rule = MacrobenchmarkRule()
@Test fun coldStartup() = rule.measureRepeated(
packageName = "com.example.app",
metrics = listOf(StartupTimingMetric(), FrameTimingMetric()),
iterations = 10,
startupMode = StartupMode.COLD,
compilationMode = CompilationMode.Partial(),
) {
pressHome()
startActivityAndWait()
}
}
// Baseline profile: precompiled code paths, generated by the same run
@RunWith(AndroidJUnit4::class)
class BaselineProfileGenerator {
@get:Rule val rule = BaselineProfileRule()
@Test fun profile() = rule.collect("com.example.app") {
pressHome()
startActivityAndWait()
device.findObject(By.text("Books")).click()
device.waitForIdle()
}
}| Metric | Target | Tool |
|---|---|---|
| Cold start | Under about 500 ms for a simple app | Macrobenchmark |
| Frame time | Under 16 ms at 60 Hz, 8 ms at 120 Hz | FrameTimingMetric, JankStats |
| Frozen frames | Zero | Perfetto, Play vitals |
| APK or AAB size | Bundle splits by density and ABI | Bundle analyser |
| Memory on a list screen | Stable over scrolling | Memory profiler, LeakCanary |
- Measure on a release build on a physical low-end device. Debug builds are more than ten times slower and hide the problems you are trying to find.
- A baseline profile is the highest-leverage performance change available: it typically cuts startup and jank by a large fraction with no code changes.
- Do work off the main thread even if it is fast. A 50 ms JSON parse on the main thread is a dropped frame, and on a slow device it is three.
💡
Compose's compiler marks restartable and skippable functions, and stability of your parameter types decides whether skipping works. An unstable type such as a mutable list or a class from a library without stability information makes a whole subtree recompose on every change.
Memory and leaks
- Add LeakCanary in debug builds. It reports a retained Activity with the path that keeps it alive, which is almost always an anonymous listener, a coroutine in a global scope, or a static reference.
- Cancel coroutines with the right scope. A
GlobalScopelaunch holding a Context is a leak with a delay fuse. - Unregister every listener: lifecycle observers, sensor callbacks, broadcast receivers, database cursors.
- Watch bitmap memory. An image decoded at full resolution and scaled down in the view costs many times what a correctly sampled decode costs.
- Use
WindowManagerto check for a heavy layout. Deeply nested views inflate measure and layout time, and Compose's LazyColumn exists to avoid rendering what is off screen.
// decode at the size you will display
val options = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(path, options)
options.inSampleSize = calculateInSampleSize(options, targetWidth, targetHeight)
options.inJustDecodeBounds = false
val bitmap = BitmapFactory.decodeFile(path, options)
// show large images with a library that owns the cache
// Coil, with a size hint so the decode matches the slot
AsyncImage(
model = ImageRequest.Builder(context).data(url).size(400).crossfade(true).build(),
contentDescription = null,
)Accessibility and Play quality
// a decorative image
Icon(Icons.Default.Star, contentDescription = null)
// a meaningful image or icon button
IconButton(onClick = onShare, modifier = Modifier.semantics { }) {
Icon(Icons.Default.Share, contentDescription = "Share this book")
}
// merge a row into one focusable announcement
Row(Modifier.semantics(mergeDescendants = true) {}) {
Text(book.title)
Text(book.author)
}
// a heading so TalkBack navigation can jump to it
Text("Latest", Modifier.semantics { heading() })
// custom actions for a gesture-heavy widget
Box(Modifier.semantics {
customActions = listOf(CustomAccessibilityAction("Delete") { onDelete(); true })
})- Every interactive element needs a minimum touch target of 48 dp, and every icon-only control needs a content description that says what it does, not what it looks like.
- Support font scaling: test at 200 percent. A fixed-height row with text in it clips, and a hard-coded
spin a small box is where accessibility and design argue. - Contrast matters: 4.5:1 for body text, 3:1 for large text. Material's colour roles are designed for this, which is why a hard-coded hex value breaks it.
- Test with TalkBack and with a keyboard, and run the Accessibility Scanner over your main screens.
# Play vitals that affect search and promotion
# ANR rate crashes and freezes in the foreground
# crash rate including background crashes
# excessive wakeups battery drain reported by the platform
# stuck partial wakelocks
#
# and store requirements, which change every year:
# targetSdk within the current window
# a privacy policy for anything collecting data
# a data safety declaration that matches the SDKs you shipRun the pre-launch report before every release. It tests on real devices, flags crashes, accessibility issues and excessive permissions, and it is free - shipping without reading it means your users find those problems first.
FAQ
What should I optimise first?
Cold start and jank, measured on a release build on a low-end device. Add a baseline profile first because it needs no code change, then look at leaks and image decoding, then at the layout of the screens that appear in your startup trace.
How much does accessibility really matter?
It is a legal requirement in many markets, and it improves the app for every user: larger touch targets, correct content descriptions and scalable text also make the app usable one-handed, in sunlight and on a broken screen.
Related
Testing Android apps Permissions, storage and publishing
Last refreshed 2026-09-18.