Flutter Performance Series: Building an Efficient Widget Tree

Shekhar Shubh
FlutterDude
13 min readSep 20, 2023

--

Introduction

Welcome to the latest installment in our comprehensive series on mastering Flutter performance! Today, we’re diving deep into one of the most crucial aspects that often goes unnoticed: Efficient Widget Tree Structure. In the Flutter universe, the way you arrange your widgets is not just a matter of good code organization — it’s a key determinant of how well your app performs. A well-structured widget tree can significantly improve your app’s speed, responsiveness, and overall user experience.

If you’ve noticed lag or jank in your Flutter apps, your widget tree could be the culprit. By understanding and applying optimization techniques, you can enhance performance without compromising on functionality. In this article, we’ll break down the principles of an efficient widget tree and provide actionable tips to help you build a blazingly fast Flutter app.

Stuck on a complex performance issue? Our team at FlutterDude specializes in Flutter app development and performance tuning. Feel free to reach out for expert guidance tailored to your needs.

Photo by John-Mark Smith

State and Stateless Widgets: The Building Blocks of Efficiency

As we embark on our journey to optimize the widget tree, the first pit stop is understanding the role of State and Stateless widgets in Flutter. These two types of widgets are the linchpins of any Flutter app, and knowing when to use which can dramatically affect your application’s performance and resource consumption.

Stateless widgets are immutable, meaning once you create them, you can’t change their properties. They are less resource-intensive and are perfect for parts of your UI that remain static. On the other hand, State widgets are dynamic and mutable, ideal for UI elements that need to be redrawn to reflect changes in data or user interaction.

In this section, we’ll delve into the nuances of these two widget types and reveal best practices for utilizing them effectively in your widget tree. By the end, you’ll gain a solid understanding of how to strategically employ State and Stateless widgets to create not just an interactive, but also a highly optimized Flutter application.

Use stateless widgets whenever possible. Stateless widgets are more efficient than stateful widgets because they do not have to rebuild every time the state changes.

// Stateless widget example
class MyStatelessWidget extends StatelessWidget {
final String text;

MyStatelessWidget(this.text);

@override
Widget build(BuildContext context) {
return Text(text);
}
}

// Stateful widget example
class MyStatefulWidget extends StatefulWidget {
@override
_MyStatefulWidgetState createState() => _MyStatefulWidgetState();
}

class _MyStatefulWidgetState extends State<MyStatefulWidget> {
String text = 'Hello, world!';

@override
Widget build(BuildContext context) {
return Text(text);
}

void changeText() {
setState(() {
text = 'Goodbye, world!';
});
}
}

In the stateless widget example, the MyStatelessWidget widget will only be rebuilt if its text property changes. However, in the stateful widget example, the MyStatefulWidget widget will be rebuilt every time the setState() method is called, even if the widget's text property does not change.

This is because stateful widgets need to maintain their own state, and the setState() method is used to notify Flutter that the state has changed. Flutter then rebuilds the widget and its descendants to reflect the new state.

Stateless widgets, on the other hand, do not need to maintain their own state. They simply render their UI based on the properties that are passed to them. This makes stateless widgets more efficient than stateful widgets.

In general, you should use stateless widgets whenever possible. This will improve the performance of your app and make it easier to maintain.

Photo by Ylanite Koppens

Unlocking Efficiency with Keys

As you advance in your journey to master Flutter performance, it’s essential to understand the role of Keys in your widget tree. Keys in Flutter are unique identifiers for widgets and serve as a powerful tool for controlling the framework’s widget-reconstruction process. While they may seem like a minor detail, using Keys effectively can yield major performance gains, especially in complex and dynamic UIs.

Keys help Flutter identify which widgets have changed and need to be rebuilt, thereby streamlining the rendering process. They are particularly useful in lists, grids, or any other collection of widgets that may change dynamically over time. Using Keys judiciously can result in fewer widget rebuilds, which in turn leads to better performance and smoother user experiences

// Before
class MyWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Text('Hello, world!');
}
}

// After
class MyWidget extends StatelessWidget {
final Key key;

MyWidget({this.key});

@override
Widget build(BuildContext context) {
return Text('Hello, world!');
}
}

In the first example, the MyWidget widget does not have a key. This means that Flutter will rebuild the widget every time the state of its parent changes.

In the second example, the MyWidget widget has a key. This tells Flutter that the widget has a unique identity, and that it should not be rebuilt unnecessarily.

Here is an example of how to use the key to prevent a widget from rebuilding unnecessarily:

class MyApp extends StatefulWidget {
@override
_MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
bool showWidget = false;

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('MyApp'),
),
body: Center(
child: showWidget ? MyWidget(key: UniqueKey()) : Container(),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
setState(() {
showWidget = !showWidget;
});
},
child: Icon(Icons.add),
),
);
}
}

In this example, the MyWidget widget is only rebuilt when the showWidget state variable changes. This is because Flutter uses the key to identify the widget and prevent it from rebuilding unnecessarily.

Using keys to identify widgets is a simple but effective way to improve the performance of your Flutter app.

Photo by Tima Miroshnichenko

Drawing the Line for Optimal Rendering

Continuing our deep dive into Flutter performance, the next crucial topic we tackle is the RepaintBoundary widget. It serves as a remarkable utility to optimize your app's painting process, which is integral for its visual rendering. If you've ever faced issues with sluggish animations or choppy transitions, understanding and leveraging RepaintBoundary could be the game-changer you need.

When placed appropriately in your widget tree, RepaintBoundary isolates parts of the widget subtree from the overall paint operation, ensuring that only the modified widgets within the boundary get repainted. This significantly minimizes the rendering workload, thereby improving your app's performance.

// Before
class MyWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Text('Hello, world!');
}
}

// After
class MyWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return RepaintBoundary(
child: Text('Hello, world!'),
);
}
}

The RepaintBoundary widget creates a new render object for its child. This means that the child widget will be rendered independently of its parent widget.

This can be useful for isolating parts of the UI that need to be repainted from the rest of the tree. For example, if you have a widget that is constantly animating, you can wrap it in a RepaintBoundary widget to prevent it from causing the rest of the UI to repaint unnecessarily.

Here is an example of how to use the RepaintBoundary widget to improve the performance of an animating widget:

class MyApp extends StatefulWidget {
@override
_MyAppState createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
double counter = 0.0;

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('MyApp'),
),
body: Center(
child: RepaintBoundary(
child: AnimatedContainer(
duration: Duration(milliseconds: 500),
curve: Curves.easeInOut,
width: counter,
height: counter,
color: Colors.red,
),
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
setState(() {
counter += 10.0;
});
},
child: Icon(Icons.add),
),
);
}
}

In this example, the AnimatedContainer widget is wrapped in a RepaintBoundary widget. This means that the AnimatedContainer widget will be rendered independently of the rest of the UI.

As a result, the rest of the UI will not repaint unnecessarily when the AnimatedContainer widget animates. This can improve the performance of the app, especially if the widget tree is deep.

The RepaintBoundary widget is a powerful tool that can be used to improve the performance of your Flutter app. However, it is important to use it carefully, as it can also have a negative impact on performance if used incorrectly.

Use Widgets Appropirte one

// Non-appropriate
class MyWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListView(
children: [
for (int i = 0; i < 100; i++) {
Text('Item ${i + 1}')
}
],
);
}
}

// Appropriate
class MyWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListView.builder(
itemCount: 100,
itemBuilder: (context, index) {
return Text('Item ${index + 1}');
},
);
}
}

In the non-appropriate example, the ListView widget is populated with a list of Text widgets created using a for loop. This is not ideal because the ListView widget will rebuild every time the loop iterates, even if the list of items has not changed.

In the appropriate example, the ListView.builder() widget is used to populate the list. The ListView.builder() widget is more efficient than the ListView widget because it only rebuilds the items that have changed.

Photo by Neale LaSalle

The Magic of ‘const’: Maximizing Efficiency with Compile-Time Constants

As we continue to navigate the intricacies of optimizing Flutter performance, our next focus is on a small but mighty tool: the const keyword. While it might appear inconspicuous, using const can have an outsized impact on your application's runtime efficiency. By declaring widgets or variables as constants, you enable the Dart compiler to perform optimizations that significantly reduce both build time and runtime overhead.

The const keyword tells Flutter that a particular widget won't change throughout its lifecycle, allowing Flutter to skip unnecessary rebuilds for that widget. This is incredibly beneficial for static elements that appear frequently across your app, such as text styles, icons, or padding widgets.

// Const string
const String myString = "Hello, world!";

// Const color
const Color myColor = Colors.red;

// Const key
const Key myKey = UniqueKey();

// Const list
const List<String> myList = ["a", "b", "c"];

// Const map
const Map<String, int> myMap = {"a": 1, "b": 2, "c": 3};

// Const widget
const MyWidget myWidget = MyWidget(text: "My widget");

In all of these examples, the const keyword is used to tell Flutter that the value will not change during the widget's lifetime. This allows Flutter to optimize the widget's rendering and layout.

Here is a more specific example:

class MyWidget extends StatelessWidget {
final String text;

const MyWidget({required this.text});

@override
Widget build(BuildContext context) {
return Text(text);
}
}

In this example, the MyWidget widget can be used as follows:

// Non-const usage
var myWidget = MyWidget(text: "My widget");

// Const usage
const myWidget = MyWidget(text: "My widget");

In the non-const usage, the MyWidget widget is created dynamically. This means that Flutter cannot optimize the widget's rendering and layout until the widget is built.

In the const usage, the MyWidget widget is created as a const object. This means that Flutter can optimize the widget's rendering and layout at compile time.

In general, you should use the const keyword whenever possible. This will improve the performance of your app and make it easier to maintain.

Here are some tips for using the const keyword:

  • Use the const keyword for values that will not change during the widget's lifetime.
  • Use the const keyword for widgets that are immutable.
  • Use the const keyword for widgets that are created as const objects.
Photo by Engin Akyurt

Extending Widget Life with AutomaticKeepAliveClientMixin

As we proceed in our series on Flutter performance optimization, we come to an invaluable yet often overlooked tool: the AutomaticKeepAliveClientMixin mixin. This special mixin is the go-to solution for retaining the state of widgets that are expensive to rebuild. It is especially useful for widgets in scrolling containers where items come in and out of view, like ListView or GridView.

Have you ever faced a situation where certain UI elements lose their state or data as users scroll through lists or switch between tabs? The AutomaticKeepAliveClientMixin mixin allows these widgets to keep their state, reducing the computational cost of having to rebuild them from scratch every time they reappear on the screen.

Here is a code example showing how to use the AutomaticKeepAliveClientMixin mixin to keep widgets alive even when they are offscreen

class MyWidget extends StatefulWidget with AutomaticKeepAliveClientMixin {
@override
_MyWidgetState createState() => _MyWidgetState();

@override
bool get wantKeepAlive => true;
}

class _MyWidgetState extends State<MyWidget> {
// State that needs to be maintained even when the widget is offscreen.
int counter = 0;

@override
Widget build(BuildContext context) {
return Text('Counter: $counter');
}
}

In this example, the MyWidget widget uses the AutomaticKeepAliveClientMixin mixin to keep itself alive even when it is offscreen. This is done by overriding the wantKeepAlive getter and returning true.

The counter state variable is maintained even when the MyWidget widget is offscreen. This is because the AutomaticKeepAliveClientMixin mixin prevents the MyWidget widget from being disposed of when it is offscreen.

Here is an example of how to use the MyWidget widget:

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('MyApp'),
),
body: Center(
child: MyWidget(),
),
);
}
}

In this example, the MyWidget widget will be kept alive even when it is offscreen. This means that the counter state variable will be maintained even when the MyWidget widget is not visible.

Using the AutomaticKeepAliveClientMixin mixin can be useful for widgets that need to maintain their state even when they are not visible. For example, a widget that represents a page in a tab bar may want to use the AutomaticKeepAliveClientMixin mixin to keep its state even when it is not the current page.

Photo by Pixabay

Visibility with Offstage Widget: An Efficient Way to Toggle UI Elements

As we advance further in our series on optimizing Flutter performance, it’s time to introduce another impactful widget in our toolkit: the Offstage widget. This widget has the unique ability to toggle its child's visibility without removing it from the widget tree. This can be particularly useful for hiding parts of your UI without losing their state or triggering a rebuild, which can be both CPU-intensive and time-consuming.

