I’m hitting a severe frame drop / UI freeze issue on Flutter (v3.38+ / Impeller on Android API 34) that I can’t isolate. The issue happens when updating a uniform value inside a custom fragment shader attached to a CustomPainter rendering inside a SliverGrid.
We are building a media grid where each item has a subtle background animation driven by a AnimationController and an AGSL fragment shader. Instead of rebuilt widgets, we use CustomPainter with repaint: animationController to draw directly to canvas.
On Skia (or iOS Impeller), this runs smoothly at 60–120 FPS. However, on Android 14 / API 34 with Impeller enabled by default, fast scrolling causes immediate visual stutter, where DevTools CPU/GPU frame times spike up to 45ms–60ms (dropping frames down to ~18-22 FPS).
Here is the setup:
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final shader = await ui.FragmentProgram.fromAsset('shaders/ripple.frag');
runApp(MyApp(shader: shader));
}
class MyApp extends StatelessWidget {
final ui.FragmentProgram shader;
const MyApp({super.key, required this.shader});
@override
Widget build(BuildContext context) {
return MaterialApp(
home: ShaderGridScreen(shader: shader),
);
}
}
class ShaderGridScreen extends StatefulWidget {
final ui.FragmentProgram shader;
const ShaderGridScreen({super.key, required this.shader});
@override
State createState() => _ShaderGridScreenState();
}
class _ShaderGridScreenState extends State
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
@override
void initState() {
super.initState() {
_controller = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
)..repeat();
}
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
mainAxisSpacing: 4,
crossAxisSpacing: 4,
),
delegate: SliverChildBuilderDelegate(
(context, index) {
return CustomPaint(
painter: ShaderTilePainter(
program: widget.shader,
progress: _controller,
index: index,
),
);
},
childCount: 300,
addRepaintBoundaries: true, // Tried true & false
addAutomaticKeepAlives: false,
),
),
],
),
);
}
}
class ShaderTilePainter extends CustomPainter {
final ui.FragmentProgram program;
final Animation progress;
final int index;
ShaderTilePainter({
required this.program,
required this.progress,
required this.index,
}) : super(repaint: progress);
@override
void paint(Canvas canvas, Size size) {
final shader = program.fragmentShader();
// Set uniforms
shader.setFloat(0, size.width);
shader.setFloat(1, size.height);
shader.setFloat(2, progress.value);
shader.setFloat(3, index.toDouble());
final paint = Paint()..shader = shader;
canvas.drawRect(Offset.zero & size, paint);
}
@override
bool shouldRepaint(covariant ShaderTilePainter oldDelegate) {
return oldDelegate.progress != progress || oldDelegate.index != index;
}
}
What I Have Tried So Far
Repaint Boundaries: Wrapped each
CustomPaintitem inside aRepaintBoundary(and toggledaddRepaintBoundaries: trueinSliverChildBuilderDelegate). This lowered raster times slightly, but GPU compilation spikes remain high when new grid tiles scroll into view.Pre-allocating FragmentShader instances: Moved
program.fragmentShader()generation intoinitState/ held in state memory instead of creating a newFragmentShaderobject insidepaint(). This reduced Dart GC pressure, but the rasterizer jank on Impeller persists.addAutomaticKeepAlives: false: Set off keep-alives so invisible tiles unmount immediately, but fast flings still hit 50ms frame gaps.Disabling Impeller (Testing Skia): Passing
--enable-impeller=falseon Android immediately resolves the issue—scrolling stays at a locked 60 FPS without frame drops.
DevTools / Performance Profile Findings
From Flutter DevTools Timeline (Performance tab):
Raster / GPU Thread Spikes: Every time a new batch of 3-6 tiles comes into view,
Impeller::CommandQueue::Submittakes ~30ms - 40ms.Pipeline Trace:
RenderCustomPaint.painttriggersGLES/Vulkan Pipeline Creationor Uniform Buffer allocation overhead per dynamic child tile under Impeller.
Environment (flutter doctor -v)
[✓] Flutter (Channel stable, 3.38.2, on Microsoft Windows 11 Pro 10.0.22631)
• Flutter version 3.38.2 on channel stable
• Engine revision 04301548fb
• Tools Dart version 3.11.0
[✓] Android toolchain - develop for Android devices (Android SDK version 34.0.0)
• Android SDK at C:\Users\Dev\AppData\Local\Android\Sdk
• Platform android-34, build-tools 34.0.0
[✓] Connected device (1 available)
• Pixel 8 Pro (mobile) • android-arm64 • Android 14 (API 34)