[Перевод] 1 год с Flutter в продакшне
Это текстовая версия моей презентации на DartUp 2020 (на английском). В ней я делюсь проблемами, с которыми мы столкнулись, обсуждаю наш архитектурный подход, рассказываю о полезных библиотеках, ну и отвечаю на вопрос, удачной ли была эта идея — взять и всё переписать.
Что мы делаем?
Наш основной продукт — система управления отелями. Большая и сложная. Еще есть несколько продуктов поменьше, один из которых — мобильное приложение, предназначенное, в основном, для обслуживающего персонала отелей. Изначально это было нативное приложение под Android и iOS, но примерно полтора года назад мы решили переписать его на Flutter. И переписали.
Для начала, пара слов о самом приложении.
В общем-то, это самое обычное B2B-приложение со всем, что от него можно ожидать: авторизация, управление профилем, сообщения и задачи, формочки и взаимодействие с бэкендом.
Однако, есть пара не совсем стандартных моментов. Во-первых, у нас не просто кастомный 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
(или Sink
, если ему надо передать какое-то событие в BlocB
). Конечно, при создании объекта мы можем передать сам BlocB
(поскольку он реализует интерфейсы Stream
и Sink
), но с точки зрения блока BlocA
неважно, откуда приходит это состояние StateB
. Поэтому, например, в тестах мы может просто передать нужный нам Stream
и не заморачиваться моками всего класса BlocB
.
Второй момент связан не столько с библиотекой flutter_bloc
, сколько с паттерном в принципе: я часто вижу, что разработчики используют BLoC как ViewModel, и состояние делают очень близким к UI-слою, так что даже такие вещи, как диалоги подтверждения или текст кнопки задаются в состоянии блока. По мне, в этом мало смысла, я предпочитаю состояние и логику UI держать в слое UI. BLoC должен отвечать за логику более высокого уровня (логику приложения, или даже бизнес-логику, как подсказывает название).
Проще всего определить, куда вынести логику — в слой UI или в BLoC — проведя мысленный эксперимент: допустим, что в какой-то момент мы решили избавиться от Flutter’а, или даже от GUI вообще, и перейти на CLI. В этом случае все изменения, в идеале, должны затронуть только UI-слой, и BLoC’и не изменятся.
Давайте теперь поговорим о принципах, которых мы придерживаемся при разработке.
Надо сказать, что в компании в целом, и в нашем отделе мобильной разработки, в частности, мы любим функциональное программирование. И хотя в хардкорное ФП мы не погружаемся (как минимум потому, что Dart — не самый подходящий для этого язык), мы стараемся, по крайней мере, взять наиболее практичные и полезные принципы из ФП и органично вписать их в ООП.
Первый принцип: иммутабельность. Для нас это значит, что, например, все модели, состояния и события представлены иммутабельными классами.
Следующий принцип — повсеместное использование чистых функций. Мы предпочитаем убрать все побочные эффекты из функций (по крайней мере, в слое бизнес логики).
Кроме того, очень поощряется использование алгебраических типов данных. Например, для состояний и событий в BLoC’ах мы используем копродукты (aka sealed classes — грубо говоря, жесткая иерархия классов с ограниченным набором подклассов). Другой пример — работа с ошибками. В слое бизнес-логики мы никогда не используем throw
. Вместо этого мы используем вспомогательный класс Either
, который определяет, что результатом могут быть либо нужные данные, либо ошибка. И клиент, получая этот результат, вынужден предусмотреть поведение в случае ошибки.
Ну и наконец (я очень надеюсь, что это лишь временный костыль), из-за того, что поддержку NNBD нам еще не завезли, приходится что-то делать с null
. Поскольку это основной источник ошибок, мы договорились, что в слое бизнес-логики все типы используются как non-nullable, и для ситуации «нет значения» мы ввели вспомогательный тип Optional
.
Библиотеки
Теперь поговорим о полезных библиотеках. Понятно, что это не исчерпывающий список всех библиотек, которые есть в приложении; это скорее те библиотеки, которые я использую практически в каждом проекте.
freezed
Во-первых, freezed — библиотека, основанная на кодогенерации, которая позволяет хоть как-то смириться с отсутствием sealed классов в Dart’е.
Типичный класс с событиями в нашем приложении выглядит как-то так:
@freezed
abstract class TasksEvent with _$TasksEvent {
const factory TasksEvent.fetchRequested() = FetchRequested;
const factory TasksEvent.fetchCompleted(Either 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 mapEventToState(TasksEvent event) => event.map(
fetchRequested: _mapFetchRequested,
fetchCompleted: _mapFetchCompleted,
filtersUpdated: _mapFiltersUpdated,
taskUpdated: _mapTaskUpdated,
taskCreated: _mapTaskCreated,
taskResolved: _mapTaskResolved,
);
Stream _mapTaskCreated(TaskCreated event) async* {
// ...
}
Если (или когда) мы добавим еще одно событие, нам придется обновить и этот код, иначе он просто не скомпилируется.
Самый большой недостаток этой библиотеки, на мой взгляд, в том, что она не работает с иммутабельными коллекциями. Для них мы используем отдельную библиотеку.
built_collection
В ней нет никакой кодогенерации, вместо этого библиотека предлагает несколько вспомогательных типов, таких как BuiltMap
или BuiltList
+ методы для обновления коллекций, основанные на паттерне Builder.
Вместе с предыдущей библиотекой получается что-нибудь такое:
yield state.copyWith(
tasks: state.tasks.rebuild((b) => b[createdTask.id] = createdTask),
);
flutter_bloc
Про эту библиотеку я уже говорил, мы используем ее для реализации BLoC. Как-то так:
@freezed
abstract class TasksState implements _$TasksState {
const factory TasksState({
@required ProcessingState fetchingState,
@required ProcessingState updateState,
@required BuiltList departments,
@required TaskFilters filters,
@required BuiltMap tasks,
}) = _TasksState;
const TasksState._();
}
@freezed
abstract class TasksEvent with _$TasksEvent {
const factory TasksEvent.fetchRequested() = FetchRequested;
const factory TasksEvent.fetchCompleted(Either 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 {
@override
TasksState get initialState => TasksState(
tasks: BuiltMap(),
departments: BuiltList(),
filters: TaskFilters());
@override
Stream mapEventToState(TasksEvent event) => event.map(
fetchRequested: _mapFetchRequested,
fetchCompleted: _mapFetchCompleted,
filtersUpdated: _mapFiltersUpdated,
taskUpdated: _mapTaskUpdated,
taskCreated: _mapTaskCreated,
taskResolved: _mapTaskResolved,
);
Stream _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
, мы отображаем его либо на состояние «Ошибка», либо на состояние «Успех», и переходим в соответствующее состояние.
json_serializable
Эта библиотека используется в основном в слое API. Она экономит кучу времени, нервов и кода, поскольку умеет генерировать код для сериализации/десериализации DTO и преобразования в/из Dart-классов.
Например, DTO может выглядеть так:
@JsonSerializable()
class GetAllTasksRequest {
GetAllTasksRequest({
this.assigneeProfileIds,
this.departmentIds,
this.createdUtc,
this.deadlineUtc,
this.closedUtc,
this.state,
this.extent,
});
final List assigneeProfileIds;
final List departmentIds;
final TimePeriodDto createdUtc;
final TimePeriodDto deadlineUtc;
final TimePeriodDto closedUtc;
final TaskStateFilter state;
final ExtentDto extent;
Map toJson() => _$GetAllTasksRequestToJson(this);
}
retrofit
Дружит с предыдущей библиотекой и генерирует реализацию методов в слое API.
Если у вас есть опыт нативной разработки под Android, то про одноименную библиотеку вы наверняка слышали. Эта библиотека работает по тем же принципам — мы определяем интерфейс класса, работающего с бэкендом, а библиотека генерирует его реализацию:
@RestApi()
abstract class RestClient {
factory RestClient(Dio dio) = _RestClient;
@anonymous
@POST('/api/general/v1/users/signIn')
Future signIn(@Body() SignInRequest request);
@anonymous
@POST('/api/general/v1/users/resetPassword')
Future resetPassword(
@Body() ResetPasswordRequestDto request,
);
@POST('/api/commander/v1/tasks/getAll')
Future getTasks(@Body() GetAllTasksRequest request);
@POST('/api/commander/v1/tasks/add')
Future createTask(@Body() CreateTaskDto request);
}
const anonymous = Extra({'isAnonymous': true});
provider
Эта библиотека у нас, в основном, для внедрения зависимостей.
Командная работа
Даже два разработчика — это уже команда, соответственно, надо организовать командную работу: определить лучшие практики, правила форматирования, структуру проекта и т.д.
Форматирование
Вот что мне нравится в 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.
Если вручную переопределять все правила лень, можно воспользоваться готовым пакетом:
CI/CD
Что касается CI/CD, то тут я скажу: «Не делайте так, как мы». Мы используем Azure Pipelines (политика компании), и хотя они настраиваемые и довольно мощные, под мобильную разработку, а тем более под Flutter, они не заточены. Поэтому, например, на облачных агентах даже нет предустановленного Flutter’а, и его надо с каждым билдом устанавливать заново. Ну и сами шаги — это дикая смесь YAML и bash-скриптов.
Вместо этого, чисто для Flutter’а, лучше взять что-нибудь более специализированное:
Bitrise — в бесплатном плане будет 1 параллельный запуск, таймаут в 30 минут на билд и 200 запусков в месяц.
Codemagic — 500 минут в месяц, macOS и 120 минут на билд безвозмездно, то есть даром.
Appcircle. Тоже есть бесплатный план с 1 параллельным запуском и 25-минутным тайм-аутом.
Кстати, не уверен насчет 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 дался очень легко. В большинстве случаев всё работает одинаково, так что скорость разработки сильно возросла, и новые версии под обе платформы выходят практически одновременно с одними и теми же фичами (и багами).
Честно говоря, я не фанат Dart’а. Мне очень не хватает возможностей Kotlin’а, но кодогенерация и упомянутые библиотеки частично спасают. Если постараться, то даже бизнес-логику можно написать на вполне приличном уровне. А возможность написать один раз и запускать везде (в том числе, и UI) перевешивает многие недостатки. Без Flutter’а нам бы понадобилось, по крайней мере, в 1,5 раза больше разработчиков — со всеми вытекающими.
Flutter, конечно, не серебряная пуля. Её вообще нет, говорят. Flutter — это инструмент, и если его применять по назначению, это шикарный инструмент.