Sitelet https://github.com/flutter/flutter/pull/192083
Skip to content

[Android] Decouple AHB swapchain transactions from Java when no platform views are active - #192083

Draft
gmackall wants to merge 6 commits into
flutter:masterfrom
gmackall:optimize-ahb-swapchain-no-pv
Draft

gmackall wants to merge 6 commits into
flutter:masterfrom
gmackall:optimize-ahb-swapchain-no-pv

Conversation

@gmackall

@gmackall gmackall commented Aug 31, 2026 •

Copy link
Copy Markdown
Member

Description

With HC++ (the SurfaceControl swapchain), every swapchain frame is presented through Java today: the raster thread synchronously creates a SurfaceControl.Transaction via JNI, and the platform thread merges it and hands it to AttachedSurfaceControl.applyTransactionOnDraw. That is needed when a frame changes View state (platform views shown/hidden/moved, the overlay, a FlutterView resize), but for the common case of an app with no platform views it costs a JNI round trip on the raster thread and ties presentation to a platform-thread task plus a View draw, which shows up as jank.

This PR applies swapchain transactions directly on the raster thread (ASurfaceTransaction_apply) whenever a frame has nothing to synchronize with the View hierarchy, and keeps the existing Java path otherwise.

This addresses the follow-up note left by Jonah in PR #162750 (commit 17cb12d):

"* Always create the SurfaceControl.Transaction in PlatformViewController2 and manage it in Java. This was done for ease of implementation. Rather than switching between SurfaceControl.Transaction objects created in the native heap or created in java, we always go through java. This also means that adding platform views shouldn't change this flow.

  • We may need to separate this after performance profiling."

How it works

  • SurfaceTransactionRouter (new, external_view_embedder/): owned by PlatformViewAndroid and shared with the HC++ view embedder and every AndroidSurfaceVKImpeller the surface factory creates. It holds

    • the route of the frame being submitted (kDirect / kPlatform; raster thread only), and
    • an atomic count of platform-routed frames that SurfaceFlinger has not committed yet.
  • Route selection (AndroidExternalViewEmbedder2::SubmitFlutterView): a frame goes through the platform thread if it

    • has platform layers, or
    • hides platform views that were visible last frame, or
    • changes the root frame size (rotation, split screen, foldables — the new buffer size must land with the ViewRootImpl draw), or
    • follows a platform-routed frame that has not been committed yet (see below).

    Otherwise it takes the direct route and no platform task is posted at all. The route is latched for the whole submission (ScopedCleanupClosure restores kDirect), so the overlay surfaces and the root surface of one frame always agree.

  • Swapchain callback (AndroidSurfaceVKImpeller::SetNativeWindow): on kDirect it returns an owned native SurfaceTransaction, which the AHB swapchain applies itself; on kPlatform it creates the transaction through Java as before.

  • No reordering across routes: direct and platform-routed transactions use different apply tokens, so SurfaceFlinger does not order them relative to each other, and a direct frame could otherwise be shown before an older platform frame that is still waiting for its View draw. PlatformViewsController2.onEndFrame() registers a TransactionCommittedListener on the transaction before applyTransactionOnDraw (merging carries listeners along); when it fires, Java calls FlutterJNI.onEndFrameTransactionCommitted() → PlatformViewAndroid::OnPlatformFrameCommitted(), which decrements the count. Frames stay on the platform route until the count drops to zero. If onEndFrame() closes the transaction without applying it (no root surface control, e.g. after detach), it reports the frame right away so the engine is not left waiting. Unmatched commits saturate at zero.

  • Explicit frame boundaries on the platform thread: new onBeginFrame2() JNI call / PlatformViewsController2.onBeginFrame(). Platform-transaction mutations are only valid between onBeginFrame() and swapTransactions(); a mutation outside a frame would otherwise sit in the pending transaction until some later platform-routed frame, which may never come now that idle frames bypass Java. This throws in debug engine builds and logs in release.

  • surfaceCreated clip callback: createSurfaceClipCallback used to record the initial crop/alpha into the pending platform transaction, outside of any frame. It now applies its own transaction with applyTransactionOnDraw + invalidate() (or apply() if there is no root surface control), so it no longer depends on a later frame going through Java. It also bails out if the platform view was disposed before the surface was created.

  • SurfaceTransaction gains a defaulted move constructor/assignment.

Notes for reviewers

  • Direct-route frames are applied as soon as the raster thread submits them; they do not set a desired present time or frame timeline (as with the pre-HC++ AHB swapchain).
  • DestroySurfaces() resets the resize tracking but intentionally leaves the uncommitted-frame count alone: frames already handed to the platform thread still commit and report themselves.
  • If a commit is ever never reported, the failure mode is staying on the (previous, always-Java) platform route, not misordered frames.

