Now that we know that the build
method can be called up to 120 times per second, the question is: do we really need to call the whole build
method of our app if only a small part of the widget tree has changed? The answer is no, of course not. So let’s review how we can make this happen.
First things first, let’s get one obvious but still important thing out of the way. The build
method is supposed to be blazing fast. After all, it can have as little as 8 ms to run without dropping frames. This is why it’s crucial to keep any long-running tasks such as network or database requests out of this method. There are better places to do that which we will explore in detail throughout this book.
Pushing rebuilds down the tree
There can be several situations when pushing the rebuilds down the tree can impact performance in a positive way.
Calling setState of StatefulWidget
One of the most used widgets is StatefulWidget
. It’s a very convenient type of widget because it can manage state changes and react to user interactions. Let’s take a look at the sample app that is created every time you start a new Flutter project: the counter app. We are interested in the code of the _MyHomePageState
class, which is the State
of MyHomePage
:
class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;
void _incrementCounter() {
setState(() { _counter++; });
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Flutter Demo Home Page'),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
const Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
// In the original Flutter code the increment button is a
// FloatingActionButton property of the Scaffold,
// but for demonstration purposes, we need a slightly
// modified version
TextButton(
onPressed: _incrementCounter,
child: const Text('Increase'),
)
],
),
),
);
}
}
The UI is very simple. It consists of a Scaffold
with an AppBar
and a FloatingActionButton
. Clicking the FloatingActionButton
increments the internal _counter
field. The body of the Scaffold
is a Column
with two Text
widgets that describe how many times the FloatingActionButton
has been clicked based on the _counter
field. The preceding example differs from the original Flutter sample in one regard: instead of using the FloatingActionButton
for handling clicks, we are using the TextButton
. So every time we click the TextButton
, the _incrementCounter
method is called, which in turn calls the setState
framework method and increments the _counter
field. Under the hood, the setState
method causes Flutter to call the build
method of _MyHomePageState
, which causes a rebuild. An important thing here is that setState
causes a rebuild of the whole MyHomePage
widget, even though we are only changing the text.
An easy way to optimize this is to push state changes down the tree by extracting them into a smaller widget. For example, we can extract everything that was inside the Center
widget of Scaffold
into a separate widget and call it CounterText
:
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Flutter Demo Home Page'),
),
body: const Center(child: CounterText()),
);
}
}
class CounterText extends StatefulWidget {
const CounterText({Key? key}) : super(key: key);
@override
State<CounterText> createState() => _CounterTextState();
}
class _CounterTextState extends State<CounterText> {
int _counter = 0;
void _incrementCounter() {
setState(() { _counter++; });
}
@override
Widget build(BuildContext context) {
return Column(...// Same code that was in the original example );
}
}
We haven’t changed any logic. We only took the code that was inside of the Center
widget of _MyHomePageState
and extracted it into a separate widget:CounterText
. By encapsulating the widgets that need to be rebuilt when an internal field changes into a separate widget, we ensure that whenever we call setState
inside of the _CounterTextState
field, only the widgets returned from the build
method of _CounterTextState
get rebuilt. The parent _MyHomePageState
doesn’t get rebuilt, because its build
method wasn’t called. We pushed the state changes down the widget tree, causing only smaller parts of the tree to get rebuilt, instead of the whole screen. In real-life app code, this scales very fast, especially if your pages are UI-heavy.
Subscribing to InheritedWidget changes via .of(context)
By extracting the changing counter text into a separate CounterText
widget in the last code snippet, we have actually made one more optimization. The interesting line for us is Theme.of(context).textTheme.headlineMedium
. You have certainly used Theme
and other widgets, such as MediaQuery
or Navigator
, via the .of(context)
pattern. Usually, those widgets extend a special type of class: InheritedWidget
. We will look deeper into its internals in the state management part (Chapters 3 and 4), but for now, we are interested in two of its properties:
- Instead of creating those widgets, we will access them via static getter and use some of their properties. This means that they were created somewhere higher up the tree. Hence, we will inherit them. If they weren’t and we still try to look them up, we will get an error.
- For some of those widgets, such as
Theme
and MediaQuery
, the .of(context)
not only returns the instance of the widget if it finds one but also adds the calling widget to a set of its subscribers. When anything in this widget changes – for example, if the Theme
was light and became dark – it will notify all of its subscribers and cause them to rebuild. So in the same way as with setState
, if you subscribe to an InheritedWidget
, changes high up in the tree will cause the rebuild of the whole widget tree starting from the widget that you have subscribed in. Push the subscription down to only those widgets that actually need it.
Extra performance tip
You may have used MediaQuery.of(context)
in order to fetch information about the screen, such as its size, paddings, and view insets. Whenever you call MediaQuery.of(context)
, you subscribe to the whole MediaQuery
widget. If you want to get updates only about the paddings (or the size, or the view insets), you can subscribe to this specific property by calling MediaQuery.paddingOf(context)
, MediaQuery.sizeOf(context)
, and so on. This is because MediaQuery
actually extends a specific type of InheritedWidget
– the InheritedModel
widget. It allows you to subscribe only to those properties that you care about as opposed to the whole widget, which can greatly contribute to widget rebuild optimization.
Avoiding redundant rebuilds
Now that we’ve learned how to scope our trees so that only smaller sections are rebuilt, let’s find out how to minimize the amount of those rebuilds altogether.
Being mindful of the widget life cycle
Stateless widgets are boring in terms of their life cycles. Stateful widgets, on the other hand, are not. Let’s take a look at the life cycle of the State
:
Figure 1.3 – Main methods of State life cycle
Here are a few things that we should care about:
- The
initState
method gets called only once per widget life cycle, much like the dispose
method.
- The
didChangeDependencies
method gets called immediately after initState
.
didChangeDependencies
is always called when an InheritedWiget
that we subscribed to has changed. This is the implementation aspect of what we have just discussed in the previous section.
- The build method always gets called after
didChangeDependencies
, didUpdateWidget
, and setState
.
This is important!
Don’t call setState
in didChangeDependencies
or didUpdateWidget
. Such calls are redundant, since the framework will always call build
after those methods.
The best performance practices in the preceding list are also the reason why it’s better to decouple your widgets into other custom widgets rather than extract them into helper methods such as Widget buildMyWidget()
. The widgets extracted into methods still access the same context or call setState
, which causes the whole encapsulating widget to rebuild, so it’s generally recommended to prefer widget classes rather than methods.
One more important thing regarding the life cycle of the State
is that once its dispose
method has been called, it will never become alive again and we will never be able to use it again. This means that if we have acquired any resources that hold a reference to this State
, such as text editing controllers, listeners, or stream subscriptions, these should be released. Otherwise, the references to these resources won’t let the garbage collector clean up this object, which will lead to memory leaks. Fortunately, it’s usually very easy to release resources by calling their own dispose
or close
methods inside the dispose
of the State
.
Caching widgets implicitly
Dart has a notion of constant constructors. We can create constant instances of classes by adding a const
keyword before the class name. But when can we do this and how can we take advantage of them in Flutter?
First of all, in order to be able to declare a const
constructor, all of the fields of the class must be marked as final
and be known at compile time. Second, it means that if we create two objects via const
constructors with the same params, such as const SizedBox(height: 16)
, only one instance will be created. Aside from saving some memory due to initializing fewer objects, this also provides benefits when used in a Flutter widget tree. Let’s return to our Element
class once again.
We remember that the class has an update
method that gets called by the framework when the underlying widget has changed its fields (but not type or key). This method changes the reference to the widget. Soon the framework calls rebuild. Since we’re working with a tree data structure, we will traverse its children. Unless your element is a leaf element, it will have children. There is a very important method in the Element
API called updateChild
. As the name says, it updates its children elements. But the interesting thing is how it does it:
#1 Element? updateChild(Element? child, Widget? newWidget,
Object? newSlot) {
#2 // A lot of code removed for demo purposes
#3
#4 final Element newChild;
#5 if (child.widget == newWidget) {
#6 newChild = child;
#7 } else if (Widget.canUpdate(child.widget, newWidget)) {
#8 child.update(newWidget);
#9 newChild = child;
#10 }
#11
#12 return newChild;
#13 }
In the preceding code, in case our current widget is the same as the new widget as determined by the ==
operator, we only reassign the pointer, and that’s it. By default, in Dart, the ==
operator returns true
only if both of the instances point to the same address in memory, which is true
if they were created via a const
constructor with the same params.
However, if the result is false
, we should check the already-familiar Widget.canUpdate
. However, aside from reassigning the pointer to the new element, we also call its update
method, which soon causes a rebuild.
Hence, if we use const
constructors, we can avoid rebuilds of whole widget subtrees. This is also sometimes referred to as caching widgets. So use const
constructors whenever possible and see whether you can extract your own widgets that can make use of const
constructors, even if nested widgets can’t.
Keep in mind that you have to actually use the const
constructor, not just declare it as a possibility. For example, we have a ConstText
widget that has a const
constructor:
class ConstText extends StatelessWidget {
const ConstText({super.key});
@override
Widget build(BuildContext context) {
return const Text('Hello World');
}
}
However, if we create an instance of this widget without using the const
constructor via the const
keyword as in the following code, then we won’t get any of the benefits of the const
constructor:
// Don't!
class ParentWidget extends StatelessWidget {
const ParentWidget({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return ConstText(); // not const!
}
}
We need to explicitly specify the const
keyword when creating an instance of the class. The correct usage of the const
constructor looks like this:
// Do
class ParentWidget extends StatelessWidget {
const ParentWidget({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return const ConstText(); // const, all good
}
}
In the preceding code, we used the const
keyword during the creation of a ConstText
widget. This way, we will get all of the benefits. This small keyword is very important.
Explicitly cache widgets
The same logic can be applied if the widget can’t be created with a const
constructor, but can be assigned to a final
field of the State
. Since you’re literally saving the pointer to the same widget instance and returning it rather than creating a new one, it will follow the same execution path as the one we saw with const
widgets. This is one of the ways in which you can work around the Container
not being const
. You might do so using the following, for example:
class _MyHomePageState extends State<MyHomePage> {
final greenContainer = Container(
color: Colors.green,
height: 100,
width: 100,
);
@override
Widget build(BuildContext context) {
return Column(
children: [
greenContainer,
Container(
color: Colors.pink,
height: 100,
width: 100,
),
],
);
}
}
In the preceding code, the update
method of the green container won’t be called. We have retained the reference to an already-existing widget by caching it in a local greenContainer
field. Hence, we return the exact same instance as in the previous build
. This falls into the case described on line 5 in the updateChild
method code snippet provided earlier in this section. If the instances are the same based on the equality operator, then the update
method is not called. On the other hand, the pink Container
will be rebuilt every time because we create a new instance of the class every time the build
method is called. This is described in line 7 of the same code snippet.
Avoiding redundant repaints
Up to this point, we have looked at tips to help you avoid causing redundant rebuilds of the widget and element trees. The truth is that the building phase is quite cheap when compared to the rendering process, as this is where all of the heavy lifting is done. Flutter optimizes this phase as much as possible, but it cannot completely control how we create our interfaces. Therefore, we may encounter cases where these optimizations are not enough or are not working effectively.
Let’s take a look at what happens when one of the render objects wants to repaint itself. We may assume that this repainting is scoped to that specific object – after all, it was the only one marked for repaint. But this is not what happens.
The thing is, even though we have scoped our widget tree, our render object tree has a relationship of its own. If a render object has been marked as needing repainting, it will not only repaint itself but also ask its parent to mark itself for repaint too. That parent then asks its parent, and so on, until the very root. And when it finally comes to painting, the object will also repaint all of its descendants. This happens until the framework encounters what is known as a repaint boundary. A repaint boundary is a Flutter way of saying “stop right here, there is nothing further to repaint.” This is done by wrapping your widget into another widget – yes, the RepaintBoundary
widget.
If we wanted to depict this flow visually, it would be something like this:
Figure 1.4 – Flow of the render object’s repainting process
Here is what’s happening in Figure 1.4:
- We start from RenderObject #12, which was the initial one to be marked for repainting.
- The object goes on to call parent.markNeedsPaint of RenderObject #10. Since the isRepaintBoundary field is false, the needsPaint gets set to true and goes on to ask the same for its parent.
- The isRepaintBoundary value of RenderObject #5 is true, so needsPaint stays false and the parent marking is stopped right there.
- Then the actual painting phase is started from the top widget marked as needsPaint. It traverses its children. Since isRepaintBoundary of RenderObject #11 is false, it traverses further.
- But isRepaintBoundary of RenderObject #15 is true, so the process is stopped right there.
So we end up repainting render objects #10, #11, and #12.
Let’s take a look at an example where a RepaintBoundary
widget can be useful – in a ListView
. This is the simplified version of the ListView
source code:
class ListView {
ListView({
super.key,
bool addRepaintBoundaries = true,
... // many more params
});
@override
Widget? build(BuildContext context, int index) {
if (addRepaintBoundaries) {
child = RepaintBoundary(child: child);
}
return child;
}
}
The ListView
constructor accepts an addRepaintBoundaries
parameter in its constructor, which by default is true
. Later, when building its children, the ListView
checks this flag, and if it’s true
, the child widget is wrapped in a RepaintBoundary
widget. This means that during scrolling, the list items don’t get repainted, which makes sense because only their offset changes, not their presentation. The RepaintBoundary
widget can be extremely efficient in cases where you have a heavy yet static widget, or when only the location on the screen changes such as during scrolling, transitions, or other animations. However, like many things, it has trade-offs. In order to display the end result on the screen, the widget tree drawing instructions need to be translated into the actual pixel data. This process is called rasterization. RepaintBoundary
can decide to cache the rasterized pixel values in memory, which is not limitless. Too many of them can ironically lead to performance issues.
There is also a good way to determine whether the RepaintBoundary
is useful in your case. Check the diagnosis
field of its renderObject
via the Flutter inspector tools. If it says something along the lines of This is an outstandingly useful repaint boundary, then it’s probably a good idea to keep it.
Optimizing scroll view performance
There are two important tips for optimizing scroll view performance:
- First, if you want to build a list of homogeneous items, the most efficient way to do so is by using the
ListView.builder
constructor. The beauty of this approach is that at any given time, by using the itemBuilder
callback that you’ve specified, the ListView
will render only those items that can actually be seen on the screen (and a tiny bit more, as determined by the cacheExtent
). This means that if you have 1,000 items in your data list, you don’t need to worry about all 1,000 of them being rendered on the screen at once – unless you have set the shrinkWrap
property to true
.
- This leads us to the second tip: the
shrinkWrap
property (available for various scroll views) forces the scroll view to calculate the layout of all its children, defeating the purpose of lazy loading. It’s often used as a quick fix for overflow errors, but there are usually better ways to address those errors without compromising performance. We’ll cover how to avoid overflow errors while maintaining performance in the next chapter.