Перейти к основному содержимому

1 year in production with Flutter: lessons learned

· 19 мин. чтения
примечание

This is a text version of my presentation at DartUP 2020.

What do we do?

Our main product is a large and complex Property Management System. Also, we have a set of smaller products, one of which is a mobile application intended mainly for housekeepers and other hotel staff. Initially, it was written as a native application for iOS and Android, but about 1.5 years ago we decided to migrate it to Flutter.

In this article, I would like to share some problems we’ve encountered so far, describe the architecture we’ve eventually come to, what libraries we’ve found useful, and eventually, I’ll answer the question: was it a good idea to re-write our app from scratch using Flutter?

First of all, let me quickly describe the functionality of the application.

This is just a common B2B app with the functionality you would normally expect from it: authentication, profile management, messages and tasks, forms, and communication with backend API.

There are, however, a couple of things that are not so common. First of all, we have a fully custom UI, or more precisely, a custom design system (that stays on the same level of hierarchy as Material Design for Android or Cupertino Design for iOS). It’s used across all our applications and has implementations for mobile and web. Another thing, which may be not so common for the majority of apps, but is pretty standard in the enterprise world: the backend serves the whole set of products, it’s not "app-centric" —  it solves a lot of different tasks, so the models and flow on the backend don’t really match the models and flows that we define in the mobile app. It’s important because it has an impact on the overall app architecture.

Architecture

Mainly, we follow Clean Architecture principles. If I would have been asked to describe these principles in just a couple of words, I would go with layers and strict directions of dependencies between these layers.

For us, one of the major layers is the API layer, where we define DTOs and methods for communication with the backend. This is a common layer that is used across the whole app. All the other functionality is divided into a set of horizontal features. Each feature can be viewed as a standalone module with more or less complete functionality, so it should communicate with other features as little as possible.

For example, one feature can be “Task management”, another one: “Messaging system” or “Profile management”. Inside of each feature we have a vertical hierarchy of some common layers with the Business Logic Layer on top (where we define our app models and interfaces for fetching and posting data).

Next comes the Data Layer  —  this is where these interfaces are implemented, and this is the only layer that communicates with the API. As I’ve mentioned earlier, our backend is pretty common, so if you have an “app-centric” backend (it often makes sense if the mobile app is the main product of your company), you probably have a pretty close match between backend and app models. In this case, the Data Layer can be excessive and a more reasonable way could be to allow the Business Logic Layer to communicate directly with the API layer and reuse its DTOs as models. With code generation support you can easily set it up in this way, and you can remove a lot of code duplication.

At the bottom, there is the Presentation Layer. This is the only layer that knows about Flutter. It also knows about the Business Logic Layer and the Data Layer and acts as the “glue” by providing necessary dependencies and combining everything together.

BLoC

As the main pattern for implementing the architecture, we use BLoC. I won’t talk a lot about the pattern itself, there are a lot of resources describing it, but: there are two main components  —  UI (actually, any client) and BLoC itself which stands for Business Logic Component. BLoC is an entity that can receive a stream of events (which can be emitted by UI or by another BLoC). It transforms these events and maps them onto a stream of states (which in turn can be consumed by the UI to render the interface or by some other BLoC):

It’s very similar to the Redux pattern (e.g., we have unidirectional data flow), but it also has some differences: e.g., we don’t have a single store (state) for the whole application, instead, the application is considered as a set of sub-states (represented by BLoCs) communicating with each other.

What I especially like about the BLoC pattern — if we limit the number of states the app can be in, and define events forcing the transition from one state to another, we will get a State Machine.

From my experience, all of the business apps, at least the ones I’ve developed, can be naturally represented using this State Machine pattern:

In our project, we’re using BLoC pattern implementation provided by bloc library. Mostly, we follow the official guidelines regarding how to structure the app, but we have a couple of differences.

The first one is about BLoC to BLoC communication (dashed arrows on the next scheme).

