Gonçalo Palma
September 30, 2019

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 Futures, Streams and Sinks. 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 PublishSubjects and BehaviorSubjects 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:

In this case, we may come up with the following flow:

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:

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:

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 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.

Follow me!

I often share some small insights on Flutter 💙