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_r1as 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 oldOpenGLRenderer,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.

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:

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 | if (mAttachInfo.mHardwareRenderer != null && mAttachInfo.mHardwareRenderer.isEnabled()) { |
If the hardware rendering path is taken, mHardwareRenderer.draw is called. Here, mHardwareRenderer refers to ThreadedRenderer, whose draw function is as follows:
1 |
|
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 | int syncResult = nSyncAndDrawFrame(mNativeProxy, frameTimeNanos, |
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 | static int android_view_ThreadedRenderer_syncAndDrawFrame(JNIEnv* env, jobject clazz, |
The RenderProxy implementation can be found in frameworks/base/libs/hwui/renderthread/RenderProxy.cpp:
1 | int RenderProxy::syncAndDrawFrame(nsecs_t frameTimeNanos, nsecs_t recordDurationNanos, |
Here, mDrawFrameTask is a DrawFrameTask object, located in frameworks/base/libs/hwui/renderthread/DrawFrameTask.cpp. Its drawFrame code is:
1 | int DrawFrameTask::drawFrame(nsecs_t frameTimeNanos, nsecs_t recordDurationNanos) { |
The implementation of postAndWait() is:
1 | void DrawFrameTask::postAndWait() { |
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 | void RenderThread::queue(RenderTask* task) { |
mQueue is a TaskQueue. It orders tasks by mRunAt and rejects queuing the same task object twice:
1 | void TaskQueue::queue(RenderTask* task) { |
Going back to the queue method in RenderThread, the wake() function is called:
1 | void Looper::wake() { |
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 | RenderThread::RenderThread() : Thread(true), Singleton<RenderThread>() |
The run method is documented in Thread:
1 | // Start the thread in threadLoop() which needs to be implemented. |
This triggers the threadLoop function, which is critical:
1 | bool RenderThread::threadLoop() { |
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 | while (RenderTask* task = nextTask(&nextWakeup)) { |
The queue can hold more than drawing tasks. For the enqueue traced here, the task is a DrawFrameTask, whose run method is:
1 | void DrawFrameTask::run() { |
unblockUiThread() pairs with the earlier mSignal.wait(mLock):
1 | void DrawFrameTask::unblockUiThread() { |
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.

1 | bool DrawFrameTask::syncFrameState(TreeInfo& info) { |
First is makeCurrent. Here, mContext is a CanvasContext object:
1 | void CanvasContext::makeCurrent() { |
mEglManager is an EglManager object:
1 | bool EglManager::makeCurrent(EGLSurface surface) { |
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 | void CanvasContext::prepareTree(TreeInfo& info) { |
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 | canvas.insertReorderBarrier(); |
The final implementation is:
1 | status_t DisplayListRenderer::drawRenderNode(RenderNode* renderNode, Rect& dirty, int32_t flags) { |
Returning to CanvasContext::prepareTree, the mRootRenderNode is the one passed during initialization. Its implementation is in RenderNode.cpp:
1 | void RenderNode::prepareTree(TreeInfo& info) { |
Further details on these operations can be explored in the source code.
2. 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 | if (CC_LIKELY(canDrawThisFrame)) { |
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 | void CanvasContext::draw() { |
2.1 EglManager::beginFrame
As shown in the complete method above, CanvasContext::draw() calls mEglManager.beginFrame():
1 | void EglManager::beginFrame(EGLSurface surface, EGLint* width, EGLint* height) { |
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 | status_t status; |
Here, mCanvas is an OpenGLRenderer object. Its prepareDirty implementation is:
1 | status_t OpenGLRenderer::prepareDirty(float left, float top, |
2.3 drawRenderNode
1 | Rect outBounds; |
Next, OpenGLRenderer::drawRenderNode is called to perform the actual rendering:
1 | status_t OpenGLRenderer::drawRenderNode(RenderNode* renderNode, Rect& dirty, int32_t replayFlags) { |
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 | renderNode->defer(deferStruct, 0); |
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 | if (status & DrawGlInfo::kStatusDrew) { |
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:
- The UI thread records View drawing state, submits a DrawFrameTask, and waits. RenderThread synchronizes native RenderNode staging state and handles textures, layers, and animations.
- When
info.prepareTexturespermits 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. - 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:

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


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
IRenderPipelinetoSkiaOpenGLPipelineorSkiaVulkanPipeline, depending on configuration. Do not assume every device uses GLES. Even an internal method namedswapBuffersin the Vulkan pipeline is noteglSwapBuffers. 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
- AOSP 5.0 ThreadedRenderer.java, Choreographer.java, ViewRootImpl.java
- AOSP 5.0 DrawFrameTask.cpp, RenderThread.cpp, RenderProxy.cpp
- AOSP 5.0 RenderNode.cpp, RenderNode.java, CanvasContext.cpp, EglManager.cpp
- AOSP 5.0 OpenGLRenderer.cpp, Looper.cpp, eglApi.cpp
- AOSP 16 DrawFrameTask.cpp, CanvasContext.cpp, Properties.cpp, SkiaVulkanPipeline.cpp
- Android graphics architecture, EGLSurfaces and OpenGL ES, BufferQueue and Gralloc
- Perfetto FrameTimeline
About Me && Blog
Below are my personal details and links. I look forward to connecting and sharing knowledge with fellow developers!
- About Me: Includes my WeChat and WeChat group links.
- Blog Navigation: A guide to the content on this blog.
- Curated Android Performance Articles: A collection of must-read performance optimization articles. Self-nominations/recommendations are welcome!
- 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.”
