Building performant Flutter widgets

Pierre-Louis Guidez
Flutter
Published in
7 min readJul 23, 2020

This article is part of a series developed after the Flutter Material team worked on making the Flutter Gallery app more performant on the web. However, the tips in this article apply to all Flutter applications. Skip to the end to find the other articles in this series.

By Anthony Robledo & Pierre-Louis Guidez

All stateless and stateful widgets implement build() methods that define how they’re rendered. A screen on an app can have hundreds or even thousands of widgets. These widgets may get built only once, or multiple times if there is an animation or some kind of interaction. While building widgets is relatively fast in Flutter, you must be vigilant in when and what you choose to build.

This article talks about building only what you need and only when you need it. Then we share how we used this approach to achieve a significant performance improvement in the Flutter Gallery web app. We’ll also share pro tips on how you can diagnose similar problems in your web app.

Only build when necessary

One important optimization is to build widgets only when it’s absolutely necessary.

Call setState() judiciously

Calling setState schedules a build() method to be called. Doing this too often can slow down the performance of a screen.

Consider the following animation, where the display of the front (the black screen) is animated to slide down to reveal the back (the checkerboard), similar to how a bottom sheet might behave. The front widget is simple, but the back widget is busy:

a front widget animating smoothly in front of a busy widget
A smooth animation
Stack(
children: [
Back(),
PositionedTransition(
rect: RelativeRectTween(
begin: RelativeRect.fromLTRB(0, 0, 0, 0),
end: RelativeRect.fromLTRB(0, MediaQuery.of(context).size.height, 0, 0),
).animate(_animationController),
child: Front(),
)
],
),

You might be tempted to set up the parent widget as follows, but in this scenario, this is wrong!

// BAD CODE
@override
void initState() {
super.initState();
_animationController = AnimationController(
duration: Duration(seconds: 3),
vsync: this,
);
_animationController.addListener(() {
setState(() {
// Rebuild when animation ticks.
});
});
}

This is not performant! Why not?

Because the animation is doing unnecessary work.

a front widget animating with jank over a busy widget
A janky animation

Here is the problematic code:

// BAD CODE
_animationController.addListener(() {
setState(() {
// Rebuild when animation ticks.
});
});
  • This style of animation is recommended when you need to animate the entire widget, but that is not what we are doing here.
  • Calling setState() in the animation listener causes the entire Stack to be rebuilt, which is not necessary!
  • The PositionedTransition widget is already an AnimatedWidget, so it rebuilds automatically when the animation ticks.
  • Calling setState() is actually not needed here!

Even though the back widget is busy, it can animate smoothly at 60 FPS. For more on calling setState judiciously, see Flutter Laggy Animations: How Not To setState.

Only build what is necessary

In addition to only building when it’s required, you want to build only the portion of the UI that actually changes. The following section focuses on creating performant lists.

Prefer ListView.builder()

First, let’s briefly cover the basics of displaying lists.

  1. To layout list items vertically, use a Column.
  2. If the list should be scrollable, use a ListView instead.
  3. If the list contains many items, use the ListView.builder constructor, which creates items as they scroll onto the screen instead of all at once. This has obvious performance benefits for complex list items and deep widget subtrees.

To illustrate the benefits of ListView.builder over ListView when you have a large number of list items, let’s look at a couple of examples.

Run the followingListView example in DartPad. Observe that all 8 items are created. (Click Console in the lower left to display the console, and then click Run. The output window has no scrollbar, but you can scroll the content and observe the console to see what is created and built when.)

ListView(
children: [
_ListItem(index: 0),
_ListItem(index: 1),
_ListItem(index: 2),
_ListItem(index: 3),
_ListItem(index: 4),
_ListItem(index: 5),
_ListItem(index: 6),
_ListItem(index: 7),
],
);

Next, run the ListView.builder example in DartPad. Observe that only the visible items are created. As you scroll, it creates (and builds) new rows.

ListView.builder(
itemBuilder: (context, index) {
return _ListItem(index: index);
},
itemCount: 8,
);

Now, run the example in DartPad whereListView’s children are created in advance, all at once when the ListView itself is created. In this scenario, it’s more efficient to use the ListView constructor.

