Как использовать BLoC для совместного использования кода во Flutter и AngularDart
Опубликовано: 2022-03-11В середине прошлого года я хотел портировать приложение для Android на iOS и в Интернете. Flutter был выбором для мобильных платформ, и я думал о том, что выбрать для веб-стороны.
Несмотря на то, что я влюбился во Flutter с первого взгляда, у меня все еще были некоторые оговорки: при распространении состояния вниз по дереву виджетов InheritedWidget
или Redux Flutter со всеми его вариациями будут выполнять эту работу, но с новым фреймворком, таким как Flutter, вы бы ожидайте, что уровень представления будет немного более реактивным, т. е. сами виджеты не будут иметь состояния и будут изменяться в соответствии с состоянием, которое они получают извне, но это не так. Кроме того, Flutter поддерживает только Android и iOS, но я хотел опубликовать его в Интернете. У меня уже есть куча бизнес-логики в моем приложении, и я хотел максимально использовать ее повторно, а идея изменить код как минимум в двух местах за одно изменение бизнес-логики была неприемлемой.
Я начал искать, как с этим справиться, и наткнулся на BLoC. Для быстрого ознакомления я рекомендую посмотреть Flutter/AngularDart — совместное использование кода, лучше вместе (DartConf 2018) , когда у вас есть время.
Шаблон BLoC
BLoC — причудливое слово, придуманное Google, означающее « компоненты бизнес -логики». Идея шаблона BLoC состоит в том, чтобы хранить как можно больше вашей бизнес-логики в чистом коде Dart, чтобы ее можно было повторно использовать на других платформах. Для этого необходимо соблюдать следующие правила:
- Общайтесь слоями. Представления взаимодействуют со слоем BLoC, который взаимодействует с репозиториями, а репозитории взаимодействуют с уровнем данных. Не пропускайте слои во время общения.
- Общайтесь через интерфейсы. Интерфейсы должны быть написаны на чистом, независимом от платформы коде Dart. Дополнительные сведения см. в документации по неявным интерфейсам.
- BLoC предоставляют доступ только к потокам и приемникам. Ввод/вывод BLoC будет обсуждаться позже.
- Сохраняйте представления простыми. Держите бизнес-логику вне представлений. Они должны только отображать данные и реагировать на действия пользователя.
- Сделайте BLoC независимыми от платформы. BLoC — это чистый код Dart, поэтому они не должны содержать логики или зависимостей, зависящих от платформы. Не переходите на условный код платформы. BLoC — это логика, реализованная в чистом Dart и работающая над базовой платформой.
- Внедрить зависимые от платформы зависимости. Это может показаться противоречащим приведенному выше правилу, но выслушайте меня. Сами BLoC не зависят от платформы, но что, если им нужно взаимодействовать с репозиторием для конкретной платформы? Введите его. Обеспечивая связь через интерфейсы и внедряя эти репозитории, мы можем быть уверены, что независимо от того, написан ли ваш репозиторий для Flutter или AngularDart, BLoC это не волнует.
И последнее, о чем следует помнить, это то, что вход для BLoC должен быть приемником, а выход — через поток. Оба они являются частью StreamController
.
Если вы строго придерживаетесь этих правил при написании своего веб-приложения (или мобильного!) приложение, создание мобильной (или веб-!) версии может быть таким же простым, как создание представлений и интерфейсов для конкретной платформы. Даже если вы только начали использовать AngularDart или Flutter, все равно легко создавать представления, обладая базовыми знаниями о платформе. Вы можете в конечном итоге повторно использовать более половины своей кодовой базы. Шаблон BLoC обеспечивает структурированность и простоту обслуживания.
Создание приложения AngularDart и Flutter BLoC Todo
Я сделал простое приложение todo во Flutter и AngularDart. Приложение использует Firecloud в качестве серверной части и реактивного подхода к созданию представлений. Приложение состоит из трех частей:
-
bloc
-
todo_app_flutter
-
todoapp_dart_angular
Вы можете добавить больше частей, например, интерфейс данных, интерфейс локализации и т. д. Следует помнить, что каждый уровень должен взаимодействовать с другим через интерфейс.
Код BLoC
В каталоге bloc/
:
-
lib/src/bloc
: модули BloC хранятся здесь как чистые библиотеки Dart, содержащие бизнес-логику. -
lib/src/repository
: интерфейсы к данным хранятся в каталоге. -
lib/src/repository/firestore
: репозиторий содержит интерфейс FireCloud для данных вместе с его моделью, и, поскольку это пример приложения, у нас есть только одна модель данныхtodo.dart
и один интерфейс для данныхtodo_repository.dart
; однако в реальном приложении будет больше моделей и интерфейсов репозитория. -
lib/src/repository/preferences
содержитpreferences_interface.dart
, простой интерфейс, который сохраняет имена пользователей, успешно вошедших в систему, в локальное хранилище в Интернете или общие настройки на мобильных устройствах.
//BLOC abstract class PreferencesInterface{ //Preferences final DEFAULT_USERNAME = "DEFAULT_USERNAME"; Future initPreferences(); String get defaultUsername; void setDefaultUsername(String username); }
Веб- и мобильные реализации должны реализовать это в магазине и получить имя пользователя по умолчанию из локального хранилища/настроек. Реализация AngularDart выглядит так:
// ANGULAR DART class PreferencesInterfaceImpl extends PreferencesInterface { SharedPreferences _prefs; @override Future initPreferences() async => _prefs = await SharedPreferences.getInstance(); @override void setDefaultUsername(String username) => _prefs.setString(DEFAULT_USERNAME, username); @override String get defaultUsername => _prefs.getString(DEFAULT_USERNAME); }
Здесь нет ничего впечатляющего — он реализует то, что ему нужно. Вы могли заметить асинхронный метод initPreferences()
, который возвращает null
. Этот метод необходимо реализовать на стороне Flutter, поскольку получение экземпляра SharedPreferences
на мобильном устройстве является асинхронным.
//FLUTTER @override Future initPreferences() async => _prefs = await SharedPreferences.getInstance();
Давайте немного остановимся на директории lib/src/bloc. Любое представление, которое обрабатывает некоторую бизнес-логику, должно иметь свой компонент BLoC. В этом каталоге вы увидите представления BLoC base_bloc.dart
, endpoints.dart
и session.dart
. Последний отвечает за вход и выход пользователя, а также за предоставление конечных точек для интерфейсов репозитория. Причина существования интерфейса сеанса заключается в том, что пакеты firebase
и firecloud
не одинаковы для Интернета и мобильных устройств и должны быть реализованы на основе платформы.
// BLOC abstract class Session implements Endpoints { //Collections. @protected final String userCollectionName = "users"; @protected final String todoCollectionName = "todos"; String userId; Session(){ _isSignedIn.stream.listen((signedIn) { if(!signedIn) _logout(); }); } final BehaviorSubject<bool> _isSignedIn = BehaviorSubject<bool>(); Stream<bool> get isSignedIn => _isSignedIn.stream; Sink<bool> get signedIn => _isSignedIn.sink; Future<String> signIn(String username, String password); @protected void logout(); void _logout() { logout(); userId = null; } }
Идея состоит в том, чтобы сохранить класс сеанса глобальным (одноэлементным). Основываясь на своем _isSignedIn.stream
, он обрабатывает переключение приложения между представлением входа в систему и списком задач и предоставляет конечные точки реализациям репозитория, если существует userId (т. е. пользователь вошел в систему).
base_bloc.dart
является базой для всех BLoC. В этом примере он обрабатывает индикатор загрузки и отображение диалогового окна ошибки по мере необходимости.
В качестве примера бизнес-логики мы рассмотрим todo_add_edit_bloc.dart
. Длинное имя файла объясняет его назначение. Он имеет закрытый метод void _addUpdateTodo(bool addUpdate)
.
// BLOC void _addUpdateTodo(bool addUpdate) { if(!addUpdate) return; //Check required. if(_title.value.isEmpty) _todoError.sink.add(0); else if(_description.value.isEmpty) _todoError.sink.add(1); else _todoError.sink.add(-1); if(_todoError.value >= 0) return; final TodoBloc todoBloc = _todo.value == null ? TodoBloc("", false, DateTime.now(), null, null, null) : _todo.value; todoBloc.title = _title.value; todoBloc.description = _description.value; showProgress.add(true); _toDoRepository.addUpdateToDo(todoBloc) .doOnDone( () => showProgress.add(false) ) .listen((_) => _closeDetail.add(true) , onError: (err) => error.add( err.toString()) ); }
Входными данными для этого метода является bool addUpdate
, и он является слушателем final BehaviorSubject<bool> _addUpdate = BehaviorSubject<bool>()
. Когда пользователь нажимает кнопку «Сохранить» в приложении, событие отправляет истинное значение этого приемника субъекта и запускает эту функцию BLoC. Этот фрагмент кода флаттера делает магию на стороне просмотра.
// FLUTTER IconButton(icon: Icon(Icons.done), onPressed: () => _todoAddEditBloc.addUpdateSink.add(true),),
_addUpdateTodo
проверяет, что заголовок и описание не пусты, и изменяет значение _todoError
BehaviorSubject на основе этого условия. Ошибка _todoError
отвечает за срабатывание отображения ошибки просмотра в полях ввода, если значение не указано. Если все в порядке, он проверяет, создавать или обновлять TodoBloc
и, наконец, _toDoRepository
выполняет запись в FireCloud.
Бизнес-логика здесь, но обратите внимание:
- Только потоки и приемники являются общедоступными в BLoC.
_addUpdateTodo
является закрытым и недоступен из представления. -
_title.value
и_description.value
заполняются пользователем, вводящим значение в текстовый ввод. Ввод текста в событии изменения текста отправляет свое значение соответствующим приемникам. Таким образом, у нас есть реактивное изменение значений в BLoC и их отображение в представлении. -
_toDoRepository
зависит от платформы и предоставляется путем внедрения.
Ознакомьтесь с кодом метода todo_list.dart
BLoC _getTodos()
. Он прослушивает моментальный снимок коллекции задач и передает данные коллекции в виде списка в своем представлении. Список просмотра перерисовывается в зависимости от изменения потока коллекции.

