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

2018年DartConf,谷歌推出了“業務邏輯組件”,即BLoC的開發模式。它的理念是在儘可能將業務邏輯隔離在純Dart代碼中,這樣就能打造在移動和Web平臺之間共享的代碼庫。通過本文作者的介紹,你會發現,如果能正確實現,BLoC會大大縮短創建移動/Web應用所需的時間。

去年年中,我想把一個Android應用移植到iOS和Web上。我打算在移動平臺上用Flutter,Web端該選擇什麼沒有想好。

雖說我對Flutter是一見鍾情,但也還是對它有些看法:Flutter的InheritedWidget或Redux(及其所有變體)在小部件樹上傳播狀態時的確做的不錯;但是對於Flutter這樣的新框架來說,你會期望視圖層的響應性能更多一些——比如,希望小部件本身是無狀態的,並根據從外部反饋的狀態來更改,但實際上並非如此。另外,Flutter彼時只支持Android和iOS,但我還想發佈到Web上。我的應用中已經有大量的業務邏輯了,我想儘可能地複用它,可是更改一次業務邏輯卻至少要更改兩個位置的代碼實在讓人無法接受。

我開始研究該如何解決這個問題,然後就遇到了BLoC。作爲快速瞭解,建議你在有空的時候觀看"Flutter/AngularDart——代碼共享,一起用更好(DartConf 2018)"這個視頻。

BLoC模式

BLoC是谷歌發明的一個花哨的名詞,意爲“業務(b)邏輯(lo)組件(c)”。BLoC模式的理念是儘量將業務邏輯存儲在純Dart代碼中,以便被其他平臺複用。爲此你必須遵循一些規則:

  • 分層通信。視圖與BLoC層通信,後者與存儲庫通信,存儲庫與數據層通信。通信時不要跳過各層。

  • 通過接口通信。接口必須使用與平臺無關的純Dart代碼編寫。更多信息請參見隱式接口的文檔

  • BLoC僅暴露流和sinks。BLoC的I/O將在後文討論。

  • 保持視圖簡單。將業務邏輯放在視圖之外。視圖只應顯示數據並響應用戶交互。

  • 使BLoC與平臺無關。BLoC是純Dart代碼,因此它們不應包含平臺專屬的邏輯或依賴項。不要分支出平臺條件代碼。BLoC是在純Dart中實現的邏輯,在其上處理基礎平臺的事務。

  • 注入平臺專屬的依賴項。這聽起來與上條規則矛盾,但請聽我解釋。BLoC本身與平臺無關,但如果它們需要與平臺專屬的存儲庫通信怎麼辦?注入它。通過接口通信並注入這些存儲庫,那麼無論你的存儲庫是爲Flutter還是AngularDart編寫的,BLoC都無所謂。

要記住的最後一件事是,BLoC的輸入應該是sink,而輸出是通過stream的。它們都是StreamController的一部分。

如果你在編寫Web(或移動端)應用時嚴格遵守這些規則,那麼在此基礎上再創建應用的移動(或Web)版本就會像創建視圖和平臺專屬界面一樣簡單。即使你剛剛開始使用AngularDart或Flutter,使用基礎的平臺知識來製作視圖也會很容易。你最終可能會複用一半以上的代碼庫。BLoC模式會使所有內容保持結構化並易於維護。

利用BLoC構建AngularDart和Flutter Todo應用

我在Flutter和AngularDart中製作了一個簡單的Todo應用。

https://github.com/budo385/todo_bloc_app

這個應用使用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);
}

這裏沒什麼特別的——它只是實現了所需的功能。你可能會注意到initPreferences()異步方法返回的是null。由於在移動設備上獲取SharedPreferences實例是異步的,因此需要在Flutter側實現此方法。

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

繼續介紹lib/src/bloc目錄。處理一些業務邏輯的任何視圖都應該有自己的BLoC組件。在此目錄中你將看到BLoCs base_bloc.dart、endpoints.dart和session.dart。最後一個負責登錄和註銷用戶,併爲存儲庫接口提供端點。需要會話界面的原因是,firebase和firecloud包在Web和移動設備上是不一樣的,必須基於平臺來實現。

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

這個想法是使會話(session)類保持全局(singleton)。它基於其_isSignedIn.stream getter來處理應用在登錄/待辦事項列表視圖之間的切換,並在存在userId(即用戶已登錄)的情況下向存儲庫實現提供端點。base_bloc.dart是所有BLoC的基礎。在此示例中,它按需處理負載指示器和錯誤對話框顯示。

至於業務邏輯示例,我們來看一下todo_add_edit_bloc.dart。這個文件的長名說明了自身的用途。它有一個私有的void method_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 _addUpdate = BehaviorSubject ()的一個偵聽器。當用戶單擊應用中的save按鈕時,該事件將發送這個subject sink真值並觸發此BLoC函數。這段Flutter代碼負責在視圖這裏搞定背後的工作。

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

_addUpdateTodo檢查標題和描述是否都不爲空,並根據此條件更改_todoError BehaviorSubject的值。如果未提供任何值,則_todoError錯誤負責觸發輸入字段上的視圖錯誤顯示。如果一切正常,它將檢查是否要創建或更新TodoBloc,最後_toDoRepository將寫入FireCloud。業務邏輯在這裏,但請注意:

  • 在BLoC中僅暴露流和sink。_addUpdateTodo是私有的,無法從視圖訪問。

  • _title.value和_description.value由用戶在文本輸入中輸入的值來填充。文本更改事件上的文本輸入將其值發送到相應的sink。這樣,我們就在BLoC中有了值的響應性更改,並在視圖中顯示它們。

  • _toDoRepository依賴平臺,並通過注入提供。

檢查一下todo_list.dart BLoC _getTodos()方法的代碼。它偵聽todo集合的快照,並將集合數據流式傳輸到其視圖中列出。視圖列表根據集合流的更改而重繪。

// 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()方法執行此操作。用每個視圖的BLoC的dispose/destroy方法來銷燬它。

// FLUTTER

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

或在AngularDart項目中:

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

注入平臺專屬的存儲庫

我們之前說過,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通過provider內置了注入功能。

// 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。我們將使用Flutter的TodoAddEdit小部件及其Web端等效的TodoDetailComponent來做介紹。它們負責顯示選定的待辦事項標題和說明,用戶可以添加或更新待辦事項。

Flutter:

// 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而做到的,它是BLoC中的一個sink,用於保存標題,並當用戶在文本字段中輸入文本時被更新。如果選擇了一個待辦事項,則通過偵聽_todoAddEditBloc.todoStream(其會保存所選的待辦事項,添加新的待辦事項時則爲空)來填充這一輸入字段的初始值。

通過文本字段的控件_titleController.text = todo.title;爲文本字段賦值。

當用戶決定保存待辦事項時,會點按應用欄中的選中圖標,並觸發_todoAddEditBloc.addUpdateSink.add(true)。這將調用我們在上一個BLoC部分中討論的_addUpdateTodo(bool addUpdate),並處理所有添加、更新或顯示錯誤的業務邏輯,然後返回給用戶。

一切都是響應式的,不需要處理小部件狀態。

AngularDart的代碼甚至更簡單。在使用provider爲組件提供其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 style="width:100%"
       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 style="width:100%"
       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的描述中。material按鈕(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構建跨平臺的桌面應用,請閱讀ToptalerStéphaneP.Péricat撰寫的電子書:《Electron:輕鬆實現的跨平臺桌面應用》

基礎知識

什麼是AngularDart?

AngularDart是Angular到Dart的移植。它的Dart代碼已編譯爲JavaScript。

AngularDart支持哪些瀏覽器?

編譯器支持IE11、Chrome、Edge、Firefox和Safari。

什麼是BLoC模式?

“業務邏輯組件”,簡稱BLoC,是一種開發模式。BLoC的理念是在儘可能將業務邏輯隔離在純Dart代碼中,這樣就能打造在移動和Web平臺之間共享的代碼庫。

BLoC在UI側採用響應式方法行不行?

BLoC模式不關心視圖,也不關心視圖如何處理用戶顯示/交互。但由於它僅使用流和sink作爲輸出和輸入,因此它非常適合視圖側的響應式方法。

作者介紹:
Marko是一位擁有超過十三年經驗的軟件開發人員,涉足過衆多挑戰和技術類型。他喜歡使用斯巴達式的簡單原則來解決問題。他還是一位出色的溝通者,在團隊領導和與客戶溝通方面擁有豐富的經驗。

原文鏈接:
https://www.toptal.com/cross-platform/code-sharing-angular-dart-flutter-bloc

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章