Now that we know that thebuild method can be called up to 120 times per second, the question is: do we really need to call thewholebuild 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 makethis happen.
First things first, let’s get one obvious but still important thing out of the way. Thebuild 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 throughoutthis book.
Pushing rebuilds down the tree
There can be several situationswhen pushing the rebuilds down the tree can impact performance in apositive way.
Calling setState of StatefulWidget
One of the most used widgets isStatefulWidget. 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 iscreated every time you start a new Flutter project: the counter app. We are interested in the code of the_MyHomePageState class, which is theStateofMyHomePage:
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 aScaffold with anAppBar and aFloatingActionButton. Clicking theFloatingActionButton increments the internal_counter field. The body of theScaffold is aColumn with twoText widgets that describe howmany times theFloatingActionButton has been clicked based on the_counter field. The preceding example differs from the original Flutter sample in one regard: instead of using theFloatingActionButton for handling clicks, we are using theTextButton. So every time we click theTextButton, the_incrementCounter method is called, which in turn calls thesetState framework method and increments the_counter field. Under the hood, thesetState method causes Flutter to call thebuild method of_MyHomePageState, which causes a rebuild. An important thing here is thatsetState causes a rebuild of the wholeMyHomePage widget, even though we are only changingthe text.
An easy way to optimize this is topush state changes down the tree by extracting them into a smaller widget. For example, we can extract everything that was inside theCenter widget ofScaffold into a separate widget and callitCounterText:
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 theCenter 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 callsetState inside of the_CounterTextState field, only the widgets returned from thebuild method of_CounterTextState get rebuilt. The parent_MyHomePageState doesn’t get rebuilt, because itsbuild 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 pagesare UI-heavy.
Subscribing to InheritedWidget changes via .of(context)
By extracting the changing counter text into a separateCounterText widget in the last code snippet, we have actually made one more optimization. The interesting line for us isTheme.of(context).textTheme.headlineMedium. You have certainly usedTheme and other widgets, such asMediaQuery orNavigator, 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 and4), but for now, we are interested in two ofits properties:
- Instead of creating thosewidgets, 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 getan error.
- For some of those widgets, such as
Theme andMediaQuery, 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 theTheme was light and became dark – it will notify all of its subscribers and cause them to rebuild. So in the same way as withsetState, if you subscribe to anInheritedWidget, 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 actuallyneed it.
Extra performance tip
You may have usedMediaQuery.of(context) in order to fetch information about the screen, such as its size, paddings, and view insets. Whenever you callMediaQuery.of(context), you subscribe to the wholeMediaQuery widget. If you want to get updates only about the paddings (or the size, or the view insets), you can subscribe to thisspecific property by callingMediaQuery.paddingOf(context),MediaQuery.sizeOf(context), and so on. This is becauseMediaQuery actually extends a specific type ofInheritedWidget – theInheritedModel 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 widgetrebuild 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 thoserebuilds 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 oftheState:
Figure 1.3 – Main methods of State life cycle
Here are a few things that we shouldcare about:
- The
initState method gets called only once per widget life cycle, much like thedispose method. - The
didChangeDependencies method gets called immediatelyafterinitState. didChangeDependencies is always called when anInheritedWiget that we subscribed to has changed. This is the implementation aspect of what we have just discussed in theprevious section.- The build method always gets called after
didChangeDependencies,didUpdateWidget,andsetState.
This is important!
Don’t callsetState indidChangeDependencies ordidUpdateWidget. Such calls are redundant, since the framework will always callbuild afterthose 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 asWidget buildMyWidget(). The widgets extracted into methods still access the same context or callsetState, which causes the whole encapsulating widget to rebuild, so it’s generally recommended to prefer widget classes ratherthan methods.
One more important thing regarding the life cycle of theState is that once itsdispose 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 thisState, 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 owndispose orclose methods inside thedispose oftheState.
Caching widgets implicitly
Dart has a notion of constant constructors. We can create constant instances of classes by adding aconst keyword before the class name. But when can we do this and how can we take advantage of themin Flutter?
First of all, in order to be able todeclare aconst constructor, all of the fields of the class must be marked asfinal and be known at compile time. Second, it means that if we create two objects viaconst constructors with the same params, such asconst 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 ourElement classonce again.
We remember that the class has anupdate 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 theElement API calledupdateChild. As the name says, it updates its children elements. But the interesting thing is how itdoes 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 returnstrue only if both of the instances point to the same address in memory, which istrue if they were created via aconst constructor with thesame params.
However, if the result isfalse, weshould check the already-familiarWidget.canUpdate. However, aside from reassigning the pointer to the new element, we also call itsupdate method, which soon causesa rebuild.
Hence, if we useconst constructors, we can avoid rebuilds of whole widget subtrees. This is also sometimes referred to as caching widgets. So useconst constructors whenever possible and see whether you can extract your own widgets that can make use ofconst constructors, even if nestedwidgets can’t.
Keep in mind that you have to actually use theconst constructor, not just declare it as a possibility. For example, we have aConstText widget that has aconst 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 theconst constructor via theconst keyword as in the following code, then we won’t get any of the benefits of theconst 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 theconst keyword when creating an instance of the class. The correct usage of theconst constructor lookslike this:
// Doclass 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 theconst keyword during the creation of aConstText widget. This way, we will get all of the benefits. This small keyword isvery important.
Explicitly cache widgets
The same logic can be applied if the widget can’t be created with aconst constructor, but can be assigned to afinal field of theState. 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 withconst widgets. This is one of the ways in which you can work around theContainer not beingconst. You might do so using the following,forexample:
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, theupdate method of the green container won’t be called. We have retained the reference to an already-existing widget by caching it in a localgreenContainer field. Hence, we return the exact same instance as in the previousbuild. This falls into the case described on line 5 in theupdateChild method code snippet providedearlier in this section. If the instances are the same based on the equality operator, then theupdate method is not called. On the other hand, the pinkContainer will be rebuilt every time because we create a new instance of the class every time thebuild method is called. This is described in line 7 of the samecode 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 notworking 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 notwhat 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 arepaint 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, theRepaintBoundary widget.
If we wanted to depict this flow visually, it would be somethinglike this:
Figure 1.4 – Flow of the render object’s repainting process
Here is what’s happening inFigure 1.4:
- We start fromRenderObject #12, which was the initial one to be markedfor repainting.
- The object goes on to callparent.markNeedsPaint ofRenderObject #10. Since theisRepaintBoundary field isfalse, theneedsPaint gets set totrue and goes on to ask the same forits parent.
- TheisRepaintBoundary value ofRenderObject #5 istrue, soneedsPaint staysfalse and the parent marking is stoppedright there.
- Then the actual painting phase is started from the top widget marked asneedsPaint. It traverses its children. SinceisRepaintBoundary ofRenderObject #11 isfalse, ittraverses further.
- ButisRepaintBoundary ofRenderObject #15 istrue, so the process is stoppedright there.
So we end up repainting render objects #10, #11,and #12.
Let’s take a look at an example where aRepaintBoundary widget can be useful – in aListView. This is the simplified version of theListViewsource 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; }}TheListView constructor accepts anaddRepaintBoundaries parameter in its constructor, which by default istrue. Later, when building its children, theListView checks this flag, and if it’strue, the child widget is wrapped in aRepaintBoundary widget. This means that during scrolling, the list items don’t get repainted, which makes sense because only their offset changes, not their presentation. TheRepaintBoundary widget can be extremely efficient in cases where you have a heavy yet static widget, orwhen 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. Thisprocess is calledrasterization.RepaintBoundary can decide to cache the rasterized pixel values in memory, which is not limitless. Too many of them can ironically lead toperformance issues.
There is also a good way to determine whether theRepaintBoundary is useful in your case. Check thediagnosis field of itsrenderObject via the Flutter inspector tools. If it says something along the lines ofThis is an outstandingly useful repaint boundary, then it’s probably agood idea tokeep it.
Optimizing scroll view performance
There are two important tips for optimizing scrollview 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 theitemBuilder callback that you’ve specified, theListView will render only those items that can actually be seen on the screen (and a tiny bit more, as determined by thecacheExtent). 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 theshrinkWrap propertytotrue. - 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 thenext chapter.