Como aproveitar o BLoC para compartilhamento de código no Flutter e AngularDart

Publicados: 2022-03-11

Em meados do ano passado, eu queria portar um aplicativo Android para iOS e web. Flutter foi a escolha para plataformas móveis, e eu estava pensando sobre o que escolher para o lado da web.

Embora me apaixonei pelo Flutter à primeira vista, ainda tinha algumas reservas: ao propagar o estado na árvore de widgets, o InheritedWidget ou Redux do Flutter - com todas as suas variações - fará o trabalho, mas com um novo framework como o Flutter, você espere que a camada de visualização seja um pouco mais reativa, ou seja, os widgets seriam sem estado e mudariam de acordo com o estado em que são alimentados de fora, mas não são. Além disso, o Flutter suporta apenas Android e iOS, mas eu queria publicar na web. Eu já tenho muita lógica de negócios em meu aplicativo e queria reutilizá-la o máximo possível, e a ideia de alterar o código em pelo menos dois lugares para uma única alteração na lógica de negócios era inaceitável.

Comecei a procurar como superar isso e me deparei com o BLoC. Para uma introdução rápida, recomendo assistir Flutter/AngularDart – Code sharing, melhor juntos (DartConf 2018) quando você tiver tempo.

Padrão BLoC

Diagrama do fluxo de comunicações na visualização, BLoC, repositório e camadas de dados

BLoC é uma palavra chique inventada pelo Google que significa “ componentes de lógica de negócios” . A ideia do padrão BLoC é armazenar o máximo possível de sua lógica de negócios em código Dart puro para que possa ser reutilizado por outras plataformas. Para conseguir isso, existem regras que você deve seguir:

  • Comunique-se em camadas. As visualizações se comunicam com a camada BLoC, que se comunica com os repositórios, e os repositórios se comunicam com a camada de dados. Não pule camadas durante a comunicação.
  • Comunique-se por interfaces. As interfaces devem ser escritas em código Dart puro e independente de plataforma. Para obter mais informações, consulte a documentação sobre interfaces implícitas.
  • BLoCs apenas expõem fluxos e sumidouros. A E/S de um BLoC será discutida posteriormente.
  • Mantenha as visualizações simples. Mantenha a lógica de negócios fora das visualizações. Eles devem exibir apenas dados e responder à interação do usuário.
  • Torne a plataforma BLoCs agnóstica. Os BLoCs são código Dart puro e, portanto, não devem conter lógica ou dependências específicas da plataforma. Não ramifique no código condicional da plataforma. BLoCs são lógicas implementadas em Dart puro e estão acima de lidar com a plataforma base.
  • Injetar dependências específicas da plataforma. Isso pode soar contraditório com a regra acima, mas ouça-me. Os próprios BLoCs são independentes de plataforma, mas e se eles precisarem se comunicar com um repositório específico da plataforma? Injete-o. Ao garantir a comunicação por meio de interfaces e injetar esses repositórios, podemos ter certeza de que, independentemente de seu repositório ser escrito para Flutter ou AngularDart, o BLoC não se importará.

Uma última coisa a ter em mente é que a entrada para um BLoC deve ser um coletor, enquanto a saída é através de um fluxo. Ambos fazem parte do StreamController .

Se você aderir estritamente a essas regras ao escrever seu aplicativo web (ou móvel!), criar uma versão móvel (ou web!) pode ser tão simples quanto fazer as visualizações e interfaces específicas da plataforma. Mesmo que você tenha acabado de começar a usar o AngularDart ou o Flutter, ainda é fácil fazer visualizações com conhecimento básico da plataforma. Você pode acabar reutilizando mais da metade de sua base de código. O padrão BLoC mantém tudo estruturado e fácil de manter.

Construindo um aplicativo AngularDart e Flutter BLoC Todo

Eu fiz um aplicativo de tarefas simples em Flutter e AngularDart. O aplicativo usa o Firecloud como back-end e uma abordagem reativa para visualizar a criação. O aplicativo tem três partes:

  • bloc
  • todo_app_flutter
  • todoapp_dart_angular

Você pode optar por ter mais partes — por exemplo, interface de dados, interface de localização etc. O que deve ser lembrado é que cada camada deve se comunicar com a outra por meio de uma interface.

O Código BLoC

