如何利用 BLoC 在 Flutter 和 AngularDart 中進行代碼共享

已發表: 2022-03-11

去年年中,我想將 Android 應用程序移植到 iOS 和 Web。 Flutter 是移動平台的選擇,我正在考慮為 web 端選擇什麼。

雖然我一見鍾情就愛上了 Flutter,但我仍然有一些保留意見:在向小部件樹下傳播狀態時,Flutter 的InheritedWidget或 Redux——以及它的所有變體——都可以完成這項工作,但是使用像 Flutter 這樣的新框架,你會期望視圖層更具反應性,即,小部件本身是無狀態的,並根據它們從外部提供的狀態而改變,但事實並非如此。 另外,Flutter 只支持 Android 和 iOS,但我想發佈到網絡上。 我的應用程序中已經有大量的業務邏輯,我想盡可能地重用它,並且在至少兩個地方更改代碼以更改業務邏輯的想法是不可接受的。

我開始尋找如何克服這個問題並遇到了 BLoC。 對於快速介紹,我建議您有空時觀看Flutter/AngularDart – 代碼共享,一起更好(DartConf 2018)

集團模式

視圖、BLoC、存儲庫和數據層中的通信流圖

BLoC是谷歌發明的一個花哨的詞,意思是“業務邏輯組件”。 BLoC 模式的想法是將盡可能多的業務邏輯存儲在純 Dart 代碼中,以便其他平台可以重用它。 為此,您必須遵循以下規則:

  • 層層交流。 視圖與 BLoC 層通信,後者與存儲庫通信,而存儲庫與數據層通信。 交流時不要跳過層。
  • 通過接口進行通信。 接口必須用純的、獨立於平台的 Dart 代碼編寫。 有關詳細信息,請參閱有關隱式接口的文檔。
  • BLoC 僅公開流和接收器。 BLoC 的 I/O 將在後面討論。
  • 保持視圖簡單。 將業務邏輯置於視圖之外。 他們應該只顯示數據並響應用戶交互。
  • 使 BLoC 平台與平台無關。 BLoC 是純 Dart 代碼,因此它們不應包含特定於平台的邏輯或依賴項。 不要分支到平台條件代碼。 BLoC 是在純 Dart 中實現的邏輯,並且在處理基礎平台之上。
  • 注入特定於平台的依賴項。 這聽起來可能與上述規則相矛盾,但請聽我說完。 BLoC 本身與平台無關,但如果它們需要與特定於平台的存儲庫進行通信怎麼辦? 注入它。 通過確保通過接口進行通信並註入這些存儲庫,我們可以確保無論您的存儲庫是為 Flutter 還是 AngularDart 編寫的,BLoC 都不會關心。

要記住的最後一件事是 BLoC 的輸入應該是一個接收器,而輸出是通過一個流。 這些都是StreamController的一部分。

如果您在編寫 Web(或移動!)應用程序時嚴格遵守這些規則,則創建移動(或 Web!)版本可以像製作視圖和特定於平台的界面一樣簡單。 即使您剛剛開始使用 AngularDart 或 Flutter,使用基本平台知識製作視圖仍然很容易。 您最終可能會重用一半以上的代碼庫。 BLoC 模式使所有內容保持結構化且易於維護。

構建 AngularDart 和 Flutter BLoC Todo 應用程序

我用 Flutter 和 AngularDart 製作了一個簡單的 todo 應用程序。 該應用程序使用 Firecloud 作為後端和反應式方法來創建視圖。 該應用程序包含三個部分:

  • bloc
  • todo_app_flutter
  • todoapp_dart_angular

您可以選擇擁有更多部分——例如,數據接口、本地化接口等。要記住的是,每一層都應該通過接口相互通信。

BLoC 代碼

bloc/目錄中:

  • lib/src/bloc :BloC 模塊作為包含業務邏輯的純 Dart 庫存儲在這裡。
  • lib/src/repository :數據的接口存儲在目錄中。
  • lib/src/repository/firestore :存儲庫包含 FireCloud 數據接口及其模型,由於這是一個示例應用程序,我們只有一個數據模型todo.dart和一個數據接口todo_repository.dart ; 然而,在現實世界的應用程序中,會有更多的模型和存儲庫接口。
  • lib/src/repository/preferences包含preferences_interface.dart ,這是一個簡單的界面,用於將成功登錄的用戶名存儲到 Web 上的本地存儲或移動設備上的共享首選項。
 //BLOC abstract class PreferencesInterface{ //Preferences final DEFAULT_USERNAME = "DEFAULT_USERNAME"; Future initPreferences(); String get defaultUsername; void setDefaultUsername(String username); }

