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

阅读数:1302 2019 年 12 月 11 日 16:59

如何利用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在Flutter和AngularDart中共享代码?

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在Flutter和AngularDart中共享代码?

我们之前说过,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);
}
}
{1}

在小部件中这样使用它:

复制代码
// 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在Flutter和AngularDart中共享代码?

视图应该尽可能简单。它们仅显示来自 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

评论

发布