Profiling: jank, memory and battery
A slow app rarely crashes. It stutters when Priya scrolls, it is killed in the background because it holds memory it does not need, it drains a battery on a ₹9,000 phone that has to last a day in the mandi, and it takes a moment too long to open. Nobody files a bug for any of that; they uninstall. Module 9 promised that the main-thread CPU work StrictMode cannot see would be found with the profiler, and module 6 promised Macrobenchmark and a release build. This lesson plants one real problem of each kind in a scratch copy of Mandi Bhav, finds each with the tool made for it, and gives the real app a Baseline Profile, measured before and after.
One rule before any number: everything here was measured on the course emulator, a Pixel 8 image on a laptop. An emulator's "phone" shares a laptop's processor and is usually faster than a mid-range Android, and noisier. These numbers are good for comparing two versions on the same emulator, and nothing else. Measure on a real cheap phone before you claim anything about one. And never measure a debug build: it is not compiled ahead of time and carries debugging hooks, and its numbers mean nothing.
The tools, and what each is for
| Question | Tool | From the command line |
|---|---|---|
| Is scrolling smooth? | Frame timing | adb shell dumpsys gfxinfo <package> |
| What exactly took the time in that frame? | A system trace | adb shell perfetto …, opened in ui.perfetto.dev or Android Studio |
| How much memory, and is it growing? | Memory summary, heap dumps | adb shell dumpsys meminfo <package>; LeakCanary |
| What keeps the phone awake? | Battery statistics | adb shell dumpsys batterystats, adb shell dumpsys power |
| How fast does it start, before and after a change? | Macrobenchmark | ./gradlew :baselineprofile:connectedBenchmarkReleaseAndroidTest |
Android Studio's Profiler wraps the middle three in one window: Profile 'app' with low overhead profiles a release-like, profileable build, which is what you want for timing; Profile 'app' with complete data uses the debug build, which you need for heap dumps and allocation tracking. The commands below show you the same data as text, which is what you will have on a build server or a colleague's laptop, and what this lesson can print.
Jank: a photo decoded on the main thread
Arjun wants the cards to show the full commodity photo instead of the small thumbnail. In a scratch copy, the photo becomes a resource and CommodityThumbnail decodes it:
@Composable
private fun CommodityThumbnail(url: String?) {
// Trap: the full-size photo, decoded on the main thread, every time a card is composed.
val resources = androidx.compose.ui.platform.LocalContext.current.resources
val photo = androidx.tracing.trace("PriceCard decode photo") {
android.graphics.BitmapFactory.decodeResource(resources, com.riztechacademy.mandibhav.R.drawable.commodity_photo)
}
androidx.compose.foundation.Image(
bitmap = photo.asImageBitmap(),
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(56.dp)
.clip(RoundedCornerShape(8.dp)),
)
}
It works and looks fine. The photo is 640 by 960 pixels, decoded in full to be shown at 56 dp. The trace("…") { } wrapper, from androidx.tracing, which Compose already brings in, names that block in a system trace; it costs next to nothing when nobody is tracing, so you can leave sections like it in release code.
Frame timing
On a release-like build (the benchmarkRelease variant this lesson's module creates, installed with ./gradlew :app:installBenchmarkRelease), a script cold-started the app, reset the counters, swiped the list five times down and five times up, and read the counters back:
adb shell dumpsys gfxinfo com.riztechacademy.mandibhav.m13 reset
adb shell input swipe 540 1900 540 600 120
adb shell dumpsys gfxinfo com.riztechacademy.mandibhav.m13
Three runs of each build:
| Clean build | Decoding on the main thread | |
|---|---|---|
| Total frames | 336, 340, 339 | 340, 340, 339 |
| Janky frames | 12, 10, 11 | 12, 11, 12 |
| Janky frames (legacy) | 9, 0, 0 | 56, 26, 51 |
| 90th percentile | 19, 18, 18 ms | 21, 20, 21 ms |
| 95th percentile | 20, 18, 18 ms | 23, 22, 24 ms |
Module 6 met this already: the main "Janky frames" count barely moves on an emulator, because it mostly measures the emulator. The legacy count and the high percentiles moved, every run, in the same direction. Something is wrong; gfxinfo cannot say what.
A system trace
Perfetto records what every thread on the phone did, with timestamps. Twelve seconds of it, with the app's own trace sections enabled by -a, while the script scrolled:
adb shell perfetto -o /data/misc/perfetto-traces/m13.pftrace -t 12s -a com.riztechacademy.mandibhav.m13 sched freq gfx view am dalvik
adb pull /data/misc/perfetto-traces/m13.pftrace
The file was 6.9 MB. Open it at ui.perfetto.dev, or drag it into Android Studio, and you get a timeline: find the app's process, its main thread, and the frames on it. Each frame is a Choreographer#doFrame slice, and inside the long ones sits the section Arjun named. A trace is also a database, and Perfetto's query language answers the question directly. This query, run with the trace_processor_shell that Macrobenchmark ships:
SELECT CASE WHEN s.name LIKE 'Choreographer#doFrame%' THEN 'Choreographer#doFrame' ELSE s.name END AS slice,
COUNT(*) AS n, ROUND(AVG(s.dur)/1e6, 2) AS avg_ms, ROUND(MAX(s.dur)/1e6, 2) AS max_ms,
SUM(CASE WHEN s.dur > 16.6e6 THEN 1 ELSE 0 END) AS over_16ms
FROM slice s JOIN thread_track tt ON s.track_id = tt.id JOIN thread t USING (utid) JOIN process p USING (upid)
WHERE p.name LIKE 'com.riztechacademy.mandibhav%' AND t.is_main_thread = 1
AND (s.name LIKE 'Choreographer#doFrame%' OR s.name LIKE 'PriceCard%')
GROUP BY 1 ORDER BY 1;
"slice","n","avg_ms","max_ms","over_16ms"
"Choreographer#doFrame",282,2.110000,20.270000,1
"PriceCard decode photo",11,6.480000,8.450000,0
and on the clean build, the same scrolling:
"slice","n","avg_ms","max_ms","over_16ms"
"Choreographer#doFrame",282,1.980000,13.340000,0
Each decode took 6.5 ms of the main thread, up to 8.5 ms, inside a frame that has 16.7 ms for everything at 60 Hz and 8.3 ms at 120 Hz. Eleven decodes in eight swipes, because a lazy grid reuses cards and only composes new ones as they scroll in. On the emulator's borrowed laptop processor that cost one late frame; on a phone several times slower, every one of those eleven frames is late. This is exactly the kind of work module 9 said StrictMode cannot see: no disk, no network, only CPU.
The fix was already in the app: Coil's AsyncImage (module 9) decodes on a background thread, at the size it is drawn, and caches the result. Put it back. If you must decode yourself, do it in a ViewModel or repository on Dispatchers.Default, with inSampleSize to decode at the size you need.
Memory: an activity that never dies
Arjun wants to log when the connection comes back, and registers a callback in MainActivity.onCreate:
// Trap: a callback that is never unregistered, and that knows the activity.
getSystemService(android.net.ConnectivityManager::class.java).registerDefaultNetworkCallback(
object : android.net.ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: android.net.Network) {
android.util.Log.d("M13Leak", "online again in ${this@MainActivity}")
}
},
)
The anonymous object refers to this@MainActivity, the system's ConnectivityManager holds the object until it is unregistered, and it never is. Every rotation creates a new activity and leaks the old one. A script read dumpsys meminfo on the debug build after launch and after six rotations:
adb shell dumpsys meminfo com.riztechacademy.mandibhav.m13
dumpsys meminfo |
After launch | After 6 rotations, leaking | After 6 rotations, clean |
|---|---|---|---|
| Activities (under Objects) | 1 | 8 | 1 |
| Views | 8 | 62 | 8 |
| Java Heap | 24,244 KB | 70,928 KB | 21,272 KB |
| TOTAL PSS | 151,861 KB | 211,822 KB | 148,234 KB |
The Activities count is the line to read first: there should be as many as are on screen. Eight activities, one alive, seven dead and still in memory, holding everything they drew. On a phone with 3 GB of memory, an app that grows like this is the one Android kills first in the background, and Play's Android vitals now counts memory use as one of its core measures.
LeakCanary
Counting activities tells you there is a leak; LeakCanary tells you who is holding it. Mandi Bhav now has it in debug builds only:
// Watches for leaked activities, fragments and ViewModels and dumps the
// heap when it finds one. Debug builds only: it adds its own launcher
// icon and pauses the app to analyse a heap, neither of which belongs
// in front of a farmer.
debugImplementation(libs.leakcanary.android)
with leakcanary = "2.14" in the catalogue, the current stable release on Maven Central (a 3.0 line exists in alpha). No code: it starts itself in a debug build, watches every destroyed activity, fragment and ViewModel, and when five are still in memory a few seconds after they should have gone, it dumps the heap and analyses it. From logcat, tag LeakCanary, after the six rotations (LeakCanary marks each step down the path with a downward arrow, written v here):
1997512 bytes retained by leaking objects
Displaying only 1 leak trace out of 3 with the same signature
┬───
│ GC Root: System class
│
├─ android.net.ConnectivityManager class
│ Leaking: NO (a class is never leaking)
│ v static ConnectivityManager.sCallbacks
│ ~~~~~~~~~~
├─ java.util.HashMap instance
│ Leaking: UNKNOWN
│ Retaining 3.3 MB in 76523 objects
│ v HashMap[instance @38946128 of android.net.NetworkRequest]
│ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
├─ com.riztechacademy.mandibhav.MainActivity$onCreate$1 instance
│ Leaking: UNKNOWN
│ Retaining 639.6 kB in 14753 objects
│ Anonymous subclass of android.net.ConnectivityManager$NetworkCallback
│ this$0 instance of com.riztechacademy.mandibhav.MainActivity with mDestroyed = true
│ v MainActivity$onCreate$1.this$0
│ ~~~~~~
╰→ com.riztechacademy.mandibhav.MainActivity instance
Leaking: YES (ObjectWatcher was watching this because com.riztechacademy.mandibhav.MainActivity received
Activity#onDestroy() callback and Activity#mDestroyed is true)
Retaining 639.6 kB in 14752 objects
Read it top to bottom: a static field in the system's ConnectivityManager holds a map, which holds Arjun's anonymous callback (MainActivity$onCreate$1), whose hidden this$0 field is the destroyed activity. The ~~~ underlines are LeakCanary's guess at the broken link. The clean build, measured the same way, reported no leaks at all.
The fix is to unregister, and the pattern that cannot forget is the one module 9's NetworkMonitor already uses: a callbackFlow that registers when collected and unregisters in awaitClose, collected from a lifecycle-aware scope. The cost of LeakCanary: it adds a second launcher icon and two storage permissions to the debug manifest, its heap dumps take space on the phone, and a dump freezes the app for a second or two (1,531 ms here, then 7.7 s of analysis in the background). In the release build, checked with apkanalyzer dex packages, there are no LeakCanary classes at all.
Battery: a wake lock that is not released
A partial wake lock keeps the processor running with the screen off. Arjun takes one so a refresh finishes even if the phone sleeps, and releases it after the refresh, but only on the path where it worked:
// Trap: keep the CPU awake for the download, and let go only if it worked.
val wakeLock = context.getSystemService(android.os.PowerManager::class.java)
.newWakeLock(android.os.PowerManager.PARTIAL_WAKE_LOCK, "MandiBhav:refresh")
wakeLock.acquire()
val changed = refreshPrices()
wakeLock.release()
With the debug build pointed at a server that does not exist, so the refresh fails, a script unplugged the emulator's battery in software, reset the statistics, tapped Refresh, went Home and waited a minute:
adb shell dumpsys battery unplug
adb shell dumpsys batterystats --reset
# ... tap Refresh, press Home, wait 60 seconds ...
adb shell dumpsys power
adb shell dumpsys batterystats com.riztechacademy.mandibhav.m13
adb shell dumpsys battery reset
PARTIAL_WAKE_LOCK 'MandiBhav:refresh' ACQ=-1m8s158ms LONG (uid=10255 pid=5616)
u0a255:
Wi-Fi network: 42.25KB received, 5.43KB sent (packets 56 received, 51 sent)
Wake lock *launch* realtime
Wake lock MandiBhav:refresh realtime
Foreground activities: 15s 40ms realtime (1 times)
The power service shows the lock still held 68 seconds later, flagged LONG, with the app in the background; it would be held until the process died. The exception from the failed refresh skipped the release(). dumpsys battery reset at the end matters: unplug changes a system-wide setting, and the emulator stays "on battery" until you reset it.
The fix, in order of preference: do not take a wake lock at all, because WorkManager holds one for you while a worker runs, which is how Mandi Bhav's daily refresh (module 10) already works; if you must, release it in finally, and give acquire a timeout so a bug cannot hold it for ever. Android vitals counts excessive partial wake locks as a core measure, with an overall bad-behaviour threshold of 5%.
Start-up, and a Baseline Profile
An Android app's Kotlin is compiled to machine code on the phone, not before. A freshly installed app starts by interpreting and compiling as it goes, and the first launches are the slowest. A Baseline Profile is a list of the classes and methods the app uses at start-up and on its main paths, shipped inside the APK, so Android compiles exactly those ahead of time at install. Google Play adds its own cloud profiles over time; a Baseline Profile helps from the first launch, and on phones that never get the app from Play.
Mandi Bhav now has a baselineprofile module, a test module that drives the release-like build from outside with UI Automator. Its build file applies com.android.test and androidx.baselineprofile (Benchmark 1.5.0, which built here with AGP 9.5.1), points at :app, and uses the connected emulator. The app applies the same plugin, depends on profileinstaller 1.4.1 (which installs the profile when the app is not installed by Play), and names the module:
implementation(libs.androidx.profileinstaller)
baselineProfile(project(":baselineprofile"))
The generator records one journey, start and scroll:
@Test
fun generate() = rule.collect(packageName = PACKAGE_NAME, includeInStartupProfile = true) {
pressHome()
startActivityAndWait()
waitForPrices()
scrollThePrices()
}
./gradlew :app:generateBaselineProfile ran it on the emulator in 150 seconds and wrote app/src/release/generated/baselineProfiles/baseline-prof.txt, 29,364 lines like:
Lcom/riztechacademy/mandibhav/ui/PricesScreenKt;
SPLcom/riztechacademy/mandibhav/ui/PricesScreenKt;->$r8$lambda$2JcfS6eJLOLCrMSrmlr2WHSm6so(Lcom/riztechacademy/mandibhav/model/CommodityPrice;)Ljava/lang/Object;
It is committed, because the release build reads it. Regenerate it when the start-up path changes, not on every commit.
Measuring it
StartupBenchmark in the same module starts the app cold ten times with each compilation mode and reports time to initial display: CompilationMode.None() for an app Android has compiled nothing of, and CompilationMode.Partial(BaselineProfileMode.Require) for the profile. On an emulator, Macrobenchmark fails every test with this, unless you suppress it:
java.lang.AssertionError: ERRORS (not suppressed): EMULATOR
ERROR: Running on Emulator
Benchmark is running on an emulator, which is not representative of
real user devices. Use a physical device to benchmark.
./gradlew :baselineprofile:connectedBenchmarkReleaseAndroidTest -Pandroid.testInstrumentationRunnerArguments.androidx.benchmark.suppressErrors=EMULATOR
| Cold start, 10 runs each | Minimum | Median | Maximum |
|---|---|---|---|
| No compilation, before the profile existed | 327.5 ms | 347.1 ms | 423.2 ms |
| No compilation, later run | 356.5 ms | 388.2 ms | 413.9 ms |
| With the Baseline Profile | 302.3 ms | 320.3 ms | 409.4 ms |
Scrolling, 10 runs each (frameDurationCpuMs) |
P50 | P90 | P95 | P99 |
|---|---|---|---|---|
| No compilation | 3.8 | 7.1 | 16.7 | 19.1 |
| With the Baseline Profile | 3.8 | 5.6 | 6.7 | 17.1 |
Read the start-up table honestly. The profile's median, 320 ms, is better than both uncompiled runs, by 27 and 68 ms; but the two uncompiled runs of the same build differ from each other by 41 ms. On this emulator the profile is worth something between 8% and 17% at start-up, depending on which uncompiled run you compare it with, and clearly more in the slow tail of scrolling, where the uncompiled P95 frame took 16.7 ms and the compiled one 6.7. On a phone without a laptop's processor behind it, both gaps are typically larger, but that is a claim to measure, not to repeat. The benchmark also wrote a Perfetto trace for every run, so a surprising number can be opened and explained.
Two practical things the emulator taught. Macrobenchmark's test APK is about 51 MB, because it carries Perfetto's trace processor for four processor types; on an emulator low on storage the install failed with INSTALL_FAILED_INSUFFICIENT_STORAGE, and freeing space was the fix. And generateBaselineProfile runs every test in the module, the benchmarks included, so it takes longer than the generator alone.
Check your work
Why did "Janky frames" hardly change while the app decoded photos on the main thread? On an emulator that count mostly reflects the emulator; the legacy count and the 90th and 95th percentiles moved every run, and the trace showed the cost directly.
How long did each decode take, and why does that matter at 60 Hz? 6.5 ms on average and up to 8.5 ms, out of 16.7 ms for the whole frame (8.3 ms at 120 Hz); on a slower phone it alone misses the frame.
Which meminfo line showed the leak first, and what was it? Activities: 8 after six rotations, where one should exist.
What did LeakCanary name as the cause? ConnectivityManager.sCallbacks holding the anonymous NetworkCallback (MainActivity$onCreate$1), whose this$0 was the destroyed MainActivity.
Why was the wake lock still held? The refresh threw, so the release() after it never ran; the power service showed it held 68 seconds later with the app in the background.
What did the Baseline Profile change on the emulator? Median cold start from 347 or 388 ms (two runs without compilation) to 320 ms, and P95 scroll frame time from 16.7 ms to 6.7 ms; indicative only.
Practice 4. After six rotations Activities should be back to 1, as for the clean build in the table, and LeakCanary should report nothing: awaitClose unregisters the callback when the collection stops with the activity. If Activities still grows, the flow is being collected somewhere that outlives the activity, such as GlobalScope or the application scope.
Practice
- Install your app's
benchmarkReleasebuild, run thegfxinfosequence three times, and keep the numbers. They are your baseline for the rest of this practice. - Plant the photo decode in a scratch copy, run the
gfxinfosequence again, then record a Perfetto trace and find thePriceCard decode photoslices in ui.perfetto.dev. How many were there, and how long was the longest? - Rotate the debug build six times and read
Activitiesindumpsys meminfo. Then plant the network callback and do it again, and wait for LeakCanary's notification. - Fix the leak by moving the logging into a
callbackFlowcollected withrepeatOnLifecycle, asNetworkMonitordoes, and repeat Practice 3. - Plant the wake lock, reproduce the
dumpsys powerline, then fix it withtry/finallyandacquire(10 * 60 * 1000L). Restore the battery withdumpsys battery resetwhen you finish. - Harder: run
StartupBenchmarkthree times on your machine and work out how far apart two runs of the same mode can be. Then decide, from your own numbers, the smallest start-up improvement you could honestly claim from one run.
Official documentation
- Inspect GPU rendering speed and overdraw — What
gfxinforeports, line by line. - Perfetto: recording traces on Android —
adb shell perfetto, its categories, and opening traces in ui.perfetto.dev. - Baseline Profiles overview — What they are, how Play's cloud profiles differ, and generating them with the Gradle plugin.
- Write a Macrobenchmark — Compilation modes, start-up modes and metrics.
- LeakCanary — Not official Android documentation, but the tool's own: how it detects leaks and how to read a leak trace.
Next: crash reporting, and reading a stack trace from a phone you will never see.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Selected students who finish a course can join our internship, with real-world projects and code review from a working engineer.
About the internship