BLoC & Cubit 状态管理模式详解

flutter_bloc 核心概念、选型指南、测试技巧 — 2026-02-06

目录
  1. 核心思想:单向数据流
  2. Cubit — 简单直接的状态管理
  3. BLoC — 事件驱动的状态管理
  4. BLoC 内部管道机制
  5. EventTransformer — BLoC 的杀手锏
  6. BLoC vs Cubit 对比
  7. 选型指南:什么时候用哪个
  8. UI 层接入方式
  9. 测试技巧与陷阱
  10. 本项目中的实际案例
  11. 进阶话题

1. 核心思想:单向数据流

UI ──(用户操作)──▶ BLoC/Cubit ──(新状态)──▶ UI

核心原则:UI 不直接修改状态,而是把"发生了什么"告诉 BLoC/Cubit,由它决定状态该怎么变,然后 UI 根据新状态重新渲染。

这跟 React 的 Redux、Vue 的 Vuex 是同一个思想 — 状态是只读的,只能通过指定方式修改

2. Cubit — 简单直接的状态管理

Cubit 是 BLoC 的简化版。没有 Event 层,直接通过方法调用来修改状态。

定义 State

class LoginState {
  final String email;
  final String password;
  final bool isLoading;
  final String? errorMessage;

  const LoginState({
    this.email = '',
    this.password = '',
    this.isLoading = false,
    this.errorMessage,
  });

  LoginState copyWith({...}) => LoginState(...);
}

定义 Cubit

class LoginCubit extends Cubit<LoginState> {
  final LoginUseCase loginUseCase;

  LoginCubit({required this.loginUseCase}) : super(const LoginState());

  // 直接是方法,没有 Event 类
  void emailChanged(String email) {
    emit(state.copyWith(email: email, errorMessage: null));
  }

  void passwordChanged(String password) {
    emit(state.copyWith(password: password));
  }

  // 异步也完全没问题
  Future<void> login() async {
    emit(state.copyWith(isLoading: true));
    final result = await loginUseCase(email: state.email, password: state.password);
    switch (result) {
      case Success():
        emit(state.copyWith(isLoading: false));
      case Failure(:final error):
        emit(state.copyWith(isLoading: false, errorMessage: error));
    }
  }
}
Cubit 的调用链:方法调用 → 直接执行 → emit(新状态) → UI 刷新
没有队列,没有 Stream 管道。调用 login() 就直接执行了。

3. BLoC — 事件驱动的状态管理

BLoC 多了一层 Event。UI 不直接调方法,而是发送 Event 对象。

定义 Event

abstract class AuthEvent {}

class AuthLoginRequested extends AuthEvent {
  final String email;
  final String password;
  AuthLoginRequested({required this.email, required this.password});
}

class AuthLogoutRequested extends AuthEvent {}
class AuthCheckRequested extends AuthEvent {}

定义 State

abstract class AuthState {}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthAuthenticated extends AuthState {
  final Employee employee;
  AuthAuthenticated(this.employee);
}
class AuthError extends AuthState {
  final String message;
  AuthError(this.message);
}

定义 BLoC

class AuthBloc extends Bloc<AuthEvent, AuthState> {
  final LoginUseCase loginUseCase;

  AuthBloc({required this.loginUseCase}) : super(AuthInitial()) {
    // 在构造函数中注册:哪种 Event 由哪个方法处理
    on<AuthLoginRequested>(_onLoginRequested);
    on<AuthLogoutRequested>(_onLogoutRequested);
    on<AuthCheckRequested>(_onCheckAuth);
  }

  Future<void> _onLoginRequested(
    AuthLoginRequested event,
    Emitter<AuthState> emit,
  ) async {
    emit(AuthLoading());
    try {
      final result = await loginUseCase(LoginParams(
        email: event.email,
        password: event.password,
      ));
      emit(AuthAuthenticated(result.employee));
    } catch (e) {
      emit(AuthError(_mapErrorMessage(e)));
    }
  }
}

4. BLoC 内部管道机制

这是理解 BLoC 与 Cubit 本质区别的关键。