// BLOC void _getTodos(){ showProgress.add(true); _toDoRepository.getToDos() .listen((todosList) { todosSink.add(todosList); showProgress.add(false); }, onError: (err) { showProgress.add(false); error.add(err.toString()); }); }
Важно помнить, что при использовании потоков или эквивалента rx потоки должны быть закрыты. Мы делаем это в методе dispose dispose()
каждого BLoC. Удалите BLoC каждого представления в его методе удаления/уничтожения.
// FLUTTER @override void dispose() { widget.baseBloc.dispose(); super.dispose(); }
Или в проекте AngularDart:
// ANGULAR DART @override void ngOnDestroy() { todoListBloc.dispose(); }
Внедрение репозиториев для конкретных платформ
Ранее мы говорили, что все, что входит в BLoC, должно быть простым Dart и не зависеть от платформы. TodoAddEditBloc
нуждается в ToDoRepository
для записи в Firestore. В Firebase есть пакеты, зависящие от платформы, и у нас должны быть отдельные реализации интерфейса ToDoRepository
. Эти реализации внедряются в приложения. Для Flutter я использовал пакет flutter_simple_dependency_injection
, и он выглядит так:
// FLUTTER class Injection { static Firestore _firestore = Firestore.instance; static FirebaseAuth _auth = FirebaseAuth.instance; static PreferencesInterface _preferencesInterface = PreferencesInterfaceImpl(); static Injector injector; static Future initInjection() async { await _preferencesInterface.initPreferences(); injector = Injector.getInjector(); //Session injector.map<Session>((i) => SessionImpl(_auth, _firestore), isSingleton: true); //Repository injector.map<ToDoRepository>((i) => ToDoRepositoryImpl(injector.get<Session>()), isSingleton: false); //Bloc injector.map<LoginBloc>((i) => LoginBloc(_preferencesInterface, injector.get<Session>()), isSingleton: false); injector.map<TodoListBloc>((i) => TodoListBloc(injector.get<ToDoRepository>(), injector.get<Session>()), isSingleton: false); injector.map<TodoAddEditBloc>((i) => TodoAddEditBloc(injector.get<ToDoRepository>()), isSingleton: false); } }
Используйте это в виджете следующим образом:
// FLUTTER TodoAddEditBloc _todoAddEditBloc = Injection.injector.get<TodoAddEditBloc>();
AngularDart имеет встроенную инъекцию через провайдеров.
// ANGULAR DART @GenerateInjector([ ClassProvider(PreferencesInterface, useClass: PreferencesInterfaceImpl), ClassProvider(Session, useClass: SessionImpl), ExistingProvider(Endpoints, Session) ])
И в компоненте:
// ANGULAR DART providers: [ overlayBindings, ClassProvider(ToDoRepository, useClass: ToDoRepositoryImpl), ClassProvider(TodoAddEditBloc), ExistingProvider(BaseBloc, TodoAddEditBloc) ],
Мы видим, что Session
является глобальным. Он предоставляет функции входа/выхода и конечные точки, используемые в ToDoRepository
и BLoC. ToDoRepository
нужен интерфейс конечных точек, который реализован в SessionImpl
и так далее. Представление должно видеть только его BLoC и ничего больше.
Просмотры
Представления должны быть максимально простыми. Они отображают только то, что поступает из BLoC, и отправляют ввод пользователя в BLoC. Мы рассмотрим это с виджетом TodoAddEdit
от Flutter и его веб-эквивалентом TodoDetailComponent
. Они отображают заголовок и описание выбранной задачи, и пользователь может добавить или обновить задачу.
Флаттер:
// FLUTTER _todoAddEditBloc.todoStream.first.then((todo) { _titleController.text = todo.title; _descriptionController.text = todo.description; });
И позже в коде…
// FLUTTER StreamBuilder<int>( stream: _todoAddEditBloc.todoErrorStream, builder: (BuildContext context, AsyncSnapshot errorSnapshot) { return TextField( onChanged: (text) => _todoAddEditBloc.titleSink.add(text), decoration: InputDecoration(hintText: Localization.of(context).title, labelText: Localization.of(context).title, errorText: errorSnapshot.data == 0 ? Localization.of(context).titleEmpty : null), controller: _titleController, ); }, ),
Виджет StreamBuilder
перестраивается в случае ошибки (ничего не вставлено). Это происходит путем прослушивания _todoAddEditBloc.todoErrorStream . _todoAddEditBloc.titleSink
_todoAddEditBloc.todoErrorStream . _todoAddEditBloc.titleSink
, который является приемником в BLoC, который содержит заголовок и обновляется при вводе пользователем текста в текстовое поле.
Начальное значение этого поля ввода (если выбрано одно задание) заполняется путем прослушивания _todoAddEditBloc.todoStream
, который содержит выбранное задание или пустой, если мы добавим новое задание.
Присвоение значения текстовому полю осуществляется его контроллером _titleController.text = todo.title;
.
Когда пользователь решает сохранить задачу, он нажимает значок галочки на панели приложений и запускает _todoAddEditBloc.addUpdateSink.add(true)
. Это вызывает _addUpdateTodo(bool addUpdate)
, о котором мы говорили в предыдущем разделе BLoC, и выполняет всю бизнес-логику добавления, обновления или отображения ошибки пользователю.
Все реактивно, и нет необходимости обрабатывать состояние виджета.
Код AngularDart еще проще. После предоставления компоненту его BLoC с помощью провайдеров код файла todo_detail.html
выполняет часть отображения данных и отправки действий пользователя обратно в BLoC.
// AngularDart <material-input #title label="{{titleStr}}" ngModel="{{(todoAddEditBloc.titleStream | async) == null ? '' : (todoAddEditBloc.titleStream | async)}}" (inputKeyPress)="todoAddEditBloc.titleSink.add($event)" [error]="(todoAddEditBloc.todoErrorStream | async) == 0 ? titleErrString : ''" autoFocus floatingLabel type="text" useNativeValidation="false" autocomplete="off"> </material-input> <material-input #description label="{{descriptionStr}}" ngModel="{{(todoAddEditBloc.descriptionStream | async) == null ? '' : (todoAddEditBloc.descriptionStream | async)}}" (inputKeyPress)="todoAddEditBloc.descriptionSink.add($event)" [error]="(todoAddEditBloc.todoErrorStream | async) == 1 ? descriptionErrString : ''" autoFocus floatingLabel type="text" useNativeValidation="false" autocomplete="off"> </material-input> <material-button animated raised role="button" class="blue" (trigger)="todoAddEditBloc.addUpdateSink.add(true)"> {{saveStr}} </material-button> <base-bloc></base-bloc>
Как и во Flutter, мы присваиваем ngModel=
значение из потока заголовков, которое является его начальным значением.
// AngularDart (inputKeyPress)="todoAddEditBloc.descriptionSink.add($event)"
Выходное событие inputKeyPress
отправляет символы, которые пользователь вводит в текстовом вводе, обратно в описание BLoC. Событие кнопки материала (trigger)="todoAddEditBloc.addUpdateSink.add(true)"
отправляет событие добавления/обновления BLoC, которое снова запускает ту же _addUpdateTodo(bool addUpdate)
в BLoC. Если вы посмотрите на код компонента todo_detail.dart
, то увидите, что там почти ничего нет, кроме строк, которые отображаются в представлении. Я поместил их туда, а не в HTML из-за возможной локализации, которую можно сделать здесь.
То же самое касается любого другого компонента — компоненты и виджеты не имеют никакой бизнес-логики.
Стоит упомянуть еще об одном сценарии. Представьте, что у вас есть представление со сложной логикой представления данных или что-то вроде таблицы со значениями, которые необходимо форматировать (даты, валюты и т. д.). У кого-то может возникнуть соблазн получить значения из BLoC и отформатировать их в представлении. Это неверно! Значения, отображаемые в представлении, должны поступать в представление уже отформатированными (строки). Причина этого в том, что само форматирование также является бизнес-логикой. Еще один пример — когда форматирование отображаемого значения зависит от какого-то параметра приложения, который можно изменить во время выполнения. Предоставляя этот параметр BLoC и используя реактивный подход к отображению, бизнес-логика форматирует значение и перерисовывает только необходимые части. Модель BLoC, которую мы имеем в этом примере, TodoBloc
, очень проста. Преобразование модели FireCloud в модель BLoC выполняется в репозитории, но при необходимости это можно сделать в BLoC, чтобы значения модели были готовы к отображению.
Подведение итогов
В этой краткой статье рассматриваются основные концепции реализации шаблона BLoC. Это рабочее доказательство того, что совместное использование кода между Flutter и AngularDart возможно, что позволяет вести нативную и кроссплатформенную разработку.
Изучив пример, вы увидите, что при правильной реализации BLoC значительно сокращает время создания мобильных/веб-приложений. Примером является ToDoRepository
и его реализация. Код реализации почти идентичен, и даже логика создания представления аналогична. После пары виджетов/компонентов можно быстро запускать массовое производство.
Я надеюсь, что эта статья даст вам представление о том, с каким удовольствием и энтузиазмом я создаю веб-приложения и мобильные приложения с использованием Flutter/AngularDart и шаблона BLoC. Если вы хотите создавать кроссплатформенные настольные приложения на JavaScript, прочтите книгу Electron: Cross-platform Desktop Apps Made Easy , написанную товарищем по Toptaler Стефаном П. Перикатом.