Elements, Keys and Flutter’s performance
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 theWidget
tree. AnElement
can be still reactivated if it was moved during the same Frame and it has aGlobalKey
- 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 SizedBox08:17:53.679: mount SizedBox
08:17:53.679: mount GestureDetector
08:17:53.679: mount Container
08:17:53.691: mount Placeholder08: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 ValueKey
s 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 Container09:41:43.352: mount SizedBox
09:41:43.352: mount MaterialApp
09:41:43.425: mount Container09: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 Container09:56:47.060: mount SizedBox09:56:47.072: activate MaterialApp
09:56:47.095: activate Container09: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(),
],
),
),
);
}
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