final listItems = [
_ListItem(index: 0),
_ListItem(index: 1),
_ListItem(index: 2),
_ListItem(index: 3),
_ListItem(index: 4),
_ListItem(index: 5),
_ListItem(index: 6),
_ListItem(index: 7),
];
@override
Widget build(BuildContext context) {
// This offers no benefit, it is actually more efficient to use the ListView constructor instead.
return ListView.builder(
itemBuilder: (context, index) {
return listItems[index];
},
itemCount: 8,
);
}

For more on lazily building lists, see Slivers, Demystified.

How we improved the Flutter Gallery web page render time by more than 2x with a single line of code

The Flutter Gallery supports over 100 locales; Those locales are listed using — you guessed it — a ListView.builder. By obtaining widget rebuild information, we noticed these list items were being built unnecessarily on startup. It was not obvious that these items were the culprit since they were in two levels of collapsed menus: the settings panel itself, and the locale expansion tile (as it turns out, the settings panel was rendered ‘invisible’ using a ScaleTransition, meaning it was very much being built).

Flutter Gallery settings panel with the locale options expanded
Flutter Gallery settings panel with the locale options expanded

By simply setting ListView.builder’s itemCount to 0 for non-expanded setting categories, we ensured that list items are only built for the expanded, visible category. The one-line PR that resolved this issue improved render time on the web by more than 2x. The key was to identify excessive widget building.

Seeing widget build counts for your application

While Flutter builds very efficiently, there are some cases where building excessively can cause performance issues. There are a few ways to identify excessive widget rebuilding.

By using Android Studio/IntelliJ

Android Studio and IntelliJ developers can use built-in tooling to show Widget rebuild information.

By modifying the framework

If you use a different editor, or would like to know widget rebuild information for the web, you can do so by adding a bit of code to the framework.

Sample output:

RaisedButton 1
RawMaterialButton 2
ExpensiveWidget 538
Header 5

Locate <Flutter path>/packages/flutter/lib/src/widgets/framework.dart. Add the following code, which counts the number of times widgets are built at startup, and outputs the results after some duration (here, 10 seconds).

bool _outputScheduled = false;
Map<String, int> _outputMap = <String, int>{};
void _output(Widget widget) {
final String typeName = widget.runtimeType.toString();
if (_outputMap.containsKey(typeName)) {
_outputMap[typeName] = _outputMap[typeName] + 1;
} else {
_outputMap[typeName] = 1;
}
if (_outputScheduled) {
return;
}
_outputScheduled = true;
Timer(const Duration(seconds: 10), () {
_outputMap.forEach((String key, int value) {
switch (widget.runtimeType.toString()) {
// Filter out widgets whose build counts we don't care about
case 'InkWell':
case 'RawGestureDetector':
case 'FocusScope':
break;
default:
print('$key $value');
}
});
});
}

Then, modify the build methods for StatelessElement and StatefulElement to call _output(widget).

class StatelessElement extends ComponentElement {
...
@override
Widget build() {
final Widget w = widget.build(this);
_output(w);
return w;
}
class StatefulElement extends ComponentElement {
...
@override
Widget build() {
final Widget w = _state.build(this);
_output(w);
return w;
}

See the resulting framework.dart file.

Note that numerous rebuilds doesn’t necessarily indicate a problem. However, it can help debug performance issues by verifying that non-visible widgets aren’t being built, for example.

Tip for web only: you can add a resetOutput function (that can be called from a browser’s developer tools) to obtain widget build counts at any point in time.

import 'dart:js' as js;

void resetOutput() {
_outputScheduled = false;
_outputMap = <String, int>{};
}
void _output(Widget widget) {
// Add this line
js.context['resetOutput'] = resetOutput;
...

See the resulting framework.dart file.

Closing Remarks

Effective performance debugging requires understanding what’s happening under the hood. These tips can help decide how to implement your next build method so that your app stays performant in all scenarios.

This post is a part of a series about what we learned when improving performance for the Flutter Gallery. Articles in the Creating performant Flutter web apps series:

For more general information, the Flutter docs on UI performance are a great place for developers of all levels to start.

Thanks to Shams Zakhour for editing this article and Ferhat Buyukkokten for debugging tips.

--

--