Cómo aprovechar BLoC para compartir código en Flutter y AngularDart

Publicado: 2022-03-11

A mediados del año pasado, quería portar una aplicación de Android a iOS y a la web. Flutter fue la elección para las plataformas móviles, y estaba pensando en qué elegir para el lado web.

Si bien me enamoré de Flutter a primera vista, todavía tenía algunas reservas: al propagar el estado en el árbol de widgets, InheritedWidget o Redux de Flutter, con todas sus variaciones, harán el trabajo, pero con un nuevo marco como Flutter, lo harías. espere que la capa de vista sea un poco más reactiva, es decir, los widgets no tendrían estado y cambiarían de acuerdo con el estado en el que se alimentan desde el exterior, pero no es así. Además, Flutter solo es compatible con Android e iOS, pero quería publicar en la web. Ya tengo mucha lógica de negocios en mi aplicación y quería reutilizarla tanto como fuera posible, y la idea de cambiar el código en al menos dos lugares para un solo cambio en la lógica de negocios era inaceptable.

Empecé a buscar cómo superar esto y me encontré con BLoC. Para una introducción rápida, recomiendo ver Flutter/AngularDart: compartir código, mejor juntos (DartConf 2018) cuando tenga tiempo.

Patrón BLOC

Diagrama del flujo de comunicaciones en las capas de vista, BLoC, repositorio y datos

BLoC es una palabra elegante inventada por Google que significa “componentes lógicos de negocios ”. La idea del patrón BLoC es almacenar la mayor cantidad posible de su lógica comercial en código Dart puro para que otras plataformas puedan reutilizarlo. Para lograr esto, hay reglas que debes seguir:

  • Comunicarse en capas. Las vistas se comunican con la capa BLoC, que se comunica con los repositorios, y los repositorios se comunican con la capa de datos. No salte capas mientras se comunica.
  • Comunicarse a través de interfaces. Las interfaces deben escribirse en código Dart puro e independiente de la plataforma. Para obtener más información, consulte la documentación sobre interfaces implícitas.
  • Los BLoC solo exponen flujos y sumideros. La E/S de un BLoC se discutirá más adelante.
  • Mantenga las vistas simples. Mantenga la lógica empresarial fuera de las vistas. Solo deben mostrar datos y responder a la interacción del usuario.
  • Haga que la plataforma BLoCs sea independiente. Los BLoC son código Dart puro y, por lo tanto, no deben contener lógica ni dependencias específicas de la plataforma. No se ramifique en el código condicional de la plataforma. Los BLoC son lógicos implementados en Dart puro y están por encima de la plataforma base.
  • Inyectar dependencias específicas de la plataforma. Esto puede sonar contradictorio con la regla anterior, pero escúchame. Los BLoC en sí mismos son independientes de la plataforma, pero ¿qué sucede si necesitan comunicarse con un repositorio específico de la plataforma? inyectarlo. Al garantizar la comunicación a través de interfaces e inyectar estos repositorios, podemos estar seguros de que, independientemente de si su repositorio está escrito para Flutter o AngularDart, a BLoC no le importará.

Una última cosa a tener en cuenta es que la entrada para un BLoC debe ser un sumidero, mientras que la salida es a través de una transmisión. Ambos son parte del StreamController .

Si se adhiere estrictamente a estas reglas mientras escribe su aplicación web (¡o móvil!), la creación de una versión móvil (¡o web!) puede ser tan simple como hacer las vistas y las interfaces específicas de la plataforma. Incluso si acaba de comenzar a usar AngularDart o Flutter, aún es fácil crear vistas con conocimientos básicos de la plataforma. Puede terminar reutilizando más de la mitad de su base de código. El patrón BLoC mantiene todo estructurado y fácil de mantener.

Creación de una aplicación AngularDart y Flutter BLoC Todo

Hice una aplicación de tareas sencillas en Flutter y AngularDart. La aplicación utiliza Firecloud como back-end y un enfoque reactivo para la creación de vistas. La aplicación tiene tres partes:

  • bloc
  • todo_app_flutter
  • todoapp_dart_angular

