CN

Detailed Explanation of Android Rendering Mechanism Based on Choreographer

Word count: 5.1kReading time: 31 min
2019/10/22
loading

This article introduces Choreographer, a class that app developers rarely encounter but is crucial in the Android Framework rendering pipeline. It covers the background of Choreographer‘s introduction, an overview, source code analysis, its relationship with MessageQueue, its use in APM, and optimization ideas from mobile manufacturers.

Choreographer coordinates animations, some input processing, and View updates with display timing. It requests frame callbacks when work is pending rather than unconditionally waking the app on every physical refresh. A 60Hz display, common in 2019, has an interval of about 16.67ms; 90Hz and 120Hz correspond to about 11.11ms and 8.33ms. Modern devices can also change refresh rates. A timely callback does not mean the image has been presented: RenderThread, GPU, composition, and display work remain. Understanding this flow helps explain Message, Handler, Looper, MessageQueue, Measure, Layout, and Draw.

Version note — reviewed 2026-10-13: Historical excerpts use AOSP android-10.0.0_r1 Choreographer as a reference and retain the 2019 screenshots. Updates cover Android 16 and API 24/31/33 observation interfaces. Source excerpts are not standalone compilable apps; vendor implementations and capture configurations are incomplete. Obsolete private interfaces are marked below.

This is the eighth article in the Systrace series, providing a brief introduction to Choreographer within Systrace.

The purpose of this series is to view the overall operation of the Android system from a different perspective using Systrace, while also providing an alternative angle for learning the Framework. Perhaps you’ve read many articles about the Framework but can never remember the code, or you’re unclear about the execution flow. Maybe from Systrace’s graphical perspective, you can gain a deeper understanding.

Series Article Index

  1. Introduction to Systrace
  2. Systrace Basics - Prerequisites for Systrace
  3. Systrace Basics - Why 60 fps?
  4. Android Systrace Basics - SystemServer Explained
  5. Systrace Basics - SurfaceFlinger Explained
  6. Systrace Basics - Input Explained
  7. Systrace Basics - Vsync Explained
  8. Systrace Basics - Vsync-App: Detailed Explanation of Choreographer-Based Rendering Mechanism
  9. Systrace Basics - MainThread and RenderThread Explained
  10. Systrace Basics - Triple Buffer Explained
  11. Systrace Basics - CPU Info Explained
  12. Systrace Smoothness in Action 1: Understanding Jank Principles
  13. Systrace Smoothness in Action 2: Case Analysis - MIUI Launcher Scroll Jank Analysis
  14. Systrace Smoothness in Action 3: FAQs During Jank Analysis
  15. Systrace Responsiveness in Action 1: Understanding Responsiveness Principles
  16. Systrace Responsiveness in Action 2: Responsiveness Analysis - Using App Startup as an Example
  17. Systrace Responsiveness in Action 3: Extended Knowledge on Responsiveness
  18. Systrace Thread CPU State Analysis Tips - Runnable
  19. Systrace Thread CPU State Analysis Tips - Running
  20. Systrace Thread CPU State Analysis Tips - Sleep and Uninterruptible Sleep

Essence of the Main Thread Mechanism

This article connects to Perfetto 5: Choreographer-based rendering flow. Looper dispatches Messages; MessageQueue does not itself execute app code. Native polling can also process file-descriptor events while obtaining the next message, including display events. “Every main-thread operation is a Message” is therefore an oversimplification.

In Perfetto, correlate the frame with the main thread, RenderThread, GPU, and SurfaceFlinger. On Android 12 / API 31 or later, with the relevant data captured, FrameTimeline expected/actual tracks help locate missed deadlines. Missing tracks do not mean zero jank. Investigate Sleeping, Runnable, and Running separately; a slice’s wall duration is not CPU time. These historical images show spaced dispatches, and method instrumentation itself affects timing.

MethodTrace Illustration

Systrace Illustration

Evolution

Choreographer arrived in Android 4.1 / API 16 to coordinate work with display timing. Both images below already contain Choreographer.doFrame and RenderThread. They cannot prove behavior before Choreographer existed. They show a different capture with denser rendering and waits; its precise configuration is unavailable.

MethodTrace Illustration

Historical method trace containing Choreographer.doFrame

Historical trace with dequeueBuffer intervals

The crop includes RenderThread dequeueBuffer intervals, but does not establish the sole bottleneck or a fixed consumption rate. Buffer availability, consumer progress, and fences require investigation; SurfaceFlinger does not simply consume a “Front Buffer” at an invariant rate.

Consistent presentation matters, but stable 50fps is not universally smoother than fluctuating 40–60fps. On a fixed 60Hz display, 50fps itself cannot be presented uniformly in whole refresh intervals. VSync coordination and buffering improve pacing opportunities; they do not guarantee a stable rate or remove the workload.

Introducing Choreographer

Choreographer requests the next applicable VSync event and schedules callbacks on its Looper thread. It does not set the panel’s refresh rate, and app VSync callbacks are not hardware interrupt handlers. A normal View window with no pending work need not continuously produce new frames.

Historical schematic: RenderThread is a CPU thread; 16.6ms is a 60Hz example

