首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

如何利用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

  • 发表于:
  • 本文为 InfoQ 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/262qJ3G4QaY0FwAGMMlo
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券