想看原文请出门右转原文传送门
版本所有,转载请注明出处。
本文主要介绍Streams,Bloc和Reactive Programming(响应式编程)的概念。 理论和实践范例。
难度:中级
介绍
我花了很长时间才找到介绍Reactive Programming,BLoC和Streams概念的方法。
由于这可以对构建应用程序的方式做出重大改变,我想要一个实际示例来说明:
- 很可能不使用它们,但有时可能更难以编码和性能更低,
- 使用它们的好处同时也是
- 使用它们的影响,正面的和(或)负面的。
用我做的伪应用程序作为一个例子,简而言之,它允许用户从在线目录中查看电影列表,按类型和发布日期过滤它们,标记/取消标记为收藏夹。 当然,一切都是互动的,用户可以在不同的页面中或在同一个页面内发生各种动作,并且可以实时观察到结果。
下面的动画展示了该程序:
当您进入此页面以获取有关Reactive Programming,BLoC和Streams的信息时,我将首先介绍它们。 此后,我将向您展示如何在实践中实施和使用它们。
什么是Stream?
介绍
为了便于想象Stream的概念,我们可以简单把Stream想象为一个有两个端口的管道,只有其中的一个允许插入一些东西。 当您将某物插入管道时,它会在管道内流动并从另一端流出。
In Flutter,
- the pipe is called a Stream
- to control the Stream, we usually
(*) use a StreamController - to insert something into the Stream, the StreamController exposes the “entrance”, called a StreamSink, accessible via the sink property
- the way out of the Stream, is exposed by the StreamController via the streamproperty
在Flutter中,
- 管道称为Stream
- 为了控制Stream,我们通常(*)使用StreamController
- 为了在
Stream
中插入一些东西,StreamController
公开了一个名为StreamSink
的“入口”,可以通过sink
属性访问 -
Stream
流出方式是由StreamController
通过stream
属性暴露的。
(*):我故意使用术语“通常”,因为很可能不使用任何StreamController
。 但是,正如您将在本文中看到的那样,我将只使用StreamControllers
。
Stream可以传达什么?
所有类型以及任何类型。 从值,事件,对象,集合,映射,错误或甚至另一个流,任何类型的数据都可以由Stream
传递 。
我怎么知道Stream传达的东西?
当您需要通知`Stream`传达某些内容时,您只需要监听`StreamController`的`stream`属性。
定义监听时,你会得到StreamSubscription对象。 通过StreamSubscription
对象,你将会接受到通知由于Stream
发生变化而带来的的通知。
只要至少有一个活动侦听器,Stream就会开始生成事件,以便每次都通知活动的StreamSubscription对象:
- 一些数据来自流,
- 当一些错误发送到流时,
- 当流关闭时。
StreamSubscription
也允许以下操作:
- 停止监听
- 暂时
- 恢复
Stream只是一个简单的管道吗?
不,Stream
还允许在流出之前处理流入其中的数据。
为了控制Stream内部数据的处理,我们使用StreamTransformer,它只是:
- 一个“捕获”Stream内部流动数据的函数
- 对数据做一些处理
- 这种转变的结果也是一个Stream
到此你应该很容易意识到你可以按顺序使用多个[StreamTransformer]()。
StreamTransformer可用于进行任何类型的处理,例如:
- 过滤:根据任何类型的条件过滤数据,
- 重新组合:重新组合数据,
- 修改:对数据应用任何类型的修改,
- 将数据注入其他流,
- 缓冲,
- 处理:根据数据进行任何类型的操作/操作,
...
Stream的类型
Stream
有两种类型。
单订阅Stream
这种类型的Stream
只允许在该Stream
的整个生命周期内使用单个监听器。
即使在第一个订阅被取消后,也无法在此类流上收听两次。
广播Stream
这是第二种类型Stream,这种Stream
允许任意个数的监听器。
可以随时向广播流添加监听器。 新的监听器将在它开始收听
Stream
时收到事件。
基本例子
任何类型的数据
第一个示例显示了“单订阅”Stream,它只是打印输入的数据。 你可能会看到无关紧要的数据类型。
StreamTransformer
第二个示例显示“广播”Stream,它传达整数值并仅打印偶数。 为此,我们应用StreamTransformer来过滤(第14行)值,只让偶数经过。
RxDart
如今,如果我不提及RxDart,那么Streams的介绍将不再完整。
RxDart
是ReactiveX API的Dart实现,它扩展了原始的Dart Streams API
以符合ReactiveX
标准。
由于它最初并未由Google定义,因此它使用不同的词汇表。 下表给出了Dart
和RxDart
之间的相关性:
| Dart | RxDart |
| :-------- | --------:|
| Stream | Observable |
| StreamController | Subject |RxDart
正如我刚刚所说的,继承了原生的[Dart Streams API]() 并且提供了3种主要的StreamController
变种:
PublishSubject
PublishSubject是一个普通的广播StreamController
,但有一种情况是例外的:当stream返回一个Observable而不是一个[Stream]()时。
如你所见,PublishSubject仅向监听器发送在订阅之后添加到Stream
的事件。
BehaviorSubject
BehaviorSubject也是一个广播StreamController,它返回一个[Observable]()而不是一个[Stream]()。
与PublishSubject
的主要区别在于BehaviorSubject
还将最后发送的事件发送给刚刚订阅的监听器。
ReplaySubject
ReplaySubject也是一个广播StreamController,它返回一个[Observable]()而不是一个[Stream]()。
默认情况下,ReplaySubject
将Stream
已经发出的所有事件作为第一个事件发送到任何新的监听器。
关于Resources的重要说明
始终释放不再需要的Resources是一种非常好的做法。
适用于:
-
StreamSubscription
- 当您不再需要收听Stream时,取消订阅; -
StreamController
- 当你不再需要StreamController时,关闭它; - 这同样适用于
RxDart Subjects
,当你不再需要BehaviourSubject
,PublishSubject
...时,请将其关闭。
如何基于由Stream提供的数据构建Widget?
Flutter提供了一个非常方便的StatefulWidget,称为StreamBuilder。
StreamBuilder监听Stream,每当某些数据输出Stream时,它会自动重建,调用其builder回调。
下面的代码演示了如何使用StreamBuilder:
StreamBuilder(
key: ...optional, the unique ID of this Widget...
stream: ...the stream to listen to...
initialData: ...any initial data, in case the stream would initially be empty...
builder: (BuildContext context, AsyncSnapshot snapshot){
if (snapshot.hasData){
return ...the Widget to be built based on snapshot.data
}
return ...the Widget to be built if no data is available
},
)
以下示例模仿默认的“ counter”应用程序,但我们将使用Stream而不再使用任何setState。
注:counter是flutter的默认生成的demo。
解释和说明:
- 第24-30行:我们正在监听stream,每当stream输出一个新的值,我们将用该值更新Text;
- 第35行:当我们点击
FloatingActionButton
时,我们递增计数器并通过接收器将其发送到Stream; 在流中注入值的事实导致侦听它的StreamBuilder重建并“刷新”计数器; - 我们不再需要State的概念,所有内容都通过
Stream
接收; - 这是一个很大的改进,因为调用
setState()
方法会强制整个Widget(和任何子窗口小部件)重建。 在这里,只重建StreamBuilder(当然还有子窗口小部件); - 我们仍然在为页面使用StatefulWidget的唯一原因,仅仅是因为我们需要通过dispose方法释放StreamController,第15行;
什么是响应式编程?
响应式编程是使用异步数据流进行编程。换句话说,从事件(例如,点击),变量的变化,消息,......到构建请求,可能改变或发生的所有事物的所有内容将被传送,由数据流触发。
很明显,所有这些意味着,通过响应应式编程,应用程序将会:
- 变得异步,
- 围绕Streams和listeners的概念进行架构,
- 当某些事情发生在某个地方(事件,变量的变化......)时,会向Stream发送通知,
- 如果“某人”收听该Stream,它将被通知并将采取适当的行动,无论其在应用程序中的位置如何。
组件之间不再存在紧密耦合。
简而言之,当Widget
向Stream
发送内容时,该Widget
不再需要知道:
- 接下来会发生什么,
- 谁可能使用这些信息(没有一个,一个或几个Widget...)
- 可能使用此信息的地方(无处,同一页面,另一个页面,或者几个页面...),
- 当这些信息可能被使用时(几乎是直接,几秒钟之后,永远不会......)。
...... Widget只关心自己的业务,就是这样!
乍一看,读到这个,这似乎可能导致应用程序的“无法控制”,但正如我们将看到的,情况恰恰相反。 它给你:
- 构建仅负责特定活动的部分应用程序的机会,
- 轻松模拟一些组件的行为,以允许更完整的测试覆盖,
- 轻松重用组件(当前应用程序或其他应用程序中的其他位置),
- 重新设计应用程序,并能够在不进行太多重构的情况下将组件从一个地方移动到另一个地方,
- ...
我们将很快看到使用响应式编程的好处......但在此之前我还需要介绍一下最后一个话题:BLoC模式。
BLoC模式
BLoC模式由来自Google的Paolo Soares和Cong Hui设计,并在2018年DartConf期间(2018年1月23日至24日)首次展示。 在YouTube上观看此视频。
BLoC代表业务逻辑组件(Business Logic Component)。
简而言之,业务逻辑(Business Logic )需要:
- 转移到一个或几个BLoC,
- 尽可能从表现层中删除。 换句话说,UI组件应该只关心UI事物而不关心业务,
- 依赖Streams独家使用输入(Sink)和输出(流),
- 保持平台独立,
- 保持环境独立。
事实上,BLoC模式最初被设想为允许独立于平台重用相同的代码:Web应用程序,移动应用程序,后端。
它到底意味着什么?
- Widgets通过Sinks向BLoC发送事件,
- BLoC通过Stream通知Widgets,
- 由BLoC实现的业务逻辑不是他们关注的问题。
从上面来看,我们可以直接看到使用BLoC的一个巨大的好处。
感谢业务逻辑与UI的分离:
- 我们可以随时更改业务逻辑,对应用程序的影响最小,
- 我们可能会更改UI而不会对业务逻辑产生任何影响,
- 现在,测试业务逻辑变得更加容易。
如何将此BLoC模式应用于Counter应用?
将BLoC模式应用于Counter 应用可能看起来有点矫枉过正,但请允许我先向你展示......
我已经听到你说“哇......为什么这一切? 这一切都是必要的吗?“
首先,是责任分离
如果你检查CounterPage(第21-45行),你会发现其中绝对没有任何业务逻辑。
此页面现在仅负责:
- 显示计数器,现在只在必要时刷新(即使页面不必知道)
- 提供按钮,当按钮按下时,将会在counter面板上请求一个动作
此外,整个业务逻辑集中在一个单独的类“IncrementBloc”中。
现在如果你需要更改业务逻辑,您只需更新方法_handleLogic(第77-80行)。 也许新的业务逻辑会要求做非常复杂的事情...... CounterPage永远不会知道它,这非常好!
其次,可测试性
现在,测试业务逻辑变得更加容易。
无需再通过UI测试业务逻辑。 只需要测试IncrementBloc
。
第三,自由组织布局
由于使用了Streams,你现在可以独立于业务逻辑组织布局。
可以从应用程序中的任何位置启动任何操作:只需调用.incrementCounter sink即可。
您可以在任何页面的任何位置显示counter,只需听取.outCounter stream。
第四,减少“build”的数量
不使用setState()而是使用StreamBuilder大大减少了“build”的数量。
从性能角度来看,这是一个巨大的进步。
只有一个限制...BLoC的可访问性
为了使所有这些工作,BLoC需要可以被访问到。
有几种方法可以访问它:
- 通过全局单例
这种方式可以实现,但不是真的推荐。 此外,由于Dart中没有类析构函数,因此你永远无法正确释放资源。
- 作为局部变量
你可以实例化BLoC的局部实例。 在某些情况下,此解决方案完全符合某些需求。 在这种情况下,你应该始终考虑在StatefulWidget中初始化,以便您
可以利用dispose()方法来释放相关资源。 - 由父级提供
使其可访问的最常见方式是通过父级Widget访问,通过StatefulWidget实现。
以下代码显示了通用BlocProvider的示例。
关于这种通用BlocProvider的一些解释
首先,如何将其作为provider使用?
如果你查看示例代码“streams_4.dart”,你将看到以下代码行(第12-15行)
home: BlocProvider(
bloc: IncrementBloc(),
child: CounterPage(),
),
通过这些代码,我们只需实例化一个新的BlocProvider,它将处理一个IncrementBloc,并将CounterPage作为子项呈现。
从那一刻开始,从BlocProvider开始的子树的任何Widget都将能够通过以代码访问IncrementBloc:
IncrementBloc bloc = BlocProvider.of(context);
可以使用多个BLoC吗?
当然,这是非常可取的。建议如下:
- (如果有任何业务逻辑)每个页面的顶部有一个BLoC,
- 为什么不是ApplicationBloc来处理应用程序状态?
- 每个“足够复杂的组件”都有相应的BLoC。
以下示例代码在整个应用程序的顶部显示ApplicationBloc,然后在CounterPage顶部显示IncrementBloc。
该示例还显示了如何检索两个bloc。
为什么不使用InheritedWidget?
在与BLoC相关的大多数文章中,你会看到通过InheritedWidget实现Provider。
当然,没有什么能阻止这种类型的实现。 然而,
- 一个InheritedWidget没有提供任何dispose方法,请记住,在不再需要资源时总是释放资源是一种很好的做法。
- 当然,没有什么能阻止你将InheritedWidget包装在另一个StatefulWidget中,但是,使用InheritedWidget增加了什么呢?
- 最后,如果不受控制,使用InheritedWidget经常会导致副作用(请参阅下面的InheritedWidget上的Reminder)。
这三点解释了我为什么选择通过StatefulWidget实现BlocProvider,这样做可以让我在Widget dispose时释放相关资源。
Flutter无法实例化泛型类型
不幸的是,Flutter无法实例化泛型类型,我们必须将BLoC的实例传递给BlocProvider。 为了在每个BLoC中强制执行dispose()方法,所有BLoC都必
须实现BlocBase接口。
InheritedWidget的一些提醒
在使用InheritedWidget并通过context.inheritFromWidgetOfExactType(...)获取指定类型最近的Widget时,每当InheritedWidget的父级或者子布局发生变化时,这个方法会自动将当前“context”(= BuildContext)注册到要重建的widget当中。
链接到BuildContext的Widget(Stateful或Stateless)的类型无关紧要。
关于BLoC的个人建议
与BLoC相关的第三条规则是:“依赖于Streams对输入(Sink)和输出(stream)的独占使用”。
我的个人经历稍微关系到这个说法......让我解释一下。
起初,BLoC模式被设想为跨平台共享相同的代码(AngularDart,...),并且从这个角度来看,该语句非常有意义。
但是,如果您只打算开发一个Flutter应用程序,那么根据我的谦逊经验,这有点矫枉过正。
如果我们坚持这种说法,那么就没有getter或settr,只有sink和stream。缺点是“所有这些都是异步的”。
我们来看两个样本来说明缺点:
- 你需要从BLoC中检索一些数据,以便使用这些数据作为应该立即显示这些参数的页面的输入(例如,想一个参数页面),如果我们不得不依赖Streams,这会使构建异步页面(很复杂)。通过Streams使其工作的示例代码可能如下所示......丑陋不是它。
- 在BLoC级别,您还需要转换某些数据的“假”注入,以触发提供您希望通过流接收的数据。使这项工作的示例代码可以是:
我不知道您的意见,但就个人而言,如果我没有任何与代码移植/共享相关的限制,我发现这太笨重了,我宁愿在需要时使用常规的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调用此页面;
6.* 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(“最喜欢的”图标)
- 强制重建_buildMovieCard(“最喜欢的”图标)
- 用于构建每个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进行了一次奇怪的调用:
// Notify the MovieCatalogBloc that we are rendering the MovieCard[index]
//通知MovieCatalogBloc我们正在渲染MovieCard[index]
movieBloc.inMovieIndex.add(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应用程序的方法。 它提供了很大的灵活性。
很快就会继续关注新文章。 快乐写代码。
文中所涉及到的源码
版本所有,转载请注明出处。