Come sfruttare BLoC per la condivisione del codice in Flutter e AngularDart
Pubblicato: 2022-03-11A metà dell'anno scorso, volevo trasferire un'app Android su iOS e sul Web. Flutter è stata la scelta per le piattaforme mobili e stavo pensando a cosa scegliere per il lato web.
Anche se mi sono innamorato di Flutter a prima vista, avevo ancora alcune riserve: durante la propagazione dello stato lungo l'albero dei widget, InheritedWidget
o Redux di Flutter, con tutte le sue variazioni, faranno il lavoro, ma con un nuovo framework come Flutter, avresti aspettatevi che il livello di visualizzazione sia un po' più reattivo, ad es. i widget sarebbero essi stessi senza stato e cambieranno in base allo stato in cui vengono alimentati dall'esterno, ma non lo sono. Inoltre, Flutter supporta solo Android e iOS, ma volevo pubblicare sul web. Ho già un sacco di logica aziendale nella mia app e volevo riutilizzarla il più possibile e l'idea di modificare il codice in almeno due punti per una singola modifica della logica aziendale era inaccettabile.
Ho iniziato a cercare in giro come superare questo problema e mi sono imbattuto in BLoC. Per una rapida introduzione, consiglio di guardare Flutter/AngularDart – Condivisione del codice, meglio insieme (DartConf 2018) quando ne hai il tempo.
Modello BLoC
BLoC è una parola di fantasia inventata da Google che significa "componenti logici di b usiness " . L'idea del modello BLoC è di archiviare quanta più logica aziendale possibile in puro codice Dart in modo che possa essere riutilizzata da altre piattaforme. Per raggiungere questo obiettivo, ci sono delle regole che devi seguire:
- Comunica a strati. Le viste comunicano con il livello BLoC, che comunica con i repository, e i repository parlano con il livello dati. Non saltare i livelli durante la comunicazione.
- Comunica tramite interfacce. Le interfacce devono essere scritte in puro codice Dart indipendente dalla piattaforma. Per ulteriori informazioni, vedere la documentazione sulle interfacce implicite.
- I BLoC espongono solo stream e sink. L'I/O di un BLoC sarà discusso in seguito.
- Mantieni le visualizzazioni semplici. Mantieni la logica aziendale fuori dalle visualizzazioni. Dovrebbero solo visualizzare i dati e rispondere all'interazione dell'utente.
- Rendi indipendente la piattaforma BLoC. I BLoC sono puro codice Dart e quindi non dovrebbero contenere logica o dipendenze specifiche della piattaforma. Non ramificarsi nel codice condizionale della piattaforma. I BLoC sono logiche implementate in Dart puro e sono al di sopra della piattaforma di base.
- Inietta le dipendenze specifiche della piattaforma. Questo può sembrare contraddittorio con la regola di cui sopra, ma ascoltami. Gli stessi BLoC sono indipendenti dalla piattaforma, ma cosa succede se hanno bisogno di comunicare con un repository specifico della piattaforma? Iniettare. Garantendo la comunicazione su interfacce e iniettando questi repository, possiamo essere sicuri che indipendentemente dal fatto che il tuo repository sia scritto per Flutter o AngularDart, al BLoC non importerà.
Un'ultima cosa da tenere a mente è che l'input per un BLoC dovrebbe essere un sink, mentre l'output è attraverso un flusso. Entrambi fanno parte di StreamController
.
Se rispetti rigorosamente queste regole mentre scrivi la tua app web (o mobile!), la creazione di una versione mobile (o web!) può essere semplice come creare le visualizzazioni e le interfacce specifiche della piattaforma. Anche se hai appena iniziato a utilizzare AngularDart o Flutter, è comunque facile creare visualizzazioni con una conoscenza di base della piattaforma. Potresti finire per riutilizzare più della metà della tua base di codice. Il modello BLoC mantiene tutto strutturato e di facile manutenzione.
Creazione di un'app AngularDart e Flutter BLoC Todo
Ho creato una semplice app da fare in Flutter e AngularDart. L'app utilizza Firecloud come back-end e un approccio reattivo per visualizzare la creazione. L'app è composta da tre parti:
-
bloc
-
todo_app_flutter
-
todoapp_dart_angular
Puoi scegliere di avere più parti, ad esempio interfaccia dati, interfaccia di localizzazione, ecc. La cosa da ricordare è che ogni livello dovrebbe comunicare con l'altro tramite un'interfaccia.
Il codice BLoC
Nella directory bloc/
:
-
lib/src/bloc
: i moduli BloC sono archiviati qui come pure librerie Dart contenenti la logica aziendale. -
lib/src/repository
: le interfacce per i dati sono archiviate nella directory. -
lib/src/repository/firestore
: il repository contiene l'interfaccia FireCloud per i dati insieme al suo modello, e poiché questa è un'app di esempio, abbiamo solo un modello di datitodo.dart
e un'interfaccia per i datitodo_repository.dart
; tuttavia, in un'app reale, ci saranno più modelli e interfacce di repository. -
lib/src/repository/preferences
contienepreferences_interface.dart
, una semplice interfaccia che memorizza i nomi utente registrati correttamente nella memoria locale sul Web o le preferenze condivise sui dispositivi mobili.
//BLOC abstract class PreferencesInterface{ //Preferences final DEFAULT_USERNAME = "DEFAULT_USERNAME"; Future initPreferences(); String get defaultUsername; void setDefaultUsername(String username); }
Le implementazioni Web e mobili devono implementarlo nel negozio e ottenere il nome utente predefinito dall'archiviazione/preferenze locali. L'implementazione di AngularDart di questo è simile a:
// 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); }
Niente di spettacolare qui: implementa ciò di cui ha bisogno. Potresti notare il metodo async initPreferences()
che restituisce null
. Questo metodo deve essere implementato sul lato Flutter poiché ottenere l'istanza SharedPreferences
su dispositivo mobile è asincrono.
//FLUTTER @override Future initPreferences() async => _prefs = await SharedPreferences.getInstance();
Continuiamo un po' con la dir lib/src/bloc. Qualsiasi vista che gestisce alcune logiche di business dovrebbe avere il suo componente BLoC. In questa directory vedrai BLoCs base_bloc.dart
, endpoints.dart
e session.dart
. L'ultimo è responsabile dell'accesso e della disconnessione dell'utente e della fornitura di endpoint per le interfacce del repository. Il motivo per cui esiste l'interfaccia di sessione è che i pacchetti firebase
e firecloud
non sono gli stessi per Web e dispositivi mobili e devono essere implementati in base alla piattaforma.
// 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; } }
L'idea è di mantenere la classe di sessione globale (singleton). Basato sul suo getter _isSignedIn.stream
, gestisce il passaggio dell'app tra la visualizzazione login/todo-list e fornisce endpoint alle implementazioni del repository se esiste l'ID utente (ovvero, l'utente ha effettuato l'accesso).
base_bloc.dart
è la base per tutti i BLoC. In questo esempio, gestisce l'indicatore di carico e la visualizzazione della finestra di dialogo di errore secondo necessità.
Per l'esempio di logica aziendale, daremo un'occhiata a todo_add_edit_bloc.dart
. Il nome lungo del file ne spiega lo scopo. Ha un metodo void privato _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()) ); }
L'input per questo metodo è bool addUpdate
ed è un listener di final BehaviorSubject<bool> _addUpdate = BehaviorSubject<bool>()
. Quando un utente fa clic sul pulsante Salva nell'app, l'evento invia a questo oggetto il valore vero e attiva questa funzione BLoC. Questo pezzo di codice flutter fa la magia sul lato della vista.
// FLUTTER IconButton(icon: Icon(Icons.done), onPressed: () => _todoAddEditBloc.addUpdateSink.add(true),),
_addUpdateTodo
controlla che sia il titolo che la descrizione non siano vuoti e modifica il valore di _todoError
BehaviorSubject in base a questa condizione. L'errore _todoError
è responsabile dell'attivazione della visualizzazione dell'errore di visualizzazione sui campi di input se non viene fornito alcun valore. Se tutto va bene, controlla se creare o aggiornare TodoBloc
e infine _toDoRepository
scrive su FireCloud.
La logica aziendale è qui, ma nota:
- Solo stream e sink sono pubblici in BLoC.
_addUpdateTodo
è privato e non è possibile accedervi dalla vista. -
_title.value
e_description.value
vengono compilati dall'utente inserendo il valore nell'input di testo. L'immissione di testo sull'evento di modifica del testo invia il suo valore ai rispettivi sink. In questo modo, abbiamo una modifica reattiva dei valori nel BLoC e la loro visualizzazione nella vista. -
_toDoRepository
dipende dalla piattaforma ed è fornito tramite injection.
Controlla il codice del todo_list.dart
BLoC _getTodos()
. Ascolta un'istantanea della raccolta di cose da fare e trasmette i dati della raccolta per elencarli nella sua vista. L'elenco di visualizzazione viene ridisegnato in base alla modifica del flusso di raccolta.

// 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()); }); }
La cosa importante da tenere presente quando si utilizzano stream o equivalenti rx è che gli stream devono essere chiusi. Lo facciamo nel metodo dispose()
di ogni BLoC. Elimina il BLoC di ciascuna vista nel relativo metodo di eliminazione/distruggi.
// FLUTTER @override void dispose() { widget.baseBloc.dispose(); super.dispose(); }
O in un progetto AngularDart:
// ANGULAR DART @override void ngOnDestroy() { todoListBloc.dispose(); }
Iniezione di repository specifici della piattaforma
Abbiamo detto prima che tutto ciò che arriva in un BLoC deve essere semplice Dart e nulla dipendente dalla piattaforma. TodoAddEditBloc
bisogno di ToDoRepository
per scrivere su Firestore. Firebase ha pacchetti dipendenti dalla piattaforma e dobbiamo avere implementazioni separate dell'interfaccia ToDoRepository
. Queste implementazioni vengono inserite nelle app. Per Flutter, ho usato il pacchetto flutter_simple_dependency_injection
e si presenta così:
// 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); } }
Usalo in un widget come questo:
// FLUTTER TodoAddEditBloc _todoAddEditBloc = Injection.injector.get<TodoAddEditBloc>();
AngularDart ha l'iniezione integrata tramite provider.
// ANGULAR DART @GenerateInjector([ ClassProvider(PreferencesInterface, useClass: PreferencesInterfaceImpl), ClassProvider(Session, useClass: SessionImpl), ExistingProvider(Endpoints, Session) ])
E in un componente:
// ANGULAR DART providers: [ overlayBindings, ClassProvider(ToDoRepository, useClass: ToDoRepositoryImpl), ClassProvider(TodoAddEditBloc), ExistingProvider(BaseBloc, TodoAddEditBloc) ],
Possiamo vedere che Session
è globale. Fornisce la funzionalità di accesso/uscita e gli endpoint utilizzati in ToDoRepository
e BLoC. ToDoRepository
necessita di un'interfaccia di endpoint implementata in SessionImpl
e così via. La vista dovrebbe vedere solo il suo BLoC e nient'altro.
Visualizzazioni
Le visualizzazioni dovrebbero essere il più semplici possibile. Visualizzano solo ciò che viene dal BLoC e inviano l'input dell'utente al BLoC. Lo esamineremo con il widget TodoAddEdit
di Flutter e il suo equivalente web TodoDetailComponent
. Visualizzano il titolo e la descrizione della cosa da fare selezionata e l'utente può aggiungere o aggiornare una cosa da fare.
svolazzare:
// FLUTTER _todoAddEditBloc.todoStream.first.then((todo) { _titleController.text = todo.title; _descriptionController.text = todo.description; });
E poi in codice...
// 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, ); }, ),
Il widget StreamBuilder
si ricostruisce se c'è un errore (nulla inserito). Ciò avviene ascoltando _todoAddEditBloc.todoErrorStream . _todoAddEditBloc.titleSink
_todoAddEditBloc.todoErrorStream . _todoAddEditBloc.titleSink
, che è un sink nel BLoC che contiene il titolo e viene aggiornato sull'utente che inserisce il testo nel campo di testo.
Il valore iniziale di questo campo di input (se è selezionata una cosa da fare) viene compilato ascoltando _todoAddEditBloc.todoStream
che contiene la cosa da fare selezionata o vuota se aggiungiamo una nuova cosa da fare.
L'assegnazione di un valore a un campo di testo viene eseguita dal suo controller _titleController.text = todo.title;
.
Quando l'utente decide di salvare la cosa da fare, preme l'icona di spunta nella barra dell'app e attiva _todoAddEditBloc.addUpdateSink.add(true)
. Ciò richiama _addUpdateTodo(bool addUpdate)
di cui abbiamo parlato nella sezione BLoC precedente e fa tutta la logica aziendale di aggiunta, aggiornamento o visualizzazione dell'errore all'utente.
Tutto è reattivo e non è necessario gestire lo stato del widget.
Il codice AngularDart è ancora più semplice. Dopo aver fornito al componente il suo BLoC, utilizzando i provider, il codice del file todo_detail.html
fa la parte di visualizzare i dati e rimandare l'interazione dell'utente 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>
Simile a Flutter, stiamo assegnando ngModel=
il valore dal flusso del titolo, che è il suo valore iniziale.
// AngularDart (inputKeyPress)="todoAddEditBloc.descriptionSink.add($event)"
L'evento di output inputKeyPress
rimanda i caratteri digitati dall'utente nell'input di testo alla descrizione del BLoC. L'evento material button (trigger)="todoAddEditBloc.addUpdateSink.add(true)"
invia l'evento di aggiunta/aggiornamento BLoC che attiva nuovamente la stessa _addUpdateTodo(bool addUpdate)
nel BLoC. Se dai un'occhiata al codice todo_detail.dart
del componente, vedrai che non c'è quasi nulla tranne le stringhe che vengono visualizzate nella vista. Li ho inseriti lì e non nell'HTML a causa della possibile localizzazione che può essere eseguita qui.
Lo stesso vale per ogni altro componente: i componenti e i widget non hanno logica di business.
Vale la pena menzionare un altro scenario. Immagina di avere una vista con una logica di presentazione dei dati complessa o qualcosa come una tabella con valori che devono essere formattati (date, valute, ecc.). Qualcuno potrebbe essere tentato di ottenere i valori da BLoC e formattarli in una vista. È sbagliato! I valori visualizzati nella vista dovrebbero arrivare alla vista già formattata (stringhe). Il motivo è che anche la formattazione stessa è logica aziendale. Un altro esempio è quando la formattazione del valore visualizzato dipende da alcuni parametri dell'app che possono essere modificati in runtime. Fornendo tale parametro a BLoC e utilizzando un approccio reattivo per visualizzare la visualizzazione, la logica aziendale formatterà il valore e ridisegnerà solo le parti necessarie. Il modello BLoC che abbiamo in questo esempio, TodoBloc
, è molto semplice. La conversione da un modello FireCloud al modello BLoC viene eseguita nel repository, ma, se necessario, può essere eseguita in BLoC in modo che i valori del modello siano pronti per la visualizzazione.
Avvolgendo
Questo breve articolo copre i concetti principali dell'implementazione del modello BLoC. È la prova pratica che la condivisione del codice tra Flutter e AngularDart è possibile, consentendo lo sviluppo nativo e multipiattaforma.
Esplorando l'esempio, vedrai che, se implementato correttamente, BLoC riduce notevolmente i tempi di creazione di app mobili/web. Un esempio è ToDoRepository
e la sua implementazione. Il codice di implementazione è quasi identico e anche la logica di composizione della vista è simile. Dopo un paio di widget/componenti, puoi iniziare rapidamente la produzione di massa.
Spero che questo articolo ti dia uno sguardo al divertimento e all'entusiasmo che provo per creare app web/mobili usando Flutter/AngularDart e il pattern BLoC. Se stai cercando di creare applicazioni desktop multipiattaforma in JavaScript, leggi Electron: app desktop multipiattaforma rese facili dal collega Toptaler Stephane P. Pericat.