CN

RenderThread Workflow in Android hwui

Word count: 3.9kReading time: 24 min
2015/08/12
loading

Preface

These are my notes from learning how hwui’s RenderThread works in Android 5.0. I haven’t covered every detail; I wanted to trace the main flow so that, next time I hit a problem, I would know where to look.

I captured the first RenderThread Draw during app startup with Systrace, as shown below. It helps show the execution order on that thread, but the end of a DrawFrame slice is not the moment pixels appear on screen: GPU work, buffer consumption, composition, and presentation still matter. A StartingWindow may appear during startup; this CPU slice alone does not establish when it disappears.

Version note (reviewed 2026-10-13): This preserves the Android 5.0 workflow and uses AOSP android-5.0.0_r1 as its source baseline. The original screenshots include device-side instrumentation, so event names need not match AOSP exactly. Incomplete excerpts below have been filled in and aligned with this tag. The old OpenGLRenderer, DisplayListRenderer, and EGL details are historical; the final section covers Android 16 counterparts and FrameTimeline from Android 12. The scope is hardware-accelerated View rendering, not every custom Surface renderer.

RenderThread Draw first frame

Starting from the Java Layer

Choreographer normally schedules pending frame callbacks against VSYNC. Not every VSYNC necessarily redraws every View. Here we follow one scheduled traversal into the drawing path:

Rendering Call Chain

The entry point is Choreographer.doFrame(), not drawFrame(). Its CALLBACK_TRAVERSAL callbacks reach ViewRootImpl.TraversalRunnable.run() → doTraversal() → performTraversals(). When drawing is needed, this reaches performDraw() → draw(boolean fullRedrawNeeded). That last method is private to ViewRootImpl, not a custom View’s onDraw().

The following excerpt shows its hardware-rendering branch; the surrounding control flow is omitted:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if (mAttachInfo.mHardwareRenderer != null && mAttachInfo.mHardwareRenderer.isEnabled()) {
mIsAnimating = false;
boolean invalidateRoot = false;
if (mHardwareYOffset != yOffset || mHardwareXOffset != xOffset) {
mHardwareYOffset = yOffset;
mHardwareXOffset = xOffset;
mAttachInfo.mHardwareRenderer.invalidateRoot();
}
mResizeAlpha = resizeAlpha;

dirty.setEmpty();

mBlockResizeBuffer = false;
mAttachInfo.mHardwareRenderer.draw(mView, mAttachInfo, this);
}

If the hardware rendering path is taken, mHardwareRenderer.draw is called. Here, mHardwareRenderer refers to ThreadedRenderer, whose draw function is as follows:

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
@Override
void draw(View view, AttachInfo attachInfo, HardwareDrawCallbacks callbacks) {
attachInfo.mIgnoreDirtyState = true;
long frameTimeNanos = mChoreographer.getFrameTimeNanos();
attachInfo.mDrawingTime = frameTimeNanos / TimeUtils.NANOS_PER_MS;

long recordDuration = 0;
if (mProfilingEnabled) {
recordDuration = System.nanoTime();
}

updateRootDisplayList(view, callbacks);

if (mProfilingEnabled) {
recordDuration = System.nanoTime() - recordDuration;
}

attachInfo.mIgnoreDirtyState = false;

// register animating rendernodes which started animating prior to renderer
// creation, which is typical for animators started prior to first draw
if (attachInfo.mPendingAnimatingRenderNodes != null) {
final int count = attachInfo.mPendingAnimatingRenderNodes.size();
for (int i = 0; i < count; i++) {
registerAnimatingRenderNode(
attachInfo.mPendingAnimatingRenderNodes.get(i));
}
attachInfo.mPendingAnimatingRenderNodes.clear();
// We don't need this anymore as subsequent calls to
// ViewRootImpl#attachRenderNodeAnimator will go directly to us.
attachInfo.mPendingAnimatingRenderNodes = null;
}

int syncResult = nSyncAndDrawFrame(mNativeProxy, frameTimeNanos,
recordDuration, view.getResources().getDisplayMetrics().density);
if ((syncResult & SYNC_INVALIDATE_REQUIRED) != 0) {
attachInfo.mViewRootImpl.invalidate();
}
}

