So nutzen Sie BLoC für Code-Sharing in Flutter und AngularDart
Veröffentlicht: 2022-03-11Mitte letzten Jahres wollte ich eine Android-App auf iOS und Web portieren. Flutter war die Wahl für mobile Plattformen, und ich überlegte, was ich für die Webseite wählen sollte.
Während ich mich auf den ersten Blick in Flutter verliebte, hatte ich immer noch einige Vorbehalte: Während Flutters InheritedWidget
oder Redux – mit all seinen Variationen – den Zustand nach unten in den Widget-Baum propagieren, wird die Arbeit erledigt, aber mit einem neuen Framework wie Flutter würden Sie es tun Erwarten Sie, dass die Ansichtsschicht etwas reaktiver wäre, dh Widgets wären selbst zustandslos und ändern sich entsprechend dem Zustand, den sie von außen erhalten, aber das sind sie nicht. Außerdem unterstützt Flutter nur Android und iOS, aber ich wollte im Web veröffentlichen. Ich habe bereits jede Menge Geschäftslogik in meiner App und wollte sie so oft wie möglich wiederverwenden, und die Idee, den Code an mindestens zwei Stellen für eine einzige Änderung der Geschäftslogik zu ändern, war nicht akzeptabel.
Ich fing an, mich umzusehen, wie ich darüber hinwegkommen könnte, und stieß auf BLoC. Für eine kurze Einführung empfehle ich, Flutter/AngularDart – Code sharing, better together (DartConf 2018) anzusehen, wenn Sie Zeit haben.
BLoC-Muster
BLoC ist ein schickes Wort, das von Google erfunden wurde und „Geschäftslogikkomponenten “ bedeutet. Die Idee des BLoC-Musters besteht darin, so viel Ihrer Geschäftslogik wie möglich in reinem Dart-Code zu speichern, damit sie von anderen Plattformen wiederverwendet werden kann. Um dies zu erreichen, gibt es Regeln, die Sie befolgen müssen:
- Kommunizieren Sie in Schichten. Ansichten kommunizieren mit der BLoC-Schicht, die mit den Repositories kommuniziert, und die Repositories kommunizieren mit der Datenschicht. Überspringen Sie beim Kommunizieren keine Ebenen.
- Kommunizieren Sie über Schnittstellen. Schnittstellen müssen in reinem, plattformunabhängigem Dart-Code geschrieben werden. Weitere Informationen finden Sie in der Dokumentation zu impliziten Schnittstellen.
- BLoCs legen nur Streams und Senken offen. Die I/O eines BLoC wird später besprochen.
- Ansichten einfach halten. Halten Sie die Geschäftslogik aus den Ansichten heraus. Sie sollten nur Daten anzeigen und auf Benutzerinteraktion reagieren.
- Machen Sie BLoCs plattformunabhängig. BLoCs sind reiner Dart-Code und sollten daher keine plattformspezifische Logik oder Abhängigkeiten enthalten. Verzweigen Sie nicht in Plattform-Bedingungscode. BLoCs sind in reinem Dart implementierte Logik und stehen über der Basisplattform.
- Fügen Sie plattformspezifische Abhängigkeiten ein. Das mag widersprüchlich zur obigen Regel klingen, aber hör mir zu. BLoCs selbst sind plattformunabhängig, aber was ist, wenn sie mit einem plattformspezifischen Repository kommunizieren müssen? Injizieren Sie es. Indem wir die Kommunikation über Schnittstellen sicherstellen und diese Repositorys einfügen, können wir sicher sein, dass es dem BLoC egal ist, ob Ihr Repository für Flutter oder AngularDart geschrieben ist.
Eine letzte Sache, die Sie beachten sollten, ist, dass die Eingabe für einen BLoC eine Senke sein sollte, während die Ausgabe durch einen Stream erfolgt. Diese sind beide Teil des StreamController
.
Wenn Sie sich beim Schreiben Ihrer Web- (oder mobilen!) App strikt an diese Regeln halten, kann das Erstellen einer mobilen (oder Web-!) Version so einfach sein wie das Erstellen der Ansichten und plattformspezifischen Schnittstellen. Selbst wenn Sie gerade erst begonnen haben, AngularDart oder Flutter zu verwenden, ist es mit grundlegenden Plattformkenntnissen immer noch einfach, Ansichten zu erstellen. Sie können am Ende mehr als die Hälfte Ihrer Codebasis wiederverwenden. Das BLoC-Muster hält alles strukturiert und pflegeleicht.
Erstellen einer Todo-App für AngularDart und Flutter BLoC
Ich habe eine einfache Aufgaben-App in Flutter und AngularDart erstellt. Die App verwendet Firecloud als Back-End und einen reaktiven Ansatz zur Erstellung von Ansichten. Die App besteht aus drei Teilen:
-
bloc
-
todo_app_flutter
-
todoapp_dart_angular
Sie können wählen, ob Sie mehr Teile haben möchten, z. B. Datenschnittstelle, Lokalisierungsschnittstelle usw. Denken Sie daran, dass jede Schicht über eine Schnittstelle mit der anderen kommunizieren sollte.
Der BLoC-Code
Im Verzeichnis bloc/
:
-
lib/src/bloc
: Die BloC-Module werden hier als reine Dart-Bibliotheken gespeichert, die die Geschäftslogik enthalten. -
lib/src/repository
: Die Schnittstellen zu Daten werden im Verzeichnis gespeichert. -
lib/src/repository/firestore
: Das Repository enthält die FireCloud-Schnittstelle zu Daten zusammen mit ihrem Modell, und da dies eine Beispiel-App ist, haben wir nur ein Datenmodelltodo.dart
und eine Schnittstelle zu den Datentodo_repository.dart
; In einer realen App wird es jedoch mehr Modelle und Repository-Schnittstellen geben. -
lib/src/repository/preferences
enthältpreferences_interface.dart
, eine einfache Schnittstelle, die erfolgreich angemeldete Benutzernamen im lokalen Speicher im Web oder freigegebene Einstellungen auf Mobilgeräten speichert.
//BLOC abstract class PreferencesInterface{ //Preferences final DEFAULT_USERNAME = "DEFAULT_USERNAME"; Future initPreferences(); String get defaultUsername; void setDefaultUsername(String username); }
Web- und Mobilimplementierungen müssen dies im Geschäft implementieren und den Standardbenutzernamen aus dem lokalen Speicher/den Einstellungen abrufen. Die AngularDart-Implementierung davon sieht folgendermaßen aus:
// 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); }
Nichts Spektakuläres hier – es implementiert, was es braucht. Möglicherweise bemerken Sie die asynchrone Methode initPreferences()
, die null
zurückgibt. Diese Methode muss auf der Flutter-Seite implementiert werden, da das Abrufen der SharedPreferences
Instanz auf Mobilgeräten asynchron ist.
//FLUTTER @override Future initPreferences() async => _prefs = await SharedPreferences.getInstance();
Bleiben wir ein wenig beim lib/src/bloc-Verzeichnis. Jede Ansicht, die Geschäftslogik verarbeitet, sollte ihre BLoC-Komponente haben. In diesem Verzeichnis sehen Sie Ansichts-BLoCs base_bloc.dart
, endpoints.dart
und session.dart
. Der letzte ist für die An- und Abmeldung des Benutzers und die Bereitstellung von Endpunkten für Repository-Schnittstellen verantwortlich. Der Grund für die Existenz der Sitzungsschnittstelle ist, dass die firebase
und firecloud
Pakete für Web und Mobile nicht identisch sind und basierend auf der Plattform implementiert werden müssen.
// 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; } }
Die Idee ist, die Session-Klasse global (Singleton) zu halten. Basierend auf seinem _isSignedIn.stream
Getter handhabt es den App-Wechsel zwischen Login-/Todo-List-Ansicht und stellt Endpunkte für Repository-Implementierungen bereit, wenn die userId existiert (dh der Benutzer ist angemeldet).
base_bloc.dart
ist die Basis für alle BLoCs. In diesem Beispiel handhabt es die Ladeanzeige und die Anzeige des Fehlerdialogs nach Bedarf.
Als Beispiel für die Geschäftslogik sehen wir uns todo_add_edit_bloc.dart
an. Der lange Name der Datei erklärt ihren Zweck. Es hat eine private void-Methode _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()) ); }
Die Eingabe für diese Methode ist bool addUpdate
und ein Listener von final BehaviorSubject<bool> _addUpdate = BehaviorSubject<bool>()
. Wenn ein Benutzer in der App auf die Schaltfläche „Speichern“ klickt, sendet das Ereignis diesen wahren Wert der Subjektsenke und löst diese BLoC-Funktion aus. Dieser Flattercode macht die Magie auf der Ansichtsseite.
// FLUTTER IconButton(icon: Icon(Icons.done), onPressed: () => _todoAddEditBloc.addUpdateSink.add(true),),
_addUpdateTodo
überprüft, ob sowohl der Titel als auch die Beschreibung nicht leer sind, und ändert den Wert von _todoError
BehaviorSubject basierend auf dieser Bedingung. Der _todoError
Fehler ist dafür verantwortlich, dass die Ansichtsfehleranzeige in Eingabefeldern ausgelöst wird, wenn kein Wert bereitgestellt wird. Wenn alles in Ordnung ist, prüft es, ob der TodoBloc
erstellt oder aktualisiert werden soll, und schließlich schreibt _toDoRepository
in FireCloud.
Die Geschäftslogik ist hier, aber beachten Sie:
- In BLoC sind nur Streams und Senken öffentlich.
_addUpdateTodo
ist privat und kann nicht aus der Ansicht aufgerufen werden. -
_title.value
und_description.value
werden gefüllt, indem der Benutzer den Wert in die Texteingabe eingibt. Texteingaben bei Textänderungsereignissen senden ihren Wert an die entsprechenden Senken. Auf diese Weise haben wir eine reaktive Änderung von Werten im BLoC und deren Anzeige in der Ansicht. -
_toDoRepository
ist plattformabhängig und wird per Injektion bereitgestellt.
Sehen Sie sich den Code der todo_list.dart
-Methode _getTodos()
von todo_list.dart an. Es wartet auf einen Schnappschuss der Aufgabensammlung und streamt die Sammlungsdaten, um sie in seiner Ansicht aufzulisten. Die Ansichtsliste wird basierend auf der Änderung des Erfassungsstroms neu gezeichnet.