As they propose in the official documentation, if you have BlocA depending on BlocB's state, you should pass BlocB as a dependency to BlocA. For me, it’s not a very clean solution, because BlocA now knows too much about other BLoCs. Instead, I prefer to define it such that BlocA has a dependency on Stream<StateB>(or Sink<EventB> if it needs to send events to BlocB). Of course, when you provide the dependency you can just pass BlocB directly (as it implements Stream<StateB> interface), but from the BlocA point of view, it doesn’t matter who exactly provides this stream of states. That allows passing some stub streams in our test code with predefined values, instead of mocking the whole BlocB class.

Another thing unrelated to the flutter_bloc library per se, but more about BLoC pattern usage: I often see that developers treat BLoCs as View Models and define their BLoC states very close to the UI level so that even things like confirmation dialogs and button texts are defined in the BLoC state. For me, it doesn’t make much sense, and I prefer having the UI state and UI logic defined in the UI layer. BLoC in my opinion should be responsible for higher level logic (app logic or business logic as the name suggests).

The best way to decide whether some logic belongs to the UI layer or to BLoC can be this thought experiment: imagine that at some point in time we decide to step away from Flutter, and even from GUI completely, and switch to CLI instead. In this case, all changes should, ideally, be done in the UI layer, and the BLoC layer shouldn’t be changed at all.


Now let’s talk about some principles we follow while developing the app.

I need to say that here in Mews as a whole (and in our mobile team in particular) we like functional programming. While we are not that deep into hardcore FP (one of the reasons is that Dart is not really the language for that), we try to at least extract practical and useful advice from FP principles and apply them in a way that they work together with OOP organically.

First, immutability. For us, it means that all our models, states, and events are immutable.

Next is the common use of pure functions. Our functions (our business logic functions, at least) are as free from side effects as possible.

Also, heavy usage of Algebraic Data Types. For example, we’re using co-product types (or sealed classes: basically a limited hierarchy where a class can have only a predefined set of subclasses) for states emitted by BLoCs and events they can accept. Another example can be the way we treat errors. In our business logic code, we banned using the throw keyword. Instead, we use a wrapper class, Either<E, R> which represents that the expected result can be either successful data or an error. In that case, a client using this returned result is forced to deal with possible failure.

Lastly (and I hope it’s a temporary hack rather than a long-running solution) is caused by the fact that we don’t have proper non-nullable types yet. It should be resolved in the near future, but for now, we have to deal with it, as it’s really a common source of errors. So, we decided in our business logic code to treat all types as non-nullable, and when we need to pass a null value we use the Optional<T> wrapper type instead.

Libraries

Now, let’s talk about libraries we’ve found useful. This is clearly not an exhaustive list of all the libraries we use in our app, but rather a starter kit that I would recommend for any app.

freezed

Code generation for immutable classes that has a simple syntax/API without compromising on the features.

First, freezed library. It’s based on code generation and removes a lot of boilerplate code if you need immutable or sealed classes.

This is how a typical BLoC events class looks in our application:


abstract class TasksEvent with _$TasksEvent {
const factory TasksEvent.fetchRequested() = FetchRequested;

const factory TasksEvent.fetchCompleted(Either<Exception, TasksData> result) =
FetchCompleted;

const factory TasksEvent.filtersUpdated(TaskFilters filters) = FiltersUpdated;

const factory TasksEvent.taskUpdated(Task task) = TaskUpdated;

const factory TasksEvent.taskCreated(Task task) = TaskCreated;

const factory TasksEvent.taskResolved(Task task) = TaskResolved;
}

Here we state that TasksBloc can only receive one of the predefined events. Later, in the TasksBloc class, we delegate these events to processing methods using the generated map method:


Stream<TasksState> mapEventToState(TasksEvent event) => event.map(
fetchRequested: _mapFetchRequested,
fetchCompleted: _mapFetchCompleted,
filtersUpdated: _mapFiltersUpdated,
taskUpdated: _mapTaskUpdated,
taskCreated: _mapTaskCreated,
taskResolved: _mapTaskResolved,
);

Stream<TasksState> _mapTaskCreated(TaskCreated event) async* {
// ...
}

The nice thing is that if (or when) at some point in time we add another event type, we’re forced to update this code as well, otherwise, it won’t compile.

