As you might already know, Flutter is the most popular cross-platform mobile framework used by developers worldwide according to a 2021 developer survey. Thus, the framework is growing incredibly fast and providing vast possibilities for developers. This comes with the responsibility to discern good practices from not that useful ones. What's more, identifying the best practice for each case.
Let’s explore some of the best practices for designing and developing with Flutter to improve code quality, readability, maintainability, and productivity.
1. Refactor code into widgets rather than methods
Consider the following widget:
Now if we want to refactor Text widgets we have two ways:
1. Refactor into method
2. Refactor into widget
Now refactoring into method might seem tempting here but when buildHello is too big it might rebuild even when nothing inside buildHello was changed.
But when it comes to refactoring into widget, we get all the benefits of widget lifecycle so it will rebuild only when something in widget changes. So this prevents unnecessary rebuilds and thus improving the performance. This also offers all the optimizations that Flutter offers for widget class.
2. Make build function pure
Consider the following stateful widget:
Here, when the rebuild happens the getText() is called every time and this might create janky UI and use more resources.
So the idea of having pure build function is that we should move out any operation from build method that can affect rebuild performance.
3. Use state management
Flutter itself doesn't impose any state management by default, so it's easy to end up with a messy combination and might depend on parameter passing or storing everything in persistent storage for storing state.
While using a simple solution for state management is always recommended, we should also consider the scalability and maintainability of the app to select it.
Furthermore, even though stateful widgets offer the simplest solution for state management, it can not scale when we want to maintain the state across multiple screens. e.g. Authentication state of User.
State management comes really handy here. It allows to have central store of things that we can use to store anything and when anything in store changes, all the widgets dependent on that will be changed automatically.
There are so many options available for state management. Depending on the experience and level of comfort of the team, we can use any of the available solutions as mentioned here. For instance, one powerful option for State Management with Flutter is the BloC pattern.
4. Have a well-defined Architecture
Since Flutter is a declarative framework, it's much easier to learn compared to native frameworks for Android and iOS. Also for Flutter, we just need to learn only one language for both design and code. But this can also lead to spaghetti code if we do not have well-defined architecture as things can get mixed up very easily.
At minimum we should have at least three layers:
While there can be many different options for the architecture, we should choose one that the team is most comfortable with. A bloc library offers a great set of examples with well-defined architecture.
5. Follow effective dart style guide
It's always better to have a defined style guide to have widely accepted conventions that can help improve the code quality. If we have a consistent style, it becomes much easier to have the team coordinated and even incorporate new developers. In this sense, maintaining a constant and regular style will help the big project in the long term.
While it's possible to define the custom style guide that the team is comfortable with, Dart also offers the official style guide that we can follow.
Moreover, it's always a great idea to have Linter in the project. This becomes very helpful when the team is large, when not everyone is aware of the style guide or when they forget to follow some rules. All the Linter rules offered by Dart can be found here.
6. Select packages carefully
The Flutter ecosystem is very supportive of the community, especially when it comes to reusable pieces of code. The typically called libraries are called packages in the Flutter ecosystem.
While it's always tempting to use a package for every functionality, we should consider the following factors before using any package:
- When was the package updated? We should always avoid using stale packages.
- How popular is the package? If the package has considerable popularity, it is much easier to find community support.
- Check the open issues in the code repository of the package. Are there any issues the can affect the functionality we are trying to integrate from that package?
- How frequently does the package get updated? This is really important if we want to take advantage of the latest Dart features.
- Are you using only little functionalities from the package? It makes more sense to write the code or copy the code than depend on the whole package when we are using only a little functionality.
7. Write tests for critical functionality
While the possibility of relying on manual testing is always there, having an automated set of tests can save a considerable amount of time and effort. As Flutter targets multiple platforms, testing every single functionality after every change would be time-consuming and require a lot of repeated effort.
Ideally speaking, having 100% code coverage for testing is always the best option, however, it might not always be possible based on available time and budget. Nevertheless, it's necessary to have at least tests to cover the critical functionality of the app.
In addition, it's important to have integration tests that allow running tests on physical devices or emulators. Tip: we can also use Firebase Test lab for running tests on a variety of devices.
8. Use MediaQuery/LayoutBuilder only when needed
Many developers tend to use MediaQuery/LayoutBuilder believing that it makes everything responsive. But in many cases, they end up using it incorrectly.
Consider the following code:
Here we think it will take only 70% of the physical width, which is right, but since the physical width can vary from device to device, the remaining 30% might look different on different devices. Also, when the child of Container has more width than 70%, it will overflow devices having smaller physical width.
This is why we should find an alternative way whenever possible instead of using MediaQuery/LayoutBuilder. To sum up, MediaQuery/LayoutBuilder should be used when we want to have a different layout depending on various breakpoints of the physical size of the device.
9. Use streams only when needed
While Streams are very powerful, using them also comes with great responsibility in order to make effective use of this resource. Using Streams with poor implementation can lead to more memory and CPU usage. Even more, forgetting to close the streams can lead to memory leaks.
What's more, using Stream only for one event seems overkill. In the case of only one event, instead of Stream, it's a good idea to use Future. Streams should be used only when we want to handle many asynchronous events.
So, in these cases, instead of using Streams, we can use something more lightweight such as ChangeNotifier for reactive UI. For more advanced functionalities, we can use something like Bloc library that focuses on using the resources effectively and offering us a simple interface to build the reactive UI.
10. Use const keyword whenever possible
Using const constructor for widgets can reduce the work required for garbage collectors. This may initially seem like a small performance improvement but it really adds up and makes a difference when the app is big enough or there is a view that gets frequently rebuilt.
Const declarations are also more hot-reload friendly. Also, we should avoid the unnecessary const keyword. Consider the following code:
Here we don't need to use const for Text widget since const is already applied to parent widget.
Dart offers following Linter rules for const:
- prefer_const_constructors
- prefer_const_declarations
- prefer_const_literals_to_create_immutables
- unnecessary_const
We hope this helps make your Flutter code more readable while also improving your app’s performance. Happy coding devs!