Jak wykorzystać BLoC do udostępniania kodu w Flutter i AngularDart

Opublikowany: 2022-03-11

W połowie zeszłego roku chciałem przenieść aplikację na Androida na iOS i sieć. Flutter był wyborem dla platform mobilnych, a ja zastanawiałem się, co wybrać dla strony internetowej.

Chociaż zakochałem się w Flutter od pierwszego wejrzenia, wciąż miałem pewne zastrzeżenia: podczas propagowania stanu w dół drzewa widżetów, Flutter's InheritedWidget lub Redux - ze wszystkimi jego odmianami - zrobią to samo, ale z nowym frameworkiem, takim jak Flutter, możesz spodziewaj się, że warstwa widoku byłaby nieco bardziej reaktywna, tj. widżety same byłyby bezstanowe i zmieniałyby się w zależności od stanu, w jakim są karmione z zewnątrz, ale tak nie jest. Również Flutter obsługuje tylko Androida i iOS, ale chciałem opublikować w Internecie. Mam już mnóstwo logiki biznesowej w swojej aplikacji i chciałem ją jak najwięcej wykorzystać ponownie, a pomysł zmiany kodu w co najmniej dwóch miejscach dla jednej zmiany logiki biznesowej był nie do przyjęcia.

Zacząłem się rozglądać, jak sobie z tym poradzić i natknąłem się na BLoC. Na szybkie wprowadzenie polecam obejrzeć Flutter/AngularDart – Udostępnianie kodu, lepiej razem (DartConf 2018) , kiedy masz czas.

Wzór BLoC

Schemat przepływu komunikacji w warstwie widoku, BLoC, repozytorium i danych

BLoC to wymyślne słowo wymyślone przez Google, oznaczające „komponenty logiki biznesowej”. Ideą wzorca BLoC jest przechowywanie jak największej części logiki biznesowej w czystym kodzie Dart, aby można go było ponownie wykorzystać na innych platformach. Aby to osiągnąć, musisz przestrzegać zasad:

  • Komunikuj się warstwami. Widoki komunikują się z warstwą BLoC, która komunikuje się z repozytoriami, a repozytoria komunikują się z warstwą danych. Nie pomijaj warstw podczas komunikacji.
  • Komunikuj się przez interfejsy. Interfejsy muszą być napisane czystym, niezależnym od platformy kodem Dart. Aby uzyskać więcej informacji, zobacz dokumentację dotyczącą niejawnych interfejsów.
  • BLoC ujawniają tylko strumienie i ujścia. We/Wy BLoC zostaną omówione później.
  • Zachowaj proste widoki. Trzymaj logikę biznesową poza widokami. Powinny tylko wyświetlać dane i reagować na interakcję użytkownika.
  • Spraw, aby platforma BLoC była agnostyczna. BLoC są czystym kodem Dart, więc nie powinny zawierać logiki ani zależności specyficznej dla platformy. Nie rozgałęziaj kodu warunkowego platformy. BLoCs są logiką zaimplementowaną w czystym Dart i dotyczą platformy podstawowej.
  • Wstrzyknij zależności specyficzne dla platformy. Może to zabrzmieć sprzecznie z powyższą zasadą, ale wysłuchaj mnie. Same BLoC są niezależne od platformy, ale co, jeśli muszą komunikować się z repozytorium specyficznym dla platformy? Wstrzyknij to. Zapewniając komunikację przez interfejsy i wstrzykiwanie tych repozytoriów, możemy być pewni, że niezależnie od tego, czy Twoje repozytorium jest napisane dla Fluttera czy AngularDart, BLoC nie będzie się tym przejmować.

Ostatnią rzeczą, o której należy pamiętać, jest to, że dane wejściowe dla BLoC powinny być ujściem, podczas gdy dane wyjściowe są przesyłane przez strumień. Oba są częścią StreamController .

Jeśli ściśle przestrzegasz tych zasad podczas pisania aplikacji internetowej (lub mobilnej!), tworzenie wersji mobilnej (lub internetowej!) może być tak proste, jak tworzenie widoków i interfejsów specyficznych dla platformy. Nawet jeśli dopiero zacząłeś używać AngularDart lub Flutter, nadal możesz łatwo tworzyć widoki z podstawową wiedzą o platformie. Możesz ponownie wykorzystać więcej niż połowę swojego kodu. Wzór BLoC sprawia, że ​​wszystko jest uporządkowane i łatwe w utrzymaniu.