The only disadvantage of this library, for me, is that currently it doesn’t play well with immutable collections, so we use a different library for them.

built_collection

Immutable collections based on the SDK collections. Each SDK collection class is split into a new immutable collection class and a corresponding mutable builder class.

built_collection library is not based on code generation. Instead, it provides some helper types, such as BuiltMap and BuiltList, and a number of helper methods for updating items in a collection using the Builder pattern.

Together with freezed library, we can use it like this:

yield state.copyWith(
tasks: state.tasks.rebuild((b) => b[createdTask.id] = createdTask),
);

flutter_bloc

Flutter Widgets that make it easy to implement the BLoC (Business Logic Component) design pattern. Built to be used with the bloc state management package.

As I’ve already mentioned, for BLoC pattern implementation we use the flutter_bloc library. This is how our typical BLoC looks like:


abstract class TasksState implements _$TasksState {
const factory TasksState({
ProcessingState<TaskFetchingError, EmptyResult> fetchingState,
ProcessingState<Exception, EmptyResult> updateState,
BuiltList<Department> departments,
TaskFilters filters,
BuiltMap<TaskId, Task> tasks,
}) = _TasksState;

const TasksState._();
}


abstract class TasksEvent with _$TasksEvent {
const factory TasksEvent.fetchRequested() = FetchRequested;

const factory TasksEvent.fetchCompleted(Either<Exception, TasksData> result) =
FetchCompleted;

const factory TasksEvent.filtersUpdated(TaskFilters filters) = FiltersUpdated;

const factory TasksEvent.taskUpdated(Task task) = TaskUpdated;

const factory TasksEvent.taskCreated(Task task) = TaskCreated;

const factory TasksEvent.taskResolved(Task task) = TaskResolved;
}

class TasksBloc extends Bloc<TasksEvent, TasksState> {

TasksState get initialState => TasksState(
tasks: BuiltMap<TaskId, Task>(),
departments: BuiltList<Department>(),
filters: TaskFilters());


Stream<TasksState> mapEventToState(TasksEvent event) => event.map(
fetchRequested: _mapFetchRequested,
fetchCompleted: _mapFetchCompleted,
filtersUpdated: _mapFiltersUpdated,
taskUpdated: _mapTaskUpdated,
taskCreated: _mapTaskCreated,
taskResolved: _mapTaskResolved,
);

Stream<TasksState> _mapTaskCreated(TaskCreated event) async* {
yield state.copyWith(updateState: const ProcessingState.loading());
final result = await _createTask(event.task);
yield* result.fold(
_triggerUpdateError,
(taskId) async* {
final createdTask = event.task.copyWith(id: taskId);
yield state.copyWith(
tasks: state.tasks.rebuild((b) => b[createdTask.id] = createdTask),
);
yield* _triggerUpdateSuccess();
},
);
}

// ...
}

Take a look at the _mapTaskCreated method: first of all, we emit the loading state and then we wait for the _createTask method to finish. This method is injected into the BLoC, the real implementation belongs to the Data Layer.

The result of this method is of type Either<Exception, TaskId>, so we map it to either error state or success state, then yield the corresponding state.

json_serializable

Automatically generate code for converting to and from JSON by annotating Dart classes.

Another library that is heavily used in the API Layer is json_serializable. It’s based on code generation and removes a ton of boilerplate code needed for serializing/deserializing DTOs and mapping them to Dart classes.

This is an example of a typical DTO:

()
class GetAllTasksRequest {
GetAllTasksRequest({
this.assigneeProfileIds,
this.departmentIds,
this.createdUtc,
this.deadlineUtc,
this.closedUtc,
this.state,
this.extent,
});

final List<String> assigneeProfileIds;
final List<String> departmentIds;
final TimePeriodDto createdUtc;
final TimePeriodDto deadlineUtc;
final TimePeriodDto closedUtc;
final TaskStateFilter state;
final ExtentDto extent;

Map<String, dynamic> toJson() => _$GetAllTasksRequestToJson(this);
}

retrofit

retrofit.dart is an dio client generator using source_gen and inspired by Chopper and Retrofit.

