Cum să folosiți BLoC pentru partajarea codului în Flutter și AngularDart
Publicat: 2022-03-11La jumătatea anului trecut, am vrut să port o aplicație Android pe iOS și web. Flutter a fost alegerea pentru platformele mobile și mă gândeam ce să aleg pentru partea web.
În timp ce m-am îndrăgostit de Flutter la prima vedere, încă mai aveam câteva rezerve: în timp ce propagam starea în arborele widget, Flutter's InheritedWidget
sau Redux - cu toate variantele sale - vor face treaba, dar cu un nou cadru precum Flutter, ai așteptați ca stratul de vizualizare să fie puțin mai reactiv, adică widget-urile ar fi ele însele apatride și se vor schimba în funcție de starea în care sunt alimentate din exterior, dar nu sunt. De asemenea, Flutter acceptă doar Android și iOS, dar am vrut să public pe web. Am deja o mulțime de logică de afaceri în aplicația mea și am vrut să o reutilizam cât mai mult posibil, iar ideea de a schimba codul în cel puțin două locuri pentru o singură schimbare a logicii de afaceri a fost inacceptabilă.
Am început să mă uit în jur cum să trec peste asta și am dat peste BLoC. Pentru o introducere rapidă, vă recomand să urmăriți Flutter/AngularDart – Partajare de cod, mai bine împreună (DartConf 2018) când aveți timp.
Model BLoC
BLoC este un cuvânt fantezist inventat de Google și înseamnă „componenti logici ai afacerii ”. Ideea modelului BLoC este de a stoca cât mai mult posibil din logica dvs. de afaceri în cod pur Dart, astfel încât să poată fi reutilizat de alte platforme. Pentru a realiza acest lucru, există reguli pe care trebuie să le urmați:
- Comunicați în straturi. Vizualizările comunică cu stratul BLoC, care comunică cu depozitele, iar depozitele vorbesc cu stratul de date. Nu sări peste straturi în timp ce comunicați.
- Comunicați prin interfețe. Interfețele trebuie să fie scrise în cod Dart pur, independent de platformă. Pentru mai multe informații, consultați documentația despre interfețele implicite.
- BLoC-urile expun doar fluxurile și chiuvetele. I/O-ul unui BLoC va fi discutat mai târziu.
- Păstrați vizualizările simple. Păstrați logica de afaceri în afara vederilor. Ar trebui să afișeze numai date și să răspundă la interacțiunea utilizatorului.
- Faceți platforma BLoC agnostică. BLoC-urile sunt cod Dart pur și, prin urmare, nu ar trebui să conțină nicio logică sau dependențe specifice platformei. Nu vă ramificați în codul condiționat de platformă. BLoC-urile sunt implementate logic în Dart pur și se ocupă mai presus de platforma de bază.
- Injectați dependențe specifice platformei. Acest lucru poate suna contradictoriu cu regula de mai sus, dar ascultă-mă. BLoC-urile în sine sunt agnostice de platformă, dar ce se întâmplă dacă trebuie să comunice cu un depozit specific platformei? Injectează-l. Asigurând comunicarea prin interfețe și injectând aceste depozite, putem fi siguri că, indiferent dacă depozitul dvs. este scris pentru Flutter sau AngularDart, BLoC nu îi va păsa.
Un ultim lucru de reținut este că intrarea pentru un BLoC ar trebui să fie o chiuvetă, în timp ce ieșirea este printr-un flux. Ambele fac parte din StreamController
.
Dacă respectați cu strictețe aceste reguli în timp ce scrieți aplicația dvs. web (sau mobilă!), crearea unei versiuni pentru mobil (sau web!) poate fi la fel de simplă ca crearea vizualizărilor și a interfețelor specifice platformei. Chiar dacă tocmai ați început să utilizați AngularDart sau Flutter, este totuși ușor să creați vizualizări cu cunoștințe de bază ale platformei. Este posibil să reutilizați mai mult de jumătate din baza de cod. Modelul BLoC menține totul structurat și ușor de întreținut.
Construirea unei aplicații AngularDart și Flutter BLoC Todo
Am creat o aplicație simplă de tot în Flutter și AngularDart. Aplicația folosește Firecloud ca back-end și o abordare reactivă pentru vizualizarea creației. Aplicația are trei părți:
-
bloc
-
todo_app_flutter
-
todoapp_dart_angular
Puteți alege să aveți mai multe părți, de exemplu, interfața de date, interfața de localizare etc. Lucrul de reținut este că fiecare strat ar trebui să comunice cu celălalt printr-o interfață.
Codul BLoC
În directorul bloc/
:
-
lib/src/bloc
: modulele BloC sunt stocate aici ca biblioteci Dart pure care conțin logica de afaceri. -
lib/src/repository
: interfețele cu date sunt stocate în director. -
lib/src/repository/firestore
: depozitul conține interfața FireCloud pentru date împreună cu modelul său și, deoarece aceasta este un exemplu de aplicație, avem doar un model de datetodo.dart
și o interfață cu dateletodo_repository.dart
; cu toate acestea, într-o aplicație din lumea reală, vor exista mai multe modele și interfețe de depozit. -
lib/src/repository/preferences
conținepreferences_interface.dart
, o interfață simplă care stochează numele de utilizator conectate cu succes în stocarea locală pe web sau preferințe partajate pe dispozitive mobile.
//BLOC abstract class PreferencesInterface{ //Preferences final DEFAULT_USERNAME = "DEFAULT_USERNAME"; Future initPreferences(); String get defaultUsername; void setDefaultUsername(String username); }
Implementările web și mobile trebuie să implementeze acest lucru în magazin și să obțină numele de utilizator implicit din stocarea/preferințele locale. Implementarea AngularDart a acesteia arată astfel:
// 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); }
Nimic spectaculos aici - implementează ceea ce are nevoie. Este posibil să observați metoda asincronă initPreferences()
care returnează null
. Această metodă trebuie implementată pe partea Flutter, deoarece obținerea instanței SharedPreferences
pe mobil este asincronă.
//FLUTTER @override Future initPreferences() async => _prefs = await SharedPreferences.getInstance();
Să rămânem puțin cu dir-ul lib/src/bloc. Orice vizualizare care se ocupă de o anumită logică de afaceri ar trebui să aibă componenta BLoC. În acest director, veți vedea BLoC-uri base_bloc.dart
, endpoints.dart
și session.dart
. Ultimul este responsabil de conectarea și deconectarea utilizatorului și de furnizarea de puncte finale pentru interfețele de depozit. Motivul pentru care există interfața de sesiune este că pachetele firebase
și firecloud
nu sunt aceleași pentru web și mobil și trebuie implementate pe platformă.
// 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; } }
Ideea este de a menține clasa de sesiune globală (singleton). Pe baza getter-ului său _isSignedIn.stream
, se ocupă de comutarea aplicației între vizualizarea autentificare/listă de activități și oferă puncte finale pentru implementările de depozit dacă userId-ul există (adică, utilizatorul este conectat).
base_bloc.dart
este baza pentru toate BLoC-urile. În acest exemplu, gestionează indicatorul de încărcare și afișarea dialogului de eroare după cum este necesar.
Pentru exemplul de logica de afaceri, vom arunca o privire la todo_add_edit_bloc.dart
. Numele lung al fișierului explică scopul acestuia. Are o metodă void privată _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()) ); }
Intrarea pentru această metodă este bool addUpdate
și este un ascultător al final BehaviorSubject<bool> _addUpdate = BehaviorSubject<bool>()
. Când un utilizator face clic pe butonul de salvare din aplicație, evenimentul trimite această valoare reală a acestui subiect și declanșează această funcție BLoC. Această bucată de cod flutter face magia în partea de vizualizare.
// FLUTTER IconButton(icon: Icon(Icons.done), onPressed: () => _todoAddEditBloc.addUpdateSink.add(true),),
_addUpdateTodo
verifică dacă titlul și descrierea nu sunt goale și modifică valoarea lui _todoError
BehaviorSubject pe baza acestei condiții. Eroarea _todoError
este responsabilă pentru declanșarea afișării erorii de vizualizare în câmpurile de intrare dacă nu este furnizată nicio valoare. Dacă totul este în regulă, verifică dacă să creeze sau să actualizeze TodoBloc
și, în cele din urmă, _toDoRepository
scrie pe FireCloud.
Logica de afaceri este aici, dar observați:
- Doar fluxurile și chiuvetele sunt publice în BLoC.
_addUpdateTodo
este privat și nu poate fi accesat din vizualizare. -
_title.value
și_description.value
sunt completate de utilizator care introduce valoarea în textul introdus. Introducerea textului la evenimentul de modificare a textului își trimite valoarea la chiuvetele respective. Astfel, avem o modificare reactivă a valorilor în BLoC și afișarea lor în vizualizare. -
_toDoRepository
depinde de platformă și este furnizat prin injecție.
Consultați codul todo_list.dart
BLoC _getTodos()
. Acesta ascultă un instantaneu al colecției todo și transmite datele colecției pentru a le lista în vizualizarea sa. Lista de vizualizare este redesenată pe baza modificării fluxului de colecție.

