This is a text version of my presentation at DartUp 2020 (in English). In it, I share the problems we encountered, discuss our architectural approach, talk about useful libraries, and answer the question whether this idea was successful - to take and rewrite everything.
What are we doing?
Our main product is a hotel management system. Big and complex. There are also a few smaller products, one of which is a mobile application designed primarily for hotel staff. Initially, it was a native app for Android and iOS, but about a year and a half ago we decided to rewrite it in Flutter. And they rewrote it.
First, a few words about the application itself.
In general, this is the most common B2B application with everything you can expect from it: authorization, profile management, messages and tasks, forms and interaction with the backend.
, . -, UI, - ( Material Design Cupertino Design, ). , , . -, , .. , . , , .
. , .
API. DTO . , . . β , .
, β " ", β "", β " ". - ( / ).
β . -. , API. , , ( , β ), . , , - API DTO . , .
. Flutter. - , "" , .
BLoC
BLoC. , , : UI- ( , ) BLoC (Business Logic Component, -). BLoC β , ( UI, BLoC). BLoC , , , UI ( ) BLoC:
Redux (, ), : , store . BLoC', "-".
, β , , - , :
, - ( , , ) .
BLoC bloc. , , .
BLoC' ( ).
: BlocA
, BlocB
, BlocB
BlocA
. , , BlocA
BLoC'. BlocA
Stream<StateB>
( Sink<EventB>
, - BlocB
). , BlocB
( Stream<StateB>
Sink<EventB>
), BlocA
, StateB
. , , Stream<StateB>
BlocB
.
flutter_bloc
, : , BLoC ViewModel, UI-, , . , , UI UI. BLoC ( , -, ).
, β UI BLoC β : , - Flutter', GUI , CLI. , , UI-, BLoC' .
, .
, , , , . ( , Dart β ), , , .
: . , , , , .
β . ( , ).
, . , BLoC' (aka sealed classes β , ). β . - throw
. Either<E, R>
, , , . , , .
( , ), - , NNBD , - null
. , , - non-nullable, " " Optional<T>
.
. , , ; , .
-, freezed β , , - sealed Dart'.
- :
@freezed
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;
}
, TasksBloc
. , TasksBloc
, , map
:
@override
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* {
// ...
}
( ) , , .
, , , . .
, , BuiltMap
BuiltList
+ , Builder.
- :
yield state.copyWith(
tasks: state.tasks.rebuild((b) => b[createdTask.id] = createdTask),
);
, BLoC. - :
@freezed
abstract class TasksState implements _$TasksState {
const factory TasksState({
@required ProcessingState<TaskFetchingError, EmptyResult> fetchingState,
@required ProcessingState<Exception, EmptyResult> updateState,
@required BuiltList<Department> departments,
@required TaskFilters filters,
@required BuiltMap<TaskId, Task> tasks,
}) = _TasksState;
const TasksState._();
}
@freezed
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> {
@override
TasksState get initialState => TasksState(
tasks: BuiltMap<TaskId, Task>(),
departments: BuiltList<Department>(),
filters: TaskFilters());
@override
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();
},
);
}
// ...
}
_mapTaskCreated
: "", _createTask
. , .
Either<Exception, TaskId>
, "", "", .
API. , , / DTO / Dart-.
, DTO :
@JsonSerializable()
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);
}
API.
Android, . β , , :
@RestApi()
abstract class RestClient {
factory RestClient(Dio dio) = _RestClient;
@anonymous
@POST('/api/general/v1/users/signIn')
Future<SignInResponse> signIn(@Body() SignInRequest request);
@anonymous
@POST('/api/general/v1/users/resetPassword')
Future<EmptyResponse> resetPassword(
@Body() ResetPasswordRequestDto request,
);
@POST('/api/commander/v1/tasks/getAll')
Future<GetAllTasksResponseDto> getTasks(@Body() GetAllTasksRequest request);
@POST('/api/commander/v1/tasks/add')
Future<TaskDto> createTask(@Body() CreateTaskDto request);
}
const anonymous = Extra({'isAnonymous': true});
, , .
β , , : , , ..
Dart', dartfmt
, . , , ", dartfmt
". , , ( ). , CI-, PR' . , , 80 . :
ββ¦for chrissake, donβt try to make 80 columns some immovable standard.β
Linus Torvalds
, dartfmt
-l
( , lines_longer_than_80_chars
). , 120 β .
Dart' β . , . β .
, / (//).
, , , ( , , , ); , CI- PR.
, :
pedantic β ;
effective_dart β Effective Dart;
mews_pedantic β .
CI/CD
CI/CD, : " , ". Azure Pipelines ( ), , , Flutter, . , , Flutter', . β YAML bash-.
, Flutter', - :
, Appcircle, Bitrise Codemagic AWS device farm β .. UI- ( ).
- Codemagic β , .
GitHub Actions, , Azure Pipelines β Flutter. 500 MB 2.000 , : macOS ( , , iOS), 10! .., macOS-, 2.000 , 200.
Flutter.
β . Dart' , . , , . , sentry.
, , Flutter β - , . , Flutter . , , ( ). , β - Flutter .
text ellipsizing ( - ?) , , .
( , , ) β NoSuchMethodError
(, Java NullPointerException
). , , Flutter' , β , .
( ). , ( , iOS ). , : " ? IDE ? flutter clean
? ?" β . , , , ( , Xcode).
, ?
. " "? , ? ?
, . . , Google . Flutter . UI β UI- Android-, . ...
: 4 ( ). , , . Android-, Flutter . , , ( ).
To be honest, I'm not a Dart fan. I really miss the capabilities of Kotlin, but code generation and the mentioned libraries partially save. If you try, even business logic can be written at a pretty decent level. And the ability to write once and run everywhere (including the UI) outweighs many disadvantages. Without Flutter, we would need at least 1.5 times more developers - with all that it implies.
Flutter is certainly not a silver bullet. She's not there at all, they say. Flutter is a tool, and when used as intended, it is a great tool.