A draggable debug panel that shows how long each stage of a frame takes, and which one is slowing you down.
- A row per stage —
input,anim,layout,drawon the main thread, thensync,command,swapon the render thread, andgpu - Says why frames drop — thermal throttling, GC pauses, missed vsync ticks, or a late start
- Measures your app, not itself — the panel draws in its own window
- Fails tests on jank — a JUnit rule with thresholds
- Nothing in release builds —
debugImplementationleaves out the panel, its provider and theSYSTEM_ALERT_WINDOWit declares
dependencies {
debugImplementation("com.timkrest:framehud:0.4.0")
}There is nothing to call. A ContentProvider starts the panel, and it follows whichever activity
has focus.
Requires minSdk 24. Frame phases come from FrameMetrics; GPU timings need API 31+ and a driver
that reports them.
⚠ layout 8.4 ms
CPU now avg peak
input 0.1 0.2 1.1
anim 0.3 0.4 2.0
layout 7.9 8.4 22.3 ◀
draw 1.2 1.4 6.7
RENDER
sync 0.4 0.5 1.9
command 0.6 0.7 3.1
swap 0.2 0.3 1.4
GPU
gpu 2.1 2.4 9.8
delay 0.3 0.4 2.2
other 0.1 0.2
TOTAL 13.2 14.5 38.6
over 4.9 6.2 30.3
pipe:cpu 10.4 11.0
win jank 4.2% p95 12.1 max 22.3
ses p50 7.1 p95 12.4 p99 19.8
ses 4312f 1m12s jank 4.2% frz0 run3
mem 84/256 ▲96 · nat 37 ▲41 MB
gc x3 · 18 ms
therm none · hr 0.68
The top line is the verdict, and ◀ marks the row it points at.
Columns read now avg peak: the current frame, the average over the window, and the peak since the
last reset. Rows summed from other rows stop after avg.
over is how far the frame ran past its budget. pipe: is the slowest stage. win is the window,
ses the session.
Reading the panel explains every row and what to do when one turns red.
debugImplementation already keeps everything out of a release build. Add framehud-noop only if
you call FrameHud outside src/debug — a release build still has to compile those lines:
releaseImplementation("com.timkrest:framehud-noop:0.4.0")It mirrors the API with empty bodies. The calls compile, nothing is measured, no window is added.
Settings live in one immutable config. Assign a copy and it takes effect at once.
FrameHud.config = FrameHud.config.copy(metricsSampleWindowSize = 240)| Option | Default | What it controls |
|---|---|---|
enabled |
true |
While false, no window is added and no frames are collected. |
overlayMode |
PREFER_SYSTEM |
APP_WINDOW keeps the panel inside the app window and never uses the permission. |
eventListeners |
[LogcatEventListener] |
Who receives jank burst, frozen frame, thermal and screen summary events. |
metricsSampleWindowSize |
120 |
Frames the rolling window keeps, behind avg and the percentiles. |
metricsThrottleIntervalMs |
400 |
How often the panel may redraw. Lower values cost more to render. |
fallbackRefreshRateHz |
60 |
Refresh rate assumed when the display reports none. |
metricsThreadName |
framehud-metrics |
Name of the collecting thread, as it shows up in traces. |
show(), hide() and toggle() are shortcuts for enabled.
The panel is not the only way to read the numbers. FrameHud.metrics, memoryStats, thermalStats
and vsyncRate are plain StateFlows. A reading groups into phases (per-stage timings), window
(fps, jank and p95 over the sampling window), session (since the last reset) and display
(refresh rate and frame budget).
You get one event per jank burst, not one per frame, with the cause already worked out. The default listener writes to logcat.
FrameHud.config = FrameHud.config.copy(
eventListeners = listOf(
LogcatEventListener,
FrameHudEventListener { event -> analytics.log(event.summary) },
),
)Events arrive on the metrics thread. Don't block it and don't touch views from it.
androidTestImplementation("com.timkrest:framehud-instrumentation:0.4.0")@get:Rule val noJank = DetectJankAfterTestSuccess(JankThresholds(maxJankPercent = 2f))The rule resets the collector before each test and checks the thresholds after the test passes, so a
failing test keeps its own error. Session totals outlive the panel, so there are still numbers after
ActivityScenario closes the activity.
To opt out, annotate a test or class with @SkipJankDetection, or call
JankAssertions.assertNoJank("scroll") at a point you choose.
With SYSTEM_ALERT_WINDOW granted, the panel lives in a system window and survives moving between
screens. Without it the window belongs to the current activity, so the panel is recreated on every
screen change, and a ⧉ button appears that opens the permission screen.
The library never opens it on its own. Set overlayMode = APP_WINDOW to stay in the app window and
hide that button.
On an emulator
The render thread and the GPU belong to the host machine, so those rows describe your desktop, not a
device. The panel marks the header EMU, labels those sections · host and greys them out.
Main-thread phases, jank and the session totals stay meaningful. That is what a jank gate on CI reads.
Installing by hand instead of the provider
Drop the provider and call FrameHud.install(application) from Application.onCreate(). The panel
comes up with the next resumed activity, so a call made later skips the screen already open.
<provider
android:name="com.timkrest.framehud.FrameHudInstaller"
android:authorities="${applicationId}.framehud-installer"
tools:node="remove" />./gradlew :sample:installDebug
A 300-row list with five toggles: blocking the main thread, overdrawing, allocating per row, nesting layouts, churning garbage. Each one moves a different metric.
- Reading the panel — what every row means, how to measure a screen, and what to do when something turns red
- API reference — generated from the sources of each release
- Roadmap — what is planned next, and what is deliberately not
- Changelog — what changed in each release
- Contributing — how to build, and what to check before opening a pull request. Contributions are covered by a CLA, which a bot will ask you to sign.
