Skip to main content

Flutter 实践: 底层通信和 bloc 框架

本文先讲如何使用 flutter bloc, 然后结合底层通信方式实现 APP.

万变不离其宗: 有哪些状态? -> 这些状态初值是什么? -> 有哪些事件改变哪些状态? -> 提供哪些方法改变状态?

Flutter Bloc 简介

完整目录:

为什么要使用 BLOC?

手动管理状态时常会造成代码不可读, 维护扩展难, 测试难等问题.

如果要解决这样的问题, 最好的办法是让 UI 都作为 "组件". 即把各个 UI 分离为组件, 然后在组件中划分"视图"和"逻辑". 而"逻辑"就是 BLOC, 也就是业务逻辑组件.

BLOC 将业务和展示分离, 这样有利于:

BLOC 核心概念

要理解 BLOC 的相关概念, 首先需要理解什么是 STREAM(流).

流的概念

流是 BLOC 的基础, 它有如下特征:

  1. 有发送端
  2. 有接收端
  3. 事件在流中传递
  4. 流是异步的, 接收者无法准确知道自己将在什么时候收到事件, 它只有等待或监听(listen)事件到达.

在 Dart 中生成流的一种方式是 async*(async generator):

// 利用异步生成器制造流
Stream<int> intStream() async* {
for (int i = 0; i < 10; ++i) {
// 每隔两秒发送一个数
await Future.delayed(Duration(2));
yield i; // yield 将事件放入流
}
}

接收者监听流:

Stream<int> stream = intStream();

stream.listen((event) {
print('拿到了一个事件' + event.toString())
});

为什么要使用流?

由于事件都是异步产生的, 此时事件产生后需要更新 APP 状态.

结合 bloc 的特点, 可以使用两个流:

  1. 事件流: 外部事件流, 外部事件被放到这个流, bloc 监听, 通过事件进行处理并改变状态.
  2. 状态更新流: 状态改变后, 新的状态数据会放到状态更新流上, 这样 UI 就可以对应进行更新了.

CUBIT 和 BLOC 对比

CUBIT 是 BLOC 的最简形式, 或者说 BLOC 扩展了 CUBIT, 如下图所示:

二者区别如下:

  1. CUBIT 可以认为是 VM 的"非绑定形态". UI 调用 CUBIT 提供的方法更新状态, 同时监听 CUBIT 的状态改变.
  2. BLOC 是 VM 的"绑定形态". UI 将事件放入 BLOC 提供的事件流, BLOC 监听事件后对状态进行更新, 随后将状态更新放入状态流, UI 监听状态流并更新.

CUBIT 的简单实现

比如一个最简单的 Counter 应用, 有一个 button 将数字增加或减少, 此时对应的 cubit 可以这样定义:

// 泛型代表的是状态的类型, 可以是复杂的状态
class CounterCubit extends Cubit<int> {
// 状态默认值 0
CounterCubit() : super(0);

// 其中 state 是 Cubit 提供的用于访问当前状态的属性
// 提供两个方法供外部使用, emit 方法将状态更新(不可变状态模式)并放入流

void increment() => emit(state + 1);
void decrement() => emit(state - 1);
}

// 如下模拟 UI 的监听:

模拟对 Cubit 的监听:

final cubit = CounterCubit();
// 监听仅打印事件
final subscription = cubit.listen(print);

cubit.increment();
cubit.decrement();
// ...

// 最后在恰当的时机释放监听并关闭 cubit:
await subscription.cancel();
await cubit.close();

BLOC 的简单实现

还是 Counter 的例子, Bloc 定义如下:

enum CounterEvents {
increment,
decrement
}

/*
或者定义事件为 class, 这样可以在其中添加数据:

abstract class CounterEvent {}
class CounterIncrementPressed extends CounterEvent {}
class CounterDecrementPressed extends CounterEvent {}

*/

class CounterBloc extends Bloc<CounterEvents, int> {
CounterBloc() : super(0) {
on<CounterIncrementPressed>((event, emit) => emit(state + 1));
on<CounterDecrementPressed>((event, emit) => emit(state - 1));
}
}

