[Android] Scroll jank with TLHC PlatformViews under merged platform/UI threads
- Dominant language
- Dart
- Stars
- 179k
- Forks
- 31.1k
- PR merge metrics
- PR metrics pending
Description
### 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
```dart
// 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 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 createState() => _NativeAdCardState();
}
class _NativeAdCardState extends State
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: {
'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
- [x] 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]
### 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]
### Perfetto SQL query comparison
Query used on both traces:
```sql
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]
[After query result]
### 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
```console
No error logs — this is a performance issue, not a crash.
```
### Flutter Doctor output
Doctor output
```console
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](https://github.com/flutter/flutter/pull/174408) removed the opt-out flag for disabling merged platform/UI threads(#150525), merged threads are now always enabled.
From my [traces](https://github.com/flutter/flutter/issues/150525#issuecomment-3610384091), 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`**
```diff
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()`):
```java
/**
* 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):
```diff
@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
```diff
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.
Contributor guide
Assessment
This issue has not been assessed yet.