原文:Reactive Programming - Streams - BLoC - Practical Use Cases 是作者 Didier Boelens 为 Reactive Programming - Streams - BLoC 写的后续
阅读本文前建议先阅读前篇,前篇中文翻译有两个版本:
[译]Flutter响应式编程:Streams和BLoC by JarvanMo
忠于原作的版本Flutter中如何利用StreamBuilder和BLoC来控制Widget状态 by 吉原拉面
省略了一些初级概念,补充了一些个人解读
前言
在了解 BLoC, Reactive Programming 和 Streams 概念后,我又花了些时间继续研究,现在非常高兴能够与你们分享一些我经常使用并且个人觉得很有用的模式(至少我是这么认为的)。这些模式为我节约了大量的开发时间,并且让代码更加易读和调试。
目录
(由于原文较长,翻译发布时进行了分割)
BlocProvider 性能优化
结合 StatefulWidget 和 InheritedWidget 两者优势构建 BlocProviderBLoC 的范围和初始化
根据 BLoC 的使用范围初始化 BLoC事件与状态管理
基于事件(Event) 的状态 (State) 变更响应表单验证
根据表单项验证来控制表单行为 (范例中包含了表单中常用的密码和重复密码比对)Part Of 模式
允许组件根据所处环境(是否在某个列表/集合/组件中)调整自身的行为
文中涉及的完整代码可在 GitHub 查看。
3. 事件与状态管理(Event - State)
有时侯需要我们编码实现一些棘手的业务流程,这些流程可能会由串行或并行、耗时长短不一、同步或异步的子流程构成的,很可能每个子流程的处理结果也是千变万化的,而且还可能需要根据其处理进度或状态进行视图更新。
而本文中「事件与状态管理」解决方案的目的就是让处理这种复杂的业务流程变得更简单。
方案是基于以下流程和规则的:
- 发出某个事件
- 该事件触发一些动作(action),这些动作会导致一个或多个状态产生/变更
- 这些状态又触发其它事件,或者产生/变更为其它状态
- 然后这些事件又根据状态的变更情况,触发其它动作
- 等等…
为了更好的展示这些概念,我还举了两个具体的例子:
-
应用初始化 (Application initialization)
很多时候我们都需要运行一系列动作来初始化 App, 这些动作可能是与服务器的交互相关联的 (例如:获取并加载一些数据)。而且在初始化过程中,可能还需要显示进度条及载入动画让用户能耐心等待。
-
用户身份验证 (Authentication)
在 App 启动后需要用户登录或注册,用户成功登录后,将跳转(重定向)到 App 的主页面; 而用户注销则将跳转(重定向)到验证页面。
为了应对所有的可能,我们将管理一系列的事件,而这些事件可能是在 App 中任何地方触发的,这使得事件和状态的管理异常复杂,所幸我们可以借助结合了 BlocEventStateBuider 的 BlocEventState 类大大降低事件和状态管理的难度。
3.1. BlocEventState 抽象类
BlocEventState 背后的逻辑是将 BLoC 定义成这样一套机制:
- 接收事件(event)作为输入
- 当新的事件触发(输入)时,调用一个对应的事件处理器 eventHandler
- 事件处理器(eventHandler)负责根据事件(event)采用适当的处理(actions)后,抛出一个或多个状态(State)作为响应
如下图所示:
定义 BlocEventState 的代码和说明如下:
import 'package:blocs/bloc_helpers/bloc_provider.dart';
import 'package:meta/meta.dart';
import 'package:rxdart/rxdart.dart';
abstract class BlocEvent extends Object {}
abstract class BlocState extends Object {}
abstract class BlocEventStateBase<BlocEvent, BlocState> implements BlocBase {
PublishSubject<BlocEvent> _eventController = PublishSubject<BlocEvent>();
BehaviorSubject<BlocState> _stateController = BehaviorSubject<BlocState>();
///
/// To be invoked to emit an event
///
Function(BlocEvent) get emitEvent => _eventController.sink.add;
///
/// Current/New state
///
Stream<BlocState> get state => _stateController.stream;
///
/// External processing of the event
///
Stream<BlocState> eventHandler(BlocEvent event, BlocState currentState);
///
/// initialState
///
final BlocState initialState;
//
// Constructor
//
BlocEventStateBase({
@required this.initialState,
}){
//
// For each received event, we invoke the [eventHandler] and
// emit any resulting newState
//
_eventController.listen((BlocEvent event){
BlocState currentState = _stateController.value ?? initialState;
eventHandler(event, currentState).forEach((BlocState newState){
_stateController.sink.add(newState);
});
});
}
@override
void dispose() {
_eventController.close();
_stateController.close();
}
}
如代码所示,我们定义的其实是一个抽象类,是需要扩展实现的,实现的重点就是定义 eventHandler 这个方法的具体行为。
当然我们还可以看到:
- Sink (代码中的 emitEvent) 作为事件 Event 的输入入口
- Stream (代码中的 state) 监听已发出的状态 State(s) 作为状态的输出出口
在这个类初始化时(参考代码中 Constructor 部分):
- 需要提供初始状态 initialState
- 创建了一个 StreamSubscription 用来监听输入的事件 (Events) 并:
- 将事件分配给事件处理器 eventHandler
- 抛出结果 state(s)
3.2. BlocEventState 的扩展实现
下方的模板代码就是基于扩展 BlocEventStateBase 抽象类实现了一个具体的 BlocEventState 类:
bloc_event_state_template.dart
class TemplateEventStateBloc extends BlocEventStateBase<BlocEvent, BlocState> {
TemplateEventStateBloc()
: super(
initialState: BlocState.notInitialized(),
);
@override
Stream<BlocState> eventHandler( BlocEvent event, BlocState currentState) async* {
yield BlocState.notInitialized();
}
}
模板代码会报错,请不要担心,这是正常的…因为我们还没有定义 BlocState.notInitialized()…后面会给出的。
这个模板只是在初始化时简单地给出了一个初始状态 initialState,并覆写了 eventHandler 方法。
还需要注意的是,我们使用了异步生成器 (asynchronous generator)语法:async* 和 yield
使用 async* 修饰符可将某个方法标记为一个异步生成器(asynchronous generator)方法,比如上面的代码中每次调用 eventHandler 方法内 yield 语句时,它都会把 yield 后面的表达式结果添加到输出 Stream 中。
如果我们需要通过一系列动作触发一系列States(后面会在范例中看到),这一点特别有用。
有关异步生成器的其他详细信息,可参考 这篇文章。
3.3. BlocEvent 和 BlocState
你可能注意到了,我们还定义了 BlocEvent 和 BlocState 两个抽象类,这两个抽象类都是要根据实际情况,也就是在实际业务场景中根据你想要触发的事件和抛出的状态来具体扩展实现的。
3.4. BlocEventStateBuilder 组件
这个模式的最后一部分就是 BlocEventStateBuilder 组件了,这个组件可以根据 BlocEventState 抛出的 State(s)作出视图层面的响应。
代码如下:
typedef Widget AsyncBlocEventStateBuilder<BlocState>(BuildContext context, BlocState state);
class BlocEventStateBuilder<BlocEvent,BlocState> extends StatelessWidget {
const BlocEventStateBuilder({
Key key,
@required this.builder,
@required this.bloc,
}): assert(builder != null),
assert(bloc != null),
super(key: key);
final BlocEventStateBase<BlocEvent,BlocState> bloc;
final AsyncBlocEventStateBuilder<BlocState> builder;
@override
Widget build(BuildContext context){
return StreamBuilder<BlocState>(
stream: bloc.state,
initialData: bloc.initialState,
builder: (BuildContext context, AsyncSnapshot<BlocState> snapshot){
return builder(context, snapshot.data);
},
);
}
}
其实这个组件除了一个 StreamBuilder 外没啥特别的,这个 StreamBuilder 的作用就是每当有新的 BlocState 抛出后,将其作为新的参数值调用 builder 方法。
好了,这些就是这个模式的全部构成,接下来我们看看可以用它们来做些啥…
3.5. 事件与状态管理例1: 应用初始化(Application Initialization)
第一个例子演示了 App 在启动时执行某些任务的情况。
一个常见的场景就是游戏的启动画面,也称 Splash 界面(不管是不是动画的),在显示真正的游戏主界面前,游戏应用会从服务器获取一些文件、检查是否需要更新、尝试与系统的「游戏中心」通讯等等;而且在完成初始化前,为了不让用户觉得应用啥都没做,可能还会显示进度条、定时切换显示一些图片等。
我给出的实现是非常简单的,只显示了完成百分比的,你可以根据自己的需要非常容易地进行扩展。
首先要做的就是定义事件和状态…
3.5.1. 定义事件: ApplicationInitializationEvent
作为例子,这里我只考虑了 2 个事件:
- start:触发初始化处理过程
- stop:用于强制停止初始化过程
它们的定义如下:
class ApplicationInitializationEvent extends BlocEvent {
final ApplicationInitializationEventType type;
ApplicationInitializationEvent({
this.type: ApplicationInitializationEventType.start,
}) : assert(type != null);
}
enum ApplicationInitializationEventType {
start,
stop,
}
3.5.2. 定义状态: ApplicationInitializationState
ApplicationInitializationState 类将提供与初始化过程相关的信息。
同样作为例子,这里我只考虑了:
- 2 个 flag:
- isInitialized 用来标识初始化是否完成
- isInitializing用来知晓我们是否处于初始化过程中
- 进度完成率 prograss
代码如下:
class ApplicationInitializationState extends BlocState {
ApplicationInitializationState({
@required this.isInitialized,
this.isInitializing: false,
this.progress: 0,
});
final bool isInitialized;
final bool isInitializing;
final int progress;
factory ApplicationInitializationState.notInitialized() {
return ApplicationInitializationState(
isInitialized: false,
);
}
factory ApplicationInitializationState.progressing(int progress) {
return ApplicationInitializationState(
isInitialized: progress == 100,
isInitializing: true,
progress: progress,
);
}
factory ApplicationInitializationState.initialized() {
return ApplicationInitializationState(
isInitialized: true,
progress: 100,
);
}
}
3.5.3. 实现 BLoC: ApplicationInitializationBloc
BLoC 将基于事件类型来处理具体的初始化过程。
代码如下:
class ApplicationInitializationBloc
extends BlocEventStateBase<ApplicationInitializationEvent, ApplicationInitializationState> {
ApplicationInitializationBloc()
: super(
initialState: ApplicationInitializationState.notInitialized(),
);
@override
Stream<ApplicationInitializationState> eventHandler(
ApplicationInitializationEvent event, ApplicationInitializationState currentState) async* {
if (!currentState.isInitialized){
yield ApplicationInitializationState.notInitialized();
}
if (event.type == ApplicationInitializationEventType.start) {
for (int progress = 0; progress < 101; progress += 10){
await Future.delayed(const Duration(milliseconds: 300));
yield ApplicationInitializationState.progressing(progress);
}
}
if (event.type == ApplicationInitializationEventType.stop){
yield ApplicationInitializationState.initialized();
}
}
}
说明:
- 当接收到 ApplicationInitializationEventType.start 事件时,进度完成率 prograss 将从
0
到100
开始计数(每次步进10
),而且未到100
时每次都将通过 yield 抛出一个新状态(state)告知初始化正在进行(isInitializing =true
)及完成进度 prograss 具体的值 - 当接收到 ApplicationInitializationEventType.stop 事件时,会认为初始化已经完成。
- 如你所见,我在循环过程中加了些延迟(delay),目的是演示 Future的适用场景(如从服务器获取数据)
3.5.4. 组合使用
现在,剩下的事情就是把代表进度完成率的计数器显示到假的 Splash 界面上:
class InitializationPage extends StatefulWidget {
@override
_InitializationPageState createState() => _InitializationPageState();
}
class _InitializationPageState extends State<InitializationPage> {
ApplicationInitializationBloc bloc;
@override
void initState(){
super.initState();
bloc = ApplicationInitializationBloc();
bloc.emitEvent(ApplicationInitializationEvent());
}
@override
void dispose(){
bloc?.dispose();
super.dispose();
}
@override
Widget build(BuildContext pageContext) {
return SafeArea(
child: Scaffold(
body: Container(
child: Center(
child: BlocEventStateBuilder<ApplicationInitializationEvent, ApplicationInitializationState>(
bloc: bloc,
builder: (BuildContext context, ApplicationInitializationState state){
if (state.isInitialized){
//
// Once the initialization is complete, let's move to another page
//
WidgetsBinding.instance.addPostFrameCallback((_){
Navigator.of(context).pushReplacementNamed('/home');
});
}
return Text('Initialization in progress... ${state.progress}%');
},
),
),
),
),
);
}
}
说明:
- 在 App 中,ApplicationInitializationBloc 并不是任何组件都需要用到,所以只在一个 StatefulWidget 中初始化(实例化)了该 BLoC
- 直接发出 ApplicationInitializationEventType.start 事件来触发 eventHandler
- 每次 ApplicationInitializationState 被抛出,都会更新文字内容
- 初始化过程完成后,跳转(重定向)到了 Home 界面
小技巧
由于无法直接跳转到 Home 界面,在 builder 方法中,使用了WidgetsBinding.instance.addPostFrameCallback() 方法来请求 Flutter 在完成渲染后执行跳转。参考 addPostFrameCallback()
3.6. 事件与状态管理例2: 用户身份验证(登录与注销)
在这个例子中,我考虑了如下场景:
- 如果用户没有登录,则自动显示登录/注册(Authentication/Registration)界面
- 用户提交登录信息后,显示一个代表正在处理的循环进度指示器(转圈圈)
- 一旦用户登录成功,将跳转到 Home 界面
- 在 App 任何地方,用户都可能注销
- 如果用户注销,将自动跳转到登录(Authentication)界面
当然以其它编程方式也可以实现这些功能,但以 BLoC 的方式来实现可能更简单。
下图解释了将要实现的方案流程:
中间跳转页面DecisionPage将负责自动将用户重定向到 Authentication 界面或 Home 界面,具体到哪个界面取决于用户的登录状态。当然 DecisionPage 不会显示给用户,也不应该将其视为一个真正的页面。
同样首先要做的是定义一些事件和状态…
3.6.1. 定义事件: AuthenticationEvent
作为例子,我只考虑了2个事件:
- login:用户成功登录时会发出该事件
- logout:用户注销时会发出该事件
它们的定义如下:
abstract class AuthenticationEvent extends BlocEvent {
final String name;
AuthenticationEvent({
this.name: '',
});
}
class AuthenticationEventLogin extends AuthenticationEvent {
AuthenticationEventLogin({
String name,
}) : super(
name: name,
);
}
class AuthenticationEventLogout extends AuthenticationEvent {}
3.6.2. 定义状态: AuthenticationState
AuthenticationState 类将提供与验证过程相关的信息。
同样作为例子,我只考虑了:
- 3 个 flag:
- isAuthenticated 用来标识验证是否完成
- isAuthenticating 用来知晓是否处于验证过程中
- hasFailed 用来表示身份是否验证失败
- 经过身份验证后的用户名:name
代码如下:
class AuthenticationState extends BlocState {
AuthenticationState({
@required this.isAuthenticated,
this.isAuthenticating: false,
this.hasFailed: false,
this.name: '',
});
final bool isAuthenticated;
final bool isAuthenticating;
final bool hasFailed;
final String name;
factory AuthenticationState.notAuthenticated() {
return AuthenticationState(
isAuthenticated: false,
);
}
factory AuthenticationState.authenticated(String name) {
return AuthenticationState(
isAuthenticated: true,
name: name,
);
}
factory AuthenticationState.authenticating() {
return AuthenticationState(
isAuthenticated: false,
isAuthenticating: true,
);
}
factory AuthenticationState.failure() {
return AuthenticationState(
isAuthenticated: false,
hasFailed: true,
);
}
}
3.6.3. 实现 BLoC: AuthenticationBloc
BLoC 将基于事件类型来处理具体的身份验证过程。
代码如下:
class AuthenticationBloc
extends BlocEventStateBase<AuthenticationEvent, AuthenticationState> {
AuthenticationBloc()
: super(
initialState: AuthenticationState.notAuthenticated(),
);
@override
Stream<AuthenticationState> eventHandler(
AuthenticationEvent event, AuthenticationState currentState) async* {
if (event is AuthenticationEventLogin) {
// Inform that we are proceeding with the authentication
yield AuthenticationState.authenticating();
// Simulate a call to the authentication server
await Future.delayed(const Duration(seconds: 2));
// Inform that we have successfuly authenticated, or not
if (event.name == "failure"){
yield AuthenticationState.failure();
} else {
yield AuthenticationState.authenticated(event.name);
}
}
if (event is AuthenticationEventLogout){
yield AuthenticationState.notAuthenticated();
}
}
}
说明:
- 当接收到 AuthenticationEventLogin事件时,会通过 yield 抛出一个新状态(state)告知身份验证正在进行(isAuthenticating =
true
) - 当身份验证一旦完成,会抛出另一个新的状态(state)告知已经完成了
- 当接收到AuthenticationEventLogout事件时,会抛出一个新状态(state)告知用户已经不在是已验证状态
3.6.4. 登录页面: AuthenticationPage
如你所见,为了便于说明,这个页面并没有做的很复杂。
代码及说明如下:
class AuthenticationPage extends StatelessWidget {
///
/// Prevents the use of the "back" button
///
Future<bool> _onWillPopScope() async {
return false;
}
@override
Widget build(BuildContext context) {
AuthenticationBloc bloc = BlocProvider.of<AuthenticationBloc>(context);
return WillPopScope(
onWillPop: _onWillPopScope,
child: SafeArea(
child: Scaffold(
appBar: AppBar(
title: Text('Authentication Page'),
leading: Container(),
),
body: Container(
child:
BlocEventStateBuilder<AuthenticationEvent, AuthenticationState>(
bloc: bloc,
builder: (BuildContext context, AuthenticationState state) {
if (state.isAuthenticating) {
return PendingAction();
}
if (state.isAuthenticated){
return Container();
}
List<Widget> children = <Widget>[];
// Button to fake the authentication (success)
children.add(
ListTile(
title: RaisedButton(
child: Text('Log in (success)'),
onPressed: () {
bloc.emitEvent(AuthenticationEventLogin(name: 'Didier'));
},
),
),
);
// Button to fake the authentication (failure)
children.add(
ListTile(
title: RaisedButton(
child: Text('Log in (failure)'),
onPressed: () {
bloc.emitEvent(AuthenticationEventLogin(name: 'failure'));
},
),
),
);
// Display a text if the authentication failed
if (state.hasFailed){
children.add(
Text('Authentication failure!'),
);
}
return Column(
children: children,
);
},
),
),
),
),
);
}
}
说明:
- 第 11 行:在页面中获取 AuthenticationBloc
- 第 24 ~ 70 行:监听被抛出的 AuthenticationState:
- 如果正在验证过程中,会显示循环进度指示器(转圈圈),告知用户正在处理中,并阻止用户访问到其它页面(第25 ~ 27 行)
- 如果验证成功,显示一个空的 Container,即不显示任何内容 (第 29 ~ 31 行)
- 如果用户还没有登录,显示2个按钮,可模拟登录成功和失败的情况
- 当点击其中一个按钮时,会发出 AuthenticationEventLogin 事件以及一些参数(通常会被用于验证处理)
- 如果身份验证失败,显示一条错误消息(第 60 ~ 64 行)
好了,没啥别的事了,很简单对不?
小技巧
你肯定注意到了,我把页面包在了 WillPopScope 里面,这是因为身份验证是必须的步骤,除非成功登录(验证通过),我不希望用户使用 Android 设备提供的 Back 键来跳过验证访问到其它页面。
3.6.5. 中间跳转页面: DecisionPage
如前所述,我希望 App 根据用户登录状态自动跳转到 AuthenticationPage 或 HomePage
代码及说明如下:
class DecisionPage extends StatefulWidget {
@override
DecisionPageState createState() {
return new DecisionPageState();
}
}
class DecisionPageState extends State<DecisionPage> {
AuthenticationState oldAuthenticationState;
@override
Widget build(BuildContext context) {
AuthenticationBloc bloc = BlocProvider.of<AuthenticationBloc>(context);
return BlocEventStateBuilder<AuthenticationEvent, AuthenticationState>(
bloc: bloc,
builder: (BuildContext context, AuthenticationState state) {
if (state != oldAuthenticationState){
oldAuthenticationState = state;
if (state.isAuthenticated){
_redirectToPage(context, HomePage());
} else if (state.isAuthenticating || state.hasFailed){
//do nothing
} else {
_redirectToPage(context, AuthenticationPage());
}
}
// This page does not need to display anything since it will
// always remind behind any active page (and thus 'hidden').
return Container();
}
);
}
void _redirectToPage(BuildContext context, Widget page){
WidgetsBinding.instance.addPostFrameCallback((_){
MaterialPageRoute newRoute = MaterialPageRoute(
builder: (BuildContext context) => page
);
Navigator.of(context).pushAndRemoveUntil(newRoute, ModalRoute.withName('/decision'));
});
}
}
提示
为了详细解释下面的问题,我们先回溯下 Flutter 处理 Pages(也就是 路由Route)的方式,即使用 Navigator 对象来管理 Routes,而 Navigator 对象创建了一个 Overlay 对象;这个 Overlay 其实是包含多个 OverlayEntry 的 Stack 对象,而每个 OverlayEntry 都包含了一个 Page;
当我们通过 Navigator.of(context) 操作路由堆栈进行压入、弹出或替换时,也会更新 Overlay 对象(也就是Stack 对象),换句话说,这些操作会导致 Stack 对象的重构;而 Stack 重构时,OverlayEntry (包括其内容 Page)也会跟着重构;
结果就是:
当我们通过 Navigator.of(context) 进行路由操作后,所有其它页面都会重构!
-
那么,为啥我要把它实现为 StatefulWidget ?
为了能够响应 AuthenticationState 任何变更,这个 page 需要在 App 整个生命周期内保留;
而根据上面的提示,每次调用 Navigator.of(context) 后,这个页面都会被重构,因此也会重构 BlocEventStateBuilder ,毫无疑问 BlocEventStateBuilder 里面的 builder 方法也会被调用;
因为这个 builder 方法是负责将用户重定向到与 AuthenticationState 对应的页面,重定向又要通过 Navigator.of(context) 来实现…明显死循环了
所以为了防止这种情况发生,我们需要将「最后一个」 AuthenticationState 存起来,只有当新的 AuthenticationState 与已存的不一样时,我们才进行重定向处理;
而实现存储就是利用 StatefulWidget 的特性,将「最后一个」 AuthenticationState 放到了 State 的 oldAuthenticationState 属性中。
-
到底是怎么运作的?
如上所诉,每当 AuthenticationState 被抛出时,BlocEventStateBuilder 会调用 builder 方法,根据 isAuthenticated 标识,我们就知道具体将用户重定向到哪个页面。
小技巧
由于在 builder 中无法直接跳转到其它界面,我们使用了WidgetsBinding.instance.addPostFrameCallback() 方法来请求 Flutter 在完成渲染后执行跳转。
此外,除了 DecisionPage 需要在整个应用生命周期保留之外,我们需要移除路由堆栈中重定向前所有其它已存在的页面,所以我们使用了 Navigator.of(context).pushAndRemoveUntil(…) 来实现这一目的。参考 pushAndRemoveUntil()
3.6.6. 用户注销
为了让用户能够注销,可以创建一个 LogOutButton,放到 App 中任何地方。
这个按钮只需要点击后发出 AuthenticationEventLogout() 事件,这个事件会触发如下的自动处理动作:
- 事件由 AuthenticationBloc 进行处理
- 处理后抛出一个 AuthentiationState(isAuthenticated =
false
) - 抛出的状态将由DecisionPage 通过 BlocEventStateBuilder 进行处理
- 最后将用户重定向到 AuthenticationPage
按钮代码如下:
class LogOutButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
AuthenticationBloc bloc = BlocProvider.of<AuthenticationBloc>(context);
return IconButton(
icon: Icon(Icons.exit_to_app),
onPressed: () {
bloc.emitEvent(AuthenticationEventLogout());
},
);
}
}
3.6.7. 注入 AuthenticationBloc
由于需要 AuthenticationBloc 在应用中任何页面都可用,所以我们将其注入为 MaterialApp 的父级,如下所示:
void main() => runApp(Application());
class Application extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider<AuthenticationBloc>(
bloc: AuthenticationBloc(),
child: MaterialApp(
title: 'BLoC Samples',
theme: ThemeData(
primarySwatch: Colors.blue,
),
home: DecisionPage(),
),
);
}
}