updateRootDisplayList(view, callbacks) updates or reuses the View tree’s display lists and records the root node when needed; its trace section is named getDisplayList. Recording commands is not the same as the GPU drawing pixels. It then calls:

1
2
int syncResult = nSyncAndDrawFrame(mNativeProxy, frameTimeNanos,
recordDuration, view.getResources().getDisplayMetrics().density);

The UI thread waits in native postAndWait() until RenderThread signals it, then returns with syncResult. This wait can end before context->draw(); it does not necessarily last until GPU completion or presentation. The signal points are shown in DrawFrameTask::run() below.

The Native Layer

The Native code is located in android_view_ThreadedRenderer.cpp. The implementation is as follows:

1
2
3
4
5
static int android_view_ThreadedRenderer_syncAndDrawFrame(JNIEnv* env, jobject clazz,
jlong proxyPtr, jlong frameTimeNanos, jlong recordDuration, jfloat density) {
RenderProxy* proxy = reinterpret_cast<RenderProxy*>(proxyPtr);
return proxy->syncAndDrawFrame(frameTimeNanos, recordDuration, density);
}

The RenderProxy implementation can be found in frameworks/base/libs/hwui/renderthread/RenderProxy.cpp:

1
2
3
4
5
int RenderProxy::syncAndDrawFrame(nsecs_t frameTimeNanos, nsecs_t recordDurationNanos,
float density) {
mDrawFrameTask.setDensity(density);
return mDrawFrameTask.drawFrame(frameTimeNanos, recordDurationNanos);
}

Here, mDrawFrameTask is a DrawFrameTask object, located in frameworks/base/libs/hwui/renderthread/DrawFrameTask.cpp. Its drawFrame code is:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
int DrawFrameTask::drawFrame(nsecs_t frameTimeNanos, nsecs_t recordDurationNanos) {
LOG_ALWAYS_FATAL_IF(!mContext, "Cannot drawFrame with no CanvasContext!");

mSyncResult = kSync_OK;
mFrameTimeNanos = frameTimeNanos;
mRecordDurationNanos = recordDurationNanos;
postAndWait();

// Reset the single-frame data
mFrameTimeNanos = 0;
mRecordDurationNanos = 0;

return mSyncResult;
}

The implementation of postAndWait() is:

1
2
3
4
5
void DrawFrameTask::postAndWait() {
AutoMutex _lock(mLock);
mRenderThread->queue(this);
mSignal.wait(mLock);
}

This enqueues the current DrawFrameTask. The UI thread then waits on a condition variable, releasing mLock while waiting and reacquiring it on return. The RenderThread queue method is:

1
2
3
4
5
6
7
8
void RenderThread::queue(RenderTask* task) {
AutoMutex _lock(mLock);
mQueue.queue(task);
if (mNextWakeup && task->mRunAt < mNextWakeup) {
mNextWakeup = 0;
mLooper->wake();
}
}

mQueue is a TaskQueue. It orders tasks by mRunAt and rejects queuing the same task object twice:

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
void TaskQueue::queue(RenderTask* task) {
// Since the RenderTask itself forms the linked list it is not allowed
// to have the same task queued twice
LOG_ALWAYS_FATAL_IF(task->mNext || mTail == task, "Task is already in the queue!");
if (mTail) {
// Fast path if we can just append
if (mTail->mRunAt <= task->mRunAt) {
mTail->mNext = task;
mTail = task;
} else {
// Need to find the proper insertion point
RenderTask* previous = 0;
RenderTask* next = mHead;
while (next && next->mRunAt <= task->mRunAt) {
previous = next;
next = next->mNext;
}
if (!previous) {
task->mNext = mHead;
mHead = task;
} else {
previous->mNext = task;
if (next) {
task->mNext = next;
} else {
mTail = task;
}
}
}
} else {
mTail = mHead = task;
}
}

