How to fix performance issues in Flutter
Mikhail Palei
Posted on November 5, 2022
How to fix common performance issues in Flutter
This article is a compilation of tips and tricks regarding performance of Flutter applications. Basically, these are the things I wish someone told me when I just started using Flutter.
Please keep in mind that it is best to test performance on a real device and in profile mode. Though, personally I use debug mode far more often since it allows me to have an instant feedback about performance since profile mode does not support hot refresh or reload.
Performance overlay.
Performance overlay is the easiest way to track performance degradation. It is accessible by calling “toggle performance overlay” action in VS Code and Android Studio. You can also enable it via DevTools.
In short: the top row tracks performance of code. The bottom row displays performance of actual drawing of pixels. The top row is in your control. It is performance of your code. The bottom row is usually outside of your control.
Both rows are horizontally divided into 16ms sections. To achieve 60 FPS you need to make sure that performance never goes above first section. If you spot that you cross these thresholds this usually means one of two things: you either doing too much things in the main thread and need to use isolates or you are doing too many things inside of your widget’s “build” method.
In screenshot below you will notice that top row is spilling over the first section. This is bad.
Widget rebuilds counter.
“Display widget rebuilds” is a neat tool that allows you to see how many times do your widgets activate “build” method. Sadly this tool is only available in Android Studio. Though I highly recommend using it even if you use different IDE. The data is simply too valuable to ignore.
This tool is a table that has 3 rows: list of widgets that were built in the app, number of widget rebuilds during last frame and a combined number of all rebuilds. Obviously the less you rebuild a widget the better. Thus if you have a lot of total rebuilds you might investigate how you use animations, how often you update your widgets arguments and how often you change the state of stateful widgets (hint: use AnimatedBuilder instead of calling “setState” method for animations).
In the screenshot below you can see that Text widget of MainPage (main_page.dart) have been built in total 3 times per session and only a single time in the last frame. Which is good. The less the better. The Text widget of TasksListItem on the other hand have been rebuild 259 times in a single frame. Which of course forced my app to lag.
Event loop and isolates.
One of the most common problems inexperienced Flutter developers encounter is a strange lag during application start. It goes like this: you launch the app, you see some loading animation, then loading animation freezes and after a second the app unfreezes and operates normally. This happens because when you request data from a remote server and receive the JSON your app can’t do anything else beside parsing the JSON for a few moments. Think of it this way: let’s say you have a list of 100 documents and each document has 10 fields. When you receive this JSON your app will have to go line by line 1000 times in order to convert this JSONs into usable Dart objects. This of course freezes the UI for a few moments.
To fix this you need to parse the JSON in a separate isolate. A quick reminder: Isolates are basically Darts equivalent of threads.
Below you will find a snippet extracted from one of my apps. There is quite a bit of code but the important line is this one: return compute(_parseListOfJsons, listOfJsons);
This line creates a new thread and instructs it to run the parsing function on a list of JSONs.
class GetTasksToDoRepository {
final FirebaseFirestore firestore;
const GetTasksToDoRepository({required this.firestore});
Future<List<Task>> call({required String userId}) async {
try {
final tasksSnapshot = await firestore
.collection('tasks')
.where('userId', isEqualTo: userId)
.where('isDone', isEqualTo: false)
.limit(250)
.get();
final listOfJsons = tasksSnapshot.docs.map((e) => e.data()).toList();
return compute(_parseListOfJsons, listOfJsons);
} catch (error, stackTrace) {
log(error.toString(), stackTrace: stackTrace);
rethrow;
}
}
}
List<Task> _parseListOfJsons(List<Map<String, dynamic>> docs) {
return docs.map(Task.fromJson).toList();
}
Follow performance best practices.
https://docs.flutter.dev/perf/best-practices
Animations.
It is important not to forget to attach SingleTickerProviderStateMixin
or TickerProviderStateMixin
. Tickers keep animations in sync with screens. For example your animations won’t use any hardware resources if they are not visible to the user, i.e. when the app is running in the background or when the user navigated to a different route mid animation. They also “smooth” out animations by adapting them to the screen refresh rate.
It is also incredibly important to use AnimatedBuilder
when ever possible. AnimatedBuilder
does two things:
1) It allows us to rebuild only the necessary widgets during animation.
2) It allows us to to stop using _animation.addListener(() => setState(() {}))
to activate the animation. Why is it important to drop setState
? Because we don’t want to repaint widgets 60 times per second. It will inevitably lead to jank.
See https://api.flutter.dev/flutter/widgets/AnimatedBuilder-class.html for more information.
Shaders and initial jank.
When your app is first loaded you may notice that animations are janky. This jank is usually easy to spot when scrolling or jumping between router pages. This happens because Flutters rendering engine does not yet have required shaders to display smooth animations. This is fixable via compiling shaders manually. See the instructions here:
https://docs.flutter.dev/perf/shader
Note: the first time I read the instructions they seemed too daunting to me. Fear not my friend. They are actually quite simple: run the app, play with animations, build the app with produced shaders file.
Repaint rainbow and RepaintBoundary.
As we know Flutter repaints (rebuilds) widgets a lot even if there are no changes to widgets arguments. In order to spot this repaints you need to enable a special development feature that is going to highlight all of the repaints of the application. I highly recommend you to
I highly encourage you to always have this option enabled during development to spot the unwanted repaints as early as possible. Place this snippet before your runApp()
in main.dart
:
debugRepaintRainbowEnabled = kDebugMode;
In fact you will be surprised how many widgets are being repainted for no good reason. For instance: once I created a very simple countdown widget. All it was supposed to do is to display a single line of text that said: “You have 60 seconds left” and each second the number would decrease. This widget used only local state (it was a stateful widget) and it didn’t have any outside dependencies. Yet when I enabled repaint rainbow I was shocked to witness that somehow once a second whole screen of the application was repainted. What I expected to see is a single Text
widget to be repainted.
The solution was to wrap my widget with RepaintBoundary
widget. When it was applied only the Text
widget was repainted once a second instead of the whole screen.
Important: do not use RepaintBoundary
excessively. If misused this widget will put too much strain on the hardware and FPS will decrease.
Repaints caused by state changes.
When using state management libraries it is always a good idea to place state builders as close to consumers as possible. Let me demonstrate:
// BAD.
class BadBuilderExample extends StatelessWidget {
const BadBuilderExample({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return BlocBuilder<ProfileCubit, ProfileState>(
builder: (context, profileState) {
return Column(
children: [
Text(profileState.profile.name),
CreatePostForm(),
Posts(),
],
);
},
);
}
}
// GOOD.
class GoodBuilderExample extends StatelessWidget {
const GoodBuilderExample({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Column(
children: [
BlocBuilder<ProfileCubit, ProfileState>(
buildWhen: (previous, current) =>
previous.profile.name != current.profile.name,
builder: (context, profileState) {
return Text(profileState.profile.name);
},
),
CreatePostForm(),
Posts(),
],
);
}
}
In BadBuilderExample every change to ProfileCubit will force the whole widget to be repainted. In GoodBuilderExample only the Text widget will be repainted. Also, notice how I threw in buildWhen
into the BlocBuilder
this function will make sure that widget will be rebuilt only when profile.name
changes. This will reduce rebuilds to an absolute minimum.
Even though I used BloC for this demonstration please remember that the general idea is important. Whatever you are doing, whatever library you are using, keep builders as close to consumers as possible.
Where to go from here.
Flutter is one of those tools that is all about how you use it. If you don’t know what you are doing you will be punished with bad performance. If you are skilled you will be rewarded with silky smooth UI. Even though this article will help you to solve 90% of your problems you might want to keep improving your knowledge. The best way to do so is to learn Flutter DevTools. Here is a good overview to get you up and running:
Posted on November 5, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.