Recently, I had the opportunity to lead a mobile app development project where we decided to use the Flutter framework. In this post, I want to share my experience with Flutter in a real project, focusing on the disappointments I faced.
Why did I choose Flutter?
Truth is we had the freedom to choose any mobile technology we wanted, from native to cross-platform, and we were almost choosing the popular React Native framework if it weren’t for an unsolved issue. We ended up choosing Flutter due to development speed and ease of accessing native features.
The dark side…
Despite being a Flutter enthusiast, it is up to a good software engineer to accept that there is no language, framework, or technology that is a solution to all problems. There is no silver bullet, and at some point, it may not be useful for what you’re trying to achieve.
“The best thing you can do over your career is not choosing a side, never have a position. […] You know you’re vulnerable when someone criticizes your technology and you get offended, getting offended is an option” — Akita, FABIO Nov 2018
Below I will discuss a few disappointments, but fear not! Solutions exist for these problems, at least to some extent.
First disappointment: The expectations, tests, and architecture
My expectations were high! I was excited to start creating something with native performance, easy and pleasant interface, and last but not least: a software with well-written tests! Yes, due to the good practices and software quality policies we follow at Codeminer 42, I really wanted to apply everything I realized was cool from the Rails community on the project I was about to start.
class MyIp {
MyIp(this._client);
final Client _client;
Future<String> get ipAddress async {
final apiResult = await _client.get('https://api.ipify.org/?format=json');
return json.decode(apiResult.body)['ip'];
}
}
// Testing code
test('it get my ip', () async {
final fakeClient = MockClient((_) async =>
Response(json.encode({"ip": "192.168.0.1"}), 200)
);
final myIp = MyIp(fakeClient);
expect(await myIp.ipAddress, "192.168.0.1");
});
In theory, writing integration, widget, or unit tests should not be complicated. The problem is in the definition of your architecture, which can hamper the way you test. For example, in native Android code with Mockito, you can perform the mock of any instance of a given class, which could be done with Dart’s implementation of Mockito as well. However, it would imply taking instances of dependencies as parameters to the constructor via dependency injection.
What’s exactly the problem?
Maybe you are thinking “The code above was really simple and easy to test, what’s the problem?”
You’ve probably heard about “State Management”, the hell of popular front-end frameworks, haven’t you? Well, if not, I strongly recommend you read about it.
Before we dive deeper into the problem, let me explain a little about what happens! Just keep in mind, what we’ve done was just a unit test!
The diagram below shows the widget render tree of an assumed ContactScreen
component, which uses MyIp
to display IP information within the Text Widget. Note that we are passing down an HttpClient
property just because we’ll need another kind of test, like a widget or integration test.
A simple widget render tree
This is the problem of state management. Imagine when your app grows — how many times will you have to pass your property down, component by component, just to get a component under integration test?
HttpClient
isn’t “a state”, but we have to pass it down regardless. Since in Dart we can’t mock already created instances, instead we need to pass down an already mocked instance. That’s bad, right?
An alternative is to hardcode the dependency instance during instantiation of the class, but it will be harder to test your software that way:
class MyIp {
final _client = Client();
Future<String> get ipAddress async {
final apiResult = await _client.get('https://api.ipify.org/?format=json');
return json.decode(apiResult.body)['ip'];
}
}
Do I need to always create constructors that receive the dependencies as parameters?
The truth is, even if you don’t want to write tests for your software, you will face the difficulty of passing down properties every once in a while. And yes, you will be supposed to figure out a way to manage the state.
While looking for a solution, I realized things were harder than I expected. Even though it’s getting bigger and bigger, the Flutter community doesn’t have the habit of writing tests. After some time in a conversation with a co-worker, I’ve discovered a few packages to make dependency injection more manageable, which would supposedly solve this problem.
Dependency Injection
The path for solutions
I had never really implemented dependency injection with an IoC framework or DI library, so I started to look for ways to do it and stumbled across the following options:
- Flutter Simple Dependency Injection by Jon Samwell
- Inject by Google
Comparing both of them, I felt compelled to use “Inject”. The resolution of dependencies at compile time captivated me but the package was tagged as “developer preview”.
On the other hand, It was my first real project using such a framework, and I faced difficulties that would hinder the delivery time of the project. So I chose the simplest and most functional alternative, which turned out to be a good fit! (Thanks, Jon Samwell!)
A little about “Flutter Simple Dependency Injection” (FSDI)
“A super simple dependency injection implementation for Flutter that behaves like any normal IoC container and does not rely on mirrors”.
To use FSDI, we need to know about the map
method, which maps our dependencies to the injector container. Conversely, the get
method is used to retrieve the mapped dependencies.
Important notice for Dart students: base knowledge about Dart Generic Types is required because the map
and get
methods rely on it to manage dependencies. Since Dart is an optionally typed language, the use of Generics enforces a restriction on the data types of the values, for example:
injector.map<SomeService>((i) => SomeService());
injector.get<SomeService>();
One killer feature of FSDI is that dependency resolution happens at runtime, while in Inject by Google it happens at compile-time. So FSDI is a nice solution for my testing problem because I can provide mock implementations per test, in an ad-hoc fashion:
class MyIp {
Injector get _injector => Injector.getInjector();
Client get _client => _injector.get<Client>();
Future<String> get ipAddress async {
final apiResult = await _client.get('https://api.ipify.org/?format=json');
return json.decode(apiResult.body)['ip'];
}
}
// Testing
void main() {
setUpAll(() {
final injector = Injector.getInjector();
final fakeClient = MockClient((_) async =>
Response(json.encode({"ip": "192.168.0.1"}), 200)
);
injector.map((i) => fakeClient);
});
test('it returns stubbed ip', () async {
final ipAddress = await MyIp().ipAddress;
expect(ipAddress, '192.168.0.1');
});
}
FSDI has other interesting features, such as:
- Support for multiple injectors (useful for unit testing or code that runs in isolation),
- Support for types and named types,
- Support for singletons,
- Support for primitive values (useful for configuration parameters like API keys or URLs.)
Is a library for managing Dependency Injection the solution?
As I said, there is no silver bullet! For this given context, it was a good fit, even though other patterns managed to structure the application in a scalable way as well, like the Container Pattern.
Second disappointment: data persistence, reflection, generators, and ORM
A very important requirement for a mobile application is that it should work even with no internet connection, which led to the decision of using the Repository Pattern. A new disappointment started when I had to perform local database queries. Even with Sqflite, a great plugin for SQlite, reusing queries with a lot of joins ended up being a complicated task.
Feeling excited again, especially because I’ve heard about class reflections, I thought to myself: “I will create a simple ORM to help me now and in future projects”. I already had a notion of what I was going to do, until …. oops, I discovered that the package for class reflection (dart:mirrors
) is not available for Flutter, only for Dart due to the AOT build process.
Another problem? No god, please!
So I began my search for an ORM. I learned a lot in this process because that’s how I realized how other teams handle this situation, whereby I learned about generators.
This led me to the Jaguar ORM, which is actually part of a framework. The lib was of great help! I confess that at the beginning I was not really in favor of using generators but now I am very grateful for their creators. I even contributed to the project as a way to pay back.
Jaguar ORM
But, what is “Generators”?
The idea is simple: for all annoying tasks you do over and over in your code base, you can create or use a generator tool to produce final code using annotations as the source.
A cool example is the json_serializable package, which automatically generates code for converting to and from JSON by annotating Dart classes:
@JsonSerializable(nullable: false)
class Person {
final String firstName;
final String lastName;
Person({this.firstName, this.lastName});
factory Person.fromJson(Map<String, dynamic> json) => _$PersonFromJson(json);
Map<String, dynamic> toJson() => _$PersonToJson(this);
}
Person _$PersonFromJson(Map<String, dynamic> json) {
return Person(
firstName: json['firstName'] as String,
lastName: json['lastName'] as String);
}
Map<String, dynamic> _$PersonToJson(Person instance) => <String, dynamic>{
'firstName': instance.firstName,
'lastName': instance.lastName
};
What happens there? Well, the code in json_serializable.g.dart
is automatically generated through the @jsonSerializable
annotation from the Person
class. However, as you can see, we still have to write the fromJson
and toJson
methods, which makes the solution not so DRY.
The conclusion is that generators make up for the lack of class reflections that would otherwise be available through dart:mirrors
, all due to Flutter’s AOT (Ahead-of-Time) build process. That said, never forget about DRY (Don’t Repeat Yourself)!
P. S.
Another annoying issue with testing regards the Flutter Driver used to write integration tests, even though this problem didn’t affect my project in particular.
Flutter uses Skia as Graphics render, and there are some points that you may get disappointed with:
- Firebase Test Lab: Using it is not possible. Since Skia *doesn’t render native elements, the Test Lab robot can’t interact with the app because it doesn’t know what’s clickable. At this point, you should test using Flutter Drive.
- Native integrations: Some integrations are able to display native elements, such as dialogs asking for permission. The problem is that Flutter Driver can’t interact with them. As a workaround to handle Android Runtime Permissions, you can use adb to grant permission to a connected device. (Issue #12561 )
About iOS, there isn’t a known workaround. What you can try to do is mock data to avoid displaying this kind of native element.
Any other disappointment?
No! These were the only two disappointments I had, which I devoted myself to finding solutions. Sometimes I drew inspiration from similar communities and at other times this was not possible.
I continued development with other incredible libs like Flutter Secure Storage to store my JWT token and Dio to create request interceptors, dramatically increasing project scalability with no problems at all.
The good parts
CI/CD — Codemagic
Now let’s get to some good parts. The first one is Codemagic, which helped generate releases and send them out to the project’s client.
Codemagic is the first CI/CD tool exclusively for Flutter apps, engineered by Flutter fans and launched on Flutter Live. Codemagic enables users to build Flutter apps hosted on GitHub with minimum effort and maximum speed thanks to preconfigured defaults.
Codemagic — publishing settings
With Codemagic, I was able to set up the delivery of staging and signed (for Google Play and iTunes) releases.
You can even create several builds with custom configurations such as the tasks to be run.
Issue tracker — Sentry
In order to monitor the application, be it in production or staging, I decided to use Sentry. With a few lines of code, it was possible to know when my app broke: where, why, which version, the logged in user and any other details I wanted to send to facilitate troubleshooting later. It also has weekly reports that allow tracking the graphs of issue records.
Reports receive on e-mail
Publishing the app
The path to publishing a Flutter application is the same as a native application, which means you will need to generate a certificate to sign the application to release a version in Android.
iOS also follows the same standards, so you will need to register the Bundle ID. Also, a registered iOS device will be necessary to generate the certificate.
One important point to note, especially for the novice community just starting out with cross-platform, is to always be aware of the settings before releasing an application, due to each submission burning you an entire version number of your application. The trick is to follow the checklist provided by the Flutter team, available in the documentation. You can check it out on:
The app I worked on is an extension of a service owned by my client. Some functionalities have not been implemented, like the creation of accounts, which has to be performed manually after establishing a contract with the user.
Following this perspective, the application asks the user for login credentials after launching, but so far there isn’t an option to create an account. As a result of this, Apple requested more details about the application: how to create an account and whether it would require paying to do so.
After responding to the form with a test account, Apple replied that the app was ready to be published.
Both publishings were approved
Wrapping everything up
The purpose of this article wasn’t to say that “Flutter is good” or “Flutter is bad”, but to show that each technology is targeted toward a certain class of problems. In some situations, it may not be a good fit and that’s OK.
Despite the difficulties, Flutter worked like a charm for my application. I hope that the decisions taken during this project will serve in some way as a reference in your choice for which technology to use in your next mobile project.
If you also had some bad experiences with Flutter or other cross-platform frameworks, leave it up in the comments! It will be a great reference to keep improving cross-platform frameworks. I also would like to recommend a talk from DartConf by Faisal where you can see good practices to solve some issues and improve development speed:
Thanks to Talysson de Oliveira, Leonardo Negreiros, Gustavo Valvassori, and Gustavo Fão Valvassori.
We want to work with you. Check out our "What We Do" section!