Going back to the queue method in RenderThread, the wake() function is called:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void Looper::wake() {
#if DEBUG_POLL_AND_WAKE
ALOGD("%p ~ wake", this);
#endif

ssize_t nWrite;
do {
nWrite = write(mWakeWritePipeFd, "W", 1);
} while (nWrite == -1 && errno == EINTR);

if (nWrite != 1) {
if (errno != EAGAIN) {
ALOGW("Could not write wake signal, errno=%d", errno);
}
}
}

In this Android 5.0 tag, wake() writes W to the wake pipe so the listening Looper can leave its wait. Not every enqueue writes to the pipe: queue calls it only when the new task is earlier than the next scheduled wakeup.

HWUI RenderThread

Where do we go next? First, let’s get familiar with RenderThread. It inherits from Thread (defined in utils/Thread.h). Here is the RenderThread constructor:

1
2
3
4
5
6
7
8
9
10
11
12
RenderThread::RenderThread() : Thread(true), Singleton<RenderThread>()
, mNextWakeup(LLONG_MAX)
, mDisplayEventReceiver(0)
, mVsyncRequested(false)
, mFrameCallbackTaskPending(false)
, mFrameCallbackTask(0)
, mRenderState(NULL)
, mEglManager(NULL) {
mFrameCallbackTask = new DispatchFrameCallbacks(this);
mLooper = new Looper(false);
run("RenderThread");
}

The run method is documented in Thread:

1
2
3
4
// Start the thread in threadLoop() which needs to be implemented.
virtual status_t run( const char* name = 0,
int32_t priority = PRIORITY_DEFAULT,
size_t stack = 0);

This triggers the threadLoop function, which is critical:

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
bool RenderThread::threadLoop() {
#if defined(HAVE_PTHREADS)
setpriority(PRIO_PROCESS, 0, PRIORITY_DISPLAY);
#endif
initThreadLocals();

int timeoutMillis = -1;
for (;;) {
int result = mLooper->pollOnce(timeoutMillis);
LOG_ALWAYS_FATAL_IF(result == Looper::POLL_ERROR,
"RenderThread Looper POLL_ERROR!");

nsecs_t nextWakeup;
// Process our queue, if we have anything
while (RenderTask* task = nextTask(&nextWakeup)) {
task->run();
// task may have deleted itself, do not reference it again
}
if (nextWakeup == LLONG_MAX) {
timeoutMillis = -1;
} else {
nsecs_t timeoutNanos = nextWakeup - systemTime(SYSTEM_TIME_MONOTONIC);
timeoutMillis = nanoseconds_to_milliseconds(timeoutNanos);
if (timeoutMillis < 0) {
timeoutMillis = 0;
}
}

if (mPendingRegistrationFrameCallbacks.size() && !mFrameCallbackTaskPending) {
drainDisplayEventQueue(true);
mFrameCallbacks.insert(
mPendingRegistrationFrameCallbacks.begin(), mPendingRegistrationFrameCallbacks.end());
mPendingRegistrationFrameCallbacks.clear();
requestVsync();
}
}

return false;
}

The for loop is infinite. pollOnce(timeoutMillis) can return because of the wake pipe, another file-descriptor event, or a timeout; with a zero timeout it does not block. It does not depend solely on wake(). nextTask() then retrieves tasks that are due:

1
2
3
4
while (RenderTask* task = nextTask(&nextWakeup)) {
task->run();
// task may have deleted itself, do not reference it again
}