Tworzenie aplikacji AngularDart i Flutter BLoC Todo

Zrobiłem prostą aplikację do zrobienia w Flutter i AngularDart. Aplikacja wykorzystuje Firecloud jako zaplecze i reaktywne podejście do tworzenia widoków. Aplikacja składa się z trzech części:

  • bloc
  • todo_app_flutter
  • todoapp_dart_angular

Możesz wybrać więcej części — na przykład interfejs danych, interfejs lokalizacji itp. Należy pamiętać, że każda warstwa powinna komunikować się z drugą za pomocą interfejsu.

Kodeks BLoC

W katalogu bloc/ :

  • lib/src/bloc : Moduły BloC są przechowywane tutaj jako czyste biblioteki Dart zawierające logikę biznesową.
  • lib/src/repository : interfejsy do danych są przechowywane w katalogu.
  • lib/src/repository/firestore : Repozytorium zawiera interfejs FireCloud do danych wraz z ich modelem, a ponieważ jest to przykładowa aplikacja, mamy tylko jeden model danych todo.dart i jeden interfejs do danych todo_repository.dart ; jednak w rzeczywistej aplikacji będzie więcej modeli i interfejsów repozytorium.
  • lib/src/repository/preferences zawiera preferences_interface.dart , prosty interfejs, który przechowuje pomyślnie zalogowane nazwy użytkowników w pamięci lokalnej w Internecie lub udostępnionych preferencjach na urządzeniach mobilnych.
 //BLOC abstract class PreferencesInterface{ //Preferences final DEFAULT_USERNAME = "DEFAULT_USERNAME"; Future initPreferences(); String get defaultUsername; void setDefaultUsername(String username); }

Implementacje internetowe i mobilne muszą zaimplementować to w sklepie i pobrać domyślną nazwę użytkownika z lokalnej pamięci/preferencji. Implementacja AngularDart wygląda tak:

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

Nie ma tu nic spektakularnego – realizuje to, czego potrzebuje. Możesz zauważyć metodę asynchroniczną initPreferences() , która zwraca null . Ta metoda musi zostać zaimplementowana po stronie Flutter, ponieważ pobieranie wystąpienia SharedPreferences na urządzeniu mobilnym jest asynchroniczne.

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

Pozostańmy trochę z katalogiem lib/src/bloc. Każdy widok, który obsługuje pewną logikę biznesową, powinien mieć swój komponent BLoC. W tym katalogu zobaczysz BLoCs base_bloc.dart , endpoints.dart i session.dart . Ten ostatni odpowiada za logowanie i wylogowanie użytkownika oraz dostarczanie punktów końcowych dla interfejsów repozytorium. Powodem istnienia interfejsu sesji jest to, że pakiety firebase i firecloud nie są takie same dla sieci web i mobile i muszą być zaimplementowane w oparciu o 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; } }

Chodzi o to, aby klasa sesji była globalna (singleton). W oparciu o swój _isSignedIn.stream obsługuje przełączanie aplikacji między widokiem logowania/listy zadań i zapewnia punkty końcowe implementacji repozytorium, jeśli istnieje identyfikator użytkownika (tj. użytkownik jest zalogowany).

base_bloc.dart jest podstawą wszystkich bloków BLoC. W tym przykładzie obsługuje wskaźnik obciążenia i wyświetlanie okna dialogowego błędu zgodnie z potrzebami.

Jako przykład logiki biznesowej przyjrzymy się todo_add_edit_bloc.dart . Długa nazwa pliku wyjaśnia jego przeznaczenie. Ma prywatną metodę void _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()) ); }

Dane wejściowe dla tej metody to bool addUpdate i jest to detektor final BehaviorSubject<bool> _addUpdate = BehaviorSubject<bool>() . Gdy użytkownik kliknie przycisk zapisywania w aplikacji, zdarzenie wysyła tę wartość true do ujścia tematu i uruchamia tę funkcję BLoC. Ten fragment kodu trzepotania robi magię po stronie widoku.

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