Together with the previous library, we heavily rely on retrofit to generate API methods implementation.

If you have experience in developing native Android apps, you’ve probably heard about the Android retrofit library that served as inspiration for this package. The idea is that you’re defining an interface with methods communicating with backend API, then the library generates the bodies of those methods, taking care of the serialization/deserialization of request and response DTOs:


()
abstract class RestClient {
factory RestClient(Dio dio) = _RestClient;


('/api/general/v1/users/signIn')
Future<SignInResponse> signIn(() SignInRequest request);


('/api/general/v1/users/resetPassword')
Future<EmptyResponse> resetPassword(
() ResetPasswordRequestDto request,
);

('/api/commander/v1/tasks/getAll')
Future<GetAllTasksResponseDto> getTasks(() GetAllTasksRequest request);

('/api/commander/v1/tasks/add')
Future<TaskDto> createTask(() CreateTaskDto request);
}

const anonymous = Extra({'isAnonymous': true});

provider

A wrapper around InheritedWidget to make them easier to use and more reusable.

Finally, provider library which we use mainly for DI in a Flutter way:


Widget build(_) => MultiProvider(
providers: [
Provider<AnalyticsManager>.value(value: _analyticsManager),
Provider<CurrentRouteObserver>.value(value: _routeObserver),
Provider<LocalisationManager>.value(value: _localisationManager),
Provider<AuthenticationManager>.value(value: _authenticationManager),
Provider<RestClient>.value(value: _restClient),
Provider<ImageUrlProvider>.value(value: _imageUrlProvider),
],
child: App(),
);


_analyticsManager ??= Provider.of(context)
..setCurrentScreen(_getScreenPath(_currentPage));

Teamwork

Let’s talk about teamwork now, because, if you have more than one developer, you already have a team, and you have to define some best practices and find some compromise on how to structure your code, etc.

Formatting

This is the thing I really like about Dart: it comes with the dartfmt tool, so there’s only one way to properly format your code. Actually, the official formatting rules sound like “whatever dartfmt produces as output.” That eliminates all the arguing about how to format the code (except for where to put trailing commas, maybe). We even set up our CI machine in a way that it fails the build if the code is not properly formatted. The only thing I don’t like about dartfmt is that it uses an 80-character line limit.

Linus Torvalds

…for chrissake, don’t try to make 80 columns some immovable standard.

Luckily, dartfmt allows you to override this setting. While you cannot tweak the lint rule (either you enable lines_longer_than_80_chars rule or disable it at all), you can at least run dartfmt command with -l parameter. For our project, we decided to go with a 120 column limit.

Analyzer

The next step is a static analysis. Again, it’s nice that Dart has a powerful and customizable static analyzer out of the box. My only concern about it is that by default it’s too forgiving. My common recommendation is to make it as strict as possible.

You can go through all the rules provided by the analyzer and override levels of each rule: whether you want it to be an error, a warning, or you prefer to disable it completely.

We have a lot of rules defined as warnings — when you’re in a development phase playing with your code, you obviously don’t want to break the hot reload just because the code isn’t properly formatted, or the variable isn’t used. At the same time, CI machines are set up in a way that the build will fail with both errors and warnings.

If you don’t want to go through all the rules and define your own levels, you can use one of these community packages:

CI/CD

Now it’s time to talk about CI/CD in more detail. This is the point where I should say: “Don’t follow our way.” We use Azure Pipelines to be aligned with the rest of the company. While it’s really powerful and customizable, it’s not specialized for Flutter or even mobile CI/CD flows. For example, if you’re using Microsoft-hosted agents, they don’t have Flutter pre-installed — this means that every build needs to start with installing Flutter. Also, build steps are defined using some low-level stuff (like bash scripts).

If you’re looking for some Flutter-centric CI/CD service, there’s a number of alternatives:

  • Bitrise. The free plan includes 1 concurrency, 30 mins/build timeout, and 200 builds a month.
  • Codemagic. You get 500 build minutes, macOS standard VM, and 120 min build timeout for free.
  • Appcircle. It also provides a free plan with 1 concurrent build, 25 minutes max build time per build, 5 minutes/month device previews, and 100 tester emails/month.