add(Event) │ ▼ ┌─────────────┐ │ Event Queue │ ← 事件先进入 FIFO 队列 └─────┬───────┘ │ ▼ ┌─────────────────┐ │ EventTransformer │ ← 控制并发策略(BLoC 的核心优势) └─────┬───────────┘ sequential / concurrent / droppable / restartable │ ▼ ┌────────────────┐ │ on<Event> handler │ ← 你注册的异步处理函数 └─────┬──────────┘ │ ▼ emit(State) │ ▼ ┌──────────────┐ │ State Stream │ → BlocBuilder / BlocListener 监听 └──────────────┘
关键理解:从 add(Event)emit(State),整条链路经过了 Dart 的 Stream 异步机制。 这意味着 add() 是 fire-and-forget,你拿不到返回值,也不知道什么时候处理完。

而 Cubit 的 login() 返回 Future,你可以 await 等它完成。

Cubit vs BLoC 调用对比

// Cubit — 你可以 await
await loginCubit.login();
// 到这一行时,login 已完成,状态已更新

// BLoC — 你不能 await
authBloc.add(AuthLoginRequested(...));
// add() 返回 void,事件刚入队列,还没处理
// 想知道结果?只能监听 state stream

5. EventTransformer — BLoC 的杀手锏

这是选择 BLoC 而不是 Cubit 的唯一理由

Transformer 行为 典型场景
sequential() 默认。一个处理完才处理下一个 大多数场景
droppable() 正在处理时,新事件直接丢弃 防重复提交(表单、支付)
restartable() 新事件来了,取消正在处理的,执行新的 搜索框防抖
concurrent() 所有事件并行处理,不排队 WebSocket 消息、独立的异步操作

实际代码示例

import 'package:bloc_concurrency/bloc_concurrency.dart';

class SearchBloc extends Bloc<SearchEvent, SearchState> {
  SearchBloc() : super(SearchInitial()) {
    // 搜索:用户快速输入 "a", "ab", "abc"
    // restartable → 取消 "a" 和 "ab" 的请求,只执行 "abc"
    on<SearchQueryChanged>(
      _onQueryChanged,
      transformer: restartable(),
    );

    // 提交订单:用户疯狂点按钮
    // droppable → 只处理第一次点击,后面的全部忽略
    on<OrderSubmitted>(
      _onOrderSubmitted,
      transformer: droppable(),
    );
  }

  Future<void> _onQueryChanged(
    SearchQueryChanged event,
    Emitter<SearchState> emit,
  ) async {
    // 加个延迟做防抖
    await Future.delayed(const Duration(milliseconds: 300));
    emit(SearchLoading());
    final results = await searchUseCase(event.query);
    emit(SearchLoaded(results));
    // 如果 restartable 取消了这个 handler,
    // await 会抛 StateError,后面的 emit 不会执行
  }
}
如果用 Cubit 实现同样的防抖:
class SearchCubit extends Cubit<SearchState> {
  Timer? _debounceTimer;     // 得自己管 Timer
  CancelToken? _cancelToken; // 得自己管取消

  void onQueryChanged(String query) {
    _debounceTimer?.cancel();      // 手动取消上一次
    _cancelToken?.cancel();       // 手动取消请求
    _debounceTimer = Timer(Duration(milliseconds: 300), () {
      _search(query);
    });
  }
  // 可以做,但比 BLoC 的 restartable() 麻烦得多
}

6. BLoC vs Cubit 全面对比

维度CubitBLoC
输入方式方法调用 cubit.login()事件对象 bloc.add(Event)
返回值可以返回 Future(可 await)add() 返回 void(不可 await)
代码量少(不需要定义 Event 类)多(需要 Event + State + BLoC)
并发控制需要手动实现EventTransformer 内置支持
可追溯性方法调用,无记录每个操作都是 Event 对象,可日志追踪
测试难度简单,直接调方法验证状态需要处理异步事件管道
Widget 测试pump/pumpAndSettle 即可需要 runAsync 等待事件处理
继承关系Cubit extends BlocBaseBloc extends BlocBase(Bloc 不继承 Cubit)

7. 选型指南

用 Cubit 的场景(80% 的情况)

用 BLoC 的场景(20% 的情况)

经验法则:如果你不需要 EventTransformer,就用 Cubit。 先用 Cubit 开始,如果后来发现需要并发控制,再升级成 BLoC — 因为 State 定义和 UI 层代码几乎不用改。

8. UI 层接入方式

不管是 BLoC 还是 Cubit,UI 层的使用方式完全一样:

BlocProvider — 提供实例

// 创建并提供(通常在路由或页面入口)
BlocProvider(
  create: (context) => LoginCubit(loginUseCase: getIt()),
  child: const LoginPage(),
)

// 多个一起提供
MultiBlocProvider(
  providers: [
    BlocProvider(create: (context) => AuthBloc(...)),
    BlocProvider(create: (context) => ThemeCubit()),
  ],
  child: const MyApp(),
)

BlocBuilder — 根据状态构建 UI

BlocBuilder<LoginCubit, LoginState>(
  builder: (context, state) {
    if (state.isLoading) return CircularProgressIndicator();
    return LoginForm();
  },
)

BlocListener — 监听状态做副作用(不重建 UI)

BlocListener<AuthBloc, AuthState>(
  listener: (context, state) {
    if (state is AuthError) {
      // 显示 SnackBar — 只需要执行一次,不需要重建 UI
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(state.message)),
      );
    }
    if (state is AuthAuthenticated) {
      // 跳转页面
      context.go('/home');
    }
  },
  child: ...,
)

BlocConsumer — Builder + Listener 合体

BlocConsumer<LoginCubit, LoginState>(
  listener: (context, state) {
    // 副作用:SnackBar、导航等
    if (state.errorMessage != null) {
      ScaffoldMessenger.of(context).showSnackBar(...);
    }
  },
  builder: (context, state) {
    // UI 构建
    return Column(children: [
      TextField(onChanged: context.read<LoginCubit>().emailChanged),
      if (state.isLoading) CircularProgressIndicator(),
    ]);
  },
)

context.read vs context.watch

// read — 不监听变化,只获取实例(用于事件回调)
onPressed: () => context.read<AuthBloc>().add(AuthLogoutRequested())

// watch — 监听变化,状态变了会重建当前 Widget(只能在 build 中用)
final isLoading = context.watch<LoginCubit>().state.isLoading;

// select — 精确监听某个字段,减少不必要的重建
final isLoading = context.select(
  (LoginCubit c) => c.state.isLoading,
);

9. 测试技巧与陷阱

9.1 Cubit 单元测试 — 简单直接

test('login success emits loaded state', () async {
  mockLoginUseCase.setResult(Success(mockUser));

  final cubit = LoginCubit(loginUseCase: mockLoginUseCase);
  cubit.emailChanged('test@example.com');
  cubit.passwordChanged('password123');

  await cubit.login();  // ← 可以 await!

  expect(cubit.state.isLoading, false);
  expect(cubit.state.errorMessage, isNull);
});

9.2 BLoC 单元测试 — 用 bloc_test 包

import 'package:bloc_test/bloc_test.dart';

blocTest<AuthBloc, AuthState>(
  'login success emits [AuthLoading, AuthAuthenticated]',
  build: () {
    mockLoginUseCase.succeedWith(mockResult);
    return AuthBloc(loginUseCase: mockLoginUseCase, ...);
  },
  act: (bloc) => bloc.add(AuthLoginRequested(
    email: 'test@example.com',
    password: 'password123',
  )),
  expect: () => [
    isA<AuthLoading>(),          // 先 loading
    isA<AuthAuthenticated>(),    // 再成功
  ],
);

9.3 Widget 测试 — 核心陷阱

陷阱:BLoC 的 Widget 测试需要 runAsync

BLoC 事件通过异步 Stream 处理。pump() 只推进 Flutter 帧,不推进 Dart 微任务队列

// ❌ Cubit 可以这样,BLoC 不行
await tester.tap(loginButton);
await tester.pumpAndSettle();
expect(find.text('Error message'), findsOneWidget); // BLoC: 找不到!

// ✅ BLoC 要这样
await tester.tap(loginButton);
// 让 BLoC 的 async handler 在真实异步环境中完成
await tester.runAsync(() => Future.delayed(Duration(milliseconds: 200)));
await tester.pump();   // 处理状态变化
await tester.pump(Duration(milliseconds: 300)); // SnackBar 动画
expect(find.text('Error message'), findsOneWidget); // ✅
Cubit 的 Widget 测试就简单得多
await tester.tap(loginButton);
await tester.pumpAndSettle(); // 直接搞定
expect(find.text('Error message'), findsOneWidget); // ✅

9.4 pump / pumpAndSettle / runAsync 区别

