Skip to content

[Android] Scroll jank with TLHC PlatformViews under merged platform/UI threads #183626

Closed

Description

@Flutter-DoHyunKim

Steps to reproduce

  1. Clone the sample repo and run flutter run --profile on a physical Android device (Flutter 3.38+)
  2. App launches with a native Android screen — tap "Open Flutter PlatformView Test" button
  3. Scroll the list containing 8 AndroidView PlatformViews (TLHC mode) — visible jank/frame drops occur

Observation: From tracing and engine code inspection, during scrolling setOffset() appears to be called every frame for each visible PlatformView. In the TLHC path, this appears to trigger a chain similar to:

setLayoutParams() → requestLayout() → view hierarchy traversal → PlatformViewWrapper.draw() (texture rendering)

Under merged platform/UI threads, this work appears to run serially on the main thread. When multiple PlatformViews are visible (around ~8 in our test case), the accumulated work can exceed the frame budget and lead to visible jank.

It appears that setOffset() bundles two very different operations into one call:

  • Lightweight: updating position metadata (left/top fields) — microseconds
  • Expensive: triggering Android's full layout + draw cycle via setLayoutParams() — observed ~8ms per view in our traces

During scroll, only the position changes — the texture content stays the same. But setLayoutParams() appears to trigger a full texture rendering pass for each PlatformView every frame regardless.

Code sample

Full sample repo: https://github.com/Flutter-DoHyunKim/platformview_scroll_issue_sample

Code sample
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'PlatformView Scroll Test',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const PlatformViewListScreen(),
    );
  }
}

class PlatformViewListScreen extends StatelessWidget {
  const PlatformViewListScreen({super.key});

  static const Set<int> platformViewIndices = {0, 6, 12, 18, 24, 30, 36, 42};

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text('PlatformView Scroll Test'),
      ),
      body: ListView.builder(
        itemCount: 100,
        itemBuilder: (context, index) {
          if (platformViewIndices.contains(index)) {
            return NativeAdCard(key: ValueKey(index), index: index);
          }
          return ProfileCard(key: ValueKey(index), index: index);
        },
      ),
    );
  }
}

class NativeAdCard extends StatefulWidget {
  final int index;
  const NativeAdCard({super.key, required this.index});

  @override
  State<NativeAdCard> createState() => _NativeAdCardState();
}

class _NativeAdCardState extends State<NativeAdCard>
    with AutomaticKeepAliveClientMixin {
  @override
  bool get wantKeepAlive => true;

  @override
  Widget build(BuildContext context) {
    super.build(context);
    return SizedBox(
      height: 150,
      child: Card(
        margin: const EdgeInsets.all(8.0),
        child: AndroidView(
          viewType: 'android_text_view',
          layoutDirection: TextDirection.ltr,
          creationParams: <String, dynamic>{
            'text': 'Ad #${widget.index}',
            'imageUrl': 'https://picsum.photos/seed/${widget.index}/1200/600',
          },
          creationParamsCodec: const StandardMessageCodec(),
        ),
      ),
    );
  }
}

The sample uses an Add-to-App structure (native Android launcher → Flutter view) to simulate a real-world scenario. The native Android side registers a simple PlatformView with ImageView + TextView to simulate ad banner views. See the full repo for the complete Android code.

Performance profiling on master channel

  • The issue still persists on the master channel

Timeline Traces

Since the captured trace also includes various internal device threads, I'm not sharing the raw trace file.
But you should be able to reproduce the same trace patterns by running the sample project and recording a System Trace directly in Android Studio on your own device.

Perfetto system traces captured during scroll with 8 PlatformViews visible.

Before (unmodified engine — jank)

Every frame shows Choreographer#doFrame → traversal → draw → Record View#draw() cycle. The PlatformChannel ScheduleHandler slices appear wide, seemingly because each one executes setLayoutParams() → requestLayout() inside.

[Before Perfetto trace]
Image

After (experimental engine fix — smooth)

Choreographer#doFrame → traversal → draw cycle appears lightweight. PlatformChannel ScheduleHandler slices are narrow, as updateOffset() only does field assignments + a lightweight layout() call.

