Mastering Flutter — take control of the app’s performance

FiveDotTwelve
Tech & startups — FiveDotTwelve blog
12 min readAug 30, 2023

--

Mastering Flutter — take control of the app’s performance

When developing mobile applications, we, as programmers, need to ensure users have the best possible experiences with the app. The visual design, cool animations, and fantastic features take a back seat if the app loads slowly or lags. Fortunately, with a bit of effort and keeping in mind some key principles, we can take control of our app’s performance.

Today, I’d like to present to you a few practices that will help prevent drops in FPS (frames per second) in the app and address other performance-related issues. I’ve selected these practices which, in my opinion, form the foundation for delivering optimal user experiences. Hope you will learn something new and helpful, enjoy!

Use separate widgets instead of helper methods

Often, when designing your application, you may encounter problems with the build method. I mean, not with the build method itself, but actually with the amount of code that is in it. Well, if you have a lot of widgets to add to your screen, you might realize that the build method is growing and growing and it can become unreadable. Then, you might consider the solution for it. Of course, there is a solution — you can split your widgets into smaller fragments. You have two ways to do it. First, you can use the helper method.

Here is an example:

Widget _buildSimpleCircleBox() { 
return Container(
width: 100,
height: 100,
decoration: const BoxDecoration(
color: Colors.blue,
shape: BoxShape.circle,
),
);
}

The second way is to use separate widgets:

class SimpleCircleBox extends StatelessWidget {
const SimpleCircleBox({super.key});

@override
Widget build(BuildContext context) {
return Container(
width: 100,
height: 100,
decoration: const BoxDecoration(
color: Colors.blue,
shape: BoxShape.circle,
),
);
}
}

Many beginner programmers (not only beginners) would choose the first option. It’s easier because most of the time you don’t have to pass any parameters into this method and you have access to all variables and other methods in this widget.

But! You must know that it can be a trap. Not always, but it’s good to know that using helper methods can be a risk for performance in your application.

I think that the easiest example to show the risk is the simple counter app, which we all know from creating our Flutter apps.

So, let’s assume we’ve just created a new Flutter project and we are not deleting any code (Without fail, each time I embark on a new Flutter project, I find myself instinctively bidding adieu to the default homepage widget, but not today).

We want to do additional stuff here! Let’s add two widgets — or rather one widget but using two different ways I’ve mentioned above. Here is a code — a simple, nice title widget (created by using a helper method):

Widget _buildWelcomeTitle() {
print('OUR HELPER METHOD BUILD');
return Container(
margin: const EdgeInsets.all(20),
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 10,
),
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.5),
spreadRadius: 5,
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
child: const Text(
'Welcome to Flutter!',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
);
}

And here we have the same component but created as a separate widget:

class WelcomeTitle extends StatelessWidget {
const WelcomeTitle({super.key});

@override
Widget build(BuildContext context) {
print('OUR SEPARATED WIDGET BUILD');
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 10,
),
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.5),
spreadRadius: 5,
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
child: const Text(
'Welcome to Flutter!',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
);
}
}

In both options, I’ve added the print method before returning the widget, I will explain why in a moment.

First, let’s add them to the build method in HomePage:

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
_buildWelcomeTitle(),
const Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
const WelcomeTitle(),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);

So, as you can see, we have our default counter app here and additionally two widgets.

Why have I added print methods? I want to show you the real difference between the helper method and a separate widget.

Let’s run the App!

I know, I know, I am not the best designer but believe me, we have everything here that we need to show the difference.

Okay, first, let’s check the console.

After the first run, both widgets were built correctly which is great.

Well, the only function of this app is pressing the button, so let’s try it. You probably won’t be surprised if I write that after clicking on the button, the number of clicks increased by one. Just magic. Nevermind, let’s check the console again.

So after clicking the button, logs look like this:

The first two logs are from the previous screen (after the first run of the app), but we can notice that there is one additional log from the helper method.
You may ask „Okay, but what’s wrong with that?”.

Well, every time we press the counter button, the whole screen is destroyed and built again to show that the counter has increased. That’s how the Flutter works. Our separate widget hasn’t been rebuilt because it has a const keyword used in the build method (Which is impossible to use with helper methods).

When using the const keyword — Flutter knows that this widget doesn’t have to be rebuilt. For example, if you set your widget as a const, and then use it multiple times in your app, Flutter will create only one instance of this widget and use it wherever it’s needed. Thus, in the future, the app may save a lot of memory.

