Search icon CANCEL
Arrow left icon
Explore Products
Best Sellers
New Releases
Books
Videos
Audiobooks
Learning Hub
Conferences
Free Learning
Arrow right icon
Arrow up icon
GO TO TOP
Flutter Design Patterns and Best Practices

You're reading from   Flutter Design Patterns and Best Practices Build scalable, maintainable, and production-ready apps using effective architectural principles

Arrow left icon
Product type Paperback
Published in Sep 2024
Publisher Packt
ISBN-13 9781801072649
Length 362 pages
Edition 1st Edition
Languages
Tools
Arrow right icon
Authors (3):
Arrow left icon
Jaime Blasco Jaime Blasco
Author Profile Icon Jaime Blasco
Jaime Blasco
Daria Orlova Daria Orlova
Author Profile Icon Daria Orlova
Daria Orlova
Esra Kadah Esra Kadah
Author Profile Icon Esra Kadah
Esra Kadah
Arrow right icon
View More author details
Toc

Table of Contents (19) Chapters Close

Preface 1. Part 1: Building Delightful User Interfaces
2. Chapter 1: Best Practices for Building UIs with Flutter FREE CHAPTER 3. Chapter 2: Responsive UIs for All Devices 4. Part 2: Connecting UI with Business Logic
5. Chapter 3: Vanilla State Management 6. Chapter 4: State Management Patterns and Their Implementations 7. Chapter 5: Creating Consistent Navigation 8. Part 3: Exploring Practical Design Patterns and Architecture Best Practices
9. Chapter 6: The Responsible Repository Pattern 10. Chapter 7: Implementing the Inversion of Control Principle 11. Chapter 8: Ensuring Scalability and Maintainability with Layered Architecture 12. Chapter 9: Mastering Concurrent Programming in Dart 13. Chapter 10: A Bridge to the Native Side of Development 14. Part 4: Ensuring App Quality and Stability
15. Chapter 11: Unit Tests, Widget Tests, and Mocking Dependencies 16. Chapter 12: Static Code Analysis and Debugging Tools 17. Index 18. Other Books You May Enjoy

Reduce, reuse, recycle!

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

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

Figure 1.4 – Flow of the render object’s repainting process

Here is what’s happening in Figure 1.4:

  1. We start from RenderObject #12, which was the initial one to be marked for repainting.
  2. 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.
  3. The isRepaintBoundary value of RenderObject #5 is true, so needsPaint stays false and the parent marking is stopped right there.
  4. 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.
  5. 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.
You have been reading a chapter from
Flutter Design Patterns and Best Practices
Published in: Sep 2024
Publisher: Packt
ISBN-13: 9781801072649
Register for a free Packt account to unlock a world of extra content!
A free Packt account unlocks extra newsletters, articles, discounted offers, and much more. Start advancing your knowledge today.
Unlock this book and the full library FREE for 7 days
Get unlimited access to 7000+ expert-authored eBooks and videos courses covering every tech area you can think of
Renews at $19.99/month. Cancel anytime