Как использовать BLoC для совместного использования кода во Flutter и AngularDart

Опубликовано: 2022-03-11

В середине прошлого года я хотел портировать приложение для Android на iOS и в Интернете. Flutter был выбором для мобильных платформ, и я думал о том, что выбрать для веб-стороны.

Несмотря на то, что я влюбился во Flutter с первого взгляда, у меня все еще были некоторые оговорки: при распространении состояния вниз по дереву виджетов InheritedWidget или Redux Flutter со всеми его вариациями будут выполнять эту работу, но с новым фреймворком, таким как Flutter, вы бы ожидайте, что уровень представления будет немного более реактивным, т. е. сами виджеты не будут иметь состояния и будут изменяться в соответствии с состоянием, которое они получают извне, но это не так. Кроме того, Flutter поддерживает только Android и iOS, но я хотел опубликовать его в Интернете. У меня уже есть куча бизнес-логики в моем приложении, и я хотел максимально использовать ее повторно, а идея изменить код как минимум в двух местах за одно изменение бизнес-логики была неприемлемой.

Я начал искать, как с этим справиться, и наткнулся на BLoC. Для быстрого ознакомления я рекомендую посмотреть Flutter/AngularDart — совместное использование кода, лучше вместе (DartConf 2018) , когда у вас есть время.

Шаблон BLoC

Схема потока коммуникаций в представлении, 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, репозиторием задач и т. д.

Ранее мы говорили, что все, что входит в 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, и отправляют ввод пользователя в 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 Стефаном П. Перикатом.

Связанный: Язык Dart: когда Java и C # недостаточно четкие