flutter_bloc 核心概念、选型指南、测试技巧 — 2026-02-06
核心原则:UI 不直接修改状态,而是把"发生了什么"告诉 BLoC/Cubit,由它决定状态该怎么变,然后 UI 根据新状态重新渲染。
这跟 React 的 Redux、Vue 的 Vuex 是同一个思想 — 状态是只读的,只能通过指定方式修改。
Cubit 是 BLoC 的简化版。没有 Event 层,直接通过方法调用来修改状态。
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(...);
}
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));
}
}
}
login() 就直接执行了。
BLoC 多了一层 Event。UI 不直接调方法,而是发送 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 {}
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);
}
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)));
}
}
}
这是理解 BLoC 与 Cubit 本质区别的关键。
add(Event) 到 emit(State),整条链路经过了 Dart 的 Stream 异步机制。
这意味着 add() 是 fire-and-forget,你拿不到返回值,也不知道什么时候处理完。
login() 返回 Future,你可以 await 等它完成。
// Cubit — 你可以 await
await loginCubit.login();
// 到这一行时,login 已完成,状态已更新
// BLoC — 你不能 await
authBloc.add(AuthLoginRequested(...));
// add() 返回 void,事件刚入队列,还没处理
// 想知道结果?只能监听 state stream
这是选择 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 不会执行
}
}
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() 麻烦得多
}
| 维度 | Cubit | BLoC |
|---|---|---|
| 输入方式 | 方法调用 cubit.login() | 事件对象 bloc.add(Event) |
| 返回值 | 可以返回 Future(可 await) | add() 返回 void(不可 await) |
| 代码量 | 少(不需要定义 Event 类) | 多(需要 Event + State + BLoC) |
| 并发控制 | 需要手动实现 | EventTransformer 内置支持 |
| 可追溯性 | 方法调用,无记录 | 每个操作都是 Event 对象,可日志追踪 |
| 测试难度 | 简单,直接调方法验证状态 | 需要处理异步事件管道 |
| Widget 测试 | pump/pumpAndSettle 即可 | 需要 runAsync 等待事件处理 |
| 继承关系 | Cubit extends BlocBase | Bloc extends BlocBase(Bloc 不继承 Cubit) |
restartable()droppable()concurrent()不管是 BLoC 还是 Cubit,UI 层的使用方式完全一样:
// 创建并提供(通常在路由或页面入口)
BlocProvider(
create: (context) => LoginCubit(loginUseCase: getIt()),
child: const LoginPage(),
)
// 多个一起提供
MultiBlocProvider(
providers: [
BlocProvider(create: (context) => AuthBloc(...)),
BlocProvider(create: (context) => ThemeCubit()),
],
child: const MyApp(),
)
BlocBuilder<LoginCubit, LoginState>(
builder: (context, state) {
if (state.isLoading) return CircularProgressIndicator();
return LoginForm();
},
)
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<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(),
]);
},
)
// 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,
);
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);
});
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>(), // 再成功
],
);
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); // ✅
await tester.tap(loginButton);
await tester.pumpAndSettle(); // 直接搞定
expect(find.text('Error message'), findsOneWidget); // ✅
| 方法 | 做了什么 | 什么时候用 |
|---|---|---|
pump() |
推进一帧(16ms) | 处理同步状态变化 |
pump(Duration) |
推进指定时间的帧 | 等待动画(SnackBar 入场 ~300ms) |
pumpAndSettle() |
反复 pump 直到没有待处理的帧 | 等所有动画完成(⚠️ 有 CircularProgressIndicator 会死循环) |
runAsync(fn) |
在真实异步环境中执行 fn | BLoC 事件处理、Timer、网络请求 mock |
| BLoC | 场景 | 是否真的需要 BLoC? |
|---|---|---|
AuthBloc |
登录/登出/检查认证状态 | Cubit 足矣。登录没有并发问题。 |
Employee App 选择 BLoC 可能是团队惯例或者预留扩展性。功能上 Cubit 完全够用。
| 类型 | 名称 | 场景 | 选型合理性 |
|---|---|---|---|
| Cubit | LoginCubit |
登录表单 | ✅ 正确,表单用 Cubit |
| BLoC | AuthBloc |
全局认证状态 | ✅ 合理,可能有多处触发登出 |
| BLoC | BookingBloc |
预约流程 | ✅ 合理,多步骤状态机 |
我们在 Widget 测试中发现:DioException 包裹 ApiException 时,toString() 会包含内部异常的 toString()。
所以 AuthBloc._mapErrorMessage() 做字符串匹配时:
DioException(error: UnauthorizedException(...)) → toString 包含 "Unauthorized" → 匹配 "Invalid email or password"DioException(type: connectionTimeout) → toString 包含 "connectionTimeout" → "connection" 先匹配 → 返回 "Network error" 而不是 "Connection timeout"DioException(error: ServerException(...)) → "ServerException" 不匹配任何关键词 → 走 catch-all教训:基于 toString() 做字符串匹配非常脆弱。应该用 error is UnauthorizedException 类型判断或检查结构化错误码。
// 方式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();
}
}
// 用 @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;
}
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());
}
// 自动把状态存到本地,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