[译]Flutter 响应式编程:Steams 和 BLoC 实践范例(4) - 表单验证

原文:Reactive Programming - Streams - BLoC - Practical Use Cases 是作者 Didier BoelensReactive Programming - Streams - BLoC 写的后续

阅读本文前建议先阅读前篇,前篇中文翻译有两个版本:

  1. [译]Flutter响应式编程:Streams和BLoC by JarvanMo
    忠于原作的版本

  2. Flutter中如何利用StreamBuilder和BLoC来控制Widget状态 by 吉原拉面
    省略了一些初级概念,补充了一些个人解读

前言

在了解 BLoC, Reactive ProgrammingStreams 概念后,我又花了些时间继续研究,现在非常高兴能够与你们分享一些我经常使用并且个人觉得很有用的模式(至少我是这么认为的)。这些模式为我节约了大量的开发时间,并且让代码更加易读和调试。

目录

(由于原文较长,翻译发布时进行了分割)

  1. BlocProvider 性能优化
    结合 StatefulWidgetInheritedWidget 两者优势构建 BlocProvider

  2. BLoC 的范围和初始化
    根据 BLoC 的使用范围初始化 BLoC

  3. 事件与状态管理
    基于事件(Event) 的状态 (State) 变更响应

  4. 表单验证
    根据表单项验证来控制表单行为 (范例中包含了表单中常用的密码和重复密码比对)

  5. 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 负责业务规则验证的处理,实现的代码如下:

bloc_reg_form_bloc.dart

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 个 TextFieldStream
  • 提供了 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 也是类似的。

首先,代码如下:

bloc_email_validator.dart

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)

StreamTransformerStream 获取输入,然后引用 Streamtransform 方法进行输入的处理,并将处理后的数据重新注入到初始的 Stream 中。

在上面的代码中,处理流程包括根据一个正则表达式检查输入的内容,如果匹配则将输入的内容重新注入到 stream 中;如果不匹配,则将错误信息注入给 stream

4.1.2. 为什么要用 stream.transform()?

如前所述,如果验证成功,StreamTransformer 会把输入的内容重新注入回 Stream,具体是怎么运作的呢?

我们先看看 Observable.combineLatest3() 这个方法,它在每个 Stream 全都抛出至少一个值之前,并不会给出任何值

如下图所示:

Observable.combineLatest3
  • 如果用户输入的 email 是有效的,emailstream 会抛出用户输入的内容,同时再作为 Observable.combineLatest3() 的一个输入
  • 如果用户输入的 email 是无效的,emailstream 中会被添加一条错误信息(而且 stream 不会抛出数据)
  • passwordretype password 也是类似的机制
  • 当它们3个都验证通过时(也就是 3 个 stream 都抛出了数据),Observable.combineLatest3() 会借助 (e, p, c) => true 方法抛出一个 true 值(见代码第 35 行)

4.1.3. 密码与重复密码验证

我在网上看到有很多关于密码与重复密码的验证问题,解决方案肯定是有很多的,这里我针对其中两种说明下。

4.1.3.1. 无错误提示的基础方案

第一种解决方案的代码如下:

bloc_password_valid_1.dart

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的处理方法进行了扩展,代码如下:

bloc_password_valid_2.dart

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 组件的实现代码:

bloc_reg_form.dart

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!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,968评论 6 482
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,601评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 153,220评论 0 344
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,416评论 1 279
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,425评论 5 374
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,144评论 1 285
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,432评论 3 401
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,088评论 0 261
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,586评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,028评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,137评论 1 334
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,783评论 4 324
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,343评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,333评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,559评论 1 262
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,595评论 2 355
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,901评论 2 345

推荐阅读更多精彩内容