No diretório bloc/ :

  • lib/src/bloc : Os módulos BloC são armazenados aqui como bibliotecas Dart puras contendo a lógica de negócios.
  • lib/src/repository : As interfaces para dados são armazenadas no diretório.
  • lib/src/repository/firestore : O repositório contém a interface FireCloud para dados junto com seu modelo, e como este é um aplicativo de exemplo, temos apenas um modelo de dados todo.dart e uma interface para os dados todo_repository.dart ; no entanto, em um aplicativo do mundo real, haverá mais modelos e interfaces de repositório.
  • lib/src/repository/preferences contém preferences_interface.dart , uma interface simples que armazena nomes de usuários conectados com sucesso em armazenamento local na web ou preferências compartilhadas em dispositivos móveis.
 //BLOC abstract class PreferencesInterface{ //Preferences final DEFAULT_USERNAME = "DEFAULT_USERNAME"; Future initPreferences(); String get defaultUsername; void setDefaultUsername(String username); }

As implementações da Web e móveis devem implementar isso na loja e obter o nome de usuário padrão do armazenamento/preferências local. A implementação AngularDart disso se parece com:

 // 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 de espetacular aqui - ele implementa o que precisa. Você pode notar o método assíncrono initPreferences() que retorna null . Esse método precisa ser implementado no lado do Flutter, pois obter a instância SharedPreferences no celular é assíncrono.

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

Vamos ficar um pouco com o diretório lib/src/bloc. Qualquer exibição que lide com alguma lógica de negócios deve ter seu componente BLoC. Neste diretório, você verá os BLoCs base_bloc.dart , endpoints.dart e session.dart . O último é responsável por conectar e desconectar o usuário e fornecer terminais para interfaces de repositório. A razão pela qual a interface de sessão existe é que os pacotes firebase e firecloud não são os mesmos para web e mobile e devem ser implementados com base na 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; } }

A ideia é manter a classe de sessão global (singleton). Com base em seu getter _isSignedIn.stream , ele lida com a alternância do aplicativo entre a visualização de login/todo-list e fornece endpoints para implementações de repositório se o userId existir (ou seja, o usuário estiver conectado).

base_bloc.dart é a base para todos os BLoCs. Neste exemplo, ele manipula o indicador de carga e a exibição da caixa de diálogo de erro conforme necessário.

Para o exemplo de lógica de negócios, veremos todo_add_edit_bloc.dart . O nome longo do arquivo explica sua finalidade. Tem um método void 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()) ); }

A entrada para este método é bool addUpdate e é um ouvinte de final BehaviorSubject<bool> _addUpdate = BehaviorSubject<bool>() . Quando um usuário clica no botão salvar no aplicativo, o evento envia o valor verdadeiro do coletor de assunto e aciona essa função BLoC. Este pedaço de código de vibração faz a mágica no lado da visualização.

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

_addUpdateTodo verifica se o título e a descrição não estão vazios e altera o valor de _todoError BehaviorSubject com base nessa condição. O erro _todoError é responsável por acionar a exibição do erro de visualização nos campos de entrada se nenhum valor for fornecido. Se tudo estiver bem, ele verifica se deve criar ou atualizar o TodoBloc e finalmente _toDoRepository faz a gravação no FireCloud.

A lógica de negócios está aqui, mas observe:

  • Apenas fluxos e coletores são públicos no BLoC. _addUpdateTodo é privado e não pode ser acessado a partir da visualização.
  • _title.value e _description.value são preenchidos pelo usuário que insere o valor na entrada de texto. A entrada de texto no evento de alteração de texto envia seu valor para os respectivos coletores. Desta forma, temos uma mudança reativa de valores no BLoC e a exibição dos mesmos na view.
  • _toDoRepository depende da plataforma e é fornecido por injeção.

Confira o código do todo_list.dart BLoC _getTodos() . Ele escuta um instantâneo da coleção de tarefas e transmite os dados da coleção para listar em sua exibição. A lista de visualizações é redesenhada com base na alteração do fluxo de coleta.

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

A coisa importante a ser observada ao usar fluxos ou equivalentes rx é que os fluxos devem ser fechados. Fazemos isso no método Dispose dispose() de cada BLoC. Descarte o BLoC de cada visualização em seu método de descarte/destruição.

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

Ou em um projeto AngularDart:

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

Injetando repositórios específicos da plataforma

Diagrama de relacionamentos entre os padrões BLoC, repositório de tarefas e mais

Dissemos antes que tudo que vem em um BLoC deve ser simples Dart e nada dependente de plataforma. TodoAddEditBloc precisa do ToDoRepository para gravar no Firestore. O Firebase tem pacotes dependentes da plataforma e devemos ter implementações separadas da interface ToDoRepository . Essas implementações são injetadas em apps. Para o Flutter, usei o pacote flutter_simple_dependency_injection e ficou assim:

 // 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 isso em um widget como este:

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

AngularDart tem injeção integrada via provedores.

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

E em um componente:

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

Podemos ver que Session é global. Ele fornece a funcionalidade de entrada/saída e os terminais usados ​​no ToDoRepository e BLoCs. ToDoRepository precisa de uma interface de endpoints que seja implementada em SessionImpl e assim por diante. A exibição deve ver apenas seu BLoC e nada mais.

