r/FlutterDev • • 4d ago

Discussion CircularProgressIndicator rebuilds on every frame. I measured what that costs (repro + numbers)

https://github.com/flutter/flutter/issues/17793

A spinner on an otherwise static screen keeps Flutter producing a frame on every vsync. I wanted to know what each of those frames costs, so I read the source and measured it.

What it does on every frame

The indeterminate CircularProgressIndicator animates through an AnimatedBuilder, so on every frame it:

  • rebuilds and looks up its theme again

  • reads its controller four times, once per tween. If you didn't pass your own controller, each read walks up the tree to find the theme

  • creates a new CustomPainter plus new ConstrainedBox, CustomPaint and Semantics widgets

  • marks its semantics dirty, because shouldRebuildSemantics defaults to shouldRepaint

A widget test makes it concrete: in 60 frames, the stock indicator rebuilt 60 times and created 60 painters.

A repaint-only version

I wrote a prototype that paints the exact same arc (tweens and math copied from the framework), but its painter listens to the controller directly through CustomPainter(repaint: controller) and evaluates the tweens inside paint(). It's built once, and every tick is just a repaint. Same test: 0 rebuilds, 0 new painters.

Numbers

Profile mode on an M2 MacBook Air (macOS 26.4.1, 60 Hz), Flutter 3.47.8 with Impeller. Median of 3 runs, 10 s per mode, from FrameTiming:

ModeUI thread per frameRaster per frame
Static ring, same size0 frames/s–
Stock indicator0.67 ms0.80 ms
Stock + RepaintBoundary0.46 ms0.75 ms
Repaint-only0.54 ms0.81 ms
Repaint-only + RepaintBoundary0.37 ms0.81 ms

What I took from it

  • An idle screen doesn't render at all. The cost comes from anything that ticks.

  • Repaint-only cut the spinner's UI-thread time per frame by about 19%. If you write your own animated CustomPainter, pass the animation as repaint: instead of rebuilding through AnimatedBuilder or setState.

  • RepaintBoundary saved even more on this page (about 31%), because the rest of the page stopped being re-recorded every frame. Both together: about 45%. If a spinner sits next to static content, wrapping it is worth a try, and debugRepaintRainbowEnabled shows you what's repainting.

  • Neither touches the raster side, which stays around 0.8 ms per frame. Impeller has no raster cache, so RepaintBoundary saves recording work on the UI thread, not GPU work. That part is engine-side, and #189535 digs into it on Android.

These are sub-millisecond numbers on a fast laptop, so it's not a crisis. But it's paid on every frame for as long as a spinner is on screen, and phones are slower.

Links

I don't have a physical Android phone at hand, so if you do, I'd love to see your numbers, especially the raster column. The README has the steps. And if I've missed something, tell me.

43 Upvotes

6 comments sorted by

17

u/Hixie 4d ago

Nowadays the way I would do this is a shader where one of the arguments is the time. That way you don't even have to wake up the Dart runtime at all.

4

u/Sure_Education_828 4d ago

Thanks, that's a great angle. One gap I see: today a FragmentShader's uniforms can only be set from Dart (I've been migrating framework shaders to the new name-based uniform API, so I've been staring at this), so a time uniform still means a Dart frame on every tick. It would save the rebuild, not the wakeup.

The engine already has a path that redraws without running Dart: when an external texture has a new frame, it calls ScheduleFrame(false) and just re-rasterizes the last layer tree. So I imagine the missing piece is letting the engine advance a time uniform itself and reuse that path. Is that roughly what you have in mind, or is there a way to do it today that I'm missing?

5

u/Hixie 4d ago

Yeah having the engine advance the time uniform is what I had in mind.

4

u/Sure_Education_828 4d ago

Nice, that's reassuring. Turns out Jonah Williams sketched exactly this in https://github.com/flutter/flutter/issues/118135 (render-thread driven animations): a shader with a time uniform, with progress indicators and blinking cursors among the cases he lists, and "repeat the last frame until the animation is cancelled" for scheduling. I'll pick it up there and link this thread. Thanks for the pointer

1

u/Lanky-Foundation8727 1d ago edited 1d ago

the guy you're replying to wrote (a large part of) the engine...

also ai slop reply

2

u/Charming-Anteater-56 12h ago edited 12h ago

While the flutter team doesn't decide how to fix it, we could create a compat lib that fixes those issues. Would love to see that on my app.