Elements, Keys and Flutter’s performance

Tomek Polański
Flutter Community
Published in
6 min readFeb 9, 2019

TL;DR: Widget Keys can improve the performance of our application in places where you do not get the promised 60 FPS.

An Element is created internally by a Widget. Its main purpose is to know where in the widget tree is the widget that created it.

Elements are expensive to create and if it’s possible, they should be reused. That can be achieved with Keys (ValueKeys and GlobalKeys).

Element’s life cycle

  • Mount — it’s called when the element is added to the tree for the first time
  • Activate — it’s called when activating previously deactivated element
  • Update — updates the RenderObject with new data
  • Deactivate — it’s called when the Element is removed/moved from the Widget tree. An Element can be still reactivated if it was moved during the same Frame and it has a GlobalKey
  • Unmount — if the Element was not reactivated during a Frame, it will be unmounted and cannot be reused anymore

To improve performance we need to use activate and update as often a possible and try to avoid triggering unmount and mount.

IMPORTANT: Most of the time you do not need special optimization as Flutter is fast, the following I would recommend using when you want to fix a visible performance problem.

Changing position in a Column/Row

In the first example, we want to change the order of how the red Container and the Placeholder are displayed:

Widget build(BuildContext context) {
return Column(
children: [
value
? const SizedBox()
: const Placeholder(),
GestureDetector(
onTap: () {
setState(() {
value = !value;
});
},
child: Container(
width: 100,
height: 100,
color: Colors.red,
),
),
!value
? const SizedBox()
: const Placeholder(),
],
);
}

This is what happens when we tap on the GestureDetector [1]:

08:17:53.652: update Column08:17:53.666: deactivate Placeholder
08:17:53.679: deactivate GestureDetector
08:17:53.679: deactivate Container
08:17:53.679: deactivate SizedBox
08:17:53.679: mount SizedBox
08:17:53.679: mount GestureDetector
08:17:53.679: mount Container
08:17:53.691: mount Placeholder
08:17:53.691: unmount SizedBox
08:17:53.698: unmount Placeholder
08:17:53.700: unmount Container
08:17:53.715: unmount GestureDetector

As you can see only the Column was updated and the rest of the items were first deactivated, the new Elements were mounted and then the old ones were disposed of.

Let’s see how long did it take to rebuild that element — for that, I use Timeline feature of the Observatory

On this graph you can see pairs of timelines:

  • Mount <widget name> — eg Mount Placeholder — this is how long did the mounting phase take
  • <widget name> — eg Placeholder — this is how long did the building of the widget take

On average, building all of those widgets took 5.5ms

How to improve this?

You can improve this by assigning ValueKeys to the root widgets that are being unmounted:

Widget build(BuildContext context) {
return Column(
children: [
value
? const SizedBox(key: ValueKey('SizedBox'))
: const Placeholder(key: ValueKey('Placeholder')),
GestureDetector(
key: ValueKey('GestureDetector'),
onTap: () {
setState(() {
value = !value;
});
},
child: Container(
width: 100,
height: 100,
color: Colors.red,
),
),
!value
? const SizedBox(key: ValueKey('SizedBox'))
: const Placeholder(key: ValueKey('Placeholder')),
],
);
}

Now we have this log output:

08:21:37.576: update Column
08:21:37.594: update SizedBox-[<’SizedBox’>]
08:21:37.596: update GestureDetector-[<’GestureDetector’>]
08:21:37.611: update Container
08:21:37.619: update Placeholder-[<’Placeholder’>]

The timeline output looks like this:

Both in the log output and the timeline you do not see any mounting. Additionally, the average building time of the Widgets is 1.6ms compared to previously 5.5ms [2].

Changing a parent of a Widget

Sometimes you would like to center your Widget on a screen, but when there is not enough space, you would like to put our Widget in a SingleChildScrollView so it would not overflow.

In those cases, you need to change where your Widget is positioned in the Widget tree:

Widget build(BuildContext context) {
final inner = MaterialApp(
home: Container(
width: 100,
height: 100,
color: Colors.red,
),
);
return GestureDetector(
onTap: () {
setState(() {
value = !value;
});
},
child: value ? SizedBox(child: inner) : inner,
);
}

In this example depending on value, we surround a pretty complex MaterialApp widget with SizedBox widget.

Let’s look at the log output [1]:

09:41:43.325: update GestureDetector09:41:43.348: deactivate MaterialApp
09:41:43.350: deactivate Container
09:41:43.352: mount SizedBox
09:41:43.352: mount MaterialApp
09:41:43.425: mount Container
09:41:43.450: unmount Container
09:41:43.476: unmount MaterialApp

The average build time as 67ms [2].

How to improve this?

To reuse the MaterialApp widget we need to assign a GlobalKey to it (normal ValueKey is not sufficient)

class _GlobalKeyWidgetState extends State<GlobalKeyWidget> {
bool value = false;
final global = GlobalKey();

@override
Widget build(BuildContext context) {
final inner = MaterialApp(
key: global,
home: Container(
width: 100,
height: 100,
color: Colors.red,
),
);
return GestureDetector(
onTap: () {
setState(() {
value = !value;
});
},
child: value ? SizedBox(child: inner) : inner,
);
}
}

The log output in the timeline is as follows:

09:56:46.993: update GestureDetector09:56:47.030: deactivate MaterialApp
09:56:47.060: deactivate Container
09:56:47.060: mount SizedBox09:56:47.072: activate MaterialApp
09:56:47.095: activate Container
09:56:47.098: update MaterialApp
09:56:47.188: update Container

Again in both the log output and the timeline, you do not see mounting for MaterialApp, only for SizedBox that is newly added. The average building time of the Widgets is 25ms compared to previously 67ms [2].

What about Slivers?

How to prevent mounting new Element vs reusing the existing one?

Same types of Widgets

Whenever you change the order of items in a list and those items are of the same type, those items will be reused:

Widget build(BuildContext context) {
return Directionality(
textDirection: TextDirection.ltr,
child: GestureDetector(
onTap: () {
setState(() {
value = !value;
});
},
child: ListView(
children: <Widget>[
value ? Placeholder(color: Colors.red) : Placeholder(),
!value ? Placeholder(color: Colors.red) : Placeholder(),
],
),
),
);
}
No additional mounting

Different types of Widgets

When you have different types of widgets and change the order, the Elements behind those Widgets will be recreated and mounted:

Widget build(BuildContext context) {
return Directionality(
textDirection: TextDirection.ltr,
child: GestureDetector(
onTap: () {
setState(() {
value = !value;
});
},
child: ListView(
children: <Widget>[
value
? Placeholder(color: Colors.red)
: Container(height: 100),
!value
? Placeholder(color: Colors.red)
: Container(height: 100),
],
),
),
);
}

Unfortunately, using LocalKeys won’t fix this issue. Using global keys instead fixes this issue but as I’ve mentioned before, misusing GlobalKeys might cause other issues.

Thanks to boformer for pointing this out!

Conclusion

Performance for Flutter in the majority of cases is good enough and does not require micro-optimizations. On the other hand, there are cases when we need to do some more work to have our applications perform at 60 FPS.

The downside of using ValueKeys is that they bloat our code and with GlobalKey we could have some errors if we duplicate them.

Used responsibly, Keys can help you bring the performance of your application right where you want it.

The source code can be found here.

If you want to learn more about Elements check out Norbert’s article.

[1] I’ve removed, for simplicity from the log, the output of the elements that are created internally

[2] Those measurements were done on a debug build on an emulator — in a release build, on a device, those would be much smaller

--

--

Tomek Polański
Flutter Community

Passionate mobile developer. One thing I like more than learning new things: sharing them