Course outline

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.

About 4 min readContent reviewed
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.

kotlin Isolate a measured CPU transformation
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.

kotlin A synchronous trace marker with guaranteed cleanup
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.

Official references