_addUpdateTodo sprawdza, czy tytuł i opis nie są puste i zmienia wartość _todoError BehaviorSubject na podstawie tego warunku. Błąd _todoError jest odpowiedzialny za wywołanie wyświetlania błędu widoku w polach wejściowych, jeśli nie podano wartości. Jeśli wszystko jest w porządku, sprawdza, czy utworzyć lub zaktualizować TodoBloc , a na koniec _toDoRepository wykonuje zapis do FireCloud.

Logika biznesowa jest tutaj, ale zauważ:

  • Tylko strumienie i ujścia są publiczne w BLoC. _addUpdateTodo jest prywatny i nie można uzyskać do niego dostępu z widoku.
  • _title.value i _description.value są wypełniane przez użytkownika wprowadzającego wartość w polu tekstowym. Wprowadzanie tekstu w zdarzeniu zmiany tekstu wysyła jego wartość do odpowiednich ujścia. W ten sposób mamy reaktywną zmianę wartości w BLoC i wyświetlanie ich w widoku.
  • _toDoRepository jest zależne od platformy i jest dostarczane przez wstrzyknięcie.

Sprawdź kod metody todo_list.dart BLoC _getTodos() . Nasłuchuje migawki kolekcji zadań do wykonania i przesyła strumieniowo dane kolekcji, aby wyświetlić listę w swoim widoku. Lista widoków jest przerysowywana na podstawie zmiany strumienia kolekcji.

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

Ważną rzeczą, o której należy pamiętać podczas używania strumieni lub odpowiedników rx, jest to, że strumienie muszą być zamknięte. Robimy to w metodzie dispose() każdego BLoC. Usuń BLoC każdego widoku w jego metodzie usuwania/niszczenia.

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

Lub w projekcie AngularDart:

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

Wstrzykiwanie repozytoriów specyficznych dla platformy

Schemat relacji między wzorcami BLoC, repozytorium rzeczy do zrobienia i nie tylko

Powiedzieliśmy wcześniej, że wszystko, co pojawia się w BLoC, musi być prostym Dartem i nic zależnego od platformy. TodoAddEditBloc potrzebuje ToDoRepository do zapisu w Firestore. Firebase posiada pakiety zależne od platformy i musimy mieć osobne implementacje interfejsu ToDoRepository . Te implementacje są wstrzykiwane do aplikacji. Dla Fluttera użyłem pakietu flutter_simple_dependency_injection i wygląda to tak:

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

Użyj tego w widżecie takim jak ten:

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

AngularDart ma wbudowany zastrzyk za pośrednictwem dostawców.

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

A w komponencie:

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

Widzimy, że Session ma charakter globalny. Zapewnia funkcjonalność logowania/wylogowania i punkty końcowe używane w ToDoRepository i BLoCs. ToDoRepository potrzebuje interfejsu punktów końcowych, który jest zaimplementowany w SessionImpl i tak dalej. Widok powinien widzieć tylko swój BLoC i nic więcej.

Wyświetlenia

Schemat umywalek i strumieni wchodzących w interakcję między BLoC a widokiem

Widoki powinny być jak najprostsze. Wyświetlają tylko to, co pochodzi z BLoC i wysyłają dane wejściowe użytkownika do BLoC. Omówimy to za pomocą widżetu TodoAddEdit firmy Flutter i jego internetowego odpowiednika TodoDetailComponent . Wyświetlają wybrany tytuł i opis zadania, a użytkownik może dodać lub zaktualizować zadanie.

Trzepotanie:

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

A później w kodzie…

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

Widżet StreamBuilder odbudowuje się, jeśli wystąpi błąd (nic nie zostało wstawione). Dzieje się tak przez nasłuchiwanie _todoAddEditBloc.todoErrorStream . _todoAddEditBloc.titleSink _todoAddEditBloc.todoErrorStream . _todoAddEditBloc.titleSink , który jest ujściem w BLoC, który przechowuje tytuł i jest aktualizowany po wprowadzeniu przez użytkownika tekstu w polu tekstowym.

Początkowa wartość tego pola wejściowego (jeśli wybrano jedno zadanie) jest wypełniana przez słuchanie _todoAddEditBloc.todoStream , który przechowuje wybrane zadanie lub puste, jeśli dodamy nowe zadanie.