The queue can hold more than drawing tasks. For the enqueue traced here, the task is a DrawFrameTask, whose run method is:

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
void DrawFrameTask::run() {
ATRACE_NAME("DrawFrame");

mContext->profiler().setDensity(mDensity);
mContext->profiler().startFrame(mRecordDurationNanos);

bool canUnblockUiThread;
bool canDrawThisFrame;
{
TreeInfo info(TreeInfo::MODE_FULL, mRenderThread->renderState());
canUnblockUiThread = syncFrameState(info);
canDrawThisFrame = info.out.canDrawThisFrame;
}

// Grab a copy of everything we need
CanvasContext* context = mContext;

// From this point on anything in "this" is *UNSAFE TO ACCESS*
if (canUnblockUiThread) {
unblockUiThread();
}

if (CC_LIKELY(canDrawThisFrame)) {
context->draw();
}

if (!canUnblockUiThread) {
unblockUiThread();
}
}

unblockUiThread() pairs with the earlier mSignal.wait(mLock):

1
2
3
4
void DrawFrameTask::unblockUiThread() {
AutoMutex _lock(mLock);
mSignal.signal();
}

The signal allows the UI thread to resume; it does not guarantee immediate CPU scheduling. If early unblocking is unavailable, the signal follows the drawing branch, whether it called draw() or skipped drawing. Neither signal is a GPU-completion notification.

RenderThread.DrawFrame

The run method of DrawFrameTask coordinates the stages shown in the initial trace diagram. Let’s break down the DrawFrame process step by step, combining the code with the trace visualization.

1. syncFrameState

syncFrameState prepares frame state on RenderThread: it processes layer updates, synchronizes the node tree, prepares textures, and advances animations.

The UI thread and RenderThread use staged rendering state. This does not copy a complete Java View tree into a second tree: Java RenderNode holds a native node through mNativeRenderNode, and that native node contains both mStagingProperties / mProperties and mStagingDisplayListData / mDisplayListData.

prepareTree pushes staging state into the rendering state in MODE_FULL: dirty properties are updated, and display-list data can be transferred by pointer rather than deeply copied every frame. canUnblockUiThread comes from info.prepareTextures; it determines whether the UI thread may be released early, not whether synchronization or GPU rendering has universally “succeeded.” For example, this version’s prepareSubTree disables the flag when ownedBitmapResources are present, so false alone does not prove that the texture cache ran out of space.

syncFrameState

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
bool DrawFrameTask::syncFrameState(TreeInfo& info) {
ATRACE_CALL();
mRenderThread->timeLord().vsyncReceived(mFrameTimeNanos);
mContext->makeCurrent();
Caches::getInstance().textureCache.resetMarkInUse();

for (size_t i = 0; i < mLayers.size(); i++) {
mContext->processLayerUpdate(mLayers[i].get());
}
mLayers.clear();
mContext->prepareTree(info);

if (info.out.hasAnimations) {
if (info.out.requiresUiRedraw) {
mSyncResult |= kSync_UIRedrawRequired;
}
}
// If prepareTextures is false, we ran out of texture cache space
return info.prepareTextures;
}

First is makeCurrent. Here, mContext is a CanvasContext object:

1
2
3
4
void CanvasContext::makeCurrent() {
// In the meantime this matches the behavior of GLRenderer, so it is not a regression
mHaveNewSurface |= mEglManager.makeCurrent(mEglSurface);
}

mEglManager is an EglManager object:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
bool EglManager::makeCurrent(EGLSurface surface) {
if (isCurrent(surface)) return false;

if (surface == EGL_NO_SURFACE) {
// If we are setting EGL_NO_SURFACE we don't care about any of the potential
// return errors, which would only happen if mEglDisplay had already been
// destroyed in which case the current context is already NO_CONTEXT
eglMakeCurrent(mEglDisplay, EGL_NO_SURFACE, EGL_NO_SURFACE, EGL_NO_CONTEXT);
} else if (!eglMakeCurrent(mEglDisplay, surface, surface, mEglContext)) {
LOG_ALWAYS_FATAL("Failed to make current on surface %p, error=%s",
(void*)surface, egl_error_str());
}
mCurrentSurface = surface;
return true;
}