Read this as a simplified historical diagram. RenderThread is a CPU thread, so labeling its entire box “GPU” is inaccurate. Buffer acquisition, GPU submission, queuing, and composition are not always serialized as drawn. The 16.6ms interval approximates 60Hz, not every stage’s complete budget or end-to-end input latency. See the 90Hz discussion.

Choreographer Overview

Choreographer executes due frame callbacks in stages and requests display events when needed. Android 10 orders them INPUT → ANIMATION → INSETS_ANIMATION → TRAVERSAL → COMMIT. It does not collect every app Message, nor does every frame necessarily perform full measure/layout/draw.

Choreographer, SurfaceFlinger, and buffering participate in scheduling; they cannot guarantee 20, 60, or 90fps. Monitoring must distinguish public FrameCallback, hidden FrameInfo, Looper dispatch timing, and presentation data. IdleHandler runs under queue-idle conditions, not reliably at the end of every frame.

Diagrams are useful, but I don’t particularly enjoy drawing them. I spend more time looking at Systrace and MethodTrace in my day-to-day work: CPU, SurfaceFlinger, SystemServer, and App activity laid out from left to right. Once you know the code, it becomes easier to connect the trace with what is happening on the phone. So I’ll mainly use Systrace here.

Choreographer Workflow from a Systrace Perspective

This Launcher scroll is a historical example. A green F is an old Systrace frame-analysis marker, not independent proof of successful on-screen presentation. The alternating background represents that trace’s VSync reference; check the axis and configuration instead of assuming 16.6ms from its colors.

On a typical hardware-accelerated View path, the main thread updates drawing state and RenderThread prepares/submits rendering work. Buffer queuing must still be correlated with GPU completion, SurfaceFlinger composition, and presentation. UI and RenderThread work can overlap; adding their wall durations does not give display latency.

Zooming into one historical frame shows Choreographer’s callbacks and the handoff to RenderThread. The old annotations are qualified below:

Choreographer Workflow

  1. Create the thread’s Choreographer, bind Looper/FrameHandler, and initialize its display receiver and callback queues.
  2. When due work needs a frame and none is scheduled, request VSync. onVsync posts an asynchronous Runnable to that Looper; its later run() invokes doFrame().
  3. doFrame checks whether work remains scheduled, measures its start delay relative to frame time, and adjusts animation time when needed.
  4. Execute due INPUT, ANIMATION, INSETS_ANIMATION, TRAVERSAL, and COMMIT callbacks in order. Traversal may synchronize drawing state with RenderThread.
  5. RenderThread submits GPU work and a Buffer. Submission returning does not imply GPU completion; fences communicate completion/dependencies for subsequent composition and presentation.
  6. Schedule another frame only if work such as animation continues.

The old annotation above calls Traversal the third callback and omits Insets Animation; it is the fourth category in Android 10. Its “rendering complete, send Buffer” caption also needs the asynchronous GPU/fence qualification above.

Here is the corresponding MethodTrace (detailed view following):

Now, let’s examine the implementation via source code.

Source Code Analysis

These are Android 10 excerpts, with omitted checks and state maintenance marked in comments. They are not complete classes to assemble verbatim. Android 16 doFrame also receives VsyncEventData and uses its interval, vsync id, and deadline; see the versioned source.

Choreographer Initialization

Singleton Initialization

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Thread local storage for the choreographer.
private static final ThreadLocal<Choreographer> sThreadInstance =
new ThreadLocal<Choreographer>() {
@Override
protected Choreographer initialValue() {
// Get current thread Looper
Looper looper = Looper.myLooper();
if (looper == null) {
throw new IllegalStateException("The current thread must have a looper!");
}
// Construct Choreographer object
Choreographer choreographer = new Choreographer(looper, VSYNC_SOURCE_APP);
if (looper == Looper.getMainLooper()) {
mMainInstance = choreographer;
}
return choreographer;
}
};

Choreographer Constructor

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
private Choreographer(Looper looper, int vsyncSource) {
mLooper = looper;
// 1. Initialize FrameHandler
mHandler = new FrameHandler(looper);
// 2. Initialize DisplayEventReceiver
mDisplayEventReceiver = USE_VSYNC
? new FrameDisplayEventReceiver(looper, vsyncSource)
: null;
mLastFrameTimeNanos = Long.MIN_VALUE;
mFrameIntervalNanos = (long)(1000000000 / getRefreshRate());
// 3. Initialize CallbacksQueues
mCallbackQueues = new CallbackQueue[CALLBACK_LAST + 1];
for (int i = 0; i <= CALLBACK_LAST; i++) {
mCallbackQueues[i] = new CallbackQueue();
}
// ... other source omitted
}

FrameHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
private final class FrameHandler extends Handler {
// ... other source omitted
public void handleMessage(Message msg) {
switch (msg.what) {
case MSG_DO_FRAME: // Start rendering the next frame
doFrame(System.nanoTime(), 0);
break;
case MSG_DO_SCHEDULE_VSYNC: // Request Vsync
doScheduleVsync();
break;
case MSG_DO_SCHEDULE_CALLBACK: // Process Callback
doScheduleCallback(msg.arg1);
break;
}
}
}