Puede elegir tener más partes, por ejemplo, interfaz de datos, interfaz de localización, etc. Lo que debe recordar es que cada capa debe comunicarse con la otra a través de una interfaz.

El código BLoC

En el directorio bloc/ :

  • lib/src/bloc : los módulos BloC se almacenan aquí como bibliotecas Dart puras que contienen la lógica comercial.
  • lib/src/repository : las interfaces a los datos se almacenan en el directorio.
  • lib/src/repository/firestore : el repositorio contiene la interfaz FireCloud para los datos junto con su modelo, y dado que esta es una aplicación de muestra, solo tenemos un modelo de datos todo.dart y una interfaz para los datos todo_repository.dart ; sin embargo, en una aplicación del mundo real, habrá más modelos e interfaces de repositorio.
  • lib/src/repository/preferences contiene preferences_interface.dart , una interfaz simple que almacena los nombres de usuario que iniciaron sesión correctamente en el almacenamiento local en la web o las preferencias compartidas en los dispositivos móviles.
 //BLOC abstract class PreferencesInterface{ //Preferences final DEFAULT_USERNAME = "DEFAULT_USERNAME"; Future initPreferences(); String get defaultUsername; void setDefaultUsername(String username); }

Las implementaciones web y móviles deben implementar esto en la tienda y obtener el nombre de usuario predeterminado del almacenamiento/preferencias locales. La implementación de AngularDart de esto se ve así:

 // 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); }

Nada espectacular aquí: implementa lo que necesita. Es posible que observe el método asíncrono initPreferences() que devuelve null . Este método debe implementarse en el lado de Flutter, ya que obtener la instancia de SharedPreferences en el dispositivo móvil es asíncrono.

 //FLUTTER @override Future initPreferences() async => _prefs = await SharedPreferences.getInstance();

Sigamos un poco con el directorio lib/src/bloc. Cualquier vista que maneje alguna lógica comercial debe tener su componente BLoC. En este directorio, verá la vista BLoC base_bloc.dart , endpoints.dart y session.dart . El último es responsable de iniciar y cerrar sesión del usuario y proporcionar puntos finales para las interfaces del repositorio. La razón por la que existe la interfaz de sesión es que los paquetes firebase y firecloud no son los mismos para web y móvil y deben implementarse en función de la plataforma.

 // 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; } }

La idea es mantener la clase de sesión global (singleton). Basado en su getter _isSignedIn.stream , maneja el cambio de la aplicación entre la vista de inicio de sesión/lista de tareas pendientes y proporciona puntos finales para las implementaciones del repositorio si existe el ID de usuario (es decir, el usuario ha iniciado sesión).

base_bloc.dart es la base para todos los BLoC. En este ejemplo, maneja el indicador de carga y la visualización del cuadro de diálogo de error según sea necesario.

Para el ejemplo de lógica empresarial, echaremos un vistazo a todo_add_edit_bloc.dart . El nombre largo del archivo explica su propósito. Tiene un método vacío privado _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()) ); }

La entrada para este método es bool addUpdate y es un oyente de final BehaviorSubject<bool> _addUpdate = BehaviorSubject<bool>() . Cuando un usuario hace clic en el botón Guardar en la aplicación, el evento envía el valor verdadero del sumidero de este sujeto y activa esta función BLoC. Esta pieza de código flutter hace la magia en el lado de la vista.

 // FLUTTER IconButton(icon: Icon(Icons.done), onPressed: () => _todoAddEditBloc.addUpdateSink.add(true),),

_addUpdateTodo comprueba que tanto el título como la descripción no estén vacíos y cambia el valor de _todoError BehaviorSubject en función de esta condición. El error _todoError es responsable de activar la visualización del error de vista en los campos de entrada si no se proporciona ningún valor. Si todo está bien, verifica si debe crear o actualizar TodoBloc y finalmente _toDoRepository escribe en FireCloud.

La lógica de negocios está aquí, pero observe:

  • Solo las transmisiones y los sumideros son públicos en BLoC. _addUpdateTodo es privado y no se puede acceder desde la vista.
  • _title.value y _description.value son llenados por el usuario que ingresa el valor en la entrada de texto. La entrada de texto en el evento de cambio de texto envía su valor a los receptores respectivos. De esta forma, tenemos un cambio reactivo de valores en el BLoC y la visualización de los mismos en la vista.
  • _toDoRepository depende de la plataforma y se proporciona por inyección.

Consulta el código del todo_list.dart BLoC _getTodos() . Escucha una instantánea de la colección de tareas pendientes y transmite los datos de la colección para enumerarlos en su vista. La lista de vistas se vuelve a dibujar en función del cambio del flujo de recopilación.

 // 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()); }); }

Lo importante que debe tener en cuenta al usar flujos o equivalentes rx es que los flujos deben estar cerrados. Hacemos eso en el método dispose() de cada BLoC. Deseche el BLoC de cada vista en su método de disposición/destrucción.

 // FLUTTER @override void dispose() { widget.baseBloc.dispose(); super.dispose(); }

O en un proyecto AngularDart:

 // ANGULAR DART @override void ngOnDestroy() { todoListBloc.dispose(); }

Inyección de repositorios específicos de la plataforma

Diagrama de relaciones entre los patrones BLoC, el repositorio de tareas pendientes y más

Dijimos antes que todo lo que viene en un BLoC debe ser simple Dart y nada dependiente de la plataforma. TodoAddEditBloc necesita ToDoRepository para escribir en Firestore. Firebase tiene paquetes que dependen de la plataforma y debemos tener implementaciones separadas de la interfaz ToDoRepository . Estas implementaciones se inyectan en las aplicaciones. Para Flutter, utilicé el paquete flutter_simple_dependency_injection y se ve así:

 // 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); } }

Use esto en un widget como este:

 // FLUTTER TodoAddEditBloc _todoAddEditBloc = Injection.injector.get<TodoAddEditBloc>();

AngularDart tiene inyección integrada a través de proveedores.

 // ANGULAR DART @GenerateInjector([ ClassProvider(PreferencesInterface, useClass: PreferencesInterfaceImpl), ClassProvider(Session, useClass: SessionImpl), ExistingProvider(Endpoints, Session) ])

Y en un componente:

 // ANGULAR DART providers: [ overlayBindings, ClassProvider(ToDoRepository, useClass: ToDoRepositoryImpl), ClassProvider(TodoAddEditBloc), ExistingProvider(BaseBloc, TodoAddEditBloc) ],

Podemos ver que Session es global. Proporciona la funcionalidad de inicio/cierre de sesión y los puntos finales utilizados en ToDoRepository y BLoC. ToDoRepository necesita una interfaz de puntos finales que se implementa en SessionImpl y así sucesivamente. La vista debería ver solo su BLoC y nada más.

Puntos de vista

Diagrama de sumideros y flujos que interactúan entre el BLoC y la vista

Las vistas deben ser lo más simples posible. Solo muestran lo que proviene del BLoC y envían la entrada del usuario al BLoC. Lo revisaremos con el widget TodoAddEdit de Flutter y su equivalente web TodoDetailComponent . Muestran el título y la descripción de la tarea pendiente seleccionada y el usuario puede agregar o actualizar una tarea pendiente.

Aleteo:

 // FLUTTER _todoAddEditBloc.todoStream.first.then((todo) { _titleController.text = todo.title; _descriptionController.text = todo.description; });

Y luego en código...

 // 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, ); }, ),

El widget StreamBuilder se reconstruye solo si hay un error (no se inserta nada). Esto sucede al escuchar _todoAddEditBloc.todoErrorStream . _todoAddEditBloc.titleSink _todoAddEditBloc.todoErrorStream . _todoAddEditBloc.titleSink , que es un sumidero en el BLoC que contiene el título y se actualiza cuando el usuario ingresa texto en el campo de texto.

