Flutter 实践: 底层通信和 bloc 框架
本文先讲如何使用 flutter bloc, 然后结合底层通信方式实现 APP.
万变不离其宗: 有哪些状态? -> 这些状态初值是什么? -> 有哪些事件改变哪些状态? -> 提供哪些方法改变状态?
Flutter Bloc 简介
完整目录:
为什么要使用 BLOC?
手动管理状态时常会造成代码不可读, 维护扩展难, 测试难等问题.
如果要解决这样的问题, 最好的办法是让 UI 都作为 "组件". 即把各个 UI 分离为组件, 然后在组件中划分"视图"和"逻辑". 而"逻辑"就是 BLOC, 也就是业务逻辑组件.
BLOC 将业务和展示分离, 这样有利于:
BLOC 核心概念
要理解 BLOC 的相关概念, 首先需要理解什么是 STREAM(流).
流的概念
流是 BLOC 的基础, 它有如下特征:
- 有发送端
- 有接收端
- 事件在流中传递
- 流是异步的, 接收者无法准确知道自己将在什么时候收到事件, 它只有等待或监听(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 的特点, 可以使用两个流:
- 事件流: 外部事件流, 外部事件被放到这个流, bloc 监听, 通过事件进行处理并改变状态.
- 状态更新流: 状态改变后, 新的状态数据会放到状态更新流上, 这样 UI 就可以对应进行更新了.
CUBIT 和 BLOC 对比
CUBIT 是 BLOC 的最简形式, 或者说 BLOC 扩展了 CUBIT, 如下图所示:
二者区别如下:
- CUBIT 可以认为是 VM 的"非绑定形态". UI 调用 CUBIT 提供的方法更新状态, 同时监听 CUBIT 的状态改变.
- 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.of
或 context.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 返回值), 保证每次状态改变只会调用一次(不会包含初始状态那次).
BlocListener
和 BlocBuilder
类似, 也提供了一个 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_test
和 bloc_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, 不然后面想替换的话, 维护比较麻烦.
context.watch
: 读取并注册观察某个状态改变context.read
: 和 BlocProvider.value 一个道理, 只是它的目的更纯粹, 只是为了获取向上走的某个 bloc 或值.context.select
: 比起 BlocBuilder 的buildWhen
更方便, 它可以指定某个状态分量改变时才更新.
比较好的例子是 appflowy 里面的 ApplicationWidget, 可以看里面是如何注册这些全局状态并进行观察的.
总结
主要就是如下这些:
- 流的概念
- Cubit 和 Bloc 基础
- BlocProvider, BlocBuilder, BlocListener, BlocConsumer, MultiBlocProvider, MultiBlocListener
- 状态和事件建模均应不可变, 且可以进行 equatable 比较
- APP 架构: 分层, 职责分离, 文件组织
- 路由的做法, 可以参考 Appflowy
- get_it 结合的做法, 可以参考 Appflowy
- 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 侧交互
- 可以参考 plugin 官方的写法生成
- 主要是 Flutter 向平台的方法通道, 以及平台向 Flutter 的事件流.