Exploring Flutter app performance improvement recommendations

Oleksandr Glushchenko
16 min readMay 19, 2024

--

The app performance is the thing that you should always keep in mind developing software products. Paying attention to the app performance makes your app reliable and, along with providing helpful features and creating an amazing user interface, helps to retain old and attract new users.

There are a lot of great articles and videos where you can find advice on improving Flutter app’s performance. Here I would like to make a general overview and explore some of them using DevTools to gain a deeper understanding.

Flutter apps are performant by default. You must just follow the recommendations. You don’t have to look for ways to improve performance if your app already works well. It will not pay off your efforts. You must just ensure the app doesn’t have the next red flags:

  • UI jank — skipping a frame that means rendering takes more time than it’s allowed (you may observe animation lags)
  • fast battery discharge
  • significant device warming

If some of those are observed you definitely should find out where the problem is. The app performance must be investigated for a real device in profile mode which is similar to release mode but allows to use DevTools:

flutter run --profile

For the purpose of performance investigation the tool Profiling timeline can be used. It’s a chart that represents a sequence of frames that have been rendered on the device screen. It uses a data from UI thread and raster thread which actually reflects the program code that was executed at the specific moment. If the code is not efficient enough (if it takes more than 16.66 milliseconds to render a frame — 60 frames per second must be produced) the bar would be red colored. Otherwise it would be blue colored. Enabling Performance overlay option allows to add the chart directly onto the device screen.

Talking about the app performance it should be mentioned that it refers to resource consuming:

  • time — an amount of time that is required to execute some part of a program or a particular piece of code. It can be measured by a number of frames per second (FPS), an app startup time, CPU usage etc.
  • space — an amount of a data storage which is needed for a program execution. It can be measured by memory (RAM) usage, an app size etc.

It’s worth to notice that software developers often have to find a balance between time and space consuming in order to maintain a decent level of the app performance.

You can find more specific performance metrics here: https://docs.flutter.dev/perf/metrics.

Exploring the performance improvement recommendations

All recommendations for the performance improvement can be narrowed down to the next:

  • for time:
    - Don’t perform a repetitive task. Use caching when it’s possible.
    - Delay an expensive task until it’s necessary. It’s a bit contradictory since if the task takes too much time and there are high chances that a user will need its result, then the better solution is to perform it beforehand in background in order not to make a user wait until it’s done.
  • for space:
    - Don’t store the objects that are not needed anymore and won’t be needed later.
    - Optimize media files usage
    (and the app size in general as well).

Don’t perform a repetitive task

The recommendation to avoid performing a repetitive task may sound obvious but don’t rush to conclusions. Of course, nobody intends to do the same work again without a need. But while a project is getting bigger its logic also becomes complicated so you might miss something. And as Flutter framework could be considered as such project you have to be careful from the very beginning.

The most popular recommendation here is do not call a method which is expensive to execute and is not intended to be executed many times in build method of a widget. build method will be called not only once after initializing a widget. It's not very efficient, for example, to make a server request every time a widget is rebuilt.

Besides that it’s important to reduce a number of rebuilds. It could be achieved by following the next recommendations:

  • using const widgets which is possible when their initial states can be determined during compile time and will not be changed further. Flutter creates a single instance of a const widget that will be reused in the widget tree and will not be created and rebuilt every time.
  • preferring widgets to methods that return a widget. It prevents from rebuilding the entire parent widget in case we use setState method to update only that subtree represented by the child widget. If a method that returns a widget is used then the entire parent widget would be rebuilt.
  • avoid to change a nesting level (structure) and types in the widget tree. If Flutter doesn’t find a corresponding widget from the previous build it creates a new widget during a rebuild. For example, it’s better to use IgnorePointer with different values of ignoring parameter instead of removing the widget from the tree when you want to hide it. If there is a need to change a nesting level consider using GlobalKey for that widget.
  • use caching for widget that provides such possibility. For example, AnimatedBuilder provides an argument child which stores a widget subtree that will not be rebuilt at every animation iteration. Otherwise if child argument is ignored and that subtree is directly specified in builder callback it would be rebuilt every time.
  • use RepaintBoundary widget to isolate child widget into a separate layer. You can find more info here - https://www.youtube.com/watch?v=Nuni5VQXARo

To find out when a widget is rebuilt use Widget rebuild stats at the Flutter Inspector tab in Android Studio. Setting global variable debugRepaintRainbowEnabled = true enables a colored border around a widget which changes its color when it's rebuilt.

Don’t forget to ensure your own code doesn’t perform a repetitive work as well. For example, you can use Network tab in DevTools to check your app doesn't make unnecessary or redundant server requests. Caching the responses is also a good way to improve the app performance.

In general it much depends on your project — think over actions the app performs and how they could be optimized.

Further, I’d like to talk about an interesting issue I encountered recently. It was observed in a log file that a request which was expected to be executed only once had been executed several times. Attempting to reproduce that I noticed the issue was happening only during a keyboard appearing and disappearing.

So let’s look at the next code:

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

@override
State<StatefulWidget> createState() => _WidgetOptimizationPageState();
}

class _WidgetOptimizationPageState extends State<WidgetOptimizationPage> {
@override
void didChangeDependencies() {
debugPrint('didChangeDependencies');
super.didChangeDependencies();
}

@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
title: Text(
'Widget Optimization',
),
),
body: Column(
children: [
Container(
margin: const EdgeInsets.symmetric(vertical: 8),
height: 44,
width: 0.75 * MediaQuery.of(context).size.width,
child: const TextField(
decoration: InputDecoration(
border: OutlineInputBorder(),
hintText: 'Search',
),
),
),
Expanded(
child: ListView.separated(
itemCount: 10,
itemBuilder: (BuildContext context, int index) {
return ListTile(
visualDensity: VisualDensity.compact,
title: Text('$index'),
);
},
separatorBuilder: (BuildContext context, int index) => const Divider(),
),
),
],
),
);
}
}

If you try to set a focus to TextField to trigger a keyboard appearing and then click on the Done button to hide it:

you would see in the console the method didChangeDependencies was executed many times. And here was a place where the request was called!

It also means the widget was rebuilt many times. While TextField was rebuilt three times (initially when the screen was opened, when the keyboard appeared and disappeared) WidgetOptimizationPage was rebuilt 12 times:

Widget rebuild stats before optimizing
Timeline events before optimizing

It’s known that the method didChangeDependencies is a suitable place to perform an action in response to a notification from InheritedWidget. I decided to search for what changes the widget is subscribed to. Since the widget was a bit complicated I had several assumptions but in the code example above it can be recognized clearly. The reason was using MediaQuery.of(context).size.width. MediaQuery is basically an InheritedWidget which has a lot of parameters including viewInsets and padding. They are changing during a keyboard animation.

The solution for the issue was using MediaQuery.sizeOf(context).width. sizeOf rebuilds the context when this specific attribute changes, not when any attribute changes and must be preferred over getting the attribute directly from the MediaQueryData returned from of method.

In the screenshots below which were made after the fix you can see WidgetOptimizationPage was rebuilt once.

Widget rebuild stats after optimizing
Timeline events after optimizing

Delay an expensive task until it’s necessary

The next principle of the performance improvement states that we shouldn’t do unnecessary work. Well, much depends on your project — its architecture, a state management approach, peculiarities of a third-party library you use, internal logic of a particular screen etc. Here I want to mention a couple things provided by Dart.

I believe all of you know that Dart, as many programming languages, won’t calculate each part of a complex condition, if it can be determined at some early stage. For example, if such condition consists of several conditions joined by OR operator and the first one returns true then the others won't be even calculated because they will have no affect on the result of the whole condition.

void testLateInitialization() async {
late final first = performCalculations();
late final second = performComplexCalculations();

if (first || second) {
debugPrint('Hello world');
}
}

bool performCalculations() {
// Let's suppose here are some calculations
return true;
}

bool performComplexCalculations() {
// Let's suppose here are some complex calculations
return false;
}

And here is one thing that is important for us — late keyword, that means a variable will get a value only when we try to access it. It allows to split the condition into meaningful parts and improve readability.

You can check this in the Debugger section of DevTools by selecting a button with a list icon which you can see in the picture below:

Green line in front of a function name means it was called during the debug session and the red one means it wasn’t.

One more thing about the late keyword - in our case it can't be used for an asynchronous function, but the asynchronous function can be called directly in the condition.

void testLateInitialization() async {
late final first = performCalculations();

if (first || await performComplexCalculations()) {
debugPrint('Hello world');
}
}

bool performCalculations() {
// Let's suppose here are some calculations
return true;
}

Future<bool> performComplexCalculations() async {
// Let's suppose here are some complex calculations
return false;
}

The effect is the same as for the synchronous function — if there is no point in calling the function then it won’t be called.

I have demonstrated how late initialization works for a single variable. Let’s talk about lazy collections. In Dart many methods that are called on List objects returns Iterable which is an abstract mixin that provides a way to iterate through its items. In fact, collections, such as List and Set, implement Iterable mixin. It has a feature that can be useful in terms of the app performance - an element of Iterable is calculated at the moment when it's requested. Let's have a look at the next code:

class ListObject {
int value;

ListObject(this.value);
}

class IterableObject {
String value;

IterableObject(this.value);
}

void testListVsIterable() {
final List<int> sourceList = List.generate(10, (index) => index);

final List<ListObject> intObjectList = sourceList.map((e) => ListObject(e)).toList();
for (var element in intObjectList.take(5)) {
debugPrint('$element');
}

final stringObjectList = sourceList.map((e) => IterableObject('$e'));
for (var element in stringObjectList.take(5)) {
debugPrint('$element');
}
}

I created two classes — ListObject and IterableObject - that are just holding a value. They are almost equivalent and are made just to distinguish them afterwards in DevTools. In the function testListVsIterable sourceList is a list of some initial data that collections of ListObject and IterableObject are created from. Pay attention that map returns Iterable so we have to call toList to have List<ListObject>. Let's iterate through first 5 elements and explore in DevTools what will happen. There you can see how many instances of a certain class were allocated. For this you have to pick Memory -> Trace Instances and choose classes you want to inspect.

Our experiment shows that 10 instances of ListObject objects and 5 instances of IterableObject were allocated. Frankly speaking I have decided to iterate only through first 5 elements of collections to demonstrate the difference between Iterable and List. As I mentioned before Iterable allows to determine how the object is created but it will be created only if it's requested. Unlike List which instantiates a collection instantly. Hence there would be no difference if we iterated through the whole collection.

In spite of the benefit of Iterable for this particular case it has a disadvantage - new instance will be created every time you're trying to access it.

Have we a need to iterate twice then would be no difference between ListObject and IterableObject.

void testListVsIterable() {
final List<int> sourceList = List.generate(10, (index) => index);

final List<ListObject> intObjectList = sourceList.map((e) => ListObject(e)).toList();
for (var element in intObjectList.take(5)) {
debugPrint('$element');
}
for (var element in intObjectList.take(5)) {
debugPrint('$element');
}

final stringObjectList = sourceList.map((e) => IterableObject('$e'));
for (var element in stringObjectList.take(5)) {
debugPrint('$element');
}
for (var element in stringObjectList.take(5)) {
debugPrint('$element');
}
}

And List would be more efficient than Iterable if we have to iterate more times.

However Iterable can be useful not only in a such artificial scenario I made up with. If you need to transfer a data between classes or layers in your app consider using Iterable instead of transforming it to List every time. Method toList actually also iterates through a collection.

Concluding, Iterable could be useful but you must be careful trying to derive benefits from it.

Don’t store the objects that are not needed anymore and won’t be needed later

Let’s investigate the next recommendation:

Cancel a subscription to a stream in the dispose method to avoid memory leaks.

To verify this statement I created a couple of screens. The first — AddingCounterPage - implements a counter, which is very similar to that provided by default when you set up a new Flutter project. It instantiates BehaviorSubject that is also passed to the next screen - MultiplyingCounterPage which multiplies its value by 2.

Let’s go through these screens forward and backward and check the objects in the memory with the Memory view from DevTools for both cases — when the stream subscriptions were not canceled and when the stream subscriptions for both screens were canceled. Memory snapshots were made on each step (their names are inside green rectangles on the picture below where the flow is presented) and were compared with each other to understand which object were created and which were destroyed.

Here is the piece of code for the first scenario — without subscription canceling.

class _AddingCounterPageState extends State<AddingCounterPage> {
final BehaviorSubject<int> counter = BehaviorSubject<int>.seeded(1);

@override
void initState() {
counter.listen((event) {
setState(() {});
});
super.initState();
}
//...
}

class _MultiplyingCounterPageState extends State<MultiplyingCounterPage> {
@override
void initState() {
widget.counter.listen((event) {
setState(() {});
});
super.initState();
}
}

There are AddingCounterPage and MultiplyingCounterPage with their states in the main-3 snapshot - they were in the memory in that instant. There are also several pairs of objects related to the subscription - StartWithStreamTransformer, _StartWithStreamSink, _MultiControllerSink. One is for AddingCounterPage and another for MultiplyingCounterPage:

There is no any difference between main-3 and main-4 snapshots. Although MultiplyingCounterPage has been dismissed all instances remained in the memory:

And only after closing AddingCounterPage the subscription objects were released with AddingCounterPage and MultiplyingCounterPage:

Subscription cancelling should help rid of this memory leak. We just save StreamSubscription on adding the listener and cancel it when widget get disposed:

class _AddingCounterPageState extends State<AddingCounterPage> {
final BehaviorSubject<int> counter = BehaviorSubject<int>.seeded(1);
late final StreamSubscription subscription;

@override
void initState() {
subscription = counter.listen((event) {
setState(() {});
});
super.initState();
}
//...

@override
void dispose() {
subscription.cancel();
super.dispose();
}
}

class _MultiplyingCounterPageState extends State<MultiplyingCounterPage> {
late final StreamSubscription subscription;

@override
void initState() {
subscription = widget.counter.listen((event) {
setState(() {});
});
super.initState();
}

//...
@override
void dispose() {
subscription.cancel();
super.dispose();
}
}

main-3 snapshot looks like for the previous scenario so I didn’t add it here. But there is the difference between main-3 and main-4 snapshots — MultiplyingCounterPage and its subscriptions objects were released:

Similarly, AddingCounterPage and its subscriptions objects were released for main-5 snapshot.

Well, this experiment proved us that this recommendation works. Don’t forget to cancel subscriptions and other objects that require to be disposed (TextEditingController, AnimationController etc.). Otherwise it could not only affect the app performance but also could break the program behavior.

Optimize media files usage

Working with media files such as images and videos has a crucial impact on the app performance. The better their quality, the more space they take up in memory and require more time to be processed and rendered. Since their quality can’t be sacrificed they usually are the most resource consuming objects. Therefore dealing with media files must be optimized.

The ways to optimize working with images are:

  1. Choose an image format that fits the most:
    - JPEG for continuous-tone images like photographs. It offers lossy compression.
    - GIF and PNG for graphics, logos, texts and images with sharp edges or which require transparency. It offers lossless compression.
    - WebP and AVIF which combine the advantages of JPEG and PNG and deliver more efficient compression.
    - SVG for vector-based graphics — simple geometric shapes such as logos, text, or icons. It’s represented by XML markup and can be rendered at every resolution without a quality loss.
  2. Quality compression is a way to reduce an image size in bytes.
    - Lossy compression makes it possible by reducing an image resolution that are the number of pixels per inch. It applies when a minor quality loss is acceptable (photographs, for example).
    - Lossless compression offers an image encoding which allows to restore the original image without any harm for quality. It’s more suitable for archival purposes (often for medical imaging, technical drawings etc.).
  3. Decrease a dimension which is an image size in pixels i.e. its height and width. It’s not efficient to display a large image on a small screen such as a mobile phone due to a redundant information it includes. Adjusting the large image to the small screen requires additional calculations. The more precise it fits to the desired dimension the more efficient the rendering process is.
  4. Caching images that are received from the network.

Let’s explore what features for image optimization Flutter has. First of all we need to add Image widget onto the screen:

Image.asset(
'lib/assets/landscape.jpg',
),

But how could we know if the image requires optimizing? DevTools has an option to highlight oversized images so you can always identify that. You can turn it on by clicking the button on Flutter inspector tab:

or by setting the global parameter programmatically:

debugInvertOversizedImages = true;

and the oversized image will be color inverted and flipped:

You can also see an error in logs:

Image lib/assets/landscape.jpg has a display size of 640×360 but a decode size of 1920×1080, which uses an additional 9600KB (assuming a device pixel ratio of 2.0).

Consider resizing the asset ahead of time, supplying a cacheWidth parameter of 640, a cacheHeight parameter of 360, or using a ResizeImage.

So, the reason why the image is considered as resource expensive is a large dimension which as we discussed earlier means it requires additional calculations during the rendering. To avoid this error parameters cacheHeight and cacheWidth should be used.

Image.asset(
'lib/assets/landscape.jpg',
cacheWidth: MediaQuery.of(context).devicePixelRatio.round() * MediaQuery.of(context).size.width.round(),
),

Here cacheWidth is calculated as multiplying of the screen width by devicePixelRatio which is the number of device pixels for each logical pixel. If only size.width is used the image becomes blurry. We must think about the image quality as well!

To ensure seamless user experience precacheImage method can be used. It empowers you to preload an image into a cache before utilizing - at widget initialization or even at main method.

You can also rely on Image.network constructor which implicitly puts an image into a cache the first time it requested or you can use third-party libs such as cached_network_image which provide additional features like displaying a placeholder while the image is downloading and displaying an error if the request failed.

As for image compression consider using plugins like flutter_image_compress.

Speaking about the entire application’s size the command --analyze-size can be used to get more clear picture and come up with the idea how it can be optimized. To reduce code size consider using the --split-debug-info when building a release version.

As the bonus recommendation it’s worth also to remind you not to execute an expensive task without a need. For example, use Opacity and ClipRRect only if it's necessary, avoid clipping in animations. Also don't execute an expensive task directly on the main thread. Use Isolate to ensure this doesn't affect UI.

Conclusion

In this article I provided you with the common principles of the app performance which are useful not only for Flutter app developing. Also we explored some specific tips for Flutter app performance improvement closer using DevTools. I hope you found this helpful. More information can be found in the list of resources.

Resources:

  1. https://docs.flutter.dev/perf
  2. https://docs.flutter.dev/tools/devtools/performance
  3. How to Improve Flutter Performance: https://www.youtube.com/watch?v=KH-3tbD7NoU
  4. Dive into DevTools: https://www.youtube.com/watch?v=_EYk-E29edo
  5. TOP 10 performance & optimization tips in Flutter: https://medium.com/@slawomirprzybylski/top-10-performance-optimization-tips-in-flutter-3a4f3f31202b
  6. Pushing Flutter to the Limit: Performance Tips Every Developer Should Know: https://medium.com/@panuj330/pushing-flutter-to-the-limit-performance-tips-every-developer-should-know-87e3d835cd49
  7. Raising the bar for Flutter App performance!: https://medium.com/@parthbhanderi01/raising-the-bar-for-flutter-app-performance-52418f7fa604
  8. Improve your Flutter Apps performance with a RepaintBoundary: https://www.youtube.com/watch?v=Nuni5VQXARo
  9. Save Your Memory Usage By Optimizing Network Images in Flutter: https://medium.com/make-android/save-your-memory-usage-by-optimizing-network-image-in-flutter-cbc9f8af47cd
  10. Flutter Skill Of MediaQuery and Performance Optimization: https://medium.com/codex/flutter-skill-of-mediaquery-and-performance-optimization-2fbf9c532fea

--

--