[After Perfetto trace]
Image

Perfetto SQL query comparison

Query used on both traces:

SELECT
    COUNT(*) AS total_frames,
    ROUND(AVG(dur) / 1e6, 2) AS avg_draw_ms,
    ROUND(MAX(dur) / 1e6, 2) AS max_draw_ms,
    SUM(CASE WHEN dur > 16600000 THEN 1 ELSE 0 END) AS janky_frames
FROM slice s
JOIN thread_track t ON s.track_id = t.id
JOIN thread th ON t.utid = th.utid
JOIN process p ON th.upid = p.upid
WHERE s.name = 'draw'
    AND p.name LIKE '%platformviewtest%';
Before (unmodified) After (experimental fix)
total_frames 358 348
avg_draw_ms 8.43 1.29
max_draw_ms 26.03 10.4
janky_frames (>16.6ms) 36 0

[Before query result]
Image

[After query result]
Image

Video demonstration

Video demonstration When recording the screen, it's hard to see the jank.

So it's more noticeable when running the sample in profile or release mode directly on the device.

What target platforms are you seeing this bug on?

Android

OS/Browser name and version | Device information

  • Device: Samsung Galaxy S10e (SM-G970N)
  • Android: 12
  • Rendering: Both Impeller and Skia — the issue appears to be in the platform view hosting layer, not the rendering backend
  • PlatformView mode: TLHC (Texture Layer Hybrid Composition)

Does the problem occur on emulator/simulator as well as on physical devices?

Yes

Is the problem only reproducible with Impeller?

No

Logs

Logs
No error logs — this is a performance issue, not a crash.

Flutter Doctor output

Doctor output
Flutter 3.42.0-1.0.pre-48 • channel [user-branch] • unknown source
Framework • revision 2ab457dfe0 (8 days ago) • 2026-03-05 08:12:01 +0900
Engine • hash 99578ad0355da00edb26301c874a3c250a5716f5 (revision e4b8dca3f1) (9 days ago) • 2026-03-03 18:24:54.000Z
Tools • Dart 3.11.1 • DevTools 2.54.1

Additional context

Call chain observed in traces

Based on Perfetto traces and engine code inspection, the following chain appears to execute during scroll:

[Dart] RenderAndroidView._setOffset()
  → AndroidViewController.setOffset()
    → PlatformChannel 'offset' message
      → [Java] PlatformViewsController.offset()
        → viewWrapper.setLayoutParams(layoutParams)
          → requestLayout()
            → draw()

When multiple views are visible (around ~8 in our test case), the accumulated work can exceed the frame budget and lead to visible jank.

Why this appears related to Flutter 3.38

Since PR #174408 removed the opt-out flag for disabling merged platform/UI threads(#150525), merged threads are now always enabled.
From my traces, the PlatformChannel ScheduleHandler appears to run in parallel with Choreographer#doFrame before thread merging, and serially on the main thread after.

Proposed engine fix (prototype tested)

As an experiment, we replaced setLayoutParams() with a lightweight updateOffset() in PlatformViewsController.offset() that:

  1. Updates left/top fields (for touch coordinate transform)
  2. Syncs LayoutParams margins (for future resize/creation calls)
  3. Calls layout() directly — position-only update that bypasses requestLayout() and the subsequent measure/layout traversal.

In our testing, this appeared to preserve existing functionality (touch accuracy, initial rendering, resize, content-change rendering via onDescendantInvalidated) while significantly reducing the per-frame rendering cost during scroll.

Note: This is purely a prototype to demonstrate the potential improvement. There may be side effects or edge cases we haven't considered — for example, accessibility highlight positioning during scroll (TalkBack), or behavior differences across Android versions. We'd appreciate the team's guidance on the right approach.

The Perfetto results in the Timeline Traces section above were measured with this prototype applied.

Prototype engine diff (click to expand)

