基于BLoC的通用框架

BLoC的各种元素

什么是IO

IOInput/Output,Input/Output是相对于BLoC来说的,Input即向BLoC输入数据,Output即从BLoC输出数据。 一个BLoC中所有的属性都是IO,负责接收来自widget层的数据(供后续操作读取数据)或者向widget层输出数据。widget层只对数据做读取操作,即widget对数据是READONLY的。 当widget需要对数据发起修改操作时,需要通过BLoCProvider.of(BuildContext)/context.of<SomeBLoC>()来获取当前widget所依附的BLoC实例,然后调用BLoC的对应方法来操作数据。

BaseIO

BaseIO是所有IO类型的祖宗类,存放了所有IO类型需要的公共属性和方法。

构造器概览

BaseIO({
  /// 初始值, 传递给内部的[_subject]
  T seedValue,

  /// IO代表的语义
  String semantics,

  /// 是否同步发射数据, 传递给内部的[_subject]
  bool sync = true,

  /// 是否使用BehaviorSubject, 如果使用, 那么IO内部的[_subject]会保存最近一次的值
  bool isBehavior = true,
})  : _semantics = semantics,
      _seedValue = seedValue,
      latest = seedValue,
      _subject = isBehavior
          ? seedValue != null
              ? BehaviorSubject<T>.seeded(seedValue, sync: sync)
              : BehaviorSubject<T>(sync: sync)
          : PublishSubject<T>(sync: sync) {
    _subject.listen((data) {
      latest = data;
      L.d('当前${semantics ??= data.runtimeType.toString()} latest: $latest'
          '\n+++++++++++++++++++++++++++END+++++++++++++++++++++++++++++');
    });
  }

BehaviorSubjectrxdart的一个类,作为一个中转站,当有数据add到BehaviorSubject时,会转发给BehaviorSubject的订阅者。作用有些类似iOS里的NotificationCenter,Android中同RxJava中的BehaviorSubject

Input

Input表示只从widget接收数据,不对widget输出数据的业务单元。常见的使用场景为接收来自TextFormField的值。

Output

Output表示只对widget输出数据,不从widget接收数据的业务单元。常见的使用场景为页面初始数据的加载,比如进入一个列表页,BLoC中的一个Output负责加载列表数据,widget层中的一个ListView绑定这个Output,进入页面前调用Output进行数据的加载,加载完成后刷新ListView。
整个过程Output不会从widget接收数据,只会对widget输出数据。

IO

语义上,IO是既可以输入数据又可以输出数据的业务单元;实现上,IOclass IO<T> extends BaseIO<T> with InputMixin, OutputMixin<T, dynamic>
常见的使用场景为勾选框,由于Flutter的哲学是widget本身不保存状态,所以像CheckBox这种widget的勾选状态是需要开发者自行维护的,CheckBox本身只负责通知开发者状态即将变化,当CheckBox的onChanged回调出发后,并不会真正的看到CheckBox被勾选,需要开发者自行更新CheckBox的checked的值之后才能看到被勾选。所以在这种场景下,数据流动就变成了CheckBox(onChanged)->BLoC(更新状态值)->CheckBox(监听新的状态值)

衍生IO

ListIO

ListIO是只接收列表类型的IO,在普通IO的基础上增加了如下方法:

/// 按条件过滤, 并发射过滤后的数据
List<T> filterItem(bool test(T element));

/// 追加, 并发射
T append(T element, {bool fromHead = false});

/// 追加一个list, 并发射
List<T> appendAll(List<T> elements, {bool fromHead = false});

/// 对list的item做变换之后重新组成list
Stream<List<S>> flatMap<S>(S mapper(T value));

/// 替换指定index的元素, 并发射
T replace(int index, T element);

/// 替换最后一个的元素, 并发射
T replaceLast(T element);

/// 替换第一个的元素, 并发射
T replaceFirst(T element);

/// 删除最后一个的元素, 并发射
T removeLast();

/// 删除一个的元素, 并发射
T remove(T element);

/// 删除第一个的元素, 并发射
T removeFirst();

/// 删除指定索引的元素, 并发射
T removeAt(int index);

BoolIO

BoolIO是只接收布尔值的IO,增加了如下方法:

/// 翻转状态值
bool toggle();

PageIO

PageIO在ListIO基础之上进一步封装了分页操作,在PageIO会自动维护当前页数,开发者只需要调用nextPage方法即可请求下一页数据,如果如要刷新数据,不能再调用IO提供的update方法,而是需要使用PageIO中提供的refresh方法。

编码规范

  • 尽量减少嵌套;
  • 尽量让一个Widget长什么样只取决于构造器参数,减少来历不明的变化引起的Widget变化,例如:
    class SomeWidget extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return StreamBuilder(
          stream: /* 某处获取来的stream */
          builder: (context, snapshot) {
            return AWidget(snapshot.data);
          }
        );
      }
    }
      
    class AWidget extends StatelessWidget {
      AWidget(AVM vm, {Key key}): super(key: key);
    
      @override
      Widget build(BuildContext context) {
        return /* 根据vm参数构造出widget */
      }
    }
    
    优于
    class SomeWidget extends StatelessWidget {
      @override
      Widget build(BuildContext context) {
        return AWidget();
      }
    }
    
    class AWidget extends StatelessWidget {
      AWidget({Key key}): super(key: key);
    
      @override
      Widget build(BuildContext context) {
        return StreamBuilder(
          stream: /* 某处获取来的stream */
          builder: (context, snapshot) {
            return /* 根据snapshot构造widget */;
          }
        );
      }
    }
    
    第二种写法虽然看着代码更少,但是跟某个stream直接绑定,导致难以复用,而且AWidget长什么样不受AWidget类内部逻辑控制(被某处获取来的stream控制),非常不直观。除非这是一个全局使用的widget,绑定的stream也是全局的事件,那么可以这样使用,否则必须要从构造器中传入参数。
  • 优先使用StatelessWidget+BLoC模式实现功能,只有功能比较简单时,可以使用StatefulWidget代替实现;
  • StatelessWidget类里应该只有
    1. build方法;
    2. override方法;
    3. 事件处理(私有)方法;
  • StatefulWidget不做限制;

名称

  • 在遵循规范的前提下,不要怕名字太长或者太啰嗦,清晰精确的名称好于莫名其妙的省略;
  • 除非是广泛通用的缩写(例如http等),否则不允许使用任何形式的缩写,无意义的缩写会让看代码的人困惑;

文件名

  • 文件名使用下划线风格;
  • 页面类以.screen.dart结尾;
  • 普通widget类以.widget.dart结尾;
  • dialog类以.dialog.dart结尾;
  • bloc类以.bloc.dart结尾;
  • 类扩展(extension)以.x.dart结尾;
  • mixin以.mixin.dart结尾;
  • 其他文件目前直接以.dart结尾;

类名

  • 页面类以Screen结尾;
  • 普通widget类根据业务属性命名即可;

方法名

  • 方法名使用驼峰命名法;
  • BLoC中的action以perform开头,比如登录动作方法命名为performLogin

变量名

  • 变量名使用驼峰命名法;

Widget结构

  • widget树中无状态变化的部分,要抽离出单独的widget类,把以构造器参数形式传入需要的数据,并修饰构造器为const

BLoC结构

  • 为了区分action和IO,IO的成员统一放在同文件内名为_ComponentMixinmixin中,action方法放在原BLoC中,一个典型的例子:
class LoginBLoC extends LocalBLoC with _ComponentMixin {
  LoginBLoC() : super('登录 BLoC');

  Future<bool> performLogin() async {
    // 获取最新的account和password并执行登录动作
  }
}

mixin _ComponentMixin on LocalBLoC {
  @override
  List<BaseIO> get disposeBag => [
        account,
        password,
      ];

  final account = Input<String>(semantics: '账户');

  final password = Input<String>(semantics: '密码');
}

文件夹结构

ui相关文件组织

  • ui文件夹下分为screen文件夹和widget文件夹,screen文件夹存放所有的单页面,widget存放全局共用的控件;
  • screen下的文件分为主screen文件和其组成部分,当组成部分比较复杂时,可以再单独为其新建文件夹;
  • 在一个文件夹下,只允许存在两层关系的ui,如果超过两层的关系,则为较复杂的那部分新建文件夹;

一些约定的写法

Dialog的使用

Dialog本身不处理任何业务逻辑,它只负责采集数据,采集完成之后通过Navigator.pop(data)回传给宿主widget,并在宿主widget进行业务操作;

widget

  • 优先使用StatelessWidget,只有当StatelessWidget实在不方便的时候再启用StatefulWidget,比如说要mixin一些辅助;
  • 优先把IO放在局部BLoC,只有当局部BLoC无法满足需求时,再放到全局BLoC;

框架模式

项目的框架模式为BLoC(Business Logic Component),是一种基于流(Stream)的模式。 一"块"UI会搭配一个专属的BLoCBLoC中存放着这一块UI所有的状态,以及所有操作数据的接口。

全局结构图

矩形为widget,圆角矩形普通类 输入图片说明

单页面结构图

矩形为widget,圆角矩形普通类

输入图片说明

网络请求时序图

以登录为例:

输入图片说明

Libraries

decorated_flutter