See demo (ignore the fps, the jank is what matters here):

Before After
Screen_recording_20260902_141338.mp4
Screen_recording_20260902_141218.mp4

Related Issues

Follow-up to #162750 and #192606

Tests

  • New surface_transaction_router_unittests.cc: default route, route latching, uncommitted-frame counting, unmatched commits.
  • external_view_embedder_unittests.cc:
    • StillEndsFrameAfterLastPlatformViewGoesAway: platform-view frame → hide frame → follow-up frame held on the platform route until commit → steady-state direct frame (no platform task) → resize with zero platform views → frame held on the platform route while the previous frame's platform task is still queued → back to direct.
    • StaysOnPlatformRouteUntilJavaReportsTheCommit: running the platform task is not enough; only the reported commit releases the platform route.
    • TransactionScopeLatchesAcrossOverlayAndRootSubmissions: overlay and root submissions of one frame observe the same route.
    • Existing HC++ tests updated for onBeginFrame2().
  • PlatformViewsController2Test.java:
    • onEndFrameReportsCommitOnlyAfterTheFrameTransactionIsCommitted (listener registered before applyTransactionOnDraw, reported on the main looper only after it fires).
    • onEndFrameReportsCommitImmediatelyWhenThereIsNoRootSurfaceControl, onEndFrameReportsCommitImmediatelyAfterDetachFromView.
    • overlayMutationOutsideOfAFrameThrowsInDebug, overlayMutationsAreAcceptedBetweenBeginFrameAndSwapOnly.
    • surfaceCreated_appliesClipDirectlyViaApplyTransactionOnDraw.
    • Existing transaction tests updated to call onBeginFrame().

PR code and description updated with Gemini / AI assistance.

@gmackall gmackall added the CICD Run CI/CD label Aug 31, 2026
@github-actions github-actions Bot added platform-android Android applications specifically engine flutter/engine related. See also e: labels. team-android Owned by Android platform team labels Aug 31, 2026
@gmackall gmackall added CICD Run CI/CD and removed CICD Run CI/CD labels Aug 31, 2026
@gmackall
gmackall force-pushed the optimize-ahb-swapchain-no-pv branch 2 times, most recently from 3dd68a7 to d1703e7 Compare September 1, 2026 17:31
@gmackall
gmackall force-pushed the optimize-ahb-swapchain-no-pv branch from 199940c to b040ea1 Compare September 3, 2026 04:54
gmackall added a commit to gmackall/flutter that referenced this pull request Sep 11, 2026
…o issue list

- Add alternative fix direction for PV-3/PV-4 using a pre-vended transaction pool
  to establish a one-way C++ -> Java dependency.
- Add PV-11: AHB swapchain triple-buffering (kMaxPendingPresents = 3u) to prevent
  30 FPS lockstep stalls under bursty rasterization.
- Add PV-12: ADPF core affinity integration to guide Energy-Aware Scheduling (EAS).
- Add PV-13: Decoupling AHB swapchain from Java when zero platform views are active (flutter#192083).
- Add PV-14: Eliminating cross-stream tx.merge() overhead in onEndFrame().
@github-actions github-actions Bot added the e: impeller Impeller rendering backend issues and features requests label Sep 15, 2026
…orm views are active

When HCPP (hybrid composition++) is enabled but no platform views are
present in the frame, the AHB swapchain no longer round-trips its
SurfaceControl transaction through Java. The raster thread creates and
applies an owned ASurfaceTransaction directly, avoiding a platform-thread
hop and the resulting dependency on main-thread availability.

Frames that contain platform views, that follow a frame which had platform
views, or that change the frame size continue to route through Java so
that platform view mutations and the Flutter content are applied in the
same transaction via applyTransactionOnDraw.

This is a squash-rebase of the original flutter#192083 branch onto master after
flutter#192606 (createTransaction publishes directly into
pendingRasterTransactions) landed; the parts of this change that
overlapped with flutter#192606 are dropped.
…ction path

Forwarding the vsync target as ASurfaceTransaction_setDesiredPresentTime
is orthogonal to routing no-platform-view frames around Java, and it has
its own correctness question: SurfaceFlinger holds a transaction back
while desiredPresentTime >= expectedPresentTime, and the value that was
being forwarded (frame_time + 1/refresh_rate from the vsync waiter) can be
a frame late across LTPO refresh-rate switches. It is split into a
follow-up change so it can be evaluated on its own.
…outer