Web 和移動實現必須在商店中實現這一點,並從本地存儲/首選項中獲取默認用戶名。 AngularDart 的實現如下所示:

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

這裡沒有什麼了不起的——它實現了它需要的東西。 您可能會注意到返回nullinitPreferences()異步方法。 這個方法需要在 Flutter 端實現,因為在移動端獲取SharedPreferences實例是異步的。

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

讓我們堅持一下 lib/src/bloc 目錄。 任何處理一些業務邏輯的視圖都應該有它的 BLoC 組件。 在此目錄中,您將看到視圖 BLoC base_bloc.dartendpoints.dartsession.dart 。 最後一個負責為用戶登錄和註銷並為存儲庫接口提供端點。 會話接口存在的原因是firebasefirecloud包對於web和mobile是不一樣的,必須基於平台來實現。

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

這個想法是保持會話類全局(單例)。 基於其_isSignedIn.stream getter,它處理登錄/待辦事項列表視圖之間的應用程序切換,並在 userId 存在(即用戶已登錄)時為存儲庫實現提供端點。

base_bloc.dart是所有 BLoC 的基礎。 在此示例中,它根據需要處理負載指示器和錯誤對話框顯示。

對於業務邏輯示例,我們將看一下todo_add_edit_bloc.dart 。 該文件的長名稱解釋了它的用途。 它有一個私有的 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()) ); }

此方法的輸入是bool addUpdate ,它是final BehaviorSubject<bool> _addUpdate = BehaviorSubject<bool>()的偵聽器。 當用戶單擊應用程序中的保存按鈕時,事件會發送此主題接收器真值並觸發此 BLoC 函數。 這段顫振代碼在視圖方面發揮了作用。

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

_addUpdateTodo檢查標題和描述都不是空的,並根據此條件更改_todoError BehaviorSubject 的值。 如果沒有提供值, _todoError錯誤負責觸發輸入字段上的視圖錯誤顯示。 如果一切正常,它會檢查是否創建或更新TodoBloc ,最後_toDoRepository會寫入 FireCloud。

業務邏輯在這裡,但請注意:

  • 只有流和彙在 BLoC 中是公開的。 _addUpdateTodo是私有的,不能從視圖中訪問。
  • _title.value_description.value由用戶在文本輸入中輸入值填充。 文本更改事件上的文本輸入將其值發送到相應的接收器。 這樣,我們就可以在 BLoC 中對值進行反應性更改,並在視圖中顯示它們。
  • _toDoRepository依賴於平台,由注入提供。

查看todo_list.dart BLoC _getTodos()方法的代碼。 它偵聽待辦事項集合的快照並將集合數據流式傳輸到其視圖中。 視圖列表根據集合流的變化重新繪製。

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

使用流或 rx 等效項時要注意的重要一點是必須關閉流。 我們在每個 BLoC 的dispose()方法中執行此操作。 在其 dispose/destroy 方法中處理每個視圖的 BLoC。

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

或者在 AngularDart 項目中:

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

注入特定於平台的存儲庫

BLoC 模式、待辦事項存儲庫等之間的關係圖

我們之前說過,BLoC 中的所有內容都必須是簡單的 Dart,並且不依賴於平台。 TodoAddEditBloc需要ToDoRepository才能寫入 Firestore。 Firebase 有依賴於平台的包,我們必須有ToDoRepository接口的單獨實現。 這些實現被注入到應用程序中。 對於 Flutter,我使用了flutter_simple_dependency_injection包,它看起來像這樣:

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

在這樣的小部件中使用它:

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

AngularDart 通過提供程序內置注入。

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

在一個組件中:

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

我們可以看到Session是全局的。 它提供了在ToDoRepository和 BLoC 中使用的登錄/註銷功能和端點。 ToDoRepository需要一個端點接口,該接口在SessionImpl等中實現。 視圖應該只看到它的 BLoC,僅此而已。

