【译文】Reactive Programming - Streams - BLoC
本译文介绍Streams、Bloc 和 Reactive Programming 的概念。理论和实践范例。对于作者的个人note没有进行翻译,请自行翻阅原文地址 原文原码。和iOS开发中的RAC相似,本文推荐重点在 <如何基于流出的数据构建Widge>!
难度:中级
本文纪实
本译文的原文是在学 BLoC 的 第三方框架 (框架的教程)而看到的推荐链接进入该文章,为了更好的实现Flutter的BLoC而进行的翻译学习,翻译完也到了文章底部竟然有推荐中文翻译 链接, 那本篇就孤芳自赏吧!也顺便记录下自己的第一篇国外技术译文吧!推荐读者结合原文 看译文效果会更佳。
笔者本文学习目的: 解耦
什么是流?
介绍 :为了便于想象Stream的概念,只需考虑一个带有两端的管道,只有一个允许在其中插入一些东西。当你将某物插入管道时,它会在管道内流动并从另一端流出。
在Flutter中
- 管道称为 Stream
- 通常(*)使用StreamController来控制Stream
- 为了插入东西到Stream中,StreamController公开了”入口”名为StreamSink,可以sink属性进行访问你
- StreamController通过stream属性公开了Stream的出口
注意: (*):我故意使用术语”通常”,因为很可能不使用任何StreamController。但是,正如你将在本文中阅读的那样,我将只使用StreamControllers。
Stream可以传递什么?
所有类型值都可以通过流传递。从值,事件,对象,集合,映射,错误或甚至另一个流,可以由stream传达任何类型的数据。
我怎么知道Stream传递的东西?
当你需要通知Stream传达某些内容时,你只需要监听StreamController 的stream属性。
定义监听器时,你会收到StreamSubscription对象。通过StreamSubscription对象,你将收到由Stream发生变化而触发通知。
只要有至少一个活动 监听器,Stream就会开始生成事件,以便每次都通知活动的 StreamSubscription对象:
- 一些数据来自流,
- 当一些错误发送到流时,
- 当流关闭时。
StreamSubscription对象也可以允许以下操作:
- 停止听
- 暂停,
- 恢复。
Stream只是一个简单的管道吗?
不,Stream还允许在流出之前处理流入其中的数据。
为了控制Stream内部数据的处理,我们使用StreamTransformer,它只是
- 一个“捕获” Stream内部流动数据的函数
- 对数据做一些处理
- 这种转变的结果也是一个Stream
你将直接从该声明中了解到,可以按顺序使用多个StreamTransformer。
StreamTransformer可以用进行任何类型的处理,例如:
- 过滤(filtering):根据任何类型的条件过滤数据,
- 重新组合(regrouping):重新组合数据,
- 修改(modification):对数据应用任何类型的修改,
- 将数据注入其他流,
- 缓冲,
- 处理(processing):根据数据进行任何类型的操作/操作,
- …
Stream流的类型
Stream有两种类型。
单订阅Stream
这种类型的Stream只允许在该Stream的整个生命周期内使用单个监听器。
即在第一个订阅被取消后,也无法在此类流上收听两次。
广播流
第二种类型的Stream允许任意数量的监听器。
可以随时向广播流添加监听器。新的监听器将在它开始收听Stream时收到事件。
基本的例子
任何类型的数据
第一个示例显示了“单订阅” 流,它只是打印输入的数据。你可能会看到无关紧要的数据类型。
streams_1.dart
1 | import 'dart:async'; |
StreamTransformer
第二个示例显示“ 广播 ” 流,它传达整数值并仅打印偶数。为此,我们应用StreamTransformer来过滤(第14行)值,只让偶数经过。
1 | import 'dart:async'; |
RxDart
所述RxDart包是用于执行 Dart 所述的ReactiveX API,它扩展了原始达特流 API符合ReactiveX标准。
由于它最初并未由Google定义,因此它使用不同的词汇表。下表给出了Dart和RxDart之间的相关性。
Dart | RxDart |
---|---|
Stream | Observable |
StreamController | Subject |
正如刚才所说,RxDart 扩展了原始的Dart Streams API并提供了StreamController的 3个主要变体:
PublishSubject
PublishSubject是普通的广播 StreamController, 有一个例外:Stream返回一个Observable,而不是Stream。
如你所见,PublishSubject仅向监听器发送在订阅之后添加到Stream的事件。
BehaviorSubject
该BehaviorSubject也是广播 StreamController,它返回一个Observable,而不是Stream。
与PublishSubject的主要区别在于BehaviorSubject还将最后发送的事件发送给刚刚订阅的监听器。
ReplaySubject
ReplaySubject 也是一个广播StreamController,它返回一个Observable,而不是Stream。
默认情况下,ReplaySubject将Stream已经发出的所有事件作为第一个事件发送给任何新的监听器。
关于资源的重要说明
经常释放不再需要的资源是一种非常好的做法。
本声明适用于:
- StreamSubscription - 当你不再需要监听Stream时,取消订阅;
- StreamController - 当你不再需要StreamController时,关闭它;
- 这同样适用于RxDart主题,当你不再需要BehaviourSubject,PublishSubject …时,请将其关闭。
如何基于由Stream提供的数据构建Widget?(重点)
Flutter提供了一个非常方便的StatefulWidget,名为StreamBuilder。
StreamBuilder监听Stream,每当某些数据输出Stream时,它会自动重建,调用其builder callback。
这是如何使用StreamBuilder:
1 | StreamBuilder<T>( |
以下示例模仿默认的 “计数器” 应用程序,但使用Stream而不再使用任何setState。
1 | import 'dart:async'; |
注意点:
- 24-30行: 我们不再需要state的概念,所有东西都通过Stream接受;
第35行:当我们点击FloatingActionButton时,我们递增计数器并通过接收器将其发送到Stream; 在流中注入值的事实导致侦听它的StreamBuilder重建并“刷新”计数器;
这是一个很大的改进,因为实际调用setState()方法的,会强制整个 Widget(和任何子小部件)重建。这里,只有StreamBuilder被重建(当然它的子部件,被streamBuilder包裹的子控件);
我们仍然在为页面使用StatefulWidget的唯一原因,仅仅是因为我们需要通过dispose方法第15行释放StreamController ;
什么是反应式编程?
反应式编程是使用异步数据流进行编程。
换句话说,任何东西比如从事件(例如点击),变量的变化,消息,……到构建请求,可能改变或发生的所有事件的所有内容都将被传送,由数据流触发。
很明显,所有这些意味着,通过反应式编程,应用程序:
- 变得异步
- 围绕Streams和listeners的概念进行架构
- 当某事发生在某处(事件,变量的变化……)时,会向Stream发送通知
- 如果 “某人” 监听该流(无论其在应用程序中的任何位置),它将被通知并将采取适当的行动.
组件之间不再存在紧密耦合。
简而言之,当Widget向Stream发送内容时,该Widget 不再需要知道:
- 接下来会发生什么
- 谁可能使用这些信息(没有一个,一个或几个小部件……)
- 可能使用此信息的地方(无处,同一屏幕,另一个,几个…)
- 当这些信息可能被使用时(几乎是直接,几秒钟之后,永远不会……)
- …… Widget只关心自己的事业,就是这样!
乍一看,读到这个,这似乎会导致应用程序“ 无法控制 ”,但正如我们将看到的,情况正好相反。它给你:
- 构建仅负责特定活动的部分应用程序的机会
- 轻松模拟一些组件的行为,以允许更完整的测试覆盖
- 轻松重用组件(当前应用程序或其他应用程序中的其他位置),
- 重新设计应用程序,并能够在不进行太多重构的情况下将组件从一个地方移动到另一个地方,
我们将很快看到优势……但在我需要介绍最后一个主题之前:BLoC模式。
BLoC 模式
BLoC模式由Paolo Soares 和 Cong Hui设计,并谷歌在2018的 DartConf 首次提出,可以在 YouTube 上观看。
BLoC表示为业务逻辑组件 (Business Logic Component)
简而言之, Business Logic需要:
- 转移到一个或几个BLoC,
- 尽可能从表示层(Presentation Layer)中删除。换句话说,UI组件应该只关心UI事物而不关心业务
- 依赖 Streams 独家使用输入(Sink)和输出(stream)
- 保持平台独立
- 保持环境独立
事实上,BLoC模式最初被设想为允许独立于平台重用相同的代码:Web应用程序,移动应用程序,后端。
它究竟意味着什么?
BLoC模式 是利用我们刚才上面所讨论的观念:Streams (流)
- Widgets 通过 Sinks 向 BLoC 发送事件(event)
- BLoC 通过流(stream)通知小部件(widgets)
- 由BLoC实现的业务逻辑不是他们关注的问题。
从这个声明中,我们可以直接看到一个巨大的好处。
由于业务逻辑与UI的分离:
- 我们可以随时更改业务逻辑,对应用程序的影响最小
- 我们可能会更改UI而不会对业务逻辑产生任何影响,
- 现在,测试业务逻辑变得更加容易。
如何将此 BLoC 模式应用于 Counter 应用程序示例中
将 BLoC 模式应用于此计数器应用程序似乎有点矫枉过正,但让我先向你展示……
代码: streams_4.dart
1 | void main() => runApp(new MyApp()); |
我已经听到你说“ 哇……为什么这一切?这都是必要的吗?”。
第一 是责任分离
1 | 如果你检查CounterPage(第21-45行),其中绝对没有任何业务逻辑。 |
第二 可测试性
1 | 现在,测试业务逻辑变得更加容易。 |
第三 自由组织布局
1 | 由于使用了Streams,你现在可以独立于业务逻辑组织布局。 |
第四 减少“build”的次数
1 | 不使用setState()而是使用StreamBuilder这一事实大大减少了“ 构建 ”的次数,只减少了所需的次数。 |
只有一个约束…… BLoC的可访问性
为了让所有这些工作,BLoC需要可访问。
有几种方法可以访问它:
通过全局单例
这种方式很有简单,但不是真的推荐。此外,由于Dart中没有类析构函数,因此你永远无法正确释放资源。作为局部变量(本地实例)
你可以实例化BLoC的本地实例。在某些情况下,此解决方案完全符合某些需求。在这种情况下,你应该始终考虑在StatefulWidget中初始化,以便你可以利用dispose()方法来释放它。
- 由父类提供
使其可访问的最常见方式是通过祖先 Widget,实现为StatefulWidget。
以下代码显示了通用 BlocProvider的示例。
代码: streams_5
1 | //所有BLoC的通用接口 |
关于这种通用BlocProvider的一些解释
首先,如何将其作为provider使用?
如果你查看示例代码“ streams_4.dart ”,你将看到以下代码行(第12-15行)
1 | home: BlocProvider<IncrementBloc>( |
通过这些代码,我们只需实例化一个新的BlocProvider,它将处理一个IncrementBloc,并将CounterPage作为子项呈现。
从那一刻开始,从BlocProvider开始的子树的任何小部件部分都将能够通过以下代码访问IncrementBloc:
1 | IncrementBloc bloc = BlocProvider.of<IncrementBloc>(context); |
可以使用多个BLoC吗?
当然,这是非常可取的。建议是:
- (如果有任何业务逻辑)每页顶部有一个BLoC,
- 为什么不是ApplicationBloc来处理应用程序状态?
- 每个“足够复杂的组件”都有相应的BLoC。
以下示例代码在整个应用程序的顶部显示ApplicationBloc,然后在CounterPage顶部显示IncrementBloc。
该示例还显示了如何检索两个blocs。
代码 streams_6.dart
1 | void main() => runApp( |
为什么不使用InheritedWidget?
在与BLoC相关的大多数文章中,你会看到通过InheritedWidget实现Provider。
当然,没有什么能阻止这种类型的实现。然而,
- 一个InheritedWidget没有提供任何dispose方法,记住,在不再需要资源时总是释放资源是一个很好的做法。
- 当然,没有什么能阻止你将InheritedWidget包装在另一个StatefulWidget中,但是,使用 InheritedWidget 增加了什么呢?
- 最后,如果不受控制,使用InheritedWidget经常会导致副作用(请参阅下面的InheritedWidget上的提醒)。
以上三点解释了我为什么选择通过StatefulWidget实现BlocProvider,这样做可以让我在Widget dispose时释放相关资源。
Flutter无法实例化泛型类型
不幸的是,Flutter无法实例化泛型类型,我们必须将BLoC的实例传递给BlocProvider。为了在每个BLoC中强制执行dispose()方法,所有BLoC都必须实现BlocBase接口。
提醒InheritedWidget
在使用InheritedWidget并通过context.inheritFromWidgetOfExactType(…)来获得指定类型最近的widget, 每次InheritedWidget的父级或者子布局发生变化时,这个方法会自动将当前“context”(= BuildContext)注册到要重建的widget当中。。
请注意,为了完全正确,我刚才解释的与InheritedWidget相关的问题只发生在我们将InheritedWidget与StatefulWidget结合使用时。当你只使用没有State的InheritedWidget时,问题就不会发生。但是……我将在下一篇文章 中回到这句话。
链接到BuildContext的Widget类型(Stateful或Stateless)无关紧要。
关于BLoC的个人建议
与BLoC相关的第三条规则是:“依赖于Streams的输入(Sink)和输出(stream)的使用优势”。
我的个人经历稍微关系到这个说法……让我解释一下。
首先,BLoC模式被设想为跨平台共享相同的代码(AngularDart,……),并且从这个角度来看,该陈述完全有意义。
但是,如果你只打算开发一个Flutter应用程序,这是基于我的谦逊经验,有点矫枉过正。
如果我们坚持声明,没有可能的getter或setter,只有sink和stream。缺点是“所有这些都是异步的”。
让我们用2个样本来说明缺点:
你需要从BLoC中检索一些数据,以便将这些数据用作应该立即显示这些参数的页面的输入(例如,想一个参数页面),如果我们不得不依赖Streams,这使得页面的构建异步(这很复杂)。通过Streams使其工作的示例代码可能如下所示……很丑陋不是吗。
1 | class FiltersPage extends StatefulWidget { |
在BLoC级别,您还需要转换某些数据的“假”注入,以触发提供您希望通过流接收的数据。使这项工作的示例代码可以是:
1 | class ApplicationBloc implements BlocBase { |
我不知道你的意见,但就个人而言,如果我没有任何与代码移植/共享相关的限制,我发现这太重了,我宁愿在需要时使用常规的getter / setter并使用Streams / Sinks来保持分离责任并在需要的地方广播信息,这很棒。
现在是时候在实践中看到这一切……
正如本文开头所提到的,我构建了一个伪应用程序来展示如何使用所有这些概念。 完整的源代码可以在 Github 上找到。
请谅解,因为这段代码远非完美,可能更好和/或更好的架构,但唯一的目标只是向您展示这一切是如何工作的。
由于源代码太多很多,我只会解释主要的几条。
电影目录的来源
我使用免费的TMDB API来获取所有电影的列表,以及海报,评级和描述。
为了能够运行此示例应用程序,您需要注册并获取API密钥(完全免费),然后将您的API密钥放在文件“/api/tmdb_api.dart”第15行。
应用程序的架构如下:
该应用程序使用到了:
3个主要的BLoC:
- ApplicationBloc(在所有内容之上),负责提供所有电影类型的列表;
- 2.FavoriteBloc(就在下面),负责处理“收藏夹”的概念;
- 3.MovieCatalogBloc(在2个主要页面之上),负责根据过滤器提供电影列表;
6个页面:
- 1.HomePage:登陆页面,允许导航到3个子页面;
- 2.ListPage:将电影列为GridView的页面,允许过滤,收藏夹选择,访问收藏夹以及在后续页面中显示电影详细信息;
- 3.ListOnePage:类似于ListPage,但电影列表显示为水平列表,下面是详细信息;
- FavoritesPage:列出收藏夹的页面,允许取消选择任何收藏夹;
- 5.* Filters:允许定义过滤器的EndDrawer:流派和最小/最大发布日期。从ListPage或ListOnePage调用此页面;
- Details*详细信息:页面仅由ListPage调用以显示电影的详细信息,但也允许选择/取消选择电影作为收藏;
1个子BLoC:
- 1.FavoriteMovieBloc,链接到MovieCardWidget或MovieDetailsWidget,以处理作为收藏的电影的选择/取消选择
5个主要Widget:
- 1.FavoriteButton:负责显示收藏夹的数量,实时,并在按下时重定向到FavoritesPage;
- 2.FavoriteWidget:负责显示一个喜欢的电影的细节并允许其取消选择;
- 3.FiltersSummary:负责显示当前定义的过滤器;
- 4.MovieCardWidget:负责将一部电影显示为卡片,电影海报,评级和名称,以及一个图标,表示该特定电影的选择是最喜欢的;
- 5.MovieDetailsWidget:负责显示与特定电影相关的详细信息,并允许其选择/取消选择作为收藏。
不同BLoCs / Streams的编排
下图显示了如何使用主要3个BLoC:
- 在BLoC的左侧,哪些组件调用Sink
- 在右侧,哪些组件监听流
例如,当MovieDetailsWidget调用inAddFavorite Sink时,会触发2个stream:
- outTotalFavorites流强制重建FavoriteButton
- outFavorites流
强制重建MovieDetailsWidget(“最喜欢的”图标)
强制重建_buildMoieCard(“最喜欢的”图标)
用于构建每个MovieDetailsWidget
观察
大多数Widget和Page都是StatelessWidgets,这意味着:
- 强制重建的setState()几乎从未使用过。 例外情况是:
在ListOnePage中,当用户点击MovieCard时,刷新MovieDetailsWidget。 这也可能是由一个stream驱动的……
在FiltersPage中允许用户在接受筛选条件之前通过Sink更改过筛选条件。- 应用程序不使用任何InheritedWidget
- 该应用程序几乎是100%BLoCs / Streams驱动,这意味着大多数小部件彼此独立,并且它们在应用程序中的位置
一个实际的例子是FavoriteButton,它显示徽章中所选收藏夹的数量。 该应用程序共有3个FavoriteButton实例,每个实例显示在3个不同的页面中。
显示电影列表(显示无限列表的技巧说明)
要显示符合过滤条件的电影列表,我们使用GridView.builder(ListPage)或ListView.builder(ListOnePage)作为无限滚动列表。
电影是通过TMDB API获取的,每次拉取20个。
提醒一下,GridView.builder和ListView.builder都将itemCount作为输入,如果提供了item数量,则表示要根据itemCount的数量来显示列表。itemBuilder的index从0到itemCount - 1不等。
正如您将在代码中看到的那样,我随意为GridView.builder添加了30多个。 理由是,在这个例子中,我们正在操纵假定的无限数量的项目(这不是完全正确但是又有谁关心这个例子)。 这将强制GridView.builder请求显示“最多30个”项目。
此外,GridView.builder和ListView.builder只在认为必须在视口中呈现某个项目(索引)时才调用itemBuilder。
MovieCatalogBloc.outMoviesList返回一个List
正如您将在代码中看到的,此例程对Sink进行了一次奇怪的调用:
1 | //通知MovieCatalogBloc我们正在渲染MovieCard[index] |
这个调用告诉MovieCatalogBloc我们要渲染MovieCard [index]。
然后_buildMovieCard(…)继续验证与MovieCard [index]相关的数据是否存在。 如果是,则渲染后者,否则显示CircularProgressIndicator。
对StreamCatalogBloc.inMovieIndex.add(index)的调用由StreamSubscription监听,StreamSubscription将索引转换为某个pageIndex数字(一页最多可计20部电影)。 如果尚未从TMDB API获取相应页面,则会调用API。 获取页面后,所有已获取电影的新列表将发送到_moviesController。 当GridView.builder监听该Stream(= movieBloc.outMoviesList)时,后者请求重建相应的MovieCard。 由于我们现在拥有数据,我们可以渲染它了。
名单和其他链接
介绍PublishSubject,BehaviorSubject和ReplaySubject的图片由ReactiveX发布。
其他一些有趣的文章值得一读:
Fundamentals of Dart Streams [Thomas Burkhart]
rx_command package [Thomas Burkhart]
Build reactive mobile apps in Flutter - companion article [Filip Hracek]
Flutter with Streams and RxDart [Brian Egan]
总结
很长的文章,但还有更多的话要说,因为对我而言,这是展开Flutter应用程序的方法。 它提供了很大的灵活性。
很快就会继续关注新文章。 快乐写代码。
这篇文章也可以在 Medium -Flutter Community 找到。
如需转载本译文,请注明出处.