// 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()); }); }
Bei der Verwendung von Streams oder RX-Äquivalenten ist zu beachten, dass Streams geschlossen werden müssen. Das machen wir in der Methode dispose dispose()
jedes BLoC. Entsorgen Sie den BLoC jeder Ansicht in ihrer Dispose/Destroy-Methode.
// FLUTTER @override void dispose() { widget.baseBloc.dispose(); super.dispose(); }
Oder in einem AngularDart-Projekt:
// ANGULAR DART @override void ngOnDestroy() { todoListBloc.dispose(); }
Einfügen von plattformspezifischen Repositories
Wir haben vorher gesagt, dass alles, was in einen BLoC kommt, einfaches Dart sein muss und nichts Plattformabhängiges. TodoAddEditBloc
benötigt ToDoRepository
, um in Firestore zu schreiben. Firebase verfügt über plattformabhängige Pakete, und wir müssen über separate Implementierungen der ToDoRepository
Schnittstelle verfügen. Diese Implementierungen werden in Apps eingefügt. Für Flutter habe ich das Paket flutter_simple_dependency_injection
verwendet und es sieht so aus:
// 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); } }
Verwenden Sie dies in einem Widget wie diesem:
// FLUTTER TodoAddEditBloc _todoAddEditBloc = Injection.injector.get<TodoAddEditBloc>();
AngularDart verfügt über eine integrierte Injektion über Anbieter.
// ANGULAR DART @GenerateInjector([ ClassProvider(PreferencesInterface, useClass: PreferencesInterfaceImpl), ClassProvider(Session, useClass: SessionImpl), ExistingProvider(Endpoints, Session) ])
Und in einer Komponente:
// ANGULAR DART providers: [ overlayBindings, ClassProvider(ToDoRepository, useClass: ToDoRepositoryImpl), ClassProvider(TodoAddEditBloc), ExistingProvider(BaseBloc, TodoAddEditBloc) ],
Wir können sehen, dass Session
global ist. Es bietet die Anmelde-/Abmeldefunktion und Endpunkte, die in ToDoRepository
und BLoCs verwendet werden. ToDoRepository
benötigt eine Endpunktschnittstelle, die in SessionImpl
implementiert ist und so weiter. Die Ansicht sollte nur ihren BLoC sehen und nicht mehr.
Ansichten
Ansichten sollten so einfach wie möglich sein. Sie zeigen nur an, was vom BLoC kommt, und senden die Eingaben des Benutzers an das BLoC. Wir werden es mit TodoAddEdit
Widget von Flutter und seinem Web-Äquivalent TodoDetailComponent
. Sie zeigen den Titel und die Beschreibung der ausgewählten Aufgabe an und der Benutzer kann eine Aufgabe hinzufügen oder aktualisieren.
Flattern:
// FLUTTER _todoAddEditBloc.todoStream.first.then((todo) { _titleController.text = todo.title; _descriptionController.text = todo.description; });
Und später im Code…
// 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, ); }, ),
Das StreamBuilder
Widget baut sich selbst neu auf, wenn ein Fehler auftritt (nichts eingefügt). Dies geschieht durch Abhören von _todoAddEditBloc.todoErrorStream . _todoAddEditBloc.titleSink
_todoAddEditBloc.todoErrorStream . _todoAddEditBloc.titleSink
, eine Senke im BLoC, die den Titel enthält und aktualisiert wird, wenn der Benutzer Text in das Textfeld eingibt.
Der Anfangswert dieses Eingabefelds (wenn eine Aufgabe ausgewählt ist) wird gefüllt, indem auf _todoAddEditBloc.todoStream
wird, das die ausgewählte Aufgabe enthält, oder eine leere, wenn wir eine neue Aufgabe hinzufügen.
Die Zuweisung von Werten zu einem Textfeld erfolgt durch seinen Controller _titleController.text = todo.title;
.
Wenn der Benutzer beschließt, die Aufgabe zu speichern, drückt er das Häkchen-Symbol in der App-Leiste und löst _todoAddEditBloc.addUpdateSink.add(true)
aus. _addUpdateTodo(bool addUpdate)
, über das wir im vorherigen BLoC-Abschnitt gesprochen haben, und die gesamte Geschäftslogik zum Hinzufügen, Aktualisieren oder Anzeigen des Fehlers für den Benutzer ausgeführt.
Alles ist reaktiv und es besteht keine Notwendigkeit, den Widget-Status zu behandeln.
AngularDart-Code ist sogar noch einfacher. Nachdem der Komponente unter Verwendung von Anbietern ihr BLoC bereitgestellt wurde, erledigt der Code der Datei todo_detail.html
den Teil der Anzeige der Daten und des Zurücksendens der Benutzerinteraktion an das 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>
Ähnlich wie bei Flutter weisen wir ngModel=
den Wert aus dem Titelstream zu, der sein Anfangswert ist.
// AngularDart (inputKeyPress)="todoAddEditBloc.descriptionSink.add($event)"
Das inputKeyPress
sendet die Zeichen, die der Benutzer in die Texteingabe eingibt, zurück an die Beschreibung des BLoC. Das Ereignis material button (trigger)="todoAddEditBloc.addUpdateSink.add(true)"
sendet das BLoC add/update-Ereignis, das erneut dieselbe _addUpdateTodo(bool addUpdate)
-Funktion im BLoC auslöst. Wenn Sie sich den todo_detail.dart
-Code der Komponente ansehen, werden Sie feststellen, dass außer den Zeichenfolgen, die in der Ansicht angezeigt werden, fast nichts vorhanden ist. Ich habe sie wegen einer möglichen Lokalisierung, die hier vorgenommen werden kann, dort und nicht im HTML platziert.
Dasselbe gilt für alle anderen Komponenten – die Komponenten und Widgets haben keine Geschäftslogik.
Ein weiteres Szenario ist erwähnenswert. Stellen Sie sich vor, Sie haben eine Ansicht mit komplexer Datendarstellungslogik oder so etwas wie eine Tabelle mit Werten, die formatiert werden müssen (Datum, Währung usw.). Jemand könnte versucht sein, die Werte von BLoC abzurufen und sie in einer Ansicht zu formatieren. Das ist falsch! Die in der Ansicht angezeigten Werte sollten bereits formatiert (Strings) zur Ansicht kommen. Der Grund dafür ist, dass die Formatierung selbst auch Geschäftslogik ist. Ein weiteres Beispiel ist, wenn die Formatierung des Anzeigewerts von einigen App-Parametern abhängt, die zur Laufzeit geändert werden können. Durch die Bereitstellung dieses Parameters für BLoC und die Verwendung eines reaktiven Ansatzes zur Anzeige der Anzeige formatiert die Geschäftslogik den Wert und zeichnet nur die benötigten Teile neu. Das BLoC-Modell in diesem Beispiel, TodoBloc
, ist sehr einfach. Die Konvertierung von einem FireCloud-Modell in das BLoC-Modell erfolgt im Repository, kann aber bei Bedarf auch in BLoC erfolgen, damit die Modellwerte angezeigt werden können.
Einpacken
Dieser kurze Artikel behandelt die Hauptkonzepte der BLoC-Musterimplementierung. Es ist ein funktionierender Beweis dafür, dass Code-Sharing zwischen Flutter und AngularDart möglich ist, was eine native und plattformübergreifende Entwicklung ermöglicht.
Wenn Sie das Beispiel untersuchen, werden Sie sehen, dass BLoC bei richtiger Implementierung die Zeit zum Erstellen von Mobil-/Web-Apps erheblich verkürzt. Ein Beispiel ist ToDoRepository
und seine Implementierung. Der Implementierungscode ist nahezu identisch und sogar die Logik zum Erstellen von Ansichten ist ähnlich. Nach ein paar Widgets/Komponenten können Sie schnell mit der Massenproduktion beginnen.
Ich hoffe, dieser Artikel gibt Ihnen einen Einblick in den Spaß und die Begeisterung, die ich beim Erstellen von Web-/Mobil-Apps mit Flutter/AngularDart und dem BLoC-Muster habe. Wenn Sie plattformübergreifende Desktop-Anwendungen in JavaScript erstellen möchten, lesen Sie Electron: Cross-platform Desktop Apps Made Easy von Toptaler-Kollege Stephane P. Pericat.