Choreographer Initialization Chain

For a new Activity window, the resume/display path can add the window, create ViewRootImpl, and obtain the current thread’s Choreographer. Animation code may initialize it earlier, and resuming an existing root does not necessarily recreate it. This is an illustrative call chain:

1
2
3
4
5
6
7
8
9
ActivityThread.handleResumeActivity(IBinder, boolean, boolean, String) (android.app)
-->WindowManagerImpl.addView(View, LayoutParams) (android.view)
-->WindowManagerGlobal.addView(View, LayoutParams, Display, Window) (android.view)
-->ViewRootImpl.ViewRootImpl(Context, Display) (android.view)
public ViewRootImpl(Context context, Display display) {
// ... other source omitted
mChoreographer = Choreographer.getInstance();
// ... other source omitted
}

FrameDisplayEventReceiver Overview

FrameDisplayEventReceiver handles Vsync registration, requests, and reception. It inherits from DisplayEventReceiver and has three key methods:

  1. onVsync – Vsync signal callback
  2. run – Executes doFrame
  3. scheduleVsync – Requests the Vsync signal
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private final class FrameDisplayEventReceiver extends DisplayEventReceiver implements Runnable {
// ... other source omitted
@Override
public void onVsync(long timestampNanos, long physicalDisplayId, int frame) {
// ... other source omitted
mTimestampNanos = timestampNanos;
mFrame = frame;
Message msg = Message.obtain(mHandler, this);
msg.setAsynchronous(true);
mHandler.sendMessageAtTime(msg, timestampNanos / TimeUtils.NANOS_PER_MS);
}
@Override
public void run() {
mHavePendingVsync = false;
doFrame(mTimestampNanos, mFrame);
}

}

scheduleVsync() is implemented by the parent DisplayEventReceiver; code accessing its private mReceiverPtr does not belong inside the subclass. FrameHandler’s MSG_DO_FRAME is principally the timer fallback when VSync is disabled; the normal VSync path uses the Runnable above.

Vsync Registration in Choreographer

The FrameDisplayEventReceiver.onVsync inner class receives Vsync callbacks and notifies the UIThread. It achieves this by listening to file descriptors initialized as follows:

android/view/Choreographer.java

1
2
3
4
5
6
7
private Choreographer(Looper looper, int vsyncSource) {
mLooper = looper;
mDisplayEventReceiver = USE_VSYNC
? new FrameDisplayEventReceiver(looper, vsyncSource)
: null;
// ... other source omitted
}

android/view/Choreographer.java

1
2
3
public FrameDisplayEventReceiver(Looper looper, int vsyncSource) {
super(looper, vsyncSource);
}

android/view/DisplayEventReceiver.java

1
2
3
4
5
6
public DisplayEventReceiver(Looper looper, int vsyncSource) {
// ... other source omitted
mMessageQueue = looper.getQueue();
mReceiverPtr = nativeInit(new WeakReference<DisplayEventReceiver>(this), mMessageQueue,
vsyncSource);
}

Separate control requests from event delivery. DisplayEventReceiver::requestNextVsync() requests the next event through the Binder IDisplayEventConnection; events are read back over BitTube.

DisplayEventDispatcher::initialize() registers the receiving FD with the native Looper. A readable event is dispatched to Java onVsync(), which posts an asynchronous Runnable. onVsync and doFrame are distinct steps, not an app hardware-interrupt handler:

Logic for Processing One Frame

The core logic resides in Choreographer.doFrame. FrameDisplayEventReceiver.onVsync posts itself, and its run method calls doFrame:

android/view/Choreographer.java

1
2
3
4
5
6
7
8
9
10
11
12
public void onVsync(long timestampNanos, long physicalDisplayId, int frame) {
// ... other source omitted
mTimestampNanos = timestampNanos;
mFrame = frame;
Message msg = Message.obtain(mHandler, this);
msg.setAsynchronous(true);
mHandler.sendMessageAtTime(msg, timestampNanos / TimeUtils.NANOS_PER_MS);
}
public void run() {
mHavePendingVsync = false;
doFrame(mTimestampNanos, mFrame);
}

doFrame performs three main tasks:

  1. Estimates dispatch-start delay relative to frame time.
  2. Records frame drawing information.
  3. Executes CALLBACK_INPUT, CALLBACK_ANIMATION, CALLBACK_INSETS_ANIMATION, CALLBACK_TRAVERSAL, and CALLBACK_COMMIT.

Dropped Frame Logic

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
void doFrame(long frameTimeNanos, int frame) {
final long startNanos;
synchronized (mLock) {
// ... other source omitted
long intendedFrameTimeNanos = frameTimeNanos;
startNanos = System.nanoTime();
final long jitterNanos = startNanos - frameTimeNanos;
if (jitterNanos >= mFrameIntervalNanos) {
final long skippedFrames = jitterNanos / mFrameIntervalNanos;
if (skippedFrames >= SKIPPED_FRAME_WARNING_LIMIT) {
Log.i(TAG, "Skipped " + skippedFrames + " frames! "
+ "The application may be doing too much work on its main thread.");
}
}
// ... other source omitted
}
// ... other source omitted
}