/*
异步的事件可以这样写:

on<SignInEvent>((event, emit) async {
// 对 event 的三种 signedInWithUserEmailAndPassword, emailChanged, passwordChanged 分别进行处理:
await event.map(
signedInWithUserEmailAndPassword: (e) async {
await _performActionOnSignIn(
state,
emit,
);
},
emailChanged: (EmailChanged value) async {
emit(state.copyWith(email: value.email, emailError: none(), successOrFail: none()));
},
passwordChanged: (PasswordChanged value) async {
emit(state.copyWith(password: value.password, passwordError: none(), successOrFail: none()));
},
);
*/

在监听者侧:

// 模拟监听
final bloc = CounterBloc();
final subscription = bloc.listen(print);
bloc.add(CounterEvent.increment);
bloc.add(CounterEvent.decrement);

// ...

// 如果是真实的 UI 最简单的监听

Center(
child: BlocBuilder<CounterBloc, int>(
builder: (context, count) {
return Text('$count', style: Theme.of(context).textTheme.headline1);
},
),
)

// 发送事件:
context.read<CounterBloc>().add(CounterIncrementPressed());
context.read<CounterBloc>().add(CounterDecrementPressed());

什么时候使用 Cubit, 什么时候使用 Bloc?

根据情况定, 如果是简单的需求, 比如不需要对事件进行异步处理(Counter 的情况), 使用 Cubit 会更直接. 当然 Bloc 可以覆盖任何情况.

原则: 先用 Cubit, 当需求无法满足时, 可以很容易地将 Cubit 重构为 Bloc.

Flutter 中实践

这一节主要讲如何将 Bloc/Cubit 注入到 Widget 树中, 以及 Bloc 框架提供的若干类的用法.

BlocProvider

BlocProvider 用于创建 Bloc 并为子树提供访问此 Bloc 的入口, 可以将它理解为是一个依赖注入 Widget, 这样单个 Bloc 可以提供给它下面的所有孩子.

BlocProvider 在创建时需要提供一个 create 参数, 用于创建 Bloc:

BlocProvider(
create: (context) => CounterBloc(),
child: Child()
);

// 这样在子树中可以通过如下方式访问:
BlocProvider.of<CounterBloc>(context)
// 由于 flutter_bloc 依赖 provider, 因此还可以这样:
context.read<CounterBloc>()

默认情况下, BlocProvider 会懒创建对应的 Bloc.

若当使用 router push 到另外一个界面后, 原子树被替换, 这个时候无法通过上述方法再访问到 Bloc, 此时可以使用 BlocProvider.value:

BlocProvider.value(
value: BlocProvider.of<CounterBloc>(context)
child: OtherChild(),
)

BlocProvider 会自动处理 Bloc 的 close.

BlocBuilder

上述 BlocProvider.ofcontext.read 方法仅能读取当前 Bloc 值, 但无法做到当 state 改变时自动更新 UI. 因此我们需要使用 BlocBuilder.

BlocBuilder 用于在 UI 上观察 Bloc 状态, 若状态变化则自动重建它所包裹的子树.

BlocBuilder 内部会监听 Bloc 中的 state 流并拿到对应的状态, 如果有新的状态输出会进行重建:

BlocBuilder<CounterBloc, int>(
builder: (context, state) {
// builder 为一个纯函数, 根据状态返回新的 widget
return Text(context.read());
},
)

BlocBuilder 还提供有一个 'when' 参数, 可以手动控制何时进行重建:

BlocBuilder<CounterBloc, int>(
builder: (context, state) {
return Text(context.read());
},
buildWhen: (previous, current) {
// 假设我们只想在 current 变小的情况下才更新界面
return previous > current;
},
),

BlocListener

使用 BlocBuilder 时, 可能出现当状态改变后需要 push 到新页面的时候(比如输出一个空状态触发跳转), 但如果多次输出会造成重复跳转, 因为 BlocBuilder 会在首次监听时也对状态初值执行 build(类似自定义的 subject 的 onlyNewValue 的区别).

此时可以使用 BlocListener, 它有一个 listener 回调(void 返回值), 保证每次状态改变只会调用一次(不会包含初始状态那次).

BlocListenerBlocBuilder 类似, 也提供了一个 when 参数 listenWhen, 可以提供额外的判断决定是否执行 listen 回调.

BlocListener主要用途是展示弹窗或进行路由等情况(因为 listener 回调并非用于重建子树):

