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是谷歌发明的一个花哨的名词,意为“业务(b)逻辑(lo)组件(c)”。BLoC模式的理念是尽量将业务逻辑存储在纯Dart代码中,以便被其他平台复用。为此你必须遵循一些规则:
要记住的最后一件事是,BLoC的输入应该是sink,而输出是通过stream的。它们都是StreamController的一部分。
如果你在编写Web(或移动端)应用时严格遵守这些规则,那么在此基础上再创建应用的移动(或Web)版本就会像创建视图和平台专属界面一样简单。即使你刚刚开始使用AngularDart或Flutter,使用基础的平台知识来制作视图也会很容易。你最终可能会复用一半以上的代码库。BLoC模式会使所有内容保持结构化并易于维护。
我在Flutter和AngularDart中制作了一个简单的Todo应用。
https://github.com/budo385/todo_bloc_app
这个应用使用Firecloud作为后端,并使用一种响应式的方法来创建视图。应用包含三个部分:
你可以添加更多内容,例如数据接口和本地化接口等。需要记住的是,每一层都应该通过一个接口与另一层通信。
在bloc/目录中有:
//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。业务逻辑在这里,但请注意:
检查一下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是Angular到Dart的移植。它的Dart代码已编译为JavaScript。
编译器支持IE11、Chrome、Edge、Firefox和Safari。
“业务逻辑组件”,简称BLoC,是一种开发模式。BLoC的理念是在尽可能将业务逻辑隔离在纯Dart代码中,这样就能打造在移动和Web平台之间共享的代码库。
BLoC模式不关心视图,也不关心视图如何处理用户显示/交互。但由于它仅使用流和sink作为输出和输入,因此它非常适合视图侧的响应式方法。
作者介绍: Marko是一位拥有超过十三年经验的软件开发人员,涉足过众多挑战和技术类型。他喜欢使用斯巴达式的简单原则来解决问题。他还是一位出色的沟通者,在团队领导和与客户沟通方面拥有丰富的经验。
原文链接: https://www.toptal.com/cross-platform/code-sharing-angular-dart-flutter-bloc
领取专属 10元无门槛券
私享最新 技术干货