isCurrent(surface) allows an early return. When switching surfaces, eglMakeCurrent binds the existing mEglContext and its read/draw surfaces to the current thread. It does not recreate the EGLContext; creation uses eglCreateContext. The branch using EGL_NO_CONTEXT releases the current binding.

After makeCurrent, mContext->prepareTree(info) is called:

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
void CanvasContext::prepareTree(TreeInfo& info) {
mRenderThread.removeFrameCallback(this);

info.damageAccumulator = &mDamageAccumulator;
info.renderer = mCanvas;
if (mPrefetechedLayers.size() && info.mode == TreeInfo::MODE_FULL) {
info.canvasContext = this;
}
mAnimationContext->startFrame(info.mode);
mRootRenderNode->prepareTree(info);
mAnimationContext->runRemainingAnimations(info);

if (info.canvasContext) {
freePrefetechedLayers();
}

int runningBehind = 0;
// TODO: This query is moderately expensive, investigate adding some sort
// of fast-path based off when we last called eglSwapBuffers() as well as
// last vsync time. Or something.
mNativeWindow->query(mNativeWindow.get(),
NATIVE_WINDOW_CONSUMER_RUNNING_BEHIND, &runningBehind);
info.out.canDrawThisFrame = !runningBehind;

if (info.out.hasAnimations || !info.out.canDrawThisFrame) {
if (!info.out.requiresUiRedraw) {
// If animationsNeedsRedraw is set don't bother posting for an RT anim
// as we will just end up fighting the UI thread.
mRenderThread.postFrameCallback(this);
}
}
}

Within this, mRootRenderNode->prepareTree(info) is the most important part. Back at the Java layer, the ThreadedRenderer initializes a pointer:

1
long rootNodePtr = nCreateRootRenderNode();

This is the native root node held by ThreadedRenderer. RenderNode.adopt() wraps that pointer in Java; it does not create a separate native root node.

1
mRootNode = RenderNode.adopt(rootNodePtr);

Next, nCreate creates a RenderProxy using the root-node pointer. The RenderProxy constructor posts a task to create CanvasContext on RenderThread and waits for the result; CanvasContext receives the same root node. This is not all performed directly on the UI thread.

updateRootDisplayList first calls updateViewTreeDisplayList. It then records the renderer’s root display list when that root needs updating or is invalid. canvas.drawRenderNode(view.getDisplayList()) records a draw reference to the root View’s node; it does not render it immediately:

1
2
3
canvas.insertReorderBarrier();
canvas.drawRenderNode(view.getDisplayList());
canvas.insertInorderBarrier();

The final implementation is:

1
2
3
4
5
6
7
8
9
10
status_t DisplayListRenderer::drawRenderNode(RenderNode* renderNode, Rect& dirty, int32_t flags) {
LOG_ALWAYS_FATAL_IF(!renderNode, "missing rendernode");

// dirty is an out parameter and should not be recorded,
// it matters only when replaying the display list
DrawRenderNodeOp* op = new (alloc()) DrawRenderNodeOp(renderNode, flags, *currentTransform());
addRenderNodeOp(op);

return DrawGlInfo::kStatusDone;
}

Returning to CanvasContext::prepareTree, the mRootRenderNode is the one passed during initialization. Its implementation is in RenderNode.cpp:

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
void RenderNode::prepareTree(TreeInfo& info) {
ATRACE_CALL();
LOG_ALWAYS_FATAL_IF(!info.damageAccumulator, "DamageAccumulator missing");

prepareTreeImpl(info);
}

void RenderNode::prepareTreeImpl(TreeInfo& info) {
info.damageAccumulator->pushTransform(this);

if (info.mode == TreeInfo::MODE_FULL) {
pushStagingPropertiesChanges(info);
}
uint32_t animatorDirtyMask = 0;
if (CC_LIKELY(info.runAnimations)) {
animatorDirtyMask = mAnimatorManager.animate(info);
}
prepareLayer(info, animatorDirtyMask);
if (info.mode == TreeInfo::MODE_FULL) {
pushStagingDisplayListChanges(info);
}
prepareSubTree(info, mDisplayListData);
pushLayerUpdate(info);

info.damageAccumulator->popTransform();
}

