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

已发表: 2022-03-11

去年年中,我想将 Android 应用程序移植到 iOS 和 Web。 Flutter 是移动平台的选择,我正在考虑为 web 端选择什么。

虽然我一见钟情就爱上了 Flutter,但我仍然有一些保留意见:在向小部件树下传播状态时,Flutter 的InheritedWidget或 Redux——以及它的所有变体——都可以完成这项工作,但是使用像 Flutter 这样的新框架,你会期望视图层更具反应性,即,小部件本身是无状态的,并根据它们从外部提供的状态而改变,但事实并非如此。 另外,Flutter 只支持 Android 和 iOS,但我想发布到网络上。 我的应用程序中已经有大量的业务逻辑,我想尽可能地重用它,并且在至少两个地方更改代码以更改业务逻辑的想法是不可接受的。

我开始寻找如何克服这个问题并遇到了 BLoC。 对于快速介绍,我建议您有空时观看Flutter/AngularDart – 代码共享,一起更好(DartConf 2018)

集团模式

视图、BLoC、存储库和数据层中的通信流图

BLoC是谷歌发明的一个花哨的词,意思是“业务逻辑组件”。 BLoC 模式的想法是将尽可能多的业务逻辑存储在纯 Dart 代码中,以便其他平台可以重用它。 为此,您必须遵循以下规则:

  • 层层交流。 视图与 BLoC 层通信,后者与存储库通信,而存储库与数据层通信。 交流时不要跳过层。
  • 通过接口进行通信。 接口必须用纯的、独立于平台的 Dart 代码编写。 有关详细信息,请参阅有关隐式接口的文档。
  • BLoC 仅公开流和接收器。 BLoC 的 I/O 将在后面讨论。
  • 保持视图简单。 将业务逻辑置于视图之外。 他们应该只显示数据并响应用户交互。
  • 使 BLoC 平台与平台无关。 BLoC 是纯 Dart 代码,因此它们不应包含特定于平台的逻辑或依赖项。 不要分支到平台条件代码。 BLoC 是在纯 Dart 中实现的逻辑,并且在处理基础平台之上。
  • 注入特定于平台的依赖项。 这听起来可能与上述规则相矛盾,但请听我说完。 BLoC 本身与平台无关,但如果它们需要与特定于平台的存储库进行通信怎么办? 注入它。 通过确保通过接口进行通信并注入这些存储库,我们可以确保无论您的存储库是为 Flutter 还是 AngularDart 编写的,BLoC 都不会关心。

要记住的最后一件事是 BLoC 的输入应该是一个接收器,而输出是通过一个流。 这些都是StreamController的一部分。

如果您在编写 Web(或移动!)应用程序时严格遵守这些规则,则创建移动(或 Web!)版本可以像制作视图和特定于平台的界面一样简单。 即使您刚刚开始使用 AngularDart 或 Flutter,使用基本平台知识制作视图仍然很容易。 您最终可能会重用一半以上的代码库。 BLoC 模式使所有内容保持结构化且易于维护。

构建 AngularDart 和 Flutter BLoC Todo 应用程序

我用 Flutter 和 AngularDart 制作了一个简单的 todo 应用程序。 该应用程序使用 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); }

这里没有什么了不起的——它实现了它需要的东西。 您可能会注意到返回nullinitPreferences()异步方法。 这个方法需要在 Flutter 端实现,因为在移动端获取SharedPreferences实例是异步的。

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

让我们坚持一下 lib/src/bloc 目录。 任何处理一些业务逻辑的视图都应该有它的 BLoC 组件。 在此目录中,您将看到视图 BLoC base_bloc.dartendpoints.dartsession.dart 。 最后一个负责为用户登录和注销并为存储库接口提供端点。 会话接口存在的原因是firebasefirecloud包对于web和mobile是不一样的,必须基于平台来实现。

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

这个想法是保持会话类全局(单例)。 基于其_isSignedIn.stream getter,它处理登录/待办事项列表视图之间的应用程序切换,并在 userId 存在(即用户已登录)时为存储库实现提供端点。

base_bloc.dart是所有 BLoC 的基础。 在此示例中,它根据需要处理负载指示器和错误对话框显示。

对于业务逻辑示例,我们将看一下todo_add_edit_bloc.dart 。 该文件的长名称解释了它的用途。 它有一个私有的 void 方法_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<bool> _addUpdate = BehaviorSubject<bool>()的侦听器。 当用户单击应用程序中的保存按钮时,事件会发送此主题接收器真值并触发此 BLoC 函数。 这段颤振代码在视图方面发挥了作用。

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

_addUpdateTodo检查标题和描述都不是空的,并根据此条件更改_todoError BehaviorSubject 的值。 如果没有提供值, _todoError错误负责触发输入字段上的视图错误显示。 如果一切正常,它会检查是否创建或更新TodoBloc ,最后_toDoRepository会写入 FireCloud。

业务逻辑在这里,但请注意:

  • 只有流和汇在 BLoC 中是公开的。 _addUpdateTodo是私有的,不能从视图中访问。
  • _title.value_description.value由用户在文本输入中输入值填充。 文本更改事件上的文本输入将其值发送到相应的接收器。 这样,我们就可以在 BLoC 中对值进行反应性更改,并在视图中显示它们。
  • _toDoRepository依赖于平台,由注入提供。

查看todo_list.dart BLoC _getTodos()方法的代码。 它侦听待办事项集合的快照并将集合数据流式传输到其视图中。 视图列表根据集合流的变化重新绘制。

 // 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()方法中执行此操作。 在其 dispose/destroy 方法中处理每个视图的 BLoC。

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

或者在 AngularDart 项目中:

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

注入特定于平台的存储库

BLoC 模式、待办事项存储库等之间的关系图

我们之前说过,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 通过提供程序内置注入。

 // 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 的内容并将用户的输入发送到 BLoC。 我们将使用 Flutter 中的TodoAddEdit小部件及其网络等效的TodoDetailComponent它。 它们显示选定的待办事项标题和描述,用户可以添加或更新待办事项。

扑:

 // 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 _todoAddEditBloc.todoErrorStream . _todoAddEditBloc.titleSink ,它是 BLoC 中的接收器,用于保存标题并在用户在文本字段中输入文本时更新。

此输入字段的初始值(如果选择了一个 todo)通过侦听_todoAddEditBloc.todoStream来填充,它保存选定的 todo 或如果我们添加新的 todo 则为空。

给文本字段赋值是由它的控制器_titleController.text = todo.title; .

当用户决定保存待办事项时,它会按下应用栏中的复选图标并触发_todoAddEditBloc.addUpdateSink.add(true) 。 这会调用我们在之前的 BLoC 部分中讨论过的_addUpdateTodo(bool addUpdate)并执行添加、更新或向用户显示错误的所有业务逻辑。

一切都是反应式的,无需处理小部件状态。

AngularDart 代码更加简单。 在使用提供程序为组件提供 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 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 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 的描述。 材质按钮(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 构建跨平台桌面应用程序,请阅读同行 Toptaler Stephane P. Pericat的 Electron:跨平台桌面应用程序变得简单

相关: Dart 语言:当 Java 和 C# 不够锋利时