What Is the Choreographer API?
The Choreographer API is Android’s internal frame timing system — the heartbeat of smooth animation. Every frame your app draws, the Choreographer orchestrates the timing. It synchronizes with the device’s VSYNC signal (the vertical refresh of your screen), and it exposes a callback mechanism so you can hook into that same frame timing for custom animations, game loops, or live wallpaper rendering.
Most developers never touch the Choreographer directly. The UI toolkit, animation framework, and Jetpack Compose all use it under the hood. But when you need frame-perfect control — when you’re building a game loop, rendering custom graphics, or measuring jank in your app — the Choreographer is the tool that makes it possible.
This post shows you how to use Choreographer.getInstance().postFrameCallback(), how VSYNC works, how to measure frame drops, and how professional apps use this API to maintain 60fps (or 120fps on high-refresh devices) without breaking a sweat.
Understanding VSYNC and Frame Timing
Your phone’s screen refreshes at a fixed rate — typically 60 times per second (60 Hz), though newer phones go 90, 120, or even 144 Hz. That refresh happens at specific moments in time. On a 60 Hz screen, a refresh happens every ~16.67 milliseconds. The Choreographer is designed to post callbacks that fire exactly when the system is ready to draw the next frame.
This is VSYNC: the vertical synchronization signal that tells your app “now is the time to draw.” If your frame takes longer than 16.67ms to render, you miss that window and the screen shows the previous frame again — a dropped frame, or jank.
The Choreographer keeps track of VSYNC and fires your callback at the right moment. Here’s the basic pattern:
val choreographer = Choreographer.getInstance()
choreographer.postFrameCallback(object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
// This is called right before the next frame is drawn
// frameTimeNanos is the system time in nanoseconds
updateGameState()
renderFrame()
// Post another callback for the next frame
choreographer.postFrameCallback(this)
}
})
The frameTimeNanos parameter is the system time (in nanoseconds) when this frame’s VSYNC fired. You can use it to calculate delta time between frames, or to synchronize animations to real wall-clock time instead of frame count.
Measuring Frame Drops and Jank
One of the most useful applications of the Choreographer is detecting frame drops. When your doFrame callback is late, you know you missed the VSYNC window.
class JankMonitor {
private var lastFrameTimeNanos = 0L
private val refreshRateNanos = (1000_000_000.0 / 60).toLong() // 16.67ms for 60 Hz
private var droppedFrames = 0
fun startMonitoring() {
val choreographer = Choreographer.getInstance()
choreographer.postFrameCallback(object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
if (lastFrameTimeNanos > 0) {
val frameDelta = frameTimeNanos - lastFrameTimeNanos
val expectedFrames = frameDelta / refreshRateNanos
if (expectedFrames > 1) {
droppedFrames += (expectedFrames - 1).toInt()
Log.w("JankMonitor", "Dropped ${expectedFrames - 1} frames")
}
}
lastFrameTimeNanos = frameTimeNanos
choreographer.postFrameCallback(this)
}
})
}
}
The key insight: if the time between two doFrame calls is more than ~16.67ms (for 60 Hz), you dropped frames. This is how profiling tools like Android Studio’s Profiler detect jank in real time.
Using Choreographer for Game Loops
Game engines need tight control over frame timing. The Choreographer is perfect for this. Here’s a minimal game loop that updates physics and renders every frame:
class GameSurfaceView(context: Context) : SurfaceView(context) {
private val gameState = GameState()
private val renderer = GameRenderer()
private lateinit var choreographer: Choreographer
init {
holder.addCallback(object : SurfaceHolder.Callback {
override fun surfaceCreated(holder: SurfaceHolder) {
choreographer = Choreographer.getInstance()
startGameLoop()
}
override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {}
override fun surfaceDestroyed(holder: SurfaceHolder) {}
})
}
private fun startGameLoop() {
var lastFrameTimeNanos = 0L
choreographer.postFrameCallback(object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
val deltaTimeMs = if (lastFrameTimeNanos > 0) {
(frameTimeNanos - lastFrameTimeNanos) / 1_000_000.0
} else {
16.67
}
lastFrameTimeNanos = frameTimeNanos
// Update game state with delta time
gameState.update(deltaTimeMs.toFloat())
// Render to surface
val canvas = holder.lockCanvas()
if (canvas != null) {
try {
renderer.render(canvas, gameState)
} finally {
holder.unlockCanvasAndPost(canvas)
}
}
// Post callback for next frame
choreographer.postFrameCallback(this)
}
})
}
}
Notice the deltaTimeMs calculation. Instead of assuming every frame is 16.67ms, you calculate the actual time since the last frame and use that to drive physics. If a frame takes 33ms (dropped one frame), the physics simulation runs twice as fast to catch up, which feels much better than stuttering.
Choreographer for Live Wallpapers
If you’re building a live wallpaper (like the Seasons Live Wallpaper), the Choreographer is essential for maintaining smooth animations without draining battery. Here’s how to integrate it:
class SeasonWallpaperService : WallpaperService() {
inner class SeasonEngine : Engine() {
private val choreographer = Choreographer.getInstance()
private val particle = ParticleSystem()
private var isRunning = false
override fun onVisibilityChanged(visible: Boolean) {
isRunning = visible
if (visible) {
startRenderLoop()
}
}
private fun startRenderLoop() {
choreographer.postFrameCallback(object : Choreographer.FrameCallback {
override fun doFrame(frameTimeNanos: Long) {
if (!isRunning) return
particle.update(frameTimeNanos)
val canvas = surfaceHolder.lockCanvas()
if (canvas != null) {
try {
canvas.drawColor(Color.BLACK) // Clear
particle.draw(canvas)
} finally {
surfaceHolder.unlockCanvasAndPost(canvas)
}
}
if (isRunning) {
choreographer.postFrameCallback(this)
}
}
})
}
}
}
The wallpaper service only renders when the screen is visible (onVisibilityChanged), so it doesn’t burn battery when the device is locked or sleeping.
Handling High-Refresh Displays
Modern phones support 90Hz, 120Hz, or even 144Hz displays. The Choreographer automatically adapts to the device’s refresh rate. Your frame callbacks still fire at the right VSYNC intervals, but the timing between frames shortens. Here’s how to adapt your code:
private fun calculateDeltaTime(frameTimeNanos: Long, lastFrameTimeNanos: Long): Float {
return if (lastFrameTimeNanos > 0) {
(frameTimeNanos - lastFrameTimeNanos) / 1_000_000f
} else {
// Default fallback; actual refresh rate detected from first frame
16.67f
}
}
By using the actual frameTimeNanos delta, your game or animation automatically adjusts to 60Hz, 90Hz, 120Hz, or any refresh rate without code changes. This is one reason why delta-time-based physics is so important.
Combining Choreographer With Custom Canvas Rendering
For maximum performance in custom rendering, you might combine the Choreographer with Canvas-based particle systems (as mentioned in the Animating the Seasons post). The Choreographer ensures your draw calls happen at the right frame boundary, and the particle system handles the actual rendering logic.
Performance Profiling With Choreographer Data
You can also use Choreographer callbacks to measure how long each phase of your frame takes:
val startTime = System.nanoTime()
val updateStart = System.nanoTime()
gameState.update(deltaTimeMs)
val updateTimeMs = (System.nanoTime() - updateStart) / 1_000_000.0
val renderStart = System.nanoTime()
val canvas = holder.lockCanvas()
renderer.render(canvas, gameState)
holder.unlockCanvasAndPost(canvas)
val renderTimeMs = (System.nanoTime() - renderStart) / 1_000_000.0
Log.d("FrameTiming", "Update: ${updateTimeMs}ms, Render: ${renderTimeMs}ms")
By timing each phase separately, you can identify bottlenecks. Is your physics too slow? Is rendering taking too long? The answer changes your optimization strategy.
Measuring Duration and Performance
For a deeper exploration of timing and performance measurement in Kotlin, check out Kotlin’s measureTimedValue and Duration API for Benchmarking — it shows how to measure code performance with clean, reusable APIs that pair well with Choreographer profiling.
Summary
The Choreographer API is Android’s answer to “when should I draw the next frame?” It synchronizes with VSYNC, exposes frame timing to your code, and lets you build game loops, live wallpapers, and custom animations that feel buttery smooth at 60fps or higher. Most of the time, you don’t need to touch it directly — the UI toolkit handles it for you. But when you do need frame-perfect control, the Choreographer is the foundation that makes it possible.
This post was written by a human with the help of Claude, an AI assistant by Anthropic.