The per-frame decision of whether the AHB swapchain applies its
SurfaceControl transaction directly from the raster thread or hands it to
Java lived on PlatformViewAndroidJNI as SetFrameUsesJavaTransactions /
FrameUsesJavaTransactions, plus an in-flight counter on the embedder.
Those facade methods never crossed JNI: they were raster-thread state that
happened to be stored on the Java bridge because the surface and the
embedder both already held it.

Introduce flutter::SurfaceTransactionRouter, a small raster-side object
that owns:

  * the latched route of the frame being submitted (kDirect / kPlatform),
    read by AndroidSurfaceVKImpeller's CreateTransactionCB for the root
    and overlay swapchains; and
  * the count of platform-routed frames that have not been committed yet,
    which keeps subsequent frames on the platform route so a direct
    ASurfaceTransaction_apply cannot overtake an applyTransactionOnDraw
    that is still waiting for a View draw.

PlatformViewAndroid owns the router and shares it with the surface
factory (so every AndroidSurfaceVKImpeller it creates, including overlay
surfaces created through the surface pool, consults the same route) and
with the HC++ external view embedder, which sets the route around each
submission and reports platform frames to it.

The facade, its mock, the JNI impl and the toggle unit test are back to
their pre-PR shape. The embedder unit tests now assert the route the
swapchain would observe during submission instead of expecting facade
calls. The test file also stops wrapping lambdas in testing::Invoke, which
current googletest marks deprecated and the engine builds with -Werror.

The commit point is unchanged in this commit: the counter is still
decremented right after onEndFrame2() returns on the platform thread. The
next commit moves it to the transaction-committed callback.
…a commit

Direct and platform-routed swapchain transactions are applied with
different apply tokens, so a direct frame can be committed ahead of an
older frame whose transaction is still parked in ViewRootImpl. The
previous guard against that was a counter that was decremented as soon
as onEndFrame2() returned plus a one-frame cooldown. Neither matches
what applyTransactionOnDraw() does: it only merges the transaction into
ViewRootImpl's pending transaction, which is applied with the next View
draw (or by ViewRootImpl itself if that draw never happens), so the
frame was being counted as committed while it was still pending.

Replace the heuristic with the actual signal. PlatformViewsController2.
onEndFrame() now registers a TransactionCommittedListener on the merged
transaction before handing it to applyTransactionOnDraw() (merging
carries listeners along, so the order matters). The listener hops to
the main thread and calls FlutterJNI.onEndFrameTransactionCommitted(),
which reaches SurfaceTransactionRouter::OnPlatformFrameCommitted()
through PlatformViewAndroid. Frames whose transaction is dropped
because there is no root SurfaceControl report themselves immediately,
since nothing will ever commit them. The raster side keeps routing
through the platform thread while the router still has uncommitted
platform frames, and the cooldown is gone.

The router's count is deliberately not reset in DestroySurfaces():
ViewRootImpl applies its pending transactions even when the window
dies, so every frame that was handed over eventually reports back, and
resetting could let a direct frame overtake one that is still pending.
…ginFrame2()

PlatformViewsController2.platformTransaction() records View-state
mutations (overlay visibility, SurfaceView clips) into a pending
transaction that is only picked up by the next swapTransactions() and
applied by that frame's onEndFrame(). Now that steady-state frames
without platform views bypass the platform thread entirely, a mutation
recorded outside of one of the embedder's platform tasks would sit
there until some later frame happens to be routed through Java, which
may never happen.

Make the window explicit. The embedder calls the new
PlatformViewAndroidJNI::onBeginFrame2() (FlutterJNI.beginFrame2() ->
PlatformViewsController2.onBeginFrame()) at the start of both platform
tasks, before any mutation. swapTransactions() closes the window, since
anything recorded after it would only be picked up by the next swap.
platformTransaction() throws an IllegalStateException in debug builds
when it is called outside of that window and logs an error otherwise.

All existing callers of platformTransaction() are reached from the
embedder's platform tasks, so the guard does not change behavior; the
surfaceCreated callback path already applies its out-of-frame clip on
its own and schedules a frame (see flutter#175546).
@gmackall
gmackall force-pushed the optimize-ahb-swapchain-no-pv branch from 96b1376 to a02ea52 Compare October 2, 2026 19:26
@github-actions github-actions Bot added the f: routes Navigator, Router, and related APIs. label Oct 2, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CICD Run CI/CD e: impeller Impeller rendering backend issues and features requests engine flutter/engine related. See also e: labels. f: routes Navigator, Router, and related APIs. platform-android Android applications specifically team-android Owned by Android platform team

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant