跳转至

BLoC 四个 Mixin

core/bloc/ 提供四个可组合的 Mixin,解决 MVI 在 Flutter 下的四个常见痛点。它们彼此正交,可任意组合:

Mixin 解决的问题
BlocAwaitMixin add(Event) 是 fire-and-forget,UI 无法 await
BlocCancelTokenMixin 网络请求取消 / 去重,绑定 BLoC 生命周期
BlocEffectMixin 一次性副作用通道(Toast / Loading / Dialog)
BlocErrorHandlerMixin 把异常包装为 Result<T>(成功 / 失败 / 取消),Failure 携带原始 Exception

统一导出:import 'package:flutter_zero_app/core/bloc/bloc.dart';


1. BlocAwaitMixin —— 让事件可等待

add 返回 void,UI 没法 await。runAwait 返回 Future<AwaitResolution<State>>,事件处理结束时自动完成。

事件即等待标识:不需要手写任何标识,事件本身就是操作身份。相同事件正在处理时再次触发,新的调用会合并到同一趟操作上等待(single-flight),不会重复发送事件、不会重复执行请求(重复下拉刷新、连续点击只跑一次);参数不同的同类事件各自独立执行、各自唤醒自己的等待方。

基本用法

用 onAwait 注册 handler(无需手写 try/finally 收尾),runAwait 传入事件本身:

class ItemBloc extends Bloc<ItemEvent, ItemState>
    with BlocAwaitMixin<ItemEvent, ItemState> {
  ItemBloc() : super(const ItemState()) {
    onAwait<ItemRefresh>(_onRefresh);
  }

  /// 只想等它跑完 → 收窄成 Future<void> 即可。
  Future<void> refresh() => runAwait(const ItemEvent.refresh());

  Future<void> _onRefresh(ItemRefresh event, Emitter<ItemState> emit) async {
    emit(state.copyWith(isRefreshing: true));
    await _repository.fetch();
    emit(state.copyWith(isRefreshing: false));
  }
}
RefreshIndicator(
  onRefresh: () => context.read<ItemBloc>().refresh(),
  child: ListView.builder(...),
)

runAwait 的 event 是位置参数(不是 event: 命名参数),且返回的是 Future<AwaitResolution<State>> 而非裸 Future。Future<void> refresh() => runAwait(...) 之所以成立,是因为 Dart 允许用 Future<void> 收窄任意 Future。

判别等待结局

需要知道「到底成了没有」时,保留 AwaitResolution 并用 switch 收窄:

Future<AwaitResolution<ItemState>> refresh() =>
    runAwait(const ItemEvent.refresh());

// 调用端
final r = await bloc.refresh();
switch (r) {
  case AwaitCompleted<ItemState>(:final value):
    // handler 跑完且未抛异常。注意:这**不代表业务成功**,
    // 业务成败请从 value(emit 后的 state)自行判断。
  case AwaitErrored<ItemState>(:final error):
    // handler 抛异常;或派发前 add() 就失败(未注册 handler / bloc 已关闭)。
  case AwaitDropped<ItemState>():
    // 被 transformer 丢弃或顶替,handler 根本没执行。
  case AwaitCancelled<ItemState>():
    // bloc 关闭,等待被废弃。
}

四个结局同属密封类 AwaitResolution<T>,switch 会被编译器检查穷尽:

结局 含义 归属层
AwaitCompleted handler 跑完且未抛异常(value = emit 后的 state) 执行层
AwaitErrored handler 抛异常,或派发前 add() 失败 执行层
AwaitDropped 被 transformer 丢弃 / 顶替,handler 未执行 调度层
AwaitCancelled bloc 关闭导致等待被废弃 生命周期

⚠️ AwaitCompleted 只表示「handler 没抛异常」,不等于业务成功。 业务成败一律从 AwaitCompleted.value(当前 state)判断。

调度策略(AwaitTransformer)

默认并发。需要排队 / 丢弃 / 重启时给 onAwait 传 transformer:

onAwait<ItemRefresh>(
  _onRefresh,
  transformer: AwaitTransformer.droppable(),
);
工厂 行为
AwaitTransformer.sequential() 排队串行执行
AwaitTransformer.concurrent() 并发执行(默认语义)
AwaitTransformer.droppable() 忙时丢弃新事件
AwaitTransformer.restartable() 新事件顶替旧事件

四个工厂集中在 AwaitTransformer 抽象类下,IDE 补全 AwaitTransformer. 一次看完,无需 import 具体实现类;泛型 E 由 onAwait 调用侧自动反推,不用手写。

要点:

  • 没有超时:runAwait 不设超时。handler 迟迟不结束时,用 BlocCancelTokenMixin / Dio 超时主动收尾;close() 会让等待以 AwaitCancelled 正常结束。
  • 页面销毁(close())时,所有未完成的等待以 AwaitCancelled 正常结束,不再抛异常——下拉刷新后立刻退出页面不会再触发未捕获错误。
  • 合并等待依赖事件的值相等性:freezed 事件天然具备;手写类未实现 == / hashCode 会退化为不合并(每次各自执行,不报错)。

2. BlocCancelTokenMixin —— 自动取消网络

按操作 key 管理 Dio CancelToken,页面销毁自动取消、连续调用自动去重。

class ItemBloc extends Bloc<ItemEvent, ItemState>
    with BlocCancelTokenMixin<ItemState> {

  Future<void> _onFetch(ItemFetch event, Emitter<ItemState> emit) async {
    try {
      final items = await _repository.fetchItems(
        cancelToken: token('items'), // 一行,全自动管理
      );
      emit(state.copyWith(items: items));
    } catch (e) {
      if (isCancelled(e)) return; // 主动取消 → 静默,无需 import Dio
      final ex = e is Exception ? e : Exception(e.toString());
      emitEffect(ex.toToastEffect());
    }
  }

  void onCancelTapped() => cancelAll(); // 取消按钮中断所有进行中请求
}

要点:

  • 去重:token('items') 每次调用都会先取消上一个同 key 请求再创建新 token。
  • 生命周期绑定:close() 自动 cancelAll(),页面 pop 后不会收到陈旧响应。
  • key 隔离:不同 key('items' / 'upload')互不影响;并发同类请求用不同 key。
  • 默认 key 为 'default',适合单一操作类型的简单 BLoC。

3. BlocEffectMixin —— 一次性副作用

提供 effectStream 与 emitEffect,副作用走独立 Stream,不污染 State。

class ItemBloc extends Bloc<ItemEvent, ItemState>
    with BlocEffectMixin<ItemState> {

  Future<void> _onSubmit(ItemSubmit event, Emitter<ItemState> emit) async {
    emitEffect(const LoadingEffect(show: true));   // 显示 Loading
    // ... 业务逻辑 ...
    emitEffect(const LoadingEffect(show: false));  // 隐藏 Loading
    emitEffect(const ToastEffect(l10nCode: 'saved'));
  }
}

关闭 BLoC 时 Mixin 自动 close() 控制器,无泄漏。UIEffect 是开放基类,自定义类型只需 extends UIEffect(详见 Effect 与 Notifiers)。


4. BlocErrorHandlerMixin —— 统一错误处理

把底层异步操作(Dio 等)的异常包装成 Result<T>(成功 / 失败 / 取消)。Failure 直接携带原始 Exception——框架不做任何业务或文案假设,提示文案由 BLoC 自行决定(见 错误处理与 Result)。

首选:runCatching

取代手写 try/catch,三态显式:

class ItemBloc extends Bloc<ItemEvent, ItemState>
    with BlocEffectMixin<ItemState>, BlocErrorHandlerMixin<ItemState> {

  Future<void> _onFetch(ItemFetch event, Emitter<ItemState> emit) async {
    final result = await runCatching(() => _repository.fetchItems());
    result.when(
      success: (items) => emit(state.copyWith(items: items)),
      failure: (ex) => emitEffect(ex.toToastEffect()),
      cancel: () {/* 主动取消,通常什么都不做 */},
    );
  }
}

runCatching 的映射规则:成功 → Success;Exception → Failure(e)(原样保留);主动取消 → Cancel;其余异常 → Failure(Exception('unknown exception'))。

能力清单

方法 作用
runCatching<T>(action) 包裹异步操作,返回 Result<T>;取消 → Cancel,Exception → Failure(e),其余 → Failure(Exception('unknown exception'))
isCancelled(error) 判断是否为主动取消(Dio 取消异常),用于避免弹 Toast

取消判断默认按 Dio 的 DioExceptionType.cancel。使用其它 HTTP 客户端时,覆盖 isCancelled 即可。


组合示例(列表刷新)

四个 Mixin 协同完成一次「下拉刷新 + 错误提示」(完整代码见 编写第一个功能模块):

class ItemBloc extends Bloc<ItemEvent, ItemState>
    with
        BlocAwaitMixin<ItemEvent, ItemState>,
        BlocEffectMixin<ItemState>,
        BlocErrorHandlerMixin<ItemState> {
  ItemBloc({required this.repository}) : super(const ItemState()) {
    onAwait<ItemRefresh>(_onRefresh);
  }

  final ItemRepository repository;

  Future<void> refresh() => runAwait(const ItemEvent.refresh());

  Future<void> _onRefresh(ItemRefresh event, Emitter<ItemState> emit) async {
    emit(state.copyWith(isRefreshing: true));
    final result = await runCatching(() => repository.fetchItems());
    result.when(
      success: (items) => emit(state.copyWith(items: items)),
      failure: (ex) => emitEffect(ex.toToastEffect()),
      cancel: () {}, // 主动取消,通常什么都不做
    );
    emit(state.copyWith(isRefreshing: false));
  }
}
  • BlocAwaitMixin 让页面 await refresh();重复下拉会合并到同一趟操作,不会重复发起请求。
  • BlocEffectMixin 发 Toast(失败提示走 ex.toToastEffect())。
  • BlocErrorHandlerMixin 用 runCatching 消除 try/catch。
  • (取消场景)若需要中途中断请求,叠加 BlocCancelTokenMixin,把 token('items') 传给 repository 即可。

本页原文:docs/zh/architecture/bloc-mixins.md

报告本页错误