El valor inicial de este campo de entrada (si se selecciona una tarea) se completa escuchando _todoAddEditBloc.todoStream que contiene la tarea seleccionada o una vacía si agregamos una nueva tarea.

La asignación de valor a un campo de texto se realiza mediante su controlador _titleController.text = todo.title; .

Cuando el usuario decide guardar la tarea pendiente, presiona el ícono de verificación en la barra de la aplicación y activa _todoAddEditBloc.addUpdateSink.add(true) . Eso invoca el _addUpdateTodo(bool addUpdate) que hablamos en la sección anterior de BLoC y hace toda la lógica comercial de agregar, actualizar o mostrar el error al usuario.

Todo es reactivo y no hay necesidad de manejar el estado del widget.

El código AngularDart es aún más simple. Después de proporcionar al componente su BLoC, utilizando proveedores, el código del archivo todo_detail.html hace la parte de mostrar los datos y enviar la interacción del usuario de vuelta al 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>

Similar a Flutter, estamos asignando ngModel= el valor de la secuencia de título, que es su valor inicial.

 // AngularDart (inputKeyPress)="todoAddEditBloc.descriptionSink.add($event)"

El evento de salida inputKeyPress envía los caracteres que el usuario escribe en la entrada de texto a la descripción del BLoC. El evento material button (trigger)="todoAddEditBloc.addUpdateSink.add(true)" envía el evento BLoC add/update que nuevamente activa la misma _addUpdateTodo(bool addUpdate) en el BLoC. Si observa el código todo_detail.dart del componente, verá que no hay casi nada excepto las cadenas que se muestran en la vista. Los coloqué allí y no en el HTML debido a la posible localización que se puede hacer aquí.

Lo mismo ocurre con todos los demás componentes: los componentes y los widgets tienen una lógica comercial cero.

Vale la pena mencionar un escenario más. Imagine que tiene una vista con una lógica de presentación de datos compleja o algo así como una tabla con valores que deben formatearse (fechas, monedas, etc.). Alguien podría tener la tentación de obtener los valores de BLoC y formatearlos en una vista. ¡Eso está mal! Los valores que se muestran en la vista deben llegar a la vista ya formateados (cadenas). La razón de esto es que el formato en sí mismo también es lógica de negocios. Un ejemplo más es cuando el formato del valor de visualización depende de algún parámetro de la aplicación que se puede cambiar en tiempo de ejecución. Al proporcionar ese parámetro a BLoC y usar un enfoque reactivo para ver la pantalla, la lógica comercial formateará el valor y volverá a dibujar solo las partes necesarias. El modelo BLoC que tenemos en este ejemplo, TodoBloc , es muy simple. La conversión de un modelo FireCloud a un modelo BLoC se realiza en el repositorio, pero si es necesario, se puede realizar en BLoC para que los valores del modelo estén listos para su visualización.

Terminando

Este breve artículo cubre los conceptos principales de implementación del patrón BLoC. Es una prueba funcional de que es posible compartir código entre Flutter y AngularDart, lo que permite un desarrollo nativo y multiplataforma.

Al explorar el ejemplo, verá que, cuando se implementa correctamente, BLoC reduce significativamente el tiempo para crear aplicaciones móviles/web. Un ejemplo es ToDoRepository y su implementación. El código de implementación es casi idéntico e incluso la lógica de composición de la vista es similar. Después de un par de widgets/componentes, puede comenzar rápidamente la producción en masa.

Espero que este artículo le dé un vistazo a la diversión y el entusiasmo que tengo al crear aplicaciones web/móviles con Flutter/AngularDart y el patrón BLoC. Si está buscando crear aplicaciones de escritorio multiplataforma en JavaScript, lea Electron: Aplicaciones de escritorio multiplataforma simplificadas por el compañero Toptaler Stephane P. Pericat.

Relacionado: The Dart Language: cuando Java y C# no son lo suficientemente nítidos