Repository navigation
Description
Steps to reproduce
- Clone the sample repo and run
flutter run --profileon a physical Android device (Flutter 3.38+) - App launches with a native Android screen — tap "Open Flutter PlatformView Test" button
- Scroll the list containing 8
AndroidViewPlatformViews (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.
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.
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 |
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.1Additional 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:
- Updates
left/topfields (for touch coordinate transform) - Syncs
LayoutParamsmargins (for future resize/creation calls) - 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
saurabh-mirajkar commentedon Mar 18, 2026
@Flutter-DoHyunKim Thanks for a detailed report. Labelling for
team-androidto investigate further.Flutter-DoHyunKim commentedon May 22, 2026
@gmackall
Hi, I noticed that Flutter 3.44.0 includes updates related to HCPP.
However, HCPP has some requirements:
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!
gmackall commentedon May 22, 2026
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.
Flutter-DoHyunKim commentedon May 23, 2026
@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!
gmackall commentedon May 24, 2026
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
gmackall commentedon Jun 13, 2026
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
Flutter-DoHyunKim commentedon Jun 29, 2026
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!
flutter-triage-bot commentedon Oct 17, 2026
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!
mit-mit commentedon Oct 26, 2026
Not stale, see #194899
Update TLHC PlatformViewWrapper offset without requesting layout (flu…
gmackall commentedon Oct 26, 2026
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
Update TLHC PlatformViewWrapper offset without requesting layout (flu…
gmackall commentedon Nov 3, 2026
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
gmackall commentedon Nov 5, 2026
#195861 has landed, so scrolling TLHC pvs themselves should feel a lot better now. Please let me know if you encounter issues