Visualizações

Diagrama de coletores e fluxos interagindo entre o BLoC e a visualização

As visualizações devem ser o mais simples possível. Eles apenas exibem o que vem do BLoC e enviam a entrada do usuário para o BLoC. Vamos analisá-lo com o widget TodoAddEdit do Flutter e seu equivalente na web TodoDetailComponent . Eles exibem o título e a descrição da tarefa selecionada e o usuário pode adicionar ou atualizar uma tarefa.

Flutuação:

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

E mais tarde no 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, ); }, ),

O widget StreamBuilder se reconstrói se houver um erro (nada inserido). Isso acontece ouvindo _todoAddEditBloc.todoErrorStream . _todoAddEditBloc.titleSink _todoAddEditBloc.todoErrorStream . _todoAddEditBloc.titleSink , que é um coletor no BLoC que contém o título e é atualizado quando o usuário insere texto no campo de texto.

O valor inicial deste campo de entrada (se um todo for selecionado) é preenchido escutando _todoAddEditBloc.todoStream que contém o todo selecionado ou um vazio se adicionarmos um novo todo.

A atribuição de valor a um campo de texto é feita por seu controlador _titleController.text = todo.title; .

Quando o usuário decide salvar o todo, ele pressiona o ícone de verificação na barra de aplicativos e aciona _todoAddEditBloc.addUpdateSink.add(true) . Isso invoca o _addUpdateTodo(bool addUpdate) sobre o qual falamos na seção BLoC anterior e faz toda a lógica de negócios de adicionar, atualizar ou exibir o erro de volta ao usuário.

Tudo é reativo e não há necessidade de manipular o estado do widget.

O código AngularDart é ainda mais simples. Depois de fornecer ao componente seu BLoC, usando provedores, o código do arquivo todo_detail.html faz a parte de exibir os dados e enviar a interação do usuário de volta ao 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>

Semelhante ao Flutter, estamos atribuindo ngModel= o valor do fluxo de título, que é seu valor inicial.

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

O evento de saída inputKeyPress envia os caracteres que o usuário digita na entrada de texto de volta para a descrição do BLoC. O evento botão material (trigger)="todoAddEditBloc.addUpdateSink.add(true)" envia o evento BLoC add/update que novamente aciona a mesma _addUpdateTodo(bool addUpdate) no BLoC. Se você der uma olhada no código todo_detail.dart do componente, verá que não há quase nada exceto as strings que são exibidas na visualização. Eu os coloquei lá e não no HTML por causa da possível localização que pode ser feita aqui.

O mesmo vale para todos os outros componentes – os componentes e widgets não têm lógica de negócios.

Mais um cenário merece destaque. Imagine que você tenha uma visão com lógica de apresentação de dados complexa ou algo como uma tabela com valores que devem ser formatados (datas, moedas, etc.). Alguém pode ficar tentado a obter os valores do BLoC e formatá-los em uma visualização. Isto é errado! Os valores exibidos na view devem vir para a view já formatada (strings). A razão para isso é que a formatação em si também é lógica de negócios. Mais um exemplo é quando a formatação do valor de exibição depende de algum parâmetro do aplicativo que pode ser alterado em tempo de execução. Ao fornecer esse parâmetro ao BLoC e usar uma abordagem reativa para exibir a exibição, a lógica de negócios formatará o valor e redesenhará apenas as partes necessárias. O modelo BLoC que temos neste exemplo, TodoBloc , é muito simples. A conversão de um modelo FireCloud para o modelo BLoC é feita no repositório, mas se necessário, pode ser feita em BLoC para que os valores do modelo estejam prontos para exibição.

Empacotando

Este breve artigo aborda os principais conceitos de implementação do padrão BLoC. É a prova de que o compartilhamento de código entre Flutter e AngularDart é possível, permitindo o desenvolvimento nativo e multiplataforma.

Explorando o exemplo, você verá que, quando implementado corretamente, o BLoC reduz significativamente o tempo para criar aplicativos móveis/web. Um exemplo é ToDoRepository e sua implementação. O código de implementação é quase idêntico e até mesmo a lógica de composição da visão é semelhante. Depois de alguns widgets/componentes, você pode iniciar rapidamente a produção em massa.

Espero que este artigo dê uma olhada na diversão e entusiasmo que tenho criando aplicativos para web/móveis usando Flutter/AngularDart e o padrão BLoC. Se você deseja criar aplicativos de desktop multiplataforma em JavaScript, leia Electron: Aplicativos de desktop multiplataforma facilitados pelo colega Toptaler Stephane P. Pericat.

Relacionado: A linguagem Dart: quando Java e C# não são suficientemente nítidos