写点什么

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

  • 2019-12-11
  • 本文字数:7556 字

    阅读完需:约 25 分钟

如何利用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 平台上的本地存储,或移动设备上的共享首选项中。


//BLOCabstract class PreferencesInterface{//Preferences final DEFAULT_USERNAME = "DEFAULT_USERNAME";
Future initPreferences(); String get defaultUsername; void setDefaultUsername(String username);}
复制代码


Web 和移动版本必须将其实现到存储中,并从本地存储/首选项中获取默认用户名。它的 AngularDart 实现如下所示:


// ANGULAR DARTclass 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@overrideFuture initPreferences() async => _prefs = await SharedPreferences.getInstance();
复制代码


继续介绍 lib/src/bloc 目录。处理一些业务逻辑的任何视图都应该有自己的 BLoC 组件。在此目录中你将看到 BLoCs base_bloc.dart、endpoints.dart 和 session.dart。最后一个负责登录和注销用户,并为存储库接口提供端点。需要会话界面的原因是,firebase 和 firecloud 包在 Web 和移动设备上是不一样的,必须基于平台来实现。


// BLOCabstract 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)。


// BLOCvoid _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 代码负责在视图这里搞定背后的工作。


// FLUTTERIconButton(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 集合的快照,并将集合数据流式传输到其视图中列出。视图列表根据集合流的更改而重绘。


// BLOCvoid _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
@overridevoid dispose() { widget.baseBloc.dispose(); super.dispose();}
复制代码


或在 AngularDart 项目中:


// ANGULAR DART@overridevoid ngOnDestroy() { todoListBloc.dispose();}
复制代码

注入平台专属的存储库


我们之前说过,BLoC 中包含的所有内容都必须是纯粹的 Dart,并且与平台无关。


TodoAddEditBloc 需要 ToDoRepository 才能写入 Firestore。Firebase 具有依赖平台的包,我们必须为不同平台分别准备 ToDoRepository 接口的实现。这些实现被注入到应用中。对于 Flutter,我使用了 flutter_simple_dependency_injection 包,它长这样:


// FLUTTERclass 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); }}
复制代码


在小部件中这样使用它:


// FLUTTERTodoAddEditBloc _todoAddEditBloc = Injection.injector.get<TodoAddEditBloc>();
复制代码


AngularDart 通过 provider 内置了注入功能。


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


在组件中:


// ANGULAR DARTproviders: [ 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;});
复制代码


然后在代码中:


// FLUTTERStreamBuilder<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


2019-12-11 16:591932
用户头像
王文婧 InfoQ编辑

发布了 126 篇内容, 共 70.2 次阅读, 收获喜欢 274 次。

关注

评论

发布
暂无评论
发现更多内容

被这个参数三杀了

hncscwc

hadoop 6月月更

主数据管理

奔向架构师

数据仓库 主数据 6月月更

数组(三)

Jason199

数组 js map 6月月更

NodeJS 操作cookie 🎀

德育处主任

node.js Node 6月月更

《知道做到》:如何从知道到做到?

郭明

如何在 Django 中创建应用程序?

海拥(haiyong.site)

django 6月月更

【愚公系列】2022年06月 通用职责分配原则(二)-创造者原则

愚公搬代码

6月月更

6月15号MOVE PROTOCOL上线测试版,如何参与呢?

EOSdreamer111

【Python技能树共建】验证码实操2案例

梦想橡皮擦

6月月更

远程办公带来的挑战和变化 | 社区征文

编程攻略

初夏征文

详解MOVE PROTOCOL的测试版,让健康运动如影随形

股市老人

spring4.1.8扩展实战之四:感知spring容器变化(SmartLifecycle接口)

程序员欣宸

Java spring Spring Framework 6月月更

企业知识管理系统应具备的功能

小炮

盲盒系统新品开发,盲盒 APP 酷炫 UI 成品源码开发

WDL22119

盲盒商城 盲盒 盲盒开发 盲盒源码 盲盒H5开发

Elux-将企业级工程化框架带入小程序开发

hiisea

小程序 taro 前端架构 前端框架 前端工程化

InfoQ 极客传媒 15 周年庆征文|你真的了解RPC和REST吗?

No Silver Bullet

Rest RPC框架 构架 6月月更 InfoQ极客传媒15周年庆

实战 | 【男保姆式】教你打开第一个微信小程序

写程序的小王叔叔

微信开发 小程序开发 6月月更

运营商动态路由的神器 :IS-IS 协议

wljslmz

6月月更 路由协议 IS-IS

c语言不挂科(上)

工程师日月

6月月更

数据库每日一题---第12天:上升的温度

知心宝贝

数据库 云计算 前端 后端 6月月更

Jetty 运行的时候出现 503 异常的原因

HoneyMoose

耳东 2022 新的开始

耳东@Erdong

6月月更

互联网公司研发效能/工程效率团队组织架构选择

laofo

互联网 DevOps 研发效能 持续交付 快手

开发增效利器—IDEA辅助插件推荐

中原银行

开发 IDEA 插件 中原银行

Java项目集成activity工作流,快速开发业务审批单据(低代码,敏捷)

金陵老街

敏捷 springboot Activity Vue 3 BPMN

Maven实用技巧

Nick

Java maven 技巧 pom 6月月更

C#入门系列(十五) -- 枚举

陈言必行

C# 六月月供

HarmonyOS应用/服务开发流程详解

坚果

6月月更

Linux开发_动态静态库创建与Makefile规则

DS小龙哥

6月月更

Git远程操作详解

乌龟哥哥

6月月更

滴灌通-服务中小企业的金融创新(36/100)

hackstoic

创业 商业模式

如何利用BLoC在Flutter和AngularDart中共享代码?_大前端_MARKO PERUTOVIĆ_InfoQ精选文章