I’m not sure about Appcircle, but Bitrise and Codemagic even have seamless integration with the AWS device farm. That means that you can easily run your UI tests on the whole fleet of real devices provided by AWS. It’s not for free, of course, you have to pay Amazon for that, but having a simple-to-set-up-integration is a nice option.

I’ve used Codemagic for one of my side projects, and it’s really very easy to set up. If you have a common Flutter CI workflow, it’s a nice service to try without worrying about maintenance.

You can also use GitHub actions, but it’s a common-purpose CI/CD service as well, so setup can be more complex. It has a free plan with 500 MB storage and 2,000 minutes a month, but there’s one important notice there: if you’re using a macOS agent (and you have to, at least for building an iOS version), the time spent by the machine is multiplied by 10! So, if you’re only using macOS agents you have 200 free minutes, not 2,000.

Pitfalls

Now, let’s talk about some pitfalls or, at least, things you need to pay attention to while developing your Flutter application.

For me, a major one is error handling. If you have an exception in Dart code, your application won’t crash. For me, as a mainly native developer, this was quite confusing at first. It’s crucial to use a proper error reporting tool when you’re publishing your application. We use Sentry (with sentry library) for that.

Another thing to keep in mind: Flutter is a cross-platform framework with all the limitations that come from that. Also, Flutter is relatively new. One problem we’ve encountered was the lack of password manager integrations (this issue should be resolved now). From time-to-time issues like this will arise: as a cross-platform framework, Flutter cannot be on the same pace with native frameworks. Once new functionality is introduced in new versions of native frameworks, some time is required for Flutter to fully adopt it.

Text ellipsizing was another unpleasant issue — the behavior is really not user-friendly and there’s no easy solution to it so far as it requires some rendering engine integration refactoring.

I’ve already mentioned the next issue, the infamous “billion-dollar mistake.” According to our error reporting tools, the majority of errors were caused by NoSuchMethodError (the analog of NullPointerException in Java). Yes, hopefully, it will be obsolete soon, when NNBD types are adopted by the framework itself and all the libraries, but for now, we have what we have.

And one more point I’d like to mention here is what I call magic. And unfortunately, it’s not some kind of good magic. From time to time, you will get some weird error messages (it happens more often with iOS builds), and you will have to do some voodoo steps to resolve them: restart your IDE, turn off and on your PC, run flutter clean, repeat these steps in some different order. Sometimes it helps and the errors are gone. But sometimes it doesn’t, and, when it doesn’t, you’re lucky if you have native developers on your team, otherwise these build errors (especially the ones that Xcode produces) can be really frustrating.

Was Flutter a good choice?

So now it’s time to answer that question. Did it make sense to re-write the app from scratch in Flutter? Was I right back then, convincing all the stakeholders to try new technology? Would I make this choice again?

Despite all the limitations I’ve talked about, some concerns, and maybe an unclear future (yeah, Google is famous for killing projects), I think it was a good choice. Flutter perfectly suits us. One of the major reasons, as I mentioned before, is our custom design system. With Flutter we need to implement it only once, and it works the same way for both platforms. It really boosts up development. Flutter has nice tools for implementing heavily customized UI, so it’s easier than doing it even for a single platform natively (especially, in Android where the native UI layer is a total mess).

In terms of a team: currently we have 4 mobile developers (including me). We all come mainly from native Android backgrounds and the transition to Flutter was really smooth. In most cases, everything works the same way on both platforms, so the speed of development has increased and versions for both iOS and Android are published practically at the same time.

Honestly speaking, Dart is not my favorite language. I really miss some of Kotlin’s features, but with code generation and with all the libraries I’ve mentioned, even the business logic can be developed in a straightforward and maintainable way. I’m happy that we only need to implement this logic once, and especially that we only need to develop UI once, otherwise, we would need at least 1.5 times more developers and all the drawbacks that come from having a larger team.


That’s it. If you have any questions feel free to contact me on Twitter or LinkedIn.