Flutter TLHC WebView Finally Hits 120 FPS: The Fix Was Rearranging Two Lines
theme: smartblue
At long last, flutter/flutter#195861 has been merged. It finally raises WebView scrolling in TLHC mode from 70 FPS to 120 FPS. The reason I kept using HC before is that TLHC WebView scrolling didn't feel comfortable. We've discussed these modes many times:
| Composition Mode | How native View is displayed | Main characteristics |
|---|---|---|
| TLHC | Android View is first drawn to a texture, then composited by Flutter | Better performance on Flutter side, but WebView fast scrolling can be choppy |
| HC | Native View stays in the Android View hierarchy, composited by Android | Better native compatibility, but has Flutter thread merging overhead |
| HCPP (experimental) | Flutter and native content each use a Surface, composited by the system | Reduces traditional HC sync overhead, has device and system requirements |
Previously, the poor fast-scrolling performance of WebView was a known issue with Texture Layer mode, because every time the WebView updates content, the drawing result of the Android View must be sent to the texture used by Flutter. The main process was:
PlatformViewWrapper.draw()is called on the Android main thread- Use
lockHardwareCanvas()to get the offscreen Surface's Canvas - Call
super.draw()to draw the WebView and its child Views - Immediately call
unlockCanvasAndPost()to submit this offscreen drawing - Notify Flutter that a new texture frame is available
The problem has always been in step 4. unlockCanvasAndPost() seems only to submit the Canvas, but behind the scenes it always triggers the drawing work of Android's RenderThread.
Android does not prepare a separate RenderThread for each HardwareRenderer; they share the RenderThread within the process. So the WebView's offscreen drawing and the application main window's drawing actually contend for the same RenderThread.
That is, when the WebView is scrolling quickly and has heavy GPU drawing tasks, this resource contention becomes obvious.
The fix turned out to be very simple, which made me feel dumb for previously having to run a local Engine to solve it.
The issue is mainly that WebView grabs the RenderThread in front of the main window. Generally, Android View drawing can be divided into two phases:
- Record View#draw: The Android main thread traverses the View and records drawing commands.
- DrawFrame: The RenderThread consumes these commands and performs GPU-related rendering work.
Normally, the main thread records the drawing content of the current window, then hands the work to the RenderThread. But TLHC is special: it also needs to draw an offscreen Surface. Previously, PlatformViewWrapper.draw() would submit this Surface before the main window's View traversal finished.
This resulted in the following timing:
And the key problem here is:
Before the main window has finished frame sync, WebView submits a heavy drawing task to the shared RenderThread ahead of schedule.
When the main window later tries to sync, the RenderThread is already busy.
But the main window's sync path needs to wait for the RenderThread to respond, so the Android main thread is also blocked.
Previous Pixel 9 Trace data roughly:
| Metric | Before | After |
|---|---|---|
| Main window frame sync time | ~5.1 ms | ~1.4 ms |
| Extra wait | ~3.5 ms | basically eliminated |
| WebView scroll FPS | ~70 | 120 |
The main window sync time is reduced by 3.7ms. Remember that on a 120Hz device, the per-frame budget is 8.33ms. Just that 3.7ms reduction is already 44% of one frame's budget!!!!!!
The fix is also simple: adjust the execution order. The previous problem was roughly:
@Override
public void draw(Canvas canvas) {
Surface surface = renderTarget.getSurface();
Canvas targetCanvas = surface.lockHardwareCanvas();
try {
super.draw(targetCanvas);
} finally {
renderTarget.scheduleFrame();
surface.unlockCanvasAndPost(targetCanvas);
}
}
The problem is in the finally block. Since super.draw() is still within the Android main window's drawing traversal, the code immediately calls unlockCanvasAndPost().
So the new implementation keeps the synchronous recording but defers the submission:
@Override
public void draw(Canvas canvas) {
// ... get surface and lock canvas
try {
super.draw(targetCanvas);
} finally {
pendingFrameSurface = targetSurface;
pendingFrameCanvas = targetCanvas;
mainHandler.postAtFrontOfQueue(
postPendingFrameRunnable
);
}
}
This way, it can wait until the current main thread traversal finishes before calling:
private void postPendingFrame(boolean scheduleFrame) {
// ... get pending Surface and Canvas
surface.unlockCanvasAndPost(canvas);
if (scheduleFrame && renderTarget != null) {
renderTarget.scheduleFrame();
}
}
Using postAtFrontOfQueue(), this API puts the Runnable at the front of the main thread message queue, giving it the chance to execute as soon as possible after the current message finishes.
So this adjustment is equivalent to:
- WebView's draw commands are still generated in
draw(). - Let the main window first complete its own frame sync.
- Then submit the WebView's offscreen frame.
- Finally notify Flutter to update the texture.
It does not move unlockCanvasAndPost() to a background thread; the call still happens on the Android main thread, but from inside the current draw traversal, it's moved to the next message queue execution. This avoids WebView occupying the RenderThread early and affecting the main window's critical sync process.
However, I feel there might still be pitfalls. For example, if you delay the original immediate unlockCanvasAndPost(), doesn't that create a new state:
The Canvas is already locked and drawing commands have been recorded, but the Surface has not been unlocked and submitted.
If the WebView is suddenly destroyed, resized, or goes to the background, can we simply wait for that Runnable?
Currently the PR does handle several scenarios, but I worry it might become a pile of spaghetti code later:
| Scenario | New handling | Problem avoided |
|---|---|---|
draw() called again in the same traversal |
Submit the previous frame first | Avoid locking the same Surface twice |
| PlatformView resized | Submit first, then resize() |
Adjusting Surface while old Canvas is pending |
| Window goes to background or becomes invisible | Submit pending frame immediately | Avoid Surface being locked for a long time |
| PlatformView destroyed | Unlock first, then release | Releasing resources while a Canvas is still pending |
unlockCanvasAndPost() throws |
Catch exception, don't notify new frame | Failed submission treated as a valid frame |
There's also an optimization to avoid creating meaningless 1×1 buffers. Previously, creating a TLHC PlatformView went like:
- Create
PlatformViewRenderTarget - Create
PlatformViewWrapper - Wrapper constructor immediately locks and clears the Surface
- RenderTarget is still default
1×1 - Then adjust RenderTarget to actual size
So, for example, creating a 1080×1920 WebView previously roughly did:
Create RenderTarget
↓
Fetch Surface at 1×1 size
↓
Allocate corresponding image resources
↓
Resize to 1080×1920
↓
Original 1×1 resources discarded
Now the new order is roughly:
final PlatformViewRenderTarget renderTarget =
makePlatformViewRenderTarget(textureRegistry);
// First adjust size
renderTarget.resize(physicalWidth, physicalHeight);
// Then create Wrapper
viewWrapper = new PlatformViewWrapper(
context,
renderTarget
);
This avoids extra ImageReader resource creation and replacement at the initial size. However, this optimization mainly affects the initialization phase of PlatformView, so the benefit may not be as obvious.
Still, the long-standing pain point of TLHC has finally been resolved, and it's quite touching.