Imagine you have widgets or even whole sections that appear conditionally in your app — perhaps a sidebar, a pop-up, or a collapsible menu. Rather than adding and removing these elements from the widget tree, which can lead to costly rebuilds, you can keep them ‘offstage’ when they are not needed.

class MyWidget extends StatefulWidget {
@override
_MyWidgetState createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
bool showWidget = false;

@override
Widget build(BuildContext context) {
return Column(
children: [
// Show a widget based on the showWidget state variable.
Offstage(
offstage: !showWidget,
child: Text('This is a widget that is only shown when showWidget is true.'),
),

// This widget will always be shown.
Text('This is a widget that is always shown.'),
],
);
}
}

In this example, the Offstage widget is used to hide the This is a widget that is only shown when showWidget is true. widget when the showWidget state variable is false.

The This is a widget that is always shown. widget will always be shown, regardless of the value of the showWidget state variable.

Here is an example of how to use the MyWidget widget:

class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('MyApp'),
),
body: Center(
child: MyWidget(),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
setState(() {
showWidget = !showWidget;
});
},
child: Icon(Icons.add),
),
);
}
}

In this example, the MyWidget widget will be shown initially. However, when the user presses the floating action button, the showWidget state variable will be toggled, and the This is a widget that is only shown when showWidget is true. widget will be hidden.

Using the Offstage widget can improve performance by reducing the amount of work that Flutter needs to do to render the UI. For example, if you have a list of items, and you only want to show a subset of the items at a time, you can use the Offstage widget to hide the items that are not currently needed.

Photo by Valentin Antonucci

Timing is Everything: Optimizing Post-Frame Operations with WidgetsBinding.instance.addPostFrameCallback()

In the final section of this article on Flutter performance, we’re taking a closer look at a powerful feature often reserved for advanced use-cases: the WidgetsBinding.instance.addPostFrameCallback() method. This method allows you to execute code immediately after the framework has completed the layout and painting process, giving you a valuable hook to perform last-minute optimizations or initiate asynchronous operations.

Why is this important? Because sometimes you need to execute logic that depends on the final render of the widget tree, whether it’s triggering animations, preloading additional content, updating UI, making network call or executing other state updates. Doing these tasks at the right time can make all the difference between a smooth, responsive app and a sluggish, janky experience.

class MyWidget extends StatefulWidget {
@override
_MyWidgetState createState() => _MyWidgetState();
}

class _MyWidgetState extends State<MyWidget> {
@override
void initState() {
super.initState();

// Defer the execution of the code until after the next frame has been rendered.
WidgetsBinding.instance.addPostFrameCallback((_) {
// Update the UI.
setState(() {
// ...
});

// Perform a network request.
// ...
});
}

@override
Widget build(BuildContext context) {
return Container();
}
}

In this example, the code that updates the UI and performs a network request is deferred until after the next frame has been rendered. This is done by using the WidgetsBinding.instance.addPostFrameCallback() method.

The WidgetsBinding.instance.addPostFrameCallback() method takes a callback function as an argument. The callback function will be executed after the next frame has been rendered.

This can be useful for tasks that do not need to be executed immediately, such as updating the UI or performing network requests. Deferring the execution of these tasks can improve the performance of the app by reducing the amount of work that needs to be done to render the UI.

Conclusion: The Road to Flutter Performance Mastery

There you have it! We’ve taken a comprehensive journey through the most effective ways to optimize your Flutter app’s performance, from the foundational aspects like State and Stateless Widgets to advanced techniques like WidgetsBinding.instance.addPostFrameCallback(). By now, you should be well-equipped to tackle any performance challenges that come your way, ensuring that your Flutter apps are as efficient and user-friendly as possible.

However, performance optimization is an ongoing process, and new best practices and tools are emerging all the time. To stay ahead of the curve, it’s essential to keep learning and experimenting.

If you have any questions or need expert guidance on Flutter performance, feel free to email us at hello@flutterdude.com.

If you find yourself stuck or need expert help to take your Flutter app to the next level, our team at FlutterDude is always here to help. Specializing in Flutter development and performance optimization, we offer tailored solutions to meet your specific needs.

Happy coding!

--

--

Shekhar Shubh
FlutterDude

Tech Enthusiast, Word Whisperer, Future Gazer. I thrive at the intersection of technology, storytelling, and insight.