File 1: PlatformViewWrapper.java

 public class PlatformViewWrapper extends FrameLayout {
   private static final String TAG = "PlatformViewWrapper";

-  private int prevLeft;
-  private int prevTop;
   private int left;
   private int top;

Add updateOffset() method (after setLayoutParams()):

  /**                                         
   * Updates the view position without triggering a full layout traversal.
   *                                          
   * Unlike setLayoutParams(), this avoids requestLayout() → measure/layout                                                                                     
   * traversal on every scroll frame. LayoutParams margins are still synced so that                                                                             
   * any subsequent requestLayout() (e.g. from resize) picks up the correct position.                                                                           
   */     
  public void updateOffset(int newLeft, int newTop) {
    this.left = newLeft;
    this.top = newTop;

    final FrameLayout.LayoutParams lp = (FrameLayout.LayoutParams) getLayoutParams();
    if (lp != null) {
      lp.leftMargin = newLeft;
      lp.topMargin = newTop;
    }

    if (!isLayoutRequested()) {
      final int width = getWidth();
      final int height = getHeight();
      if (width > 0 && height > 0) {
        layout(newLeft, newTop, newLeft + width, newTop + height);
      }
    }
  }

Simplify onTouchEvent() (since layout() keeps mLeft == left in sync):

   @Override
   public boolean onTouchEvent(@NonNull MotionEvent event) {
     if (touchProcessor == null) {
       return super.onTouchEvent(event);
     }
     final Matrix screenMatrix = new Matrix();
-    switch (event.getAction()) {
-      case MotionEvent.ACTION_DOWN:
-        prevLeft = left;
-        prevTop = top;
-        screenMatrix.postTranslate(left, top);
-        break;
-      case MotionEvent.ACTION_MOVE:
-        screenMatrix.postTranslate(prevLeft, prevTop);
-        prevLeft = left;
-        prevTop = top;
-        break;
-      case MotionEvent.ACTION_UP:
-      default:
-        screenMatrix.postTranslate(left, top);
-        break;
-    }
+    screenMatrix.postTranslate(left, top);
     return touchProcessor.onTouchEvent(event, screenMatrix);
   }

File 2: PlatformViewsController.java — offset() handler

         public void offset(int viewId, double top, double left) {
           // ... (null checks unchanged)
           final int physicalTop = toPhysicalPixels(top);
           final int physicalLeft = toPhysicalPixels(left);
-          final FrameLayout.LayoutParams layoutParams =
-              (FrameLayout.LayoutParams) viewWrapper.getLayoutParams();
-          layoutParams.topMargin = physicalTop;
-          layoutParams.leftMargin = physicalLeft;
-          viewWrapper.setLayoutParams(layoutParams);
+          viewWrapper.updateOffset(physicalLeft, physicalTop);
         }

I'd be happy to submit a PR if the team agrees with this approach.

Activity

  1. saurabh-mirajkar commented on Mar 18, 2026

    @saurabh-mirajkar
    Contributor

    @Flutter-DoHyunKim Thanks for a detailed report. Labelling for team-android to investigate further.

  2. added
    platform-androidAndroid applications specifically
    platform-views: tlhcIssues specific to the Android Texture Layer Hybrid Composition platform views backend
    team-androidOwned by Android platform team
    and removed
    in triagePresently being triaged by the triage team
    on Mar 18, 2026
  3. added
    P2Important issues not at the top of the work list
    on Mar 24, 2026
  4. Flutter-DoHyunKim commented on May 22, 2026

    @Flutter-DoHyunKim
    Author

    @gmackall

    Hi, I noticed that Flutter 3.44.0 includes updates related to HCPP.

    However, HCPP has some requirements:

    • Android API 34 or later
    • Vulkan rendering support

    We plan to use HCPP where supported, but for older Android versions or devices without Vulkan, we still need to use TLHC.

    Based on the issue I shared earlier, is there any plan to improve TLHC performance under the merged-thread?

    Thanks!

  5. gmackall commented on May 22, 2026

    @gmackall
    Member

    @gmackall

    Hi, I noticed that Flutter 3.44.0 includes updates related to HCPP.

    However, HCPP has some requirements:

    • Android API 34 or later
    • Vulkan rendering support

    We plan to use HCPP where supported, but for older Android versions or devices without Vulkan, we still need to use TLHC.

    Based on the issue I shared earlier, is there any plan to improve TLHC performance under the merged-thread?

    Thanks!

    There are no plans to improve TLHC performance currently.

    Note that TLHC is the only option on those devices. Opting your app in to HCPP does not mean abandoning devices which don't meet the requirements - those devices simply will simply fall back to the old platform view implementation you have chosen.

    Unless you have encountered an issue with hcpp there isn't a reason to avoid opting in. It provides improved performance on the devices that support it, and doesn't impact the devices that do not.

  6. Flutter-DoHyunKim commented on May 23, 2026

    @Flutter-DoHyunKim
    Author

    @gmackall

    Thanks for the response.

    We will definitely use HCPP on devices that support it.

    However, I'm concerned that older devices falling back to TLHC will still experience the jank issue I reported after the merged-thread was enabled.

    In my testing, the proposed updateOffset() approach showed performance improvements.

    Thanks!

  7. gmackall commented on May 24, 2026

    @gmackall
    Member

    @gmackall

    Thanks for the response.

    We will definitely use HCPP on devices that support it.

    However, I'm concerned that older devices falling back to TLHC will still experience the jank issue I reported after the merged-thread was enabled.

    In my testing, the proposed updateOffset() approach showed performance improvements.

    Thanks!

    Yes, I've been meaning to try out your suggestion and see how it impacts performance, as well as see if there are any unintended side effects. I'm out this upcoming week but will try it out the following week

  8. gmackall commented on Jun 13, 2026

    @gmackall
    Member

    @gmackall
    Thanks for the response.
    We will definitely use HCPP on devices that support it.
    However, I'm concerned that older devices falling back to TLHC will still experience the jank issue I reported after the merged-thread was enabled.
    In my testing, the proposed updateOffset() approach showed performance improvements.
    Thanks!

    Yes, I've been meaning to try out your suggestion and see how it impacts performance, as well as see if there are any unintended side effects. I'm out this upcoming week but will try it out the following week

    Have been testing this out. Haven't noticed any issues with input comparing side by side to not using the your suggestion, and also talkback still seems to work. Will continue to test it for a bit, seems promising

  9. Flutter-DoHyunKim commented on Jun 29, 2026

    @Flutter-DoHyunKim
    Author

    Hi @gmackall, hope your week is going well!
    Just wanted to follow up on this issue — any updates on your end?

    Let me know if there's anything I can help with!

  10. added
    ⏳Bot is counting down the days until it unassigns the issue
    on Oct 17, 2026
  11. flutter-triage-bot commented on Oct 17, 2026

    @flutter-triage-bot

    This issue is assigned to @gmackall but has had no recent status updates. Please consider unassigning this issue if it is not going to be addressed in the near future. This allows people to have a clearer picture of what work is actually planned. Thanks!

  12. mit-mit commented on Oct 26, 2026

    @mit-mit
    Member

    Not stale, see #194899

  13. gmackall commented on Oct 26, 2026

    @gmackall
    Member

    TLHC scroll jank should be significantly improved on master now. #194899 also changed how we handle scrolling in general for TLHC, so please let me know if you encounter any positioning/scroll related issues

  14. gmackall commented on Nov 3, 2026

    @gmackall
    Member

    24. TLHC scroll jank should be significantly improved on master now. #194899 also changed how we handle scrolling in general for TLHC, so please let me know if you encounter any positioning/scroll related issues

    To be clear, that linked pr improved scroll jank when scrolling Flutter content that contained a TLHC pv, but not when scrolling a TLHC platform view itself (i.e. a scrollable webview).

    I'm hoping to also land a significant perf improvement to that second case soon #195861

  15. gmackall commented on Nov 5, 2026

    @gmackall
    Member

    #195861 has landed, so scrolling TLHC pvs themselves should feel a lot better now. Please let me know if you encounter issues

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

⏳Bot is counting down the days until it unassigns the issueP2Important issues not at the top of the work listfrom: performance templateIssues created via a performance issue templateplatform-androidAndroid applications specificallyplatform-views: tlhcIssues specific to the Android Texture Layer Hybrid Composition platform views backendteam-androidOwned by Android platform teamtriaged-androidTriaged by Android platform team

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions