[译]Flutter 响应式编程:Steams 和 BLoC 实
原文: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 两者优势构建 BlocProvider -
BLoC 的范围和初始化
根据 BLoC 的使用范围初始化 BLoC -
事件与状态管理
基于事件(Event) 的状态 (State) 变更响应 -
表单验证
根据表单项验证来控制表单行为 (范例中包含了表单中常用的密码和重复密码比对) -
Part Of 模式
允许组件根据所处环境(是否在某个列表/集合/组件中)调整自身的行为
文中涉及的完整代码可在 GitHub 查看。
4. 表单验证
BLoC 另一个有意思的应用场景就是表单的验证,比如:
- 验证某个 TextField 表单项是否满足一些业务规则
- 业务规则验证错误时显示提示信息
- 根据业务规则自动处理表单组件是否可用
下面的例子中,我用了一个名叫 RegistrationForm 的表单,这个表单包含3个 TextField (分别为电子邮箱email、密码password和重复密码 confirmPassword)以及一个按钮 RaisedButton 用来发起注册处理
想要实现的业务规则有:
- email 需要是有效的电子邮箱地址,如果不是的话显示错误提示信息
- password 也必须需有效,即包括至少1个大写字母、1个小写字母、1个数字和1个特殊字符在内,且不少于8位字符,如果不是的话也需要显示错误提示信息
- 重复密码 retype password 除了需要和 password 一样的验证规则外,还需要和 password 完全一样,如果不是的话,显示错误提示信息
- register 按钮只有在以上所有规则都验证通过后才能使用
4.1. RegistrationFormBloc
如前所述,这个 BLoC 负责业务规则验证的处理,实现的代码如下:
class RegistrationFormBloc extends Object with EmailValidator, PasswordValidator implements BlocBase {
final BehaviorSubject<String> _emailController = BehaviorSubject<String>();
final BehaviorSubject<String> _passwordController = BehaviorSubject<String>();
final BehaviorSubject<String> _passwordConfirmController = BehaviorSubject<String>();
//
// Inputs
//
Function(String) get onEmailChanged => _emailController.sink.add;
Function(String) get onPasswordChanged => _passwordController.sink.add;
Function(String) get onRetypePasswordChanged => _passwordConfirmController.sink.add;
//
// Validators
//
Stream<String> get email => _emailController.stream.transform(validateEmail);
Stream<String> get password => _passwordController.stream.transform(validatePassword);
Stream<String> get confirmPassword => _passwordConfirmController.stream.transform(validatePassword)
.doOnData((String c){
// If the password is accepted (after validation of the rules)
// we need to ensure both password and retyped password match
if (0 != _passwordController.value.compareTo(c)){
// If they do not match, add an error
_passwordConfirmController.addError("No Match");
}
});
//
// Registration button
Stream<bool> get registerValid => Observable.combineLatest3(
email,
password,
confirmPassword,
(e, p, c) => true
);
@override
void dispose() {
_emailController?.close();
_passwordController?.close();
_passwordConfirmController?.close();
}
}
说明:
- 我们最先初始化了 3 个 BehaviorSubject,用来处理表单中 3 个 TextField 的 Stream
- 提供了 3 个 Function(String) ,用来接收来自 TextField 的输入
- 提供了 3 个 Stream<String> ,在 TextField 验证失败时,显示各自的错误信息
- 同时还提供了 1 个 Stream<bool>,作用是根据全部表单项的验证结果,控制 RaisedButton 是否可用(enable/disabe)
好了,我们来深入了解更多的细节…
你可能注意到了,这个 BLoC 类的代码有点特殊,是这样的:
class RegistrationFormBloc extends Object
with EmailValidator, PasswordValidator
implements BlocBase {
...
}
使用了 with 关键字表明这个类用到了 MIXINS (一种在另一个类中重用类代码的方法),而且为了使用 with,这个类还需要基于 Object 类进行扩展。这些 mixins 包含了 email 和 password 各自的验证方式。
关于 Mixins 更多信息建议阅读 Romain Rastel 的这篇文章。
4.1.1. 表单验证用到的 Mixins
我这里只对 EmailValidator 进行说明,因为 PasswordValidator 也是类似的。
首先,代码如下:
const String _kEmailRule = r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$";
class EmailValidator {
final StreamTransformer<String,String> validateEmail =
StreamTransformer<String,String>.fromHandlers(handleData: (email, sink){
final RegExp emailExp = new RegExp(_kEmailRule);
if (!emailExp.hasMatch(email) || email.isEmpty){
sink.addError('Entre a valid email');
} else {
sink.add(email);
}
});
}
这个类提供了一个 final 的方法(validateEmail),这个方法其实返回的是一个 StreamTransformer 实例
提示
StreamTransformer 的调用方式为:stream.transform(StreamTransformer)
StreamTransformer 从 Stream 获取输入,然后引用 Stream 的 transform 方法进行输入的处理,并将处理后的数据重新注入到初始的 Stream 中。
在上面的代码中,处理流程包括根据一个正则表达式检查输入的内容,如果匹配则将输入的内容重新注入到 stream 中;如果不匹配,则将错误信息注入给 stream
4.1.2. 为什么要用 stream.transform()?
如前所述,如果验证成功,StreamTransformer 会把输入的内容重新注入回 Stream,具体是怎么运作的呢?
我们先看看 Observable.combineLatest3() 这个方法,它在每个 Stream 全都抛出至少一个值之前,并不会给出任何值
如下图所示:
Observable.combineLatest3- 如果用户输入的 email 是有效的,email 的 stream 会抛出用户输入的内容,同时再作为 Observable.combineLatest3() 的一个输入
- 如果用户输入的 email 是无效的,email 的 stream 中会被添加一条错误信息(而且 stream 不会抛出数据)
- password 和 retype password 也是类似的机制
- 当它们3个都验证通过时(也就是 3 个 stream 都抛出了数据),Observable.combineLatest3() 会借助 (e, p, c) => true 方法抛出一个
true
值(见代码第 35 行)
4.1.3. 密码与重复密码验证
我在网上看到有很多关于密码与重复密码的验证问题,解决方案肯定是有很多的,这里我针对其中两种说明下。
4.1.3.1. 无错误提示的基础方案
第一种解决方案的代码如下:
Stream<bool> get registerValid => Observable.combineLatest3(
email,
password,
confirmPassword,
(e, p, c) => (0 == p.compareTo(c))
);
这个解决方案只是在验证了两个密码之后,将它们进行比较,如果它们一样,则会抛出一个 true
值。
等下我们会看到,Register 按钮是否可用是依赖于 registerValid stream 的,如果两个密码不一样,registerValid stream 就不会抛出任何值,所以 Register 按钮依然是不可用状态。
但是,用户不会接收到任何错误提示信息,所以也不明白发生了什么。
4.1.3.2. 具有错误提示的方案
另一种方案是把 confirmPassword stream的处理方法进行了扩展,代码如下:
Stream<String> get confirmPassword => _passwordConfirmController.stream.transform(validatePassword)
.doOnData((String c){
// If the password is accepted (after validation of the rules)
// we need to ensure both password and retyped password match
if (0 != _passwordController.value.compareTo(c)){
// If they do not match, add an error
_passwordConfirmController.addError("No Match");
}
});
一旦 retype password 业务规则验证通过, 用户输入的内容会被 Stream 抛出,并调用 doOnData() 方法,在该方法中通过 _passwordController.value.compareTo() 获取是否与 password stream 中的数据一样,如果不一样,我们就可用添加错误提示了。
4.2. RegistrationForm 组件
在解释说明前我们先来看看 Form 组件的实现代码:
class RegistrationForm extends StatefulWidget {
@override
_RegistrationFormState createState() => _RegistrationFormState();
}
class _RegistrationFormState extends State<RegistrationForm> {
RegistrationFormBloc _registrationFormBloc;
@override
void initState() {
super.initState();
_registrationFormBloc = RegistrationFormBloc();
}
@override
void dispose() {
_registrationFormBloc?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Form(
child: Column(
children: <Widget>[
StreamBuilder<String>(
stream: _registrationFormBloc.email,
builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
return TextField(
decoration: InputDecoration(
labelText: 'email',
errorText: snapshot.error,
),
onChanged: _registrationFormBloc.onEmailChanged,
keyboardType: TextInputType.emailAddress,
);
}),
StreamBuilder<String>(
stream: _registrationFormBloc.password,
builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
return TextField(
decoration: InputDecoration(
labelText: 'password',
errorText: snapshot.error,
),
obscureText: false,
onChanged: _registrationFormBloc.onPasswordChanged,
);
}),
StreamBuilder<String>(
stream: _registrationFormBloc.confirmPassword,
builder: (BuildContext context, AsyncSnapshot<String> snapshot) {
return TextField(
decoration: InputDecoration(
labelText: 'retype password',
errorText: snapshot.error,
),
obscureText: false,
onChanged: _registrationFormBloc.onRetypePasswordChanged,
);
}),
StreamBuilder<bool>(
stream: _registrationFormBloc.registerValid,
builder: (BuildContext context, AsyncSnapshot<bool> snapshot) {
return RaisedButton(
child: Text('Register'),
onPressed: (snapshot.hasData && snapshot.data == true)
? () {
// launch the registration process
}
: null,
);
}),
],
),
);
}
}
说明:
- 因为 RegisterFormBloc 只是用于表单的验证处理,所以仅在表单组件中初始化(实例化)是合适的
- 每个 TextField 都包含在一个StreamBuilder<String> 中,以便能够响应验证过程的任何结果(见代码中的errorText:snapshot.error)
- 每次 TextField 中输入的内容发生改变时,我们都将已输入的内容通过 onChanged:_registrationFormBloc.onEmailChanged (输入email情况下) 发送给 BLoC 进行验证,
-
RegisterButton 同样也包含在一个 StreamBuilder<bool> 中
- 如果 _registrationFormBloc.registerValid 抛出了值,onPressed 将在用户点击时对抛出的值进行后续处理
- 如果没有值抛出,onPressed 方法被指定为 null,按钮会被置为不可用状态
好了!可用看到在表单组件中,是看不到任何和业务规则相关的代码的,这意味着我们可以随意修改业务规则,而不需要对表单组件本身进行任何修改,简直 excellent!