BlocListener<CounterBloc, int>(
listener: (context, state) {
// 跳转到新页面
Navigator.of(context).pushNamed('routeName');
},
listenWhen: (previous, current) {
return previous > current;
},
),

BlocConsumer

当在代码的某个位置即要根据状态进行 UI 更新, 也需要监听状态进行诸如弹窗或跳转时, 可以使用 BlocConsumer.

BlocConsumer 可以理解为 BlocBuilder + BlocListener, 它提供这两个类的对应四个回调, 如下所示:

BlocConsumer<CounterBloc, int>(
listener: (context, state) {
Navigator.of(context).pushNamed('routeName');
},
listenWhen: (previous, current) {
return previous > current;
},
builder: (context, state) {
return Text(state.toString());
},
buildWhen: (previous, current) {
return current % 2 == 0;
},
),

MultiBlocProvider, MultiBlocListener

如果子树需要处理多个 Bloc 中的数据, 可以使用这两个类. 但基本上没有这样的情况去向一个子树的 UI 对应多个 Bloc(就像没有一个 View 对应多个 VM 那样), 使用 MultiBlocProvider 的一个主要目的是配合使用 MultiBlocListener.

Bloc APP 架构

在实际开发时, event 发送到 bloc 后, 通常在处理时需要进行额外的服务调用等.

将 Bloc 理解为 VM + 表现层业务实现, 它会调用核心业务以及数据访问层提供的服务.

在 UI 层依赖 Bloc 或响应式地更新数据. 当 UI 需要发送动作给 Bloc 时, 如果是发给 Cubit, 直接拿到 Cubit 调用方法, 如果是 Bloc, 拿到后调用 add 添加事件即可.

有一个小问题: 如何在不同的 bloc 间通信(如何在 vm 间通信)? 一个办法是状态上移, 但假设 bloc 里面必须要用到另外一个 bloc 中的状态呢? 办法就是在一个 bloc 中依赖另外一个:

class SomeBloc extends Bloc {
final OtherBloc otherBloc;
StreamSubscription? subscription;

SomeBloc(this.otherBloc) {
// 需要手动监听并处理这个监听的释放
subscription = otherBloc.listen((state) {
// ...
});
}

// 需要对 subscription 进行 cancel, 重写 bloc 的 close 方法, 然后调用 subscription 的 cancel.
}

可以看到非常丑陋, 状态要么上移要么下移都可以解决的问题, 没有必要这样做了.

上移的做法是在 UI 中转一下, 同时依赖多个 Bloc, 其中一个状态改变后, 可以发送事件给另外一个 Bloc 以便触发状态更新.

所以必须注意: 不要直接在两个 BLOC 间进行直接通信!!! 肯定不会有两个 VM 会进行直接通信的!!!

那文件夹结构呢?

推荐使用场景隔离的方式进行, 当场景比较复杂需要将其分离为类似多个 view - vm 的对应关系时, logic 和 presentation 中对应文件夹也应这样组织:

presentation:

  • home: 主页 UI
  • buy: 购买页 UI

logic:

  • home: 主页 bloc
  • buy: 购买页 bloc

这样分离后, 当需要替换新 UI 时, 不需要过多关注 bloc 中的内容.

BLOC 仅包含轻业务逻辑, 核心业务及数据访问应放到下层实现, 由 Bloc 调用.

Bloc Testing

需要依赖 flutter_testbloc_test 两个. 测试 Bloc 在指定输入的情况下有预期输出.

Bloc 高级概念: 路由和 access

为理解为什么会出现这样的情况, 先简单介绍 Flutter 中的路由, 再来看如何在 Routing 场景下共享同一个 Bloc.

Flutter Routing

在 Flutter routing 时, 需要提供 route name 和对应的页面. 有如下三种类型的路由方式:

  • 匿名路由: 直接调用 Navigator.of(context).push 方法传入 Route<T> 时, 就是匿名路由(因为没有给 route 指定名称), 适用于简单应用.
  • 名字路由: 比如在 MaterialApp 构造时传入的 Map<String, WidgetBuilder>? 类型的 routes 参数, 适用于简单或中等复杂程度的路由情况.
  • 生成路由: 即提供一系列返回 Route<T> 的方法或回调, 用于生成对应名字的路由, 使用者可以通过 push(route) 跳转. 适用于复杂应用且灵活性好.

由于路由的时候, 会在导航栈中加入一个 route, 前一个 route 的整棵树被替换为新的 route 对应完整子树.

共享 Bloc 的办法(不推荐)

这里仅说明有这样的能力, 在实际开发中, 正常情况下想想也不会存在有不同页面依赖同一个 VM 的时候... 状态上移即可解决这样的共享问题.

导航到下一个 screen 时, 下一个 screen 的 context 如果无法向上发现之前注入的 bloc, 就会出现在两个 screen 间共享同一个 bloc 的疑问...

办法就是在推到下一个 route 时, 将 route 对应的 Widget 树根包一层, 提前将之前的 bloc 注入进去:

// 需要注意 context 参数是上层的还是这层的. MaterialPageRoute 提供的 contxt 已经是下一个屏幕顶部的了. 拿数据应传上一个屏幕的.
// 或者提前拿到, 类似 final value = BlocProvider.of<CounterBloc>(context)

Navigator.of(context).push(MaterialPageRoute(builder: (ctx) {
return BlocProvider.value(
value: BlocProvider.of<CounterBloc>(context),
child: Scaffold(),
);
}));

实际就是将前一个屏幕的 bloc 拿到, 再包裹到下一个屏幕的顶上...

flutter_bloc 依赖 Provider 后提供的便捷方法

**注意: 要使用这些方法, 最好是显式依赖 provider, 不然后面想替换的话, 维护比较麻烦.

  1. context.watch: 读取并注册观察某个状态改变
  2. context.read: 和 BlocProvider.value 一个道理, 只是它的目的更纯粹, 只是为了获取向上走的某个 bloc 或值.
  3. context.select: 比起 BlocBuilder 的 buildWhen 更方便, 它可以指定某个状态分量改变时才更新.

比较好的例子是 appflowy 里面的 ApplicationWidget, 可以看里面是如何注册这些全局状态并进行观察的.

总结

主要就是如下这些:

  1. 流的概念
  2. Cubit 和 Bloc 基础
  3. BlocProvider, BlocBuilder, BlocListener, BlocConsumer, MultiBlocProvider, MultiBlocListener
  4. 状态和事件建模均应不可变, 且可以进行 equatable 比较
  5. APP 架构: 分层, 职责分离, 文件组织
  6. 路由的做法, 可以参考 Appflowy
  7. get_it 结合的做法, 可以参考 Appflowy
  8. provider 提供全局状态的做法, 可以参考 Appflowy

额外内容如下.

BlocObserver

BlocObserver: 用于在所有 bloc 状态改变时提供观察, 主要用于日志, 如下所示

class ApplicationBlocObserver extends BlocObserver {

// ignore: unnecessary_overrides
void onTransition(Bloc bloc, Transition transition) {
// Log.debug("[current]: ${transition.currentState} \n\n[next]: ${transition.nextState}");
// Log.debug("${transition.nextState}");
super.onTransition(bloc, transition);
}


void onError(BlocBase bloc, Object error, StackTrace stackTrace) {
Log.debug(error);
super.onError(bloc, error, stackTrace);
}

// @override
// void onEvent(Bloc bloc, Object? event) {
// Log.debug("$event");
// super.onEvent(bloc, event);
// }
}

而要覆盖默认的 BlocObserver, 需要调用:

// 在新的 Zone 运行第一个参数的回调, 后两个参数是可选的
BlocOverrides.runZoned(
() {},
blocObserver: observer,
eventTransformer: transformer,
)

什么是 Flutter 的 Zone?

Zone 是一个在异步调用过程中保持稳定的环境, main 函数默认在 root Zone 中运行, 开发者可以将代码放入到新建的 Zone 中, 通过新 Zone 修改默认 Zone 的代码运行行为, Flutter 中 Zone 作为代码运行的上下文环境. 一个 Isolate 中可以有多个 Zone.

还意味着在一个 Zone 中的未捕捉异常不会影响到其他 Zone.

详见这篇文章, 解释地非常好.

另外这篇文章可以作为补充阅读.

底层和 Flutter 侧交互

  1. 可以参考 plugin 官方的写法生成
  2. 主要是 Flutter 向平台的方法通道, 以及平台向 Flutter 的事件流.