Refactoring a Flutter Project — a Story About Progression and Decisions
When doing any IT project, we sometimes don’t think on the weight of every decision we are making. Abstracting too much or too less, using X Y or Z library, gluing up code that “will be fixed later”, and so many other countless examples. Each of these decisions has a cost. The peculiar thing is that cost is going to bite us back not today, not tomorrow, but eventually, and when that eventual day comes, we may loose hours, days or weeks trying to retrace our steps or fixing our mess of a codebase.
This article is going to expose one of those stories. It’s not an article about how to create the “perfect architecture”, nor the best approaches for each problem, but it is an article about how I struggled with these issues and how I found a solution that, for the moment, works perfectly in my case.
Also, this also serves to show that it’s okay to make mistakes. When we are inexperienced, both in programming or in a specific framework or technology, we will make bad decisions eventually. That’s not what matters, what matters is how we handle them in the future.
So let’s begin our story.
The starting BLoCs of an App
When coming to Flutter and Dart, it might take a while to get into the flow of how asynchronous programming works here. Since there is no multi-threading in Dart (only the concept of Isolates, which you can read more about in this article from Didier Boelens[INSERT LINK]), we can only rely on Future
s, Stream
s and Sink
s. But if we want to use Vanilla BLoC in our app, we have to go through the pains of learning them.
Thankfully, the concept of BLoC itself is deceptively simple: a BLoC is a helper class in which inputs are Streams
and outputs are Streams
. We can also choose to use RxDart (if we are comfortable with the concepts of RX) and use PublishSubject
s and BehaviorSubject
s to expose the Stream
and Sink
, leading us to create BLoCs such as the following example:
import 'package:rxdart/rxdart.dart';
class ABloc {
/// Input from the user
var _inputSubject = PublishSubject<int>();
Sink<int> get inputSink => _inputSubject.sink;
/// Output for the user
var _outputSubject = PublishSubject<int>();
Stream<int> get outputStream => _outputSubject.stream;
ABloc() {
/// Get the input stream, add 1 and return to the view
_inputSubject
.stream
.listen((value) => _outputSubject.add(value + 1));
}
}
This simple BLoC’s purpose is to get input data from the user via inputSink
, add 1 to that value and then output data in the outputStream
.
But this is just one BLoC, and for one screen only. Eventually, our app grows and we might see that we have recurrent functionalities that we are copying and pasting on each BLoC, such as relaying loading events or error messages. And we stop to think.
Effectively, copying and pasting our code is reusing the same functionality in different classes. However, let us imagine that we have over 20 BLoCs with pasted code. In this situation, the cost of wanting to change this functionality would be tremendous, since we would have to analyse each class, change the code, and guarantee that it didn’t break anything in each screen. So, before proceeding we might want to find a better solution to manage this recurrent code in a way that if we have to change it, we change it in one place only.
One way we can approach this is to create a Base Class that holds all the major functionalities, but this might not fit every screen, because there’s a specific piece of code that only exists in 30% of your classes. For that, we have Mixins, which you can read more about in this article. Again, we need to analyze our project and see what best fits our needs, since either abusing Base Classes or Mixins can eventually lead to more complications down the line. Why? Because we might feel tempted to add code to these classes and mixins, and there will be a point where we create a new screen that requires part of those functionalities, but not all, and so we must yet again refactor our code.
Mixing Up the BLoCs - Where the Logic begins to crumble
Let us suppose that we have an app that has a list of all the produces we have in our house, divided by category: greens and meats. We want to be able to know how many products we have of each category, as well as checking and modifying the list of meats and vegetables that we have.
We can divide this app in 4 screens:
- A screen that lists types of ingredients: greens and meats - Categories Screen
- A screen with the list of produces for a category: eg.: greens should have a list with Lettuce, Beans and Tomatoes - Item List Screen
- A screen to add a new produce - Add Item Screen
- A screen to edit a produce - Edit Item Screen
In this case, we may come up with the following flow:
- Categories Screen fetches data from the API and passes a filtered list only containing the correct category to the Item List Screen.
- When a user taps an item, the item is passed as an argument to the Edit Screen
- When a user taps the “Create new Item” button, a category id is passed as as argument to the Add Screen
This can be roughly translated in the following BLoCs:
class ListBloc extends BaseBloc {
// Streams the lists of items
var _listOfItemsSubject = BehaviorSubject<List<Items>>();
Stream<List<Items>> get listOfItemsStream => _listOfItemsSubject.stream;
// Sink to fetch new data
var _fetchItemsSubject = PublishSubject<Event>();
Sink<Event> get fetchItemsSink => _fetchItemsSubject.sink;
ListBloc(ItemsManager manager) {
// Every time we receive a fetch event, we fetch new items
_fetchItemsSubject.stream
.flatMap((_) => manager.fetchItems())
.listen(_listOfItemsSubject.add, error: (e) => handleError(e));
// When this BLoC is created, we want to have a list of items, so we fetch it
_fetchItemsSubject.add(Event())
}
}
class CategoryBloc extends BaseBloc {
// Stream to stream the list, and Sink to add the list when the BLoC is created
var _listOfItemsSubject = BehaviorSubject<List<Items>>();
Sink<List<Items>> get listOfItemsSink => _listOfItemsSubject.sink;
Stream<List<Items>> get listOfItemsStream => _listOfItemsSubject.stream;
CategoryBloc() {
// ...
}
}
class AddBloc extends BaseBloc {
// Sink to receive the event to add a new item
var _addItemSubject = PublishSubject<Input>();
Sink<Input> get addItemSink => _addItemSubject.sink;
// Stream with the response from the server, the new item
var _addResponseSubject = PublishSubject<Item>();
Stream<Item> get addResponseStream => _addResponseSubject.stream;
AddBloc(ItemsManager manager) {
// When we receive the new item event, send this item to our server
_addItemSubject
.stream
.flatMap((input) => manager.addItem(input))
.listen(_addResponseSubject.add, error: (e) => handleError(e));
}
}
class EditBloc extends BaseBloc {
// Sink to receive the event to edit an item
var _editItemSubject = PublishSubject<Input>();
Sink<Input> get editItemSink => _editItemSubject.sink;
// Stream with the response from the server, the edited item
var _editResponseSubject = PublishSubject<Item>();
Stream<Item> get editResponseStream => _editResponseSubject.stream;
AddBloc(ItemsManager manager) {
// When we receive the edit item event, send the edited item to our server
_editItemSubject
.stream
.flatMap((input) => manager.editItem(input))
.listen(_editResponseSubject.add, error: (e) => handleError(e));
}
}
Colocar gráfico em que se mostra como é que app está estruturada, e com que parte é que cada bloc comunica
Category - comunica para tirar a lista da API List - não comunica com API Add/Edit - comunica com API
As we can see from the schematics:
- In the
Category Screen
we communicate with the API, and pass a filtered list to theList Screen
. - In the
List Screen
screen we then pass either the category id or the produce object to theAdd Screen
orEdit Screen
, respectively.
This clearly has a problem: when we are adding new data or editing data, how can we call the API again to fetch new data and display the updated data on the List Screen
screen?
What if the BLoCs could communicate between each other? On the upside, this would solve the problem, since the Edit BLoC
or Add BLoC
could send an event directly to the Category BLoC
. But is it the correct solution?
After thinking for a while, we may come with a couple of reasons not to use this approach:
- We may have some references problems if one of the BLoCs is recreated, for example, if the
Category BLoC
is recreated for some reason after theAdd BLoC
is initialised, then it will call methods on an object that’s no longer in use. - As Ivan Montiel writes in his Low Coupling, High Cohesion article: “/If Module A knows too much about Module B, changes to the internals of Module B may break functionality in Module A/.”
- This approach forces us to have dependencies between BLoCs and this may lead to a circular dependency, where
Add BLoC
is a dependency ofCategory Screen
butCategory Screen
is also a dependency ofAdd BLoC
.
The alternatives? There are plenty, but since this article has been based on a true story, we’ll discuss the solution that was at the time chosen for this problem.
Sharing a Single Source of Truth
One thing that wasn’t discussed so far is how we are treating data.
At the moment, we retrieve data from the internet, pass it from screen to screen and mutate it. But, if we change our data by either adding or editing an item, the list in the Category BLoC
will not reflect those changes. This means that the list that we currently have in Category BLoC
and other BLoCs is different. If we can’t fetch a new list from the Category BLoC
, then we will have two different lists inside the app containing different data, or on other words, different sources of truth.
On the other hand, we don’t want our BLoCs to be communicating with each other, but that does not mean that we can have the UI layer communicating with different BLoCs. So, we devise a new plan: we store the individual screen business logic in each screen’s BLoC, such as adding and editing, but we have a parallel BLoC that will hold the single source of truth for all data and whose task is to fetch data from the internet and update that list.
image
With this, we can call the fetch command from any screen and since we only have one list, when we are adding or editing an item, this BLoC can have methods to update the current list. This is specially important if the user has a slow internet connection or if he suddenly has no internet connection, since we can show up-to-date information.
class CommonBloc extends BaseBloc {
var _listOfItemsSubject = BehaviorSubject<List<Items>>();
Stream<List<Items>> get listOfItemsStream => _listOfItemsSubject.stream;
var _fetchItemsSubject = PublishSubject<Event>();
Sink<Event> get fetchItemsSink => _fetchItemsSubject.sink;
CommonBloc(ItemsManager manager) {
_fetchItemsSubject.stream
.flatMap((_) => manager.fetchItems())
.listen(_listOfItemsSubject.add, error: (e) => handleError(e));
_fetchItemsSubject.add(Event())
}
}
class ListBloc extends BaseBloc {
ListBloc() {
//...
}
}
class CategoryBloc extends BaseBloc {
CategoryBloc() {
// ...
}
}
class AddBloc extends BaseBloc {
var _addItemSubject = PublishSubject<Input>();
Sink<Input> get addItemSink => _addItemSubject.sink;
var _addResponseSubject = PublishSubject<Item>();
Stream<Item> get addResponseStream => _addResponseSubject.stream;
AddBloc(ItemsManager manager) {
_addItemSubject
.stream
.flatMap((input) => manager.addItem(input))
.listen(_addResponseSubject.add, error: (e) => handleError(e));
}
}
class EditBloc extends BaseBloc {
var _editItemSubject = PublishSubject<Input>();
Sink<Input> get editItemSink => _editItemSubject.sink;
var _editResponseSubject = PublishSubject<Item>();
Stream<Item> get editResponseStream => _editResponseSubject.stream;
AddBloc(ItemsManager manager) {
_editItemSubject
.stream
.flatMap((input) => manager.editItem(input))
.listen(_editResponseSubject.add, error: (e) => handleError(e));
}
}
However, as we might see from the principle, we have a new problem: instead of having BLoCs communicating with each other, we have the UI communicating with multiple BLoCs at the same time and at the same time multiple screens listening to the same stream, which means that we have to be careful in how we dispose and listen again for streams.
And before we start engineering a new solution, we are going to use one of Flutter’s main strengths: the community.
An Architecture on Feedback
Flutter’s community is amazing, from the Discord servers, to Slack or even Twitter. We can quickly ask for an opinion or feedback on a specific topic or, if we are feeling brave, sharing our code and asking for an honest opinion.
Or, if we’re lucky, we can ask a good friend for an honest opinion. A good friend like Antonello Galipò. After sharing this information, he gives us a different perspective on how we should approach the problem:
My idea for a bloc is that it holds all the business logic for a feature (not a screen), which can be distributed among different screens. (…) You have the “type management” feature. You fetch them, see them, edit them, save them.
As with before, let’s discuss the benefits and cons of using this approach:
- We give up on using a BLoC per screen and we now have a BLoC that’s being shared through every screen of a feature. This means, as before, if we have a stream that is listened by multiple screens, we must be careful in how we dispose the BLoC and re-listen to the stream when coming back to the screen.
- We have to pay attention in when and how we are creating a new instance of this BLoC, since if a new BLoC is generated when we are using this feature, all data will be lost.
- However, using one BLoC per feature means that we only have a single source of truth, so all screens present the same data.
- Having one BLoC is easier to manage than having multiple BLoCs, since we just need to either provide it or pass it as an argument to subsequent screens when navigating. This also means that instead of having to dispose each BLoC in each screen, now we just dispose our BLoC if we navigate away from this feature, leaving the responsibility to one screen only.
- Since all screens have access to this BLoC, when updating or adding items, we can directly call the
fetch
method to update our current list. - Using one BLoC means that we don’t need to use
initState
to add data to theSink
s of each BLoC. If we don’t need to use either theinitState
ordispose
methods this also means that we can convert our widgets toStatelessWidgets
.
We can now proceed to the implementation of this BLoC.
class ItemsBloc extends BaseBloc {
//region Fetch Items
var _listOfItemsSubject = BehaviorSubject<List<Items>>();
Stream<List<Items>> get listOfItemsStream => _listOfItemsSubject.stream;
var _fetchItemsSubject = PublishSubject<Event>();
Sink<Event> get fetchItemsSink => _fetchItemsSubject.sink;
//endregion
//region Add Items
var _addItemSubject = PublishSubject<Input>();
Sink<Input> get addItemSink => _addItemSubject.sink;
var _addResponseSubject = PublishSubject<Item>();
Stream<Item> get addResponseStream => _addResponseSubject.stream;
//endregion
//region Edit Items
var _editItemSubject = PublishSubject<Input>();
Sink<Input> get editItemSink => _editItemSubject.sink;
var _editResponseSubject = PublishSubject<Item>();
Stream<Item> get editResponseStream => _editResponseSubject.stream;
//endregion
ItemsBloc(ItemsManager manager) {
_fetchItems();
_addItems();
_editItems();
}
void _fetchItems() {
_fetchItemsSubject.stream
.flatMap((_) => manager.fetchItems())
.listen(_listOfItemsSubject.add, error: (e) => handleError(e));
_fetchItemsSubject.add(Event())
}
void _addItems() {
_addItemSubject
.stream
.flatMap((input) => manager.addItem(input))
.listen(_addResponseSubject.add, error: (e) => handleError(e));
}
void _editItems() {
_editItemSubject
.stream
.flatMap((input) => manager.editItem(input))
.listen(_editResponseSubject.add, error: (e) => handleError(e));
}
}
Conclusion
As stated before, the purpose of this article is not to find the perfect or correct solution to this problem. One week, two weeks from now, a new library, framework or even a simple idea can prompt us to reinvent our whole structure again, for better or worse.
What is the purpose of this article is to show you that it’s okay to have doubts in the decisions that we make when programming. We are continuing learning either by creating new code or by discussing with our colleagues, friends and community. We have to be able to take new feedback and let go of our ideas if they don’t provide the best solution to our problem. However, on the other hand, we must also be critical of each new solution that is published every week, else we’ll be changing into Redux, provider
, Mobx or any other new state management framework.
Flutter has an amazing community that thrives on sharing and helping others. Since we have this unique opportunity, we must use it to learn, grow and teach. We must accept that we sometimes do make mistakes and know when to ask for help, while at the same time being open to being helped.
Lastly, I hope that this article serves as a cautionary tale. We are constantly making decisions when coding, and sometimes we have that feeling of “I don’t think that this is the best way, but I’ll quickly change it in the near future”. But then comes a release. Then comes a new feature. Then comes a bug fix report. And then 4 months have passed and we don’t know why we used that specific logic, sometimes even with good documentation. And what would be 30 minutes to one hours in the beginning, turned into countless hours of bug fixing since we have so much code that depends on that “quick and dirty fix”. Let’s not do that. If we are going to make it, let’s make it right the first time.
Want to get the latest articles and news? Subscribe to the newsletter here 👇
And for other articles, check the rest of the blog! Blog - Gonçalo Palma