Android development / Advanced: reliable mobile systems and releases / Lesson 15
Performance measurement and diagnosis
Turn a slow reading-list interaction into a measurable hypothesis, then improve the responsible bottleneck without sacrificing correctness.
On this page
Lesson goals
- Distinguish startup, frame, memory, and background-work costs.
- Use representative release-like measurements and bounded optimizations.
Define the user-visible delay
Slow startup, delayed taps, janky scrolling, memory pressure, and battery drain require different evidence. Define a concrete journey such as opening a cached list and scrolling fifty rows. Record device class, build variant, dataset, temperature, and whether the process is cold or warm. Debug builds and emulators are useful for investigation but may distort final timing. A performance target needs a measurement method and a distribution, not one impressive best-case number. Compare equivalent conditions before concluding that a code change improved the experience.
Find expensive work on the critical path
The main thread handles input and rendering coordination, so blocking disk operations, decoding, or long computation can delay frames. Use a system trace and profiler to identify what actually occupies time. Moving everything to I/O is not a universal fix: CPU-intensive computation belongs on an appropriate compute dispatcher, while unnecessary work should be removed. Inject dispatchers for testability and ensure results remain valid when requests are canceled or superseded. StrictMode can reveal accidental disk or network work during development, but it is diagnostic evidence rather than a complete performance test.
class TitleIndexer(private val compute: CoroutineDispatcher) {
suspend fun index(titles: List<String>): Map<String, List<String>> =
withContext(compute) {
titles.groupBy { it.firstOrNull()?.uppercaseChar()?.toString() ?: "#" }
}
}
Inject a suitable compute dispatcher such as Dispatchers.Default from the composition root, and a test dispatcher in tests. This grouping policy is deliberately simple and not a complete locale-aware index. Offloading is justified only when measurement shows enough work to matter.
Measure composition, layout, and drawing separately
Recomposition is not automatically a performance defect. Investigate whether expensive calculations repeat, unstable inputs trigger unnecessary work, or layout and drawing dominate instead. Stable lazy-list keys preserve item identity; appropriate content types can help reuse. Decode images at a size suited to display and use a lifecycle-aware image loader rather than full-resolution bitmaps in every row. Avoid marking types immutable unless that promise is true. Compiler skipping behavior depends on the selected Compose toolchain, so inspect its reports and traces instead of applying outdated stability folklore blindly.
Mark meaningful phases
Trace sections connect an application operation to the system timeline without requiring verbose logs in a hot path. Keep sections short, pair begin and end in a finally block, and avoid personal data in labels. A section around synchronous mapping can reveal whether decoding or sorting dominates a refresh. A simple thread-bound trace section must not be stretched across arbitrary coroutine suspension; use the appropriate supported tracing API for asynchronous work. Correlate application markers with frame misses, database activity, and scheduling before deciding where to optimize.
fun sortedTitles(titles: List<String>): List<String> {
Trace.beginSection("reading-sort")
return try {
titles.sorted()
} finally {
Trace.endSection()
}
}
Import android.os.Trace and call from a measured app path. The label contains no user text. This method is synchronous, so beginSection and endSection execute on the same thread; do not insert a suspending call inside this wrapper.
Validate improvements and memory ownership
Macrobenchmark measures end-user journeys such as startup and scrolling under controlled conditions; microbenchmarks isolate smaller operations. Baseline Profiles can improve important runtime paths but should be generated and verified for representative journeys, not treated as a substitute for efficient algorithms. Inspect retained activities, oversized caches, and unbounded collections when memory grows. A leak is unwanted retention, while a large but intentionally bounded cache needs a different analysis. Repeat the original journey after a change and confirm correctness, accessibility, and resource use remain acceptable before accepting a faster number.
Common errors and fixes
A claimed speedup disappears in release
- Why it happens
- Only debug timing or incomparable runs were measured.
- How to fix it
- Use equivalent release-like benchmark conditions.
- Prevent it next time
- Record dataset, device, and process state with results.
Memory grows after every rotation
- Why it happens
- A long-lived object retains obsolete activities.
- How to fix it
- Remove the reference or use the correct lifecycle-scoped owner.
- Prevent it next time
- Inspect ownership before introducing singletons or callbacks.
Optimization makes rows show stale content
- Why it happens
- Incorrect immutability or caching assumptions suppress needed updates.
- How to fix it
- Restore truthful state contracts and cache by complete identity.
- Prevent it next time
- Pair performance checks with correctness scenarios.
Check your understanding
1. Scrolling drops frames while recomposition counts are low. What should be investigated next?
Show worked solution
Inspect frame traces for layout, drawing, image decoding, garbage collection, and main-thread blocking. Low recomposition does not imply cheap frames. Use the same dataset and window configuration, identify the dominant phase, and change one measured bottleneck before running the comparison again.
2. A cache improves startup but keeps every downloaded cover forever. Propose a bounded policy and verification.
Show worked solution
Set explicit memory and disk limits with eviction appropriate to reproducible assets. Store only display-sized images in memory and avoid activity references. Measure repeated browsing beyond the limit, verify memory reaches a steady range, and confirm evicted assets reload correctly without losing unique user content.
Key takeaways
- Performance begins with a reproducible user journey.
- Trace the responsible phase before optimizing.
- A useful improvement preserves correctness and bounded resource use.