You may say that rebuilding the title component is not an expensive operation and of course, I agree with that. But, let’s say we have added animations to this widget and also other widgets (using helper methods to return them), for example the ListView. Our screen is growing and growing causing each click of the counter button to rebuild all the components we’ve added. Now it can become a really big problem for our performance.

Let’s be honest, it’s not a good idea to rebuild all animations on the screen with each click of the button. Animations are expensive. ListViews are expensive. Big widgets are also expensive. And there is the biggest advantage of separate widgets — thanks to them, Flutter can recognize which widgets shouldn’t be rebuilt.

As if that wasn’t enough, using a separate widget has additional benefits — if you need to use your component more than once in your application, then you can simply extract it to the new file and access it throughout the app.

Summarizing, you can use helper methods but preferably only for small pieces of code and not for returning the whole widgets. Using separate widgets will be much, much better for your future apps.

Finally, here is a full main.dart file code if you want to try it on yourself:

import 'package:flutter/material.dart';

void main() {
runApp(const MyApp());
}

class MyApp extends StatelessWidget {
const MyApp({super.key});

@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter Demo',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
useMaterial3: true,
),
home: const MyHomePage(title: 'Flutter Demo Home Page'),
);
}
}

class MyHomePage extends StatefulWidget {
const MyHomePage({super.key, required this.title});

final String title;

@override
State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
int _counter = 0;

void _incrementCounter() {
setState(() {
_counter++;
});
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(widget.title),
),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
_buildWelcomeTitle(),
const Text(
'You have pushed the button this many times:',
),
Text(
'$_counter',
style: Theme.of(context).textTheme.headlineMedium,
),
const WelcomeTitle(),
],
),
),
floatingActionButton: FloatingActionButton(
onPressed: _incrementCounter,
tooltip: 'Increment',
child: const Icon(Icons.add),
),
);
}

Widget _buildWelcomeTitle() {
print('OUR HELPER METHOD BUILD');
return Container(
margin: const EdgeInsets.all(20),
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 10,
),
decoration: BoxDecoration(
color: Colors.blue,
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.5),
spreadRadius: 5,
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
child: const Text(
'Welcome to Flutter!',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
);
}
}

class WelcomeTitle extends StatelessWidget {
const WelcomeTitle({super.key});

@override
Widget build(BuildContext context) {
print('OUR SEPARATED WIDGET BUILD');
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 10,
),
decoration: BoxDecoration(
color: Colors.orange,
borderRadius: BorderRadius.circular(10),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.5),
spreadRadius: 5,
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
child: const Text(
'Welcome to Flutter!',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
);
}
}

Here is more info about that from the official Flutter’s video: Widgets vs helper methods | Decoding Flutter

Shrinkwrap is not always your friend

What is the common point of most mobile applications? Lists, they are just everywhere! List of posts, list of photos, list of contacts… I could go on like this endlessly! Since this is a highly popular functionality among mobile apps, I believe we can address the issue of how to ensure that our lists in Flutter do not impact the performance of our application.

Let’s move on to show an example.

Surely you have had to display a list of widgets on the screen more than once. You used the ListView widget because it is easy to use. Then, you displayed the widgets in the list and… of course, everything worked as it should.

Well, until you tried to put another ListView inside an already existing one. Then the app went crazy, started screaming, and something like this appeared on the screen:

Let’s dive into the console:

In short, the error displayed tells us that the inner list is trying to take up all the space in its parent, which is also the ListView. However, due to the fact that the ListView can have an infinite number of elements, and thus an infinite length, the internal list is not able to determine its size.

Below the description of the error, there is information about possible solutions. We can consider using a Column or Wrap widget or the CustomScrollView (which I will talk about later).

New Flutter programmers may not notice anything strange about these solutions, but those who have been using Flutter for a while may notice that previously one of the possible solutions that Flutter itself suggested was to set the shrinkWrap parameter to true in the internal list. Well, it was changed in Flutter 3.3.0, but still we can find out that using shrinkWrap is still recommended on some websites. I want to warn you about this.

When shrinkWrap is set to true in your inner list, the widget’s height will dynamically adjust to match the height of its content. Essentially, using shrinkWrap causes a complete evaluation of a list of widgets. To better illustrate it, here is the code example:

class MyHomePage extends StatefulWidget {
const MyHomePage({
super.key,
});

@override
State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
int get _getRandomNumberOfElements {
Random random = Random();
return random.nextInt(91) + 10;
}

@override
Widget build(BuildContext context) {
return Scaffold(
body: ListView(
children: [
ListView.builder(
shrinkWrap: true,
itemBuilder: (context, index) {
return MyWidget(
number: index,
);
},
itemCount: _getRandomNumberOfElements,
),
ListView.builder(
shrinkWrap: true,
itemBuilder: (context, index) {
return MyWidget(
number: index,
);
},
itemCount: _getRandomNumberOfElements,
),
],
),
);
}
}

At first glance, everything seems fine but what if inner lists contain lots of widgets with images, animations or whatever expensive things? Well, Flutter will need to fully process and display them. This can lead to a high likelihood of issues like dropped frames and an overall decrease in smooth performance — which can make our app unpleasant to use for our users.

One solution mentioned by the latest Flutter is to use CustomScrollView. Let’s rebuild our code:

class MyHomePage extends StatefulWidget {
const MyHomePage({
super.key,
});

@override
State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
int get _getRandomNumberOfElements {
Random random = Random();
return random.nextInt(91) + 10;
}

@override
Widget build(BuildContext context) {
return Scaffold(
body: CustomScrollView(
slivers: [
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
return MyWidget(
number: index,
);
},
childCount: _getRandomNumberOfElements,
),
),
SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
return MyWidget(
number: index,
);
},
childCount: _getRandomNumberOfElements,
),
)
],
),
);
}
}

Now, instead of using nested ListViews, we are using SliverLists inside the CustomScrollView.

What are Slivers? Well, slivers are the low API elements, they can be seen as a more detailed level of control, offering the ability to implement scrollable areas (ListView and GridView are built using SliverLists). Slivers can lazily build your UI (Same as ListView.builder), so that’s why they can be useful for implementing efficient scrolling of a large number of widgets.

In our case, after using CustomScrollView + SliverLists, the application was brought back to life, allowing us to scroll through both lists. Additionally, we don’t have to worry if our widgets are expensive because everything is loaded in a lazy way. Amazing!

I leave the official Flutter videos here if you would like to know more: ShrinkWrap vs Slivers | Decoding Flutter and Unbounded height / width | Decoding Flutter.

Now let’s move on to lesser-known but equally important practices.

Beware of using Opacity Widget

As the official Flutter documentation says, if we want to use the opacity on the simple widget, it’s better to just draw it with the semi-transparent color instead of wrapping it in Opacity.

Why? Using the Opacity widget creates an extra layer to render for Flutter, which can be expensive especially when we are using animations (The widget will be rebuilt for every frame). If you can’t just set opacity as a parameter, go for AnimatedOpacity, FadeInImage widgets. They may help with keeping the performance at a very good level.

Remember to dispose your controllers and close your streams

In many guides (I hope all of them) regarding various controllers (TextEditingController, StreamController etc.), the authors mention that if we bring a controller to life, we must then bury them (yeah, that’s brutally true).

The dispose method is used for this, but what does it actually do? It’s used to release the memory allocated to variables, when a state object is removed. So for example, if you are using a stream in your application, then you have to release memory allocated to the stream controller.

Otherwise, your app may start to have problems with memory and can cause performance issues. Of course, to see any difference in performance, many controllers would have to be undisposed, but I think it’s worth being aware of any issues and proceeding in the best possible way right away.

Use setState with caution

setState function is used for simple state management in StatefulWidgets. It triggers Flutter to rebuild the where widget where setState was called. The problem I want to point out here is partly related to point 1 as it also relates to rebuilding components. So, when using setState we have to be careful that our widget is not too big.

This function is best used with the smallest widgets, so you don’t have to worry that Flutter will have a lot of work to rebuild the screen, e.g. every time you click a button.

Ending

Using the practices outlined, we are able to take control of the performance of our applications. If they load quickly and run smoothly, then users are likely to appreciate our efforts when it comes to design, animation, etc. In the future, it is possible that there will be an article on more advanced practices, but for the moment, these few practices presented I think will be enough. Good luck in your adventure with Flutter and application development!

And if you’d like to learn more about Flutter, check out these articles: Themes in Flutter and how to use Material You dynamic colors and Flutter architecture: implementing the MVVM pattern.

Originally published at https://fivedottwelve.com on August 30, 2023.

--

--