The calculation is startNanos - frameTimeNanos: how late this doFrame starts relative to the supplied frame timestamp. Integer division estimates missed scheduling intervals, not this frame’s rendering duration or the full count of missed presentations. The surrounding source also adjusts animation frame time and handles backward time.

Historical sequence diagram: jitter uses the supplied frame timestamp and actual interval

The diagram’s start_time should mean the timestamp carried by the event, not when Java happens to receive it. Its divisor must not be hardcoded to 16ms.

Historical example of earlier main-thread work delaying a later callback

Earlier work can delay the next callback, but this is not simply a detector for “the previous frame.” GPU, composition, or presentation misses may produce no such warning. Runnable scheduling delays can contribute without the app continuously running on CPU. The final annotation also reverses the subtraction; the source subtracts the frame timestamp from dispatch-start time.

Recording Frame Drawing Info

Internal android.graphics.FrameInfo records some stage timestamps, and HWUI adds later fields. It is marked @hide, not a stable app SDK class. Stage boundaries are not per-business-callback timings. The following Android 10 indices must not be used blindly for newer output formats.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// Various flags set to provide extra metadata about the current frame
private static final int FLAGS = 0;

// Is this the first-draw following a window layout?
public static final long FLAG_WINDOW_LAYOUT_CHANGED = 1;

// A renderer associated with just a Surface, not with a ViewRootImpl instance.
public static final long FLAG_SURFACE_CANVAS = 1 << 2;

@LongDef(flag = true, value = {
FLAG_WINDOW_LAYOUT_CHANGED, FLAG_SURFACE_CANVAS })
@Retention(RetentionPolicy.SOURCE)
public @interface FrameInfoFlags {}

// The intended vsync time, unadjusted by jitter
private static final int INTENDED_VSYNC = 1;

// Jitter-adjusted vsync time, this is what was used as input into the
// animation & drawing system
private static final int VSYNC = 2;

// The time of the oldest input event
private static final int OLDEST_INPUT_EVENT = 3;

// The time of the newest input event
private static final int NEWEST_INPUT_EVENT = 4;

// When input event handling started
private static final int HANDLE_INPUT_START = 5;

// When animation evaluations started
private static final int ANIMATION_START = 6;

// When ViewRootImpl#performTraversals() started
private static final int PERFORM_TRAVERSALS_START = 7;

// When View:draw() started
private static final int DRAW_START = 8;

doFrame records times from Vsync to markPerformTraversalsStart:

1
2
3
4
5
6
7
8
9
10
11
12
13
void doFrame(long frameTimeNanos, int frame) {
// ... other source omitted
mFrameInfo.setVsync(intendedFrameTimeNanos, frameTimeNanos);
// Process CALLBACK_INPUT Callbacks
mFrameInfo.markInputHandlingStart();
// Process CALLBACK_ANIMATION Callbacks
mFrameInfo.markAnimationsStart();
// Process CALLBACK_INSETS_ANIMATION Callbacks
// Process CALLBACK_TRAVERSAL Callbacks
mFrameInfo.markPerformTraversalsStart();
// Process CALLBACK_COMMIT Callbacks
// ... other source omitted
}

Executing Callbacks

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void doFrame(long frameTimeNanos, int frame) {
// ... other source omitted
// Process CALLBACK_INPUT Callbacks
doCallbacks(Choreographer.CALLBACK_INPUT, frameTimeNanos);
// Process CALLBACK_ANIMATION Callbacks
doCallbacks(Choreographer.CALLBACK_ANIMATION, frameTimeNanos);
// Process CALLBACK_INSETS_ANIMATION Callbacks
doCallbacks(Choreographer.CALLBACK_INSETS_ANIMATION, frameTimeNanos);
// Process CALLBACK_TRAVERSAL Callbacks
doCallbacks(Choreographer.CALLBACK_TRAVERSAL, frameTimeNanos);
// Process CALLBACK_COMMIT Callbacks
doCallbacks(Choreographer.CALLBACK_COMMIT, frameTimeNanos);
// ... other source omitted
}

Input Callback Stack

input callback typically executes ViewRootImpl.ConsumeBatchedInputRunnable:

android/view/ViewRootImpl.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
final class ConsumeBatchedInputRunnable implements Runnable {
@Override
public void run() {
doConsumeBatchedInput(mChoreographer.getFrameTimeNanos());
}
}
void doConsumeBatchedInput(long frameTimeNanos) {
if (mConsumeBatchedInputScheduled) {
mConsumeBatchedInputScheduled = false;
if (mInputEventReceiver != null) {
if (mInputEventReceiver.consumeBatchedInputEvents(frameTimeNanos)
&& frameTimeNanos != -1) {
scheduleConsumeBatchedInput();
}
}
doProcessInputEvents();
}
}

This illustrates a batched-input path into DecorView/Activity/View dispatch. Not all input waits for the INPUT callback; keys, unbatched events, and other paths need separate analysis:

Animation Callback Stack

Commonly used via View.postOnAnimation:

1
2
3
4
5
6
7
8
9
public void postOnAnimation(Runnable action) {
final AttachInfo attachInfo = mAttachInfo;
if (attachInfo != null) {
attachInfo.mViewRootImpl.mChoreographer.postCallback(
Choreographer.CALLBACK_ANIMATION, action, null);
} else {
getRunQueue().post(action);
}
}

Used for operations like startScroll and Fling:

The actual stack depends on the posted callback. This image shows AnimationHandler and ValueAnimator/AnimatorSet evaluation; it does not by itself establish an AbsListView FlingRunnable:

FrameCallback also uses CALLBACK_ANIMATION:

1
2
3
4
5
6
7
8
public void postFrameCallbackDelayed(FrameCallback callback, long delayMillis) {
if (callback == null) {
throw new IllegalArgumentException("callback must not be null");
}

postCallbackDelayedInternal(CALLBACK_ANIMATION,
callback, FRAME_CALLBACK_TOKEN, delayMillis);
}

Traversal Stack

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
void scheduleTraversals() {
if (!mTraversalScheduled) {
mTraversalScheduled = true;
// Insert a synchronization barrier; asynchronous messages remain eligible
mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
mChoreographer.postCallback(
Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
}
}

final class TraversalRunnable implements Runnable {
@Override
public void run() {
// Start measure, layout, draw
doTraversal();
}
}
void doTraversal() {
if (mTraversalScheduled) {
mTraversalScheduled = false;
// Remove SyncBarrier
mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);
performTraversals();
}
}
// Simplified outline; surrounding state and other branches are omitted.
private void performTraversals() {
// measure
if (focusChangedDueToTouchMode || mWidth != host.getMeasuredWidth() || mHeight != host.getMeasuredHeight() || contentInsetsChanged || updatedConfiguration) {
performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);
}
// layout
if (didLayout) {
performLayout(lp, mWidth, mHeight);
}
// draw
if (!cancelDraw && !newSurface) {
performDraw();
}
}

These ViewRootImpl excerpts retain a conditional outline, with local variables supplied by omitted surrounding code. Not every frame performs measure/layout/draw. A barrier does not raise CPU scheduling priority; its queue semantics are explained below.

doTraversal TraceView Example

Requesting the Next Frame’s Vsync

Continuous operations like animations or scrolling need a stable frame output. Further work may request another frame. invalidate and requestLayout can schedule traversal, but state checks and coalescing mean they do not each generate a separate VSync.

ObjectAnimator Animation Logic

android/animation/ObjectAnimator.java

1
2
3
public void start() {
super.start();
}

android/animation/ValueAnimator.java

1
2
3
4
5
6
7
8
9
private void start(boolean playBackwards) {
// ... other source omitted
addAnimationCallback(0);
// ... other source omitted
}
private void addAnimationCallback(long delay) {
// ... other source omitted
getAnimationHandler().addAnimationFrameCallback(this, delay);
}

android/animation/AnimationHandler.java

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public void addAnimationFrameCallback(final AnimationFrameCallback callback, long delay) {
if (mAnimationCallbacks.size() == 0) {
getProvider().postFrameCallback(mFrameCallback);
}
// ... other source omitted
}

private final Choreographer.FrameCallback mFrameCallback = new Choreographer.FrameCallback() {
@Override
public void doFrame(long frameTimeNanos) {
doAnimationFrame(getProvider().getFrameTime());
if (mAnimationCallbacks.size() > 0) {
getProvider().postFrameCallback(this);
}
}
};

The provider forwards to Choreographer; the internal path queues due work and schedules a frame, or posts a delayed scheduling message:

1
2
3
public void postFrameCallback(Choreographer.FrameCallback callback) {
mChoreographer.postFrameCallback(callback);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
private void postCallbackDelayedInternal(int callbackType,
Object action, Object token, long delayMillis) {
synchronized (mLock) {
final long now = SystemClock.uptimeMillis();
final long dueTime = now + delayMillis;
mCallbackQueues[callbackType].addCallbackLocked(dueTime, action, token);

if (dueTime <= now) {
// Coalesce frame scheduling, then request VSync if needed.
scheduleFrameLocked(now);
} else {
Message msg = mHandler.obtainMessage(MSG_DO_SCHEDULE_CALLBACK, action);
msg.arg1 = callbackType;
msg.setAsynchronous(true);
mHandler.sendMessageAtTime(msg, dueTime);
}
}
}

AnimationHandler reposts while animation callbacks remain. Public FrameCallback is one-shot; a continuous loop must explicitly repost and be cancelled when no longer needed:

Source Code Summary

  1. getInstance() returns the calling Looper thread’s instance, not a process-wide singleton; a missing Looper causes an exception.
  2. Requests go through the display connection, events through FD/Looper dispatch, and doFrame through an asynchronous Message. None is an app hardware-interrupt handler.
  3. FrameCallback is one-shot and runs in ANIMATION. One callback does not guarantee one new Buffer.
  4. Android 10/16 order due work as INPUT, ANIMATION, INSETS_ANIMATION, TRAVERSAL, COMMIT. Earlier updates can affect the later traversal.
  5. COMMIT runs after traversal, not after GPU/SF completion or presentation. ActivityThread.scheduleTrimMemory() uses it when the main Choreographer exists, but this is one use, not an onTrimMemory call every frame.
  6. ListView obtain/setup can be driven by input scrolling, continuing animation, or layout. Read the actual stack rather than inferring the cause from a stage name.

APM and Choreographer

These interfaces observe different stages; their numbers are not interchangeable FPS or dropped-frame counts:

  • FrameCallback observes callback pacing on its Looper, not Buffer/presentation counts.
  • gfxinfo ... framestats exposes window HWUI statistics with version-specific fields.
  • SurfaceFlinger --latency is a historical diagnostic whose target layer and implementation must match.
  • The private PageFlip query is historical and unavailable under its old code in Android 16.
  • Choreographer skip warnings indicate dispatch-start lateness, not complete presentation behavior.
  • Looper/BlockCanary measures message dispatch, not displayed frames.

Android 7 / API 24 provides Window.OnFrameMetricsAvailableListener. Its dropCountSinceLastInvocation counts dropped metric notifications, not dropped display frames. On Android 12 / API 31 and later, where captured, Perfetto FrameTimeline links expected/actual app and SF frames to help investigate CPU, GPU, and composition delays.

Using FrameCallback.doFrame

1
2
3
public interface FrameCallback {
void doFrame(long frameTimeNanos);
}

Use it on a thread with a Looper, normally the main thread for View apps:

1
2
3
4
5
6
7
Choreographer choreographer = Choreographer.getInstance();
Choreographer.FrameCallback callback = frameTimeNanos -> {
// Update animation state using this frame time; keep the work short.
};
choreographer.postFrameCallback(callback);
// If no longer needed before delivery, cancel on the same Looper thread:
// choreographer.removeFrameCallback(callback);

postFrameCallback() is public from API 16 and one-shot. A continuous observer must repost and stop appropriately; the observer itself requests more frames, so callback counts are not necessarily app draw counts. frameTimeNanos is the animation frame time, possibly jitter-adjusted, not the instant execution starts.

TinyDancer was the historical callback-based pacing project referenced here. Its displayed rate is not automatically composited screen FPS. API 33 adds postVsyncCallback() with FrameData/FrameTimeline information such as candidate deadlines and expected presentation times. These are predictions, not measured presentation; use the data within the callback’s valid lifetime.

Using FrameInfo

1
adb shell dumpsys gfxinfo com.meizu.flyme.launcher framestats

Substitute the actual package. The preserved sample below says Window: StatusBar; it must not be presented as verified output of that Launcher command in the same run.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Window: StatusBar
Stats since: 17990256398ns
Total frames rendered: 1562
Janky frames: 361 (23.11%)
50th percentile: 6ms
90th percentile: 23ms
95th percentile: 36ms
99th percentile: 101ms
Number Missed Vsync: 33
Number High input latency: 683
Number Slow UI thread: 273
Number Slow bitmap uploads: 8
Number Slow issue draw commands: 18
Number Frame deadline missed: 287
HISTOGRAM: 5ms=670 6ms=128 7ms=84 8ms=63 9ms=38 10ms=23 11ms=21 12ms=20 13ms=25 14ms=39 15ms=65 16ms=36 17ms=51 18ms=37 19ms=41 20ms=20 21ms=19 22ms=18 23ms=15 24ms=14 25ms=8 26ms=4 27ms=6 28ms=3 29ms=4 30ms=2 31ms=2 32ms=6 34ms=12 36ms=10 38ms=9 40ms=3 42ms=4 44ms=5 46ms=8 48ms=6 53ms=6 57ms=4 61ms=1 65ms=0 69ms=2 73ms=2 77ms=3 81ms=4 85ms=1 89ms=2 93ms=0 97ms=2 101ms=1 105ms=1 109ms=1 113ms=1 117ms=1 121ms=2 125ms=1 129ms=0 133ms=1 150ms=2 200ms=3 250ms=0 300ms=1 350ms=1 400ms=0 450ms=0 500ms=0 550ms=0 600ms=0 650ms=0

---PROFILEDATA---
Flags,IntendedVsync,Vsync,OldestInputEvent,NewestInputEvent,HandleInputStart,AnimationStart,PerformTraversalsStart,DrawStart,SyncQueued,SyncStart,IssueDrawCommandsStart,SwapBuffers,FrameCompleted,DequeueBufferDuration,QueueBufferDuration,
0,10158314881426,10158314881426,9223372036854775807,0,10158315693363,10158315760759,10158315769821,10158316032165,10158316627842,10158316838988,10158318055915,10158320387269,10158321770654,428000,773000,
0,10158332036261,10158332036261,9223372036854775807,0,10158332799196,10158332868519,10158332877269,10158333137738,10158333780654,10158333993206,10158335078467,10158337689561,10158339307061,474000,885000,
0,10158348665353,10158348665353,9223372036854775807,0,10158349710238,10158349773102,10158349780863,10158350405863,10158351135967,10158351360446,10158352300863,10158354305654,10158355814509,471000,836000,
0,10158365296729,10158365296729,9223372036854775807,0,10158365782373,10158365821019,10158365825238,10158365975290,10158366547946,10158366687217,10158367240706,10158368429248,10158369291852,269000,476000,

Keep and parse the CSV header: Android/HWUI versions change fields. Timestamps use the implementation’s monotonic clock in nanoseconds; Duration columns are durations, not timestamps. The old OldestInputEvent=9223372036854775807 and NewestInputEvent=0 combination indicates no valid input timestamps, not enormous input latency. FrameCompleted is not screen presentation. Slow-frame categories can overlap and must not be added as disjoint bad-frame counts.

Using SurfaceFlinger

These are Android 10-era diagnostic examples. List actual layers and query the relevant one; names change with windows, recreation, and versions:

1
2
adb shell dumpsys SurfaceFlinger --list
adb shell dumpsys SurfaceFlinger --latency 'com.meizu.flyme.launcher/com.meizu.flyme.launcher.Launcher#0'

In Android 10 SurfaceFlinger::dumpStatsLocked() and FrameTracker::dumpStats(), the first line is the refresh interval, followed by columns:

  1. A: desiredPresentTime, desired presentation time.
  2. B: actualPresentTime, recorded actual presentation time.
  3. C: frameReadyTime, when contents became ready for presentation, possibly obtained from a ready fence.

These are not app draw start, pre-submission VSync, and hardware acceptance. That implementation uses a 128-entry ring but dumps 127 slots per tracker. Zero and pending INT64_MAX entries are not valid frames. Check the target, time range, validity, monotonicity, gaps, and refresh changes rather than assuming 128 new frames per command.

The old ceil((C - A) / refresh_period) formula and “a change means jank” rule are not universal. Desired, ready, and actual times measure different things; even persistently high latency can leave that value unchanged. Analyze presentation intervals and expected deadlines according to the implementation, preferably correlating FrameTimeline where supported.

Using SurfaceFlinger PageFlip

Historical private interface, not a current app-monitoring recipe. Android 10’s private debug transaction 1013 returned the default display’s PageFlip count. Even with access, that was display-level activity, not one app’s frame count. The old command also omitted shell; correcting it to adb shell service call SurfaceFlinger 1013 does not make it public or remove permission restrictions.

Android 16 source explicitly contains:

1
2
case 1013: // Unused.
return NAME_NOT_FOUND;

The old example that read transact(1013) as a count and calculated app FPS is therefore removed. Use public window metrics or version-appropriate trace data. Never ignore transaction/return status or treat unsupported output as zero frames.

Choreographer Skip Warnings

Android 10 defaults SKIPPED_FRAME_WARNING_LIMIT to 30 via debug.choreographer.skipwarning, read when the class initializes. This is a logging threshold, not proof that smaller delays are smooth or that every frame has been measured.

1
2
3
4
5
6
7
if (jitterNanos >= mFrameIntervalNanos) {
final long skippedFrames = jitterNanos / mFrameIntervalNanos;
if (skippedFrames >= SKIPPED_FRAME_WARNING_LIMIT) {
Log.i(TAG, "Skipped " + skippedFrames + " frames! "
+ "The application may be doing too much work on its main thread.");
}
}

Frame time, dispatch start, work completion, and presentation remain separate. Changing the warning threshold does not fix latency.

BlockCanary

Tools such as BlockCanary time the boundaries of Looper message dispatch. This is an excerpt of the older dispatch path:

1
2
3
4
5
6
7
8
9
10
// Excerpt inside Looper.loop(); obtaining the next message is omitted.
Printer logging = me.mLogging;
if (logging != null) {
logging.println(">>>>> Dispatching to " + msg.target + " " +
msg.callback + ": " + msg.what);
}
msg.target.dispatchMessage(msg);
if (logging != null) {
logging.println("<<<<< Finished to " + msg.target + " " + msg.callback);
}

It measures dispatch wall time including waits, not pure CPU time or the complete RenderThread/GPU/SF chain. It also does not cover every native-poll callback outside dispatch. A backlog of individually short messages can delay work without one long-message warning. Logging, string construction, and sampling have overhead, and setMessageLogging must coexist with other observers. This is not zero-overhead, complete jank detection.

MessageQueue and Choreographer

In Android 10 MessageQueue.next(), a queue-head barrier with target == null makes the queue search forward for an asynchronous message, still respecting its when. Synchronous messages behind the barrier wait for removal. This neither preempts a running message nor raises CPU scheduling priority.

Message.setAsynchronous() is public from API 22, and Handler.createAsync() from API 28. “Asynchronous” means exempt from synchronization barriers, not automatically first, on another thread, or immediate. Without a barrier, normal queue timing/order still applies. The framework’s barrier insertion/removal APIs are not public app SDK contracts.

SyncBarrier Example in Choreographer

This ViewRootImpl outline omits other state management. scheduleTraversals() inserts the barrier and registers traversal work. An asynchronous VSync message can bypass the barrier and let doFrame run that callback:

1
2
3
4
5
6
7
8
9
void scheduleTraversals() {
if (!mTraversalScheduled) {
mTraversalScheduled = true;
mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
mChoreographer.postCallback(
Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
// ... other source omitted
}
}

doTraversal() removes the barrier before performTraversals(), not after GPU completion:

1
2
3
4
5
6
7
8
void doTraversal() {
if (mTraversalScheduled) {
mTraversalScheduled = false;
mHandler.getLooper().getQueue().removeSyncBarrier(mTraversalBarrier);
// ... other source omitted
performTraversals();
}
}

Asynchronous eligibility cannot bypass a long task already executing. Private barriers or arbitrary message reordering are not general app jank fixes.

Manufacturer Optimizations

Working on the system side gives us room to change source code and try scheduling policies. I can’t share the proprietary implementation here, but I can outline the ideas. If you’re interested, we can discuss them privately. The examples below do not include controlled results.

Input-event Optimization

AOSP already has CALLBACK_INPUT and batched-input consumption. The historical idea concerns changing input scheduling or responding earlier, not adding input support to a Choreographer that has none. Earlier work must be evaluated alongside batching, resampling, power, and presentation pacing. It is not automatically better.

Background Animation Optimization

An app’s self-reposting callbacks or animations can continue creating work when no longer useful. Decisions to stop them should account for lifecycle, window visibility, multi-window operation, and the actual feature; not every background callback is meaningless.

Historical continuous doFrame activity; the screenshot does not independently prove visibility state

I recorded this historical com.netease.newsreader.activity capture as a background case. It shows dense frame activity but no independent visibility or power evidence, and makes no claim about the current app. Apps should cancel unneeded animations/callbacks; platform policies must also preserve legitimate visible-window and background behavior.

Frame Scheduling Optimization

“Compose without waiting for VSync” was an OEM exploration direction, not a public app switch. Scheduling still depends on buffers, fences, display hardware, and refresh constraints. Immediate scheduling can increase power or disrupt pacing without reducing end-to-end latency. No implementation or comparison is provided, so this is not a verified optimization result.

App Startup Optimization

First locate long work, locks, and queues on the launch critical path. System-level message reordering must preserve lifecycle and dependencies; moving arbitrary “important” messages to the front risks incorrect order and starvation. Apps should reduce critical-path work, defer unnecessary initialization, and measure first-frame and interactive readiness separately.

High-refresh-rate Optimization

60/90/120Hz correspond to about 16.67/11.11/8.33ms; the original 8.8ms for 120Hz was incorrect. Refresh intervals are not a full independent budget for every thread. Evaluate app/game load, rate transitions, thermals, power, and pacing together rather than requiring every app to render at maximum rate continuously.

References

Versioned primary sources are linked in the relevant sections. These are the original background references; they do not establish current platform behavior:

  1. Historical VSync explanation
  2. Gityuan’s Choreographer article
  3. Choreographer API reference
  4. Historical Jishuwen reference
  5. Historical Juejin entry
  6. Android development course

Article Discussion

Original Chinese discussion pages: Zhihu and Juejin.

About Me && Blog

I welcome discussion with fellow developers:

  1. About me: personal introduction and contact information.
  2. Blog navigation.
  3. Android performance resources: recommendations are welcome.
  4. Android performance community: The paid community stopped operating in 2026 and no longer accepts new members; this link preserves its history.

One person can go faster; a group can go further.

WeChat QR code

CATALOG
  1. 1. Series Article Index
  2. 2. Essence of the Main Thread Mechanism
    1. 2.1. Evolution
    2. 2.2. Introducing Choreographer
  3. 3. Choreographer Overview
    1. 3.1. Choreographer Workflow from a Systrace Perspective
    2. 3.2. Choreographer Workflow
  4. 4. Source Code Analysis
    1. 4.1. Choreographer Initialization
      1. 4.1.1. Singleton Initialization
      2. 4.1.2. Choreographer Constructor
      3. 4.1.3. FrameHandler
      4. 4.1.4. Choreographer Initialization Chain
    2. 4.2. FrameDisplayEventReceiver Overview
    3. 4.3. Vsync Registration in Choreographer
    4. 4.4. Logic for Processing One Frame
      1. 4.4.1. Dropped Frame Logic
      2. 4.4.2. Recording Frame Drawing Info
      3. 4.4.3. Executing Callbacks
    5. 4.5. Requesting the Next Frame’s Vsync
      1. 4.5.1. ObjectAnimator Animation Logic
    6. 4.6. Source Code Summary
  5. 5. APM and Choreographer
    1. 5.1. Using FrameCallback.doFrame
    2. 5.2. Using FrameInfo
    3. 5.3. Using SurfaceFlinger
    4. 5.4. Using SurfaceFlinger PageFlip
    5. 5.5. Choreographer Skip Warnings
    6. 5.6. BlockCanary
  6. 6. MessageQueue and Choreographer
    1. 6.1. SyncBarrier Example in Choreographer
  7. 7. Manufacturer Optimizations
    1. 7.1. Input-event Optimization
    2. 7.2. Background Animation Optimization
    3. 7.3. Frame Scheduling Optimization
    4. 7.4. App Startup Optimization
    5. 7.5. High-refresh-rate Optimization
  8. 8. References
  9. 9. Article Discussion
  10. 10. About Me && Blog