意見

BLoC 和視圖之間交互的接收器和流圖

視圖應該盡可能簡單。 它們只顯示來自 BLoC 的內容並將用戶的輸入發送到 BLoC。 我們將使用 Flutter 中的TodoAddEdit小部件及其網絡等效的TodoDetailComponent它。 它們顯示選定的待辦事項標題和描述,用戶可以添加或更新待辦事項。

撲:

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

後來在代碼中......

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

如果出現錯誤(未插入任何內容), StreamBuilder小部件會自行重建。 這是通過收聽_todoAddEditBloc.todoErrorStream . _todoAddEditBloc.titleSink _todoAddEditBloc.todoErrorStream . _todoAddEditBloc.titleSink ,它是 BLoC 中的接收器,用於保存標題並在用戶在文本字段中輸入文本時更新。

此輸入字段的初始值(如果選擇了一個 todo)通過偵聽_todoAddEditBloc.todoStream來填充,它保存選定的 todo 或如果我們添加新的 todo 則為空。

給文本字段賦值是由它的控制器_titleController.text = todo.title; .

當用戶決定保存待辦事項時,它會按下應用欄中的複選圖標並觸發_todoAddEditBloc.addUpdateSink.add(true) 。 這會調用我們在之前的 BLoC 部分中討論過的_addUpdateTodo(bool addUpdate)並執行添加、更新或向用戶顯示錯誤的所有業務邏輯。

一切都是反應式的,無需處理小部件狀態。

AngularDart 代碼更加簡單。 在使用提供程序為組件提供 BLoC 之後, todo_detail.html文件代碼執行顯示數據並將用戶交互發送回 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>

與 Flutter 類似,我們分配ngModel=來自標題流的值,這是它的初始值。

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

inputKeyPress輸出事件將用戶在文本輸入中鍵入的字符發送回 BLoC 的描述。 材質按鈕(trigger)="todoAddEditBloc.addUpdateSink.add(true)"事件發送 BLoC 添加/更新事件,該事件再次觸發 BLoC 中的相同_addUpdateTodo(bool addUpdate)函數。 如果你看一下組件的todo_detail.dart代碼,你會發現除了視圖上顯示的字符串之外幾乎什麼都沒有。 我將它們放在那里而不是在 HTML 中,因為可以在此處進行本地化。

其他所有組件也是如此——組件和小部件的業務邏輯為零。

還有一種情況值得一提。 想像一下,您有一個具有復雜數據表示邏輯的視圖,或者類似於具有必須格式化的值(日期、貨幣等)的表格。 有人可能會想從 BLoC 中獲取值並在視圖中對其進行格式化。 那是錯誤的! 視圖中顯示的值應該來自已經格式化的視圖(字符串)。 原因是格式化本身也是業務邏輯。 另一個例子是顯示值的格式取決於某些可以在運行時更改的應用程序參數。 通過向 BLoC 提供該參數並使用反應式方法來查看顯示,業務邏輯將格式化該值並僅重繪所需的部分。 我們在此示例中使用的 BLoC 模型TodoBloc非常簡單。 從 FireCloud 模型到 BLoC 模型的轉換在存儲庫中完成,但如果需要,可以在 BLoC 中完成,以便模型值可以顯示。

包起來

這篇簡短的文章涵蓋了 BLoC 模式實現的主要概念。 它證明了 Flutter 和 AngularDart 之間的代碼共享是可能的,允許本地和跨平台開發。

瀏覽該示例,您會發現,如果正確實施,BLoC 可以顯著縮短創建移動/Web 應用程序的時間。 一個例子是ToDoRepository及其實現。 實現代碼幾乎相同,甚至視圖組成邏輯也相似。 在幾個小部件/組件之後,您可以快速開始批量生產。

我希望這篇文章能讓你一睹我使用 Flutter/AngularDart 和 BLoC 模式製作 Web/移動應用程序的樂趣和熱情。 如果您希望使用 JavaScript 構建跨平台桌面應用程序,請閱讀同行 Toptaler Stephane P. Pericat的 Electron:跨平台桌面應用程序變得簡單

相關: Dart 語言:當 Java 和 C# 不夠鋒利時