Przypisanie wartości do pola tekstowego jest wykonywane przez jego kontroler _titleController.text = todo.title; .

Gdy użytkownik zdecyduje się zapisać zadanie, naciska ikonę wyboru na pasku aplikacji i wyzwala _todoAddEditBloc.addUpdateSink.add(true) . To wywołuje _addUpdateTodo(bool addUpdate) , o którym mówiliśmy w poprzedniej sekcji BLoC i wykonuje całą logikę biznesową polegającą na dodawaniu, aktualizowaniu lub wyświetlaniu błędu użytkownikowi.

Wszystko jest reaktywne i nie ma potrzeby obsługi stanu widżetu.

Kod AngularDart jest jeszcze prostszy. Po dostarczeniu komponentowi jego BLoC, przy użyciu dostawców, kod pliku todo_detail.html zajmuje się wyświetlaniem danych i wysyłaniem interakcji użytkownika z powrotem do 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>

Podobnie jak Flutter, przypisujemy ngModel= wartość ze strumienia tytułowego, która jest jego wartością początkową.

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

Zdarzenie wyjściowe inputKeyPress wysyła znaki wpisane przez użytkownika w tekście wejściowym z powrotem do opisu BLoC. Zdarzenie przycisku materiału (trigger)="todoAddEditBloc.addUpdateSink.add(true)" wysyła zdarzenie BLoC add/update, które ponownie wyzwala tę samą _addUpdateTodo(bool addUpdate) w BLoC. Jeśli spojrzysz na kod todo_detail.dart komponentu, zobaczysz, że nie ma prawie nic poza ciągami wyświetlanymi w widoku. Umieściłem je tam, a nie w HTML ze względu na możliwą lokalizację, którą można tutaj zrobić.

To samo dotyczy każdego innego komponentu — komponenty i widżety mają zerową logikę biznesową.

Warto wspomnieć o jeszcze jednym scenariuszu. Wyobraź sobie, że masz widok ze złożoną logiką prezentacji danych lub coś w rodzaju tabeli z wartościami, które należy sformatować (daty, waluty itp.). Ktoś mógłby pokusić się o pobranie wartości z BLoC i sformatowanie ich w widoku. To jest źle! Wartości wyświetlane w widoku powinny przychodzić do widoku już sformatowanego (ciągi). Powodem tego jest to, że samo formatowanie jest również logiką biznesową. Jeszcze jeden przykład to sytuacja, w której formatowanie wartości wyświetlanej zależy od jakiegoś parametru aplikacji, który można zmienić w czasie wykonywania. Dostarczając ten parametr do BLoC i stosując reaktywne podejście do wyświetlania wyświetlania, logika biznesowa sformatuje wartość i przerysuje tylko potrzebne części. Model BLoC, który mamy w tym przykładzie, TodoBloc , jest bardzo prosty. Konwersja z modelu FireCloud na model BLoC odbywa się w repozytorium, ale w razie potrzeby można to zrobić w BLoC, aby wartości modelu były gotowe do wyświetlenia.

Zawijanie

W tym krótkim artykule omówiono główne koncepcje implementacji wzorca BLoC. Jest to działający dowód na to, że udostępnianie kodu między Flutter i AngularDart jest możliwe, co pozwala na rozwój natywny i międzyplatformowy.

Eksplorując przykład, zobaczysz, że poprawnie zaimplementowany BLoC znacznie skraca czas tworzenia aplikacji mobilnych/webowych. Przykładem jest ToDoRepository i jego implementacja. Kod implementacji jest prawie identyczny, a nawet logika tworzenia widoków jest podobna. Po kilku widżetach/komponentach możesz szybko rozpocząć masową produkcję.

Mam nadzieję, że ten artykuł rzuci okiem na zabawę i entuzjazm, jaki mam przy tworzeniu aplikacji webowych/mobilnych przy użyciu Flutter/AngularDart i wzorca BLoC. Jeśli chcesz tworzyć wieloplatformowe aplikacje komputerowe w języku JavaScript, przeczytaj artykuł Electron: Cross-platform Desktop Apps Made Easy autorstwa innego Toptalera Stephane'a P. Pericata.

Powiązane: Język Dart: kiedy Java i C# nie są wystarczająco ostre