Further details on these operations can be explored in the source code.

2. draw

Draw

After synchronization, draw() is called only if canDrawThisFrame is true. This version checks whether the consumer is running behind and can skip this draw and schedule a later frame callback.

1
2
3
if (CC_LIKELY(canDrawThisFrame)) {
context->draw();
}

CanvasContext::draw() is in frameworks/base/libs/hwui/renderthread/CanvasContext.cpp. Below is the complete AOSP 5.0 method, including the previously omitted beginFrame, viewport, and dirty-region handling. The original vendor-specific overdraw debug additions are not part of this AOSP tag.

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
41
42
43
44
45
46
47
48
void CanvasContext::draw() {
LOG_ALWAYS_FATAL_IF(!mCanvas || mEglSurface == EGL_NO_SURFACE,
"drawRenderNode called on a context with no canvas or surface!");

profiler().markPlaybackStart();

SkRect dirty;
mDamageAccumulator.finish(&dirty);

EGLint width, height;
mEglManager.beginFrame(mEglSurface, &width, &height);
if (width != mCanvas->getViewportWidth() || height != mCanvas->getViewportHeight()) {
mCanvas->setViewport(width, height);
dirty.setEmpty();
} else if (!mDirtyRegionsEnabled || mHaveNewSurface) {
dirty.setEmpty();
} else {
if (!dirty.isEmpty() && !dirty.intersect(0, 0, width, height)) {
ALOGW("Dirty " RECT_STRING " doesn't intersect with 0 0 %d %d ?",
SK_RECT_ARGS(dirty), width, height);
dirty.setEmpty();
}
profiler().unionDirty(&dirty);
}

status_t status;
if (!dirty.isEmpty()) {
status = mCanvas->prepareDirty(dirty.fLeft, dirty.fTop,
dirty.fRight, dirty.fBottom, mOpaque);
} else {
status = mCanvas->prepare(mOpaque);
}

Rect outBounds;
status |= mCanvas->drawRenderNode(mRootRenderNode.get(), outBounds);

profiler().draw(mCanvas);

mCanvas->finish();

profiler().markPlaybackEnd();

if (status & DrawGlInfo::kStatusDrew) {
swapBuffers();
}

profiler().finishFrame();
}

2.1 EglManager::beginFrame

As shown in the complete method above, CanvasContext::draw() calls mEglManager.beginFrame():

1
2
3
4
5
6
7
8
9
10
11
12
void EglManager::beginFrame(EGLSurface surface, EGLint* width, EGLint* height) {
LOG_ALWAYS_FATAL_IF(surface == EGL_NO_SURFACE,
"Tried to beginFrame on EGL_NO_SURFACE!");
makeCurrent(surface);
if (width) {
eglQuerySurface(mEglDisplay, surface, EGL_WIDTH, width);
}
if (height) {
eglQuerySurface(mEglDisplay, surface, EGL_HEIGHT, height);
}
eglBeginFrame(mEglDisplay, surface);
}

This method ensures the context/surface binding and queries dimensions before calling Android’s then-current eglBeginFrame entry point. In this tag’s frameworks/native/opengl/libs/EGL/eglApi.cpp, that entry mainly traces and validates the display and surface; it is not a standard EGL operation that waits for the GPU to begin a frame.

2.2 prepareDirty

1
2
3
4
5
6
7
status_t status;
if (!dirty.isEmpty()) {
status = mCanvas->prepareDirty(dirty.fLeft, dirty.fTop,
dirty.fRight, dirty.fBottom, mOpaque);
} else {
status = mCanvas->prepare(mOpaque);
}

Here, mCanvas is an OpenGLRenderer object. Its prepareDirty implementation is:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
status_t OpenGLRenderer::prepareDirty(float left, float top,
float right, float bottom, bool opaque) {
setupFrameState(left, top, right, bottom, opaque);

// Layer renderers will start the frame immediately
// The framebuffer renderer will first defer the display list
// for each layer and wait until the first drawing command
// to start the frame
if (currentSnapshot()->fbo == 0) {
syncState();
updateLayers();
} else {
return startFrame();
}

return DrawGlInfo::kStatusDone;
}

2.3 drawRenderNode

1
2
Rect outBounds;
status |= mCanvas->drawRenderNode(mRootRenderNode.get(), outBounds);

Next, OpenGLRenderer::drawRenderNode is called to perform the actual rendering:

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
status_t OpenGLRenderer::drawRenderNode(RenderNode* renderNode, Rect& dirty, int32_t replayFlags) {
status_t status;
// All the usual checks and setup operations (quickReject, setupDraw, etc.)
// will be performed by the display list itself
if (renderNode && renderNode->isRenderable()) {
// compute 3d ordering
renderNode->computeOrdering();
if (CC_UNLIKELY(mCaches.drawDeferDisabled)) {
status = startFrame();
ReplayStateStruct replayStruct(*this, dirty, replayFlags);
renderNode->replay(replayStruct, 0);
return status | replayStruct.mDrawGlStatus;
}

bool avoidOverdraw = !mCaches.debugOverdraw && !mCountOverdraw; // shh, don't tell devs!
DeferredDisplayList deferredList(*currentClipRect(), avoidOverdraw);
DeferStateStruct deferStruct(deferredList, *this, replayFlags);
renderNode->defer(deferStruct, 0);

flushLayers();
status = startFrame();

return deferredList.flush(*this, dirty) | status;
}

// Even if there is no drawing command(Ex: invisible),
// it still needs startFrame to clear buffer and start tiling.
return startFrame();
}

Here, renderNode is the renderer’s root node. The default deferred path organizes drawing operations, processes pending layer updates, then starts the window-target frame and replays batches. These four lines summarize that path:

1
2
3
4
renderNode->defer(deferStruct, 0);
flushLayers();
status = startFrame();
return deferredList.flush(*this, dirty) | status;

defer / flush batch and order operations while preserving drawing semantics; they do not arbitrarily reorder every command or create a new FBO for every View. They issue work to the graphics driver, and returning does not prove GPU completion. For the historical details, see Luo Shengyang’s Display List rendering analysis.

2.4 swapBuffers

1
2
3
if (status & DrawGlInfo::kStatusDrew) {
swapBuffers();
}

CanvasContext::swapBuffers() calls EglManager::swapBuffers(), which invokes eglSwapBuffers(mEglDisplay, surface) and handles errors such as an invalid surface. The consumer still needs to acquire the buffer, and SurfaceFlinger/HWC must compose and present it. Buffer handoff also involves synchronization fences: returning from swap is neither proof of GPU completion nor proof that the pixels are already on screen.

2.5 FinishFrame

1
profiler().finishFrame();

This ends the profiler’s frame accounting; it is not a GPU- or presentation-completion barrier.

Summary

Since I’m a bit lazy and my summarizing skills don’t quite match Luo’s, I’ll follow his outline here, with the technical boundaries clarified against AOSP 5.0:

  1. The UI thread records View drawing state, submits a DrawFrameTask, and waits. RenderThread synchronizes native RenderNode staging state and handles textures, layers, and animations.
  2. When info.prepareTextures permits early release, the UI thread is signaled before the drawing branch; otherwise it is signaled afterward. That branch may also skip drawing. Neither path is a notification of GPU completion or final presentation.
  3. The default deferred path processes offscreen layers that need updates and renders the window into a graphics buffer obtained through the Surface/BufferQueue producer interface. The buffer is submitted to its consumer; for this application window, SurfaceFlinger/HWC then compose and present the result.

Step 2 enables overlap: while RenderThread processes the current frame, the UI thread may begin preparing the next. The threads still have synchronization and resource dependencies, so early unblocking cannot guarantee that the next frame meets its deadline.

In the Android 4.x View/HWUI path discussed here, display-list recording and playback were mainly CPU work on the UI thread, rather than split across this RenderThread. The UI thread had to finish its current drawing call before handling subsequent work. GPU and SurfaceFlinger work could still be asynchronous; the whole graphics system did not run on the Main Thread alone. The historical trace below illustrates the UI-side work:

Blocking Draw Flow

The following two historical Android 5.0 traces illustrate overlap between the threads:

Main Thread and Render Thread Parallelism

Render Thread Early Wakeup

The second trace shows the next UI traversal overlapping the previous frame’s RenderThread work, illustrating reduced serial waiting. It does not include complete GPU-fence, SurfaceFlinger, or presentation evidence, so it cannot establish that exactly one frame was dropped or that the second frame was unaffected. RenderThread queuing, GPU backlog, and BufferQueue backpressure can still affect later frames.

2026 Update: Following the Pipeline Today

  • Android 16 / AOSP android-16.0.0_r1: DrawFrameTask still posts work, synchronizes state, and conditionally releases the UI thread. Queue interfaces, frame metadata, and trace names have changed. This tag emits DrawFrames <vsyncId> and carries deadline and interval information; the 5.0 signatures and a fixed frame budget are not current contracts.
  • Rendering backends: CanvasContext dispatches through IRenderPipeline to SkiaOpenGLPipeline or SkiaVulkanPipeline, depending on configuration. Do not assume every device uses GLES. Even an internal method named swapBuffers in the Vulkan pipeline is not eglSwapBuffers. The old OpenGLRenderer/DeferredDisplayList analysis above remains historical.
  • Android 12 / API 31 onward: Perfetto FrameTimeline provides Expected/Actual tracks, jank types, and app-to-SurfaceFlinger frame relationships. An app Actual slice ends at the later of GPU completion and buffer posting, which still differs from final presentation; follow the corresponding SurfaceFlinger/DisplayFrame to inspect the display outcome. If those tracks were not captured, a DrawFrame CPU slice alone cannot establish full frame latency or a dropped-frame count.

The original sequence is still a useful way to investigate, provided the questions remain separate: is the UI thread recording display lists or waiting for synchronization? Is RenderThread waiting to be scheduled, preparing textures/drawing, or waiting for a buffer? Have the GPU and display stages completed their work? This identifies the stage to optimize instead of attributing every long slice to onDraw().

Source and Documentation Checks


About Me && Blog

Below are my personal details and links. I look forward to connecting and sharing knowledge with fellow developers!

  1. About Me: Includes my WeChat and WeChat group links.
  2. Blog Navigation: A guide to the content on this blog.
  3. Curated Android Performance Articles: A collection of must-read performance optimization articles. Self-nominations/recommendations are welcome!
  4. Android Performance Knowledge Planet: The paid community stopped operating in 2026 and no longer accepts new members; this link preserves its history.

“If you want to go fast, go alone. If you want to go far, go together.”

WeChat QR Code

CATALOG
  1. 1. Preface
  2. 2. Starting from the Java Layer
  3. 3. The Native Layer
  4. 4. HWUI RenderThread
  5. 5. RenderThread.DrawFrame
    1. 5.1. 1. syncFrameState
    2. 5.2. 2. draw
      1. 5.2.1. 2.1 EglManager::beginFrame
      2. 5.2.2. 2.2 prepareDirty
      3. 5.2.3. 2.3 drawRenderNode
      4. 5.2.4. 2.4 swapBuffers
      5. 5.2.5. 2.5 FinishFrame
  6. 6. Summary
  7. 7. 2026 Update: Following the Pipeline Today
  8. 8. Source and Documentation Checks
  • About Me && Blog