方法做了什么什么时候用
pump() 推进一帧(16ms) 处理同步状态变化
pump(Duration) 推进指定时间的帧 等待动画(SnackBar 入场 ~300ms)
pumpAndSettle() 反复 pump 直到没有待处理的帧 等所有动画完成(⚠️ 有 CircularProgressIndicator 会死循环)
runAsync(fn) 在真实异步环境中执行 fn BLoC 事件处理、Timer、网络请求 mock

10. 本项目中的实际案例

Employee App — 用了 BLoC

BLoC场景是否真的需要 BLoC?
AuthBloc 登录/登出/检查认证状态 Cubit 足矣。登录没有并发问题。

Employee App 选择 BLoC 可能是团队惯例或者预留扩展性。功能上 Cubit 完全够用。

Guest App — 混合使用

类型名称场景选型合理性
Cubit LoginCubit 登录表单 ✅ 正确,表单用 Cubit
BLoC AuthBloc 全局认证状态 ✅ 合理,可能有多处触发登出
BLoC BookingBloc 预约流程 ✅ 合理,多步骤状态机

DioException.toString() 的坑

我们在 Widget 测试中发现:DioException 包裹 ApiException 时,toString() 会包含内部异常的 toString()

所以 AuthBloc._mapErrorMessage() 做字符串匹配时:

教训:基于 toString() 做字符串匹配非常脆弱。应该用 error is UnauthorizedException 类型判断或检查结构化错误码。

11. 进阶话题

11.1 Bloc-to-Bloc 通信

// 方式1:BlocListener 在 UI 层桥接
BlocListener<AuthBloc, AuthState>(
  listener: (context, state) {
    if (state is AuthAuthenticated) {
      // 登录成功后,触发另一个 BLoC 加载数据
      context.read<ProfileBloc>().add(LoadProfile());
    }
  },
)

// 方式2:在 BLoC 内部监听另一个 BLoC 的 stream
class ProfileBloc extends Bloc<...> {
  late final StreamSubscription _authSub;

  ProfileBloc(AuthBloc authBloc) : super(...) {
    _authSub = authBloc.stream.listen((state) {
      if (state is AuthAuthenticated) {
        add(LoadProfile(state.employee.id));
      }
    });
  }

  @override
  Future<void> close() {
    _authSub.cancel();
    return super.close();
  }
}

11.2 Freezed 搭配(推荐)

// 用 @freezed 自动生成 copyWith、==、toString
@freezed
class LoginState with _$LoginState {
  const factory LoginState({
    @Default('') String email,
    @Default('') String password,
    @Default(false) bool isLoading,
    String? errorMessage,
  }) = _LoginState;
}

// 事件也可以用 freezed 的 union type
@freezed
class AuthEvent with _$AuthEvent {
  const factory AuthEvent.loginRequested({
    required String email,
    required String password,
  }) = AuthLoginRequested;
  const factory AuthEvent.logoutRequested() = AuthLogoutRequested;
}

11.3 BlocObserver — 全局监控

class AppBlocObserver extends BlocObserver {
  @override
  void onEvent(Bloc bloc, Object? event) {
    super.onEvent(bloc, event);
    debugPrint('[${bloc.runtimeType}] Event: $event');
  }

  @override
  void onTransition(Bloc bloc, Transition transition) {
    super.onTransition(bloc, transition);
    debugPrint('[${bloc.runtimeType}] ${transition.currentState} → ${transition.nextState}');
  }

  @override
  void onError(BlocBase bloc, Object error, StackTrace stackTrace) {
    debugPrint('[${bloc.runtimeType}] Error: $error');
    super.onError(bloc, error, stackTrace);
  }
}

// main.dart 中注册
void main() {
  Bloc.observer = AppBlocObserver();
  runApp(const MyApp());
}

11.4 HydratedBloc — 状态持久化

// 自动把状态存到本地,App 重启后恢复
class ThemeCubit extends HydratedCubit<ThemeMode> {
  ThemeCubit() : super(ThemeMode.system);

  void toggle() => emit(
    state == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark,
  );

  @override
  ThemeMode fromJson(Map<String, dynamic> json) =>
    ThemeMode.values[json['index'] as int];

  @override
  Map<String, dynamic> toJson(ThemeMode state) =>
    {'index': state.index};
}

Celoria 项目内部文档 · 基于 flutter_bloc 8.x · 2026-02-06