At 60 fps, you have 16.67ms to produce each frame. Miss that budget and the user sees jank — a visible stutter. At 120 fps, the budget shrinks to 8.33ms. Understanding the rendering pipeline tells you exactly where to look when frames drop.
The Rendering Pipeline
Main Thread Render Thread GPU
─────────────────────────────────────────────────────────────
Input handling
Animations
Layout (measure/layout)
Drawing (Canvas operations) → DisplayList → GPU rasterization → Screen
Main thread produces the display list (recorded draw commands). Render thread (hardware-accelerated path) replays the display list. GPU rasterizes to the framebuffer.
Jank happens when the main thread exceeds its frame budget, causing the Render thread to skip a frame (Choreographer misses a vsync).
Detecting Jank
Method 1: Android Studio Profiler
Profile → CPU → Select "System Trace"
→ Record while scrolling
→ Look for frames that exceed 16ms in the "Frames" row
→ Click a long frame → see which method consumed the time
Method 2: gfxinfo
adb shell dumpsys gfxinfo com.example.myapp framestats
# Outputs per-frame timing for the last 120 frames
# Look for "Janky frames: N (X.XX%)"
Method 3: StrictMode
// In debug builds — catch IO on main thread
if (BuildConfig.DEBUG) {
StrictMode.setThreadPolicy(
StrictMode.ThreadPolicy.Builder()
.detectDiskReads()
.detectDiskWrites()
.detectNetwork()
.penaltyLog() // logcat warning
// .penaltyDeath() // crash — useful in CI
.build()
)
}
Common Jank Causes
1. Synchronous Work on Main Thread
// ❌ Disk read on main thread — StrictMode will catch this
class MainActivity : AppCompatActivity() {
override fun onResume() {
val prefs = getSharedPreferences("prefs", MODE_PRIVATE) // synchronous disk read
val token = prefs.getString("token", null)
}
}
// ✅ Move to background thread; use DataStore
lifecycleScope.launch {
val token = dataStore.data.first()[TOKEN_KEY]
}
2. Layout Thrashing
// ❌ Reading layout properties forces a layout pass mid-frame
view.width // forces measure if dirty
view.translationX = 50f // marks layout dirty
view.width // forces measure AGAIN — thrashing
// ✅ Cache layout properties; batch writes
val currentWidth = view.width // one read
view.translationX = 50f // write
// Don't read width again in the same frame
3. Expensive onDraw
// ❌ Path creation in onDraw — called every frame
override fun onDraw(canvas: Canvas) {
val path = Path() // allocation every frame
path.moveTo(0f, 0f)
canvas.drawPath(path, paint)
}
// ✅ Create path once; update only when data changes
private val path = Path()
fun updatePath(points: List<PointF>) {
path.reset()
points.firstOrNull()?.let { path.moveTo(it.x, it.y) }
points.drop(1).forEach { path.lineTo(it.x, it.y) }
invalidate() // trigger redraw with the pre-built path
}
override fun onDraw(canvas: Canvas) {
canvas.drawPath(path, paint) // no allocation
}
Frame Metrics API
Record per-frame rendering times programmatically:
window.addOnFrameMetricsAvailableListener({ _, frameMetrics, _ ->
val totalDuration = frameMetrics.getMetric(FrameMetrics.TOTAL_DURATION) / 1_000_000
if (totalDuration > 16) {
Log.w("Jank", "Slow frame: ${totalDuration}ms")
}
}, Handler(Looper.getMainLooper()))
Render Thread Optimization
Use hardware layers to promote frequently animated views to the GPU:
// Animate a view frequently (alpha, translation, scale)
view.setLayerType(View.LAYER_TYPE_HARDWARE, null) // cached on GPU
startAnimation()
// After animation completes:
view.setLayerType(View.LAYER_TYPE_NONE, null) // release GPU layer
Key Takeaways
| Tool | What it finds |
|---|---|
| Android Studio CPU Profiler | Which methods consume frame time |
gfxinfo framestats | Jank rate and frame timing data |
| StrictMode | Synchronous disk/network on main thread |
| Frame Metrics API | Per-frame timing for production monitoring |
LAYER_TYPE_HARDWARE | Cache animated views on GPU to skip re-draw |