Modular Flutter Apps — Design and Considerations
When working in a big project in native Android Development, we may suffer a lot from long build times and an incoherent code structure. That’s why many projects tend to divide its codebase into multiple modules. This ensures not only that the project is divided into smaller units that can be independently changed but it also reduces build time since each module can be built separately and, if a module has not been changed, when building the app it won’t be rebuilt. Some apps report a decrease of the total build time from 1 minute and 10 seconds to 17 seconds.
In the Flutter verse, we might wonder: can we do the same? And if so, what will we achieve? And is it worth it? Let’s explore it.
Dividing a project into different modules
Though we do not have the Gradle system that let us easily divide the project into different modules, we can take advantage of the pub package system. How? We can add a new dependency to our project by adding it to the pubspec.yaml
file, where it is going to be downloaded from pub.dev. However, we can also use this sytem to retrieve a library that is currently hosted locally in our machine by referencing its path instead:
dependencies:
# other dependencies
# our module
login_module:
path: ../firebase_login
This way, we can both easily add new modules and replace a module with another just by changing the path
that it points to.
But what practical use cases can we have for this feature? Let’s take an example app that has a login page and a home page.
To the user, it does not really matter if this app does the login via Firebase or via API calls to a backend server since she will always have to put her email and password in order to access the home page. However the same does not apply to the codebase, since the code needed to login with the firebase_auth
is going to be widely different than the code we use to login via REST API calls using dio
or http
. Thus, if we want to isolate the logic of each of these types of login, we may create a Manager class that will login the user given the username and password.
To keep things simple, let us assume that a login via firebase returns a Firebased $username
String and a login via the backend returns a Servered $username
String. Furthermore, since we do not want to put this logic in the UI dart file, we create separe files and separate classed for each login that use the Singleton pattern so that we can easily access them.
class FirebaseManager {
FirebaseManager._();
static final FirebaseManager instance = FirebaseManager._();
String login(String username, String password) => "Firebased $username";
}
class ServerManager {
ServerManager._();
static final ServerManager instance = ServerManager._();
String login(String username, String password) => “Servered $username”;
}
To use these either of these methods in our Widget, we can use one of the following:
String _loginWithServer(String email, String password) {
return ServerManager.instance
.login(email, password);
}
String _loginWithFirebase(String email, String password) {
return FirebaseManager
.login(email, password);
}
And though both methods have the same arguments and the same return type, we will have:
- One single method called
_login
and one import at the top of the file. If we want to change from one implementation to another we will have to change the import and the body of the_login
method. - Two methods, as seen above, called
_loginWithServer
and_loginWithFirebase
and the two imports. To change the implementation from one to another we will have to go to the UI code and change the method being called.
In both situations, there is a cost of changing from one implementation to another. Moreover, the UI does not need to be aware if we are using Firebase or a backend system, so what if we could always use the same import and the same method names? We can do that by creating two different modules and adding them as libraries in our pubspec.yaml
file.
To do that, we start by creating each module via the command line:
$ flutter create --org com.vanethos.login firebase_login
$ flutter create --org com.vanethos.login server_login
In our project, two new flutter modules have been added to the project.
Then, for each of the modules we will do the following:
- Copy the necessary code for the library and place it in the
/src
folder - Create a
login.dart
file where we expose the module as a library and includes anexport
statement to the file in the/src
directory.
So, for the firebase_login
module we have:
// file: example_login.dart
library example_login;
export 'src/firebase_login.dart';
// file: src/firebase_login.dart
class LoginManager {
LoginManager._();
static final LoginManager instance = LoginManager._();
String login(String username, String password) => "Firebased $username";
}
name: example_login
description: Firebase Login System
version: 1.0.0+1
environment:
sdk: ">=2.1.0 <3.0.0"
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^0.1.2
dev_dependencies:
flutter_test:
sdk: flutter
And for the server_login
we have:
// file: example_login.dart
library example_login;
export 'src/server_login.dart';
// file: src/server_login.dart
class LoginManager {
LoginManager._();
static final LoginManager instance = LoginManager._();
String login(String username, String password) => "Servered $username";
}
name: example_login
description: Custom Backend Login System
version: 1.0.0+1
environment:
sdk: ">=2.1.0 <3.0.0"
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^0.1.2
dev_dependencies:
flutter_test:
sdk: flutter
With the files having the same project name, class name and library name, with the only differences being the filename in the src
folder and the actual business logic, the UI will only have to include 1 import to be able to use these libraries, as we have stated earlier. So, effectively, to we can declare the implementation that we require at any given moment in the pubspec.yaml
file of the main project:
name: flutter_modular_app
description: A Flutter app that shows how to approach a modular design
version: 1.0.0+1
environment:
sdk: ">=2.1.0 <3.0.0"
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^0.1.2
example_login:
path: ./server_login
This will ensure that our UI will use the following method to login:
// file: login_page.dart
import 'package:example_login/example_login.dart';
//...
String _login() {
return LoginManager.instance
.login(_emailTextController.text, _passwordTextController.text);
}
To change the implementation from server_login
to firebase_login
, we would just have to change the path
we are currently assigning in the pubspec.yaml
file:
name: flutter_modular_app
description: A Flutter app that shows how to approach a modular design
version: 1.0.0+1
environment:
sdk: ">=2.1.0 <3.0.0"
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^0.1.2
example_login:
path: ./firebase_login
In this example, we only used local files, however the same would apply if we have our files hosted in a git repo or even if we are using libraries that are in the pub.dev platform.
Benefits of modularisation
As we saw, if we construct our modules in a specific way, we can easily “plug-in” a new module to be used in our app without having to change our codebase. So we can easily change from a login system to another or from a database implementation to another and so on.
Additionally, it will make us look to our code differently. Since we are not able to put all our business logic in the login_page.dart
file where we have all the widgets, we will have to create small units of code to manage a specific set of features of our app. This does not only have the benefit of making our code easier to understand, but it also makes our code easier to test, since we can easily create a test implementation for the LoginManager
or we can mock it with mockito
to have control over what each function should return.
Finally, we might wonder if we have the same added benefits as we see in Android Development in terms of compile times, and so we test it.
To test it, a 40+k line of code application was divided into 5 different modules and then it was tested the time that it takes to build the android app in both situations via the following bash script:
#!/bin/bash
flutter clean
cd android
./gradlew clean
cd ..
START="$(python -c 'import time; print int(round(time.time() * 1000))')"
flutter build apk -t lib/main_dev.dart --flavor dev
END="$(python -c 'import time; print int(round(time.time() * 1000))')"
TOTAL=$(($END-$START))
echo "Total time: $TOTAL"
In the end, after testing 5 builds for the modular and non-modular app, we see that the modular app has a near-zero increase of build time - 0.59 seconds. This is due to the fact that Dart compiles every dependency that is has for each run and as such we cannot have built modules as we have in the Native Android world.
Real-World applications for modularisation
Though we do like to have cleaner code, it is not often that we need to change an implementation back-and-forth from firebase login to server login, so are there use cases for this approach? Let us explore some possible applications for this approach.
Creating a mobile SDK
At the company I currently work on, we have an Identity Provider mobile app that can manage logins in a website via our service. One of our objectives was to give our clients the opportunity to integrate part of our code into their apps so that they could use our login feature without the need for the users to download a new app.
From the development point-of-view, this is almost exactly the same code that was used in the mobile app, so we could easily copy-and-paste-it into a new project and the case was solved.
However, what if we needed to change how we authenticate users into our system? Then we had two different code bases to change code in. The solution to this case was to divide the app into different modules, for example a login module, that can easily be used by the customers and by our app.
Using the same REST API calls
We may have to manage a handful of apps that all use the same API calls and data structure. Imagine a school that has an app that has different UI for students and teachers, with different data and functionality being shown in each case, but at the core it has 80% of the same API calls and data structures.
In this case, it is best to think of create a module just for the data layer with the common functionality so that each app can use it as a library.
Using a Clean-Architecture structure
We can also apply some of the fundaments of the Clean Architecture and divide our app into three separate layers:
- Data - in this layer we have all the API calls to the server and respective models,
remote
and all the shared-preferences and databases,local
. - UI - contains all classes that are used to show information to the user, widgets, BLoCs, utilitarian classes for UI-related code, the
MaterialApp
orCuppertinoApp
class, dimensions, assets paths and UI-only models. - Domain - this layer will be the bridge between the Data and the UI. Since it has a dependency on Data, it will call the necessary method to retrieve data from the API or database and map it in a way that is readable by the UI. Additionally, it will hold any business logic needed to manage the app. This layer only exposes methods to the UI, and it does not have any dependency to the UI.
By building a modular app where we create a data
, domain
and ui
modules, we can assure that for example the data layer will not have any reference to the UI or the domain
since it does not have any dependency to it.
This will also make it easier for teams to work in different parts of the project at the same time. Since the domain
knows that the method getBooks
will result in a List<Book>
, the developers working on the data
layer can easily change the URL endpoints or the implementation from http
to dio
without any problem if they can always output the same List<Book>
return type for the getBooks
method.
Conclusion
Though there are some real-world scenarios where dividing a Flutter app into different modules has an added benefit, it is a process that can be quite difficult to implement in a relatively-large codebase that does not have decoupled code or dependency injection.
It is also a task that requires a lot of planning, since it is easy to create several small modules that may lead to dependency issues and a lot of headaches in the long run.
However, it does force us to rethink the way that we build our apps and it is a good solution when working in project with large teams or when we have to ship part of our codebase as a SDK.
In the end, this will depend on the circumstances of each project. If we are working in a small app where we make a handful of API requests to one server it might be the best option, but if we have a common codebase that is used by 20 apps in our company, it may be the best approach to developing those apps.
What are your thoughts? Do you think that your codebase would improve if you divided it into modules?
The code for the example given in this article is hosted in Github:
-
Non-modular approach: GitHub - Vanethos/flutter_modular_app: Example of Flutter Modular App
-
Modular approach: GitHub - Vanethos/flutter_modular_app at feature/modular_approach
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