// 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()); }); }
Lucrul important de care trebuie să fiți conștient atunci când utilizați fluxuri sau echivalentul rx este că fluxurile trebuie să fie închise. Facem asta în metoda dispose()
a fiecărui BLoC. Eliminați BLoC-ul fiecărei vederi în metoda sa de eliminare/distrugere.
// FLUTTER @override void dispose() { widget.baseBloc.dispose(); super.dispose(); }
Sau într-un proiect AngularDart:
// ANGULAR DART @override void ngOnDestroy() { todoListBloc.dispose(); }
Injectarea arhivelor specifice platformei
Am spus înainte că tot ceea ce vine într-un BLoC trebuie să fie simplu Dart și să nu depindă nimic de platformă. TodoAddEditBloc
are nevoie de ToDoRepository
pentru a scrie în Firestore. Firebase are pachete dependente de platformă și trebuie să avem implementări separate ale interfeței ToDoRepository
. Aceste implementări sunt injectate în aplicații. Pentru Flutter, am folosit pachetul flutter_simple_dependency_injection
și arată astfel:
// 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); } }
Utilizați acest lucru într-un widget ca acesta:
// FLUTTER TodoAddEditBloc _todoAddEditBloc = Injection.injector.get<TodoAddEditBloc>();
AngularDart are injecție încorporată prin intermediul furnizorilor.
// ANGULAR DART @GenerateInjector([ ClassProvider(PreferencesInterface, useClass: PreferencesInterfaceImpl), ClassProvider(Session, useClass: SessionImpl), ExistingProvider(Endpoints, Session) ])
Și într-o componentă:
// ANGULAR DART providers: [ overlayBindings, ClassProvider(ToDoRepository, useClass: ToDoRepositoryImpl), ClassProvider(TodoAddEditBloc), ExistingProvider(BaseBloc, TodoAddEditBloc) ],
Putem vedea că Session
este globală. Oferă funcționalitatea de conectare/deconectare și punctele finale utilizate în ToDoRepository
și BLoC. ToDoRepository
are nevoie de o interfață pentru punctele finale care este implementată în SessionImpl
și așa mai departe. Vederea ar trebui să-și vadă doar BLoC și nimic mai mult.
Vizualizări
Vizualizările ar trebui să fie cât mai simple posibil. Ele afișează doar ceea ce vine din BLoC și trimite intrarea utilizatorului către BLoC. O vom analiza cu widgetul TodoAddEdit
de la Flutter și echivalentul său web TodoDetailComponent
. Acestea afișează titlul și descrierea de tot selectate, iar utilizatorul poate adăuga sau actualiza o activitate.
Flutter:
// FLUTTER _todoAddEditBloc.todoStream.first.then((todo) { _titleController.text = todo.title; _descriptionController.text = todo.description; });
Și mai târziu în cod...
// 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, ); }, ),
Widgetul StreamBuilder
se reconstruiește singur dacă există o eroare (nimic inserat). Acest lucru se întâmplă ascultând _todoAddEditBloc.todoErrorStream . _todoAddEditBloc.titleSink
_todoAddEditBloc.todoErrorStream . _todoAddEditBloc.titleSink
, care este o chiuvetă din BLoC care deține titlul și este actualizată când utilizatorul introduce text în câmpul de text.
Valoarea inițială a acestui câmp de introducere (dacă este selectat un toto) este completat ascultând _todoAddEditBloc.todoStream
care deține tot selectat sau unul gol dacă adăugăm un todo nou.
Atribuirea valorii unui câmp de text se face de către controlerul acestuia _titleController.text = todo.title;
.
Când utilizatorul decide să salveze tot, apăsă pictograma de verificare din bara de aplicații și declanșează _todoAddEditBloc.addUpdateSink.add(true)
. Aceasta invocă _addUpdateTodo(bool addUpdate)
despre care am vorbit în secțiunea anterioară BLoC și face toată logica de afaceri de a adăuga, actualiza sau afișa eroarea înapoi către utilizator.
Totul este reactiv și nu este nevoie să gestionați starea widgetului.
Codul AngularDart este și mai simplu. După ce a furnizat componentei BLoC, folosind furnizori, codul de fișier todo_detail.html
face parte din afișarea datelor și trimiterea interacțiunii utilizatorului înapoi la 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 cu Flutter, atribuim ngModel=
valoarea din fluxul de titlu, care este valoarea sa inițială.
// AngularDart (inputKeyPress)="todoAddEditBloc.descriptionSink.add($event)"
Evenimentul de ieșire inputKeyPress
trimite caracterele tastate de utilizator în textul introdus înapoi la descrierea BLoC. Evenimentul butonul material (trigger)="todoAddEditBloc.addUpdateSink.add(true)"
trimite evenimentul BLoC add/update care declanșează din nou aceeași _addUpdateTodo(bool addUpdate)
în BLoC. Dacă aruncați o privire la codul todo_detail.dart
al componentei, veți vedea că nu există aproape nimic în afară de șirurile care sunt afișate pe vizualizare. Le-am plasat acolo și nu în HTML din cauza posibilei localizări care se poate face aici.
Același lucru este valabil și pentru orice altă componentă - componentele și widget-urile au nicio logică de afaceri.
Încă un scenariu merită menționat. Imaginați-vă că aveți o vedere cu o logică complexă de prezentare a datelor sau ceva de genul unui tabel cu valori care trebuie formatate (date, valute etc.). Cineva ar putea fi tentat să obțină valorile din BLoC și să le formateze într-o vizualizare. Este gresit! Valorile afișate în vizualizare ar trebui să vină în vizualizarea deja formatată (șiruri). Motivul este că formatarea în sine este, de asemenea, logica de afaceri. Încă un exemplu este atunci când formatarea valorii de afișare depinde de un parametru al aplicației care poate fi modificat în timpul de execuție. Prin furnizarea acelui parametru către BLoC și folosind o abordare reactivă pentru vizualizarea afișajului, logica de afaceri va formata valoarea și va redesena doar părțile necesare. Modelul BLoC pe care îl avem în acest exemplu, TodoBloc
, este foarte simplu. Conversia de la un model FireCloud la modelul BLoC se face în depozit, dar dacă este necesar, se poate face în BLoC, astfel încât valorile modelului să fie gata pentru afișare.
Încheierea
Acest scurt articol acoperă conceptele principale de implementare a modelului BLoC. Este o dovadă a faptului că partajarea de cod între Flutter și AngularDart este posibilă, permițând dezvoltarea nativă și multiplatformă.
Explorând exemplul, veți vedea că, atunci când este implementat corect, BLoC scurtează semnificativ timpul de creare a aplicațiilor mobile/web. Un exemplu este ToDoRepository
și implementarea acestuia. Codul de implementare este aproape identic și chiar și logica de compunere a vederii este similară. După câteva widget-uri/componente, puteți începe rapid producția de masă.
Sper că acest articol vă va oferi o privire asupra distracției și entuziasmului pe care îl am în realizarea de aplicații web/mobile folosind Flutter/AngularDart și modelul BLoC. Dacă doriți să construiți aplicații desktop multiplatformă în JavaScript, citiți Electron: Aplicații desktop multiplatformă făcute ușor de colegul Toptaler Stephane P. Pericat.