跪拜 Guibai
← Back to the summary

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:

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:

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:

image

And the key problem here is:

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:

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:

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.