关于 bloc
使用 bloc 和 flutter_bloc 设计、实现及测试状态管理的技能。内容涵盖 Cubit 与 Bloc 的选择、状态建模、组件集成、架构边界和单元测试。
主要功能
- 01Cubit 与 Bloc 的选择指导
- 02密封类与状态枚举建模
- 03Bloc 和 Cubit 实现规范
- 04集成 flutter_bloc 组件
- 05使用 bloc_test 编写单元测试
适用场景
- 01为 Flutter 功能创建状态管理
- 02建模加载、成功与失败状态
- 03将 bloc 状态连接到组件
- 04测试状态变化与行为
安装
复制下面的命令,在终端中运行。
$
npx skills add https:/ / github.com/ evanca/ flutter-ai-rules --skill bloc安装后在 Claude Code 中输入/bloc确认已加载。
详细介绍
Bloc 技能
使用 bloc 和 flutter_bloc 库设计、实现和测试状态管理。
适用场景
在以下情况使用此技能:
- 为某个功能创建新的 Cubit 或 Bloc。
- 对状态建模(选择密封类,还是带状态枚举的单一状态类)。
- 在 Widget 树中接入
BlocBuilder、BlocListener、BlocConsumer或BlocProvider。 - 为 Cubit 或 Bloc 编写单元测试。
- 在 Cubit 和 Bloc 之间做出选择。
- 重构现有状态管理,使其遵循 bloc 约定。
1. Cubit 与 Bloc
| 场景 | 使用 |
|---|---|
| 状态简单,无需事件 | Cubit |
| 流程复杂,需要追踪事件 | Bloc |
| 需要高级事件处理(防抖、节流) | 带事件转换器的 Bloc |
默认使用 Cubit。只有在需求增加时才重构为 Bloc。
2. 命名约定
事件(仅限 Bloc)
- 使用过去时命名:
LoginButtonPressed、UserProfileLoaded。 - 格式:
BlocSubject+ 可选名词 + 动词。 - 初始加载事件:
BlocSubjectStarted(例如,AuthenticationStarted)。 - 基础事件类:
BlocSubjectEvent。
状态
- 使用名词命名(状态是某个时间点的快照)。
- 基础状态类:
BlocSubjectState。 - 密封子类:
BlocSubject+Initial|InProgress|Success|Failure。- 示例:
LoginInitial、LoginInProgress、LoginSuccess、LoginFailure。
- 示例:
- 单类方案:
BlocSubjectState+BlocSubjectStatus枚举(initial、loading、success、failure)。
3. 状态建模
适合使用带子类的密封类的情况
- 状态定义明确且互斥。
- 需要类型安全且穷尽的
switch。 - 存在子类专属属性。
@immutable
sealed class LoginState extends Equatable {
const LoginState();
}
final class LoginInitial extends LoginState {
@override
List<Object?> get props => [];
}
final class LoginInProgress extends LoginState {
@override
List<Object?> get props => [];
}
final class LoginSuccess extends LoginState {
const LoginSuccess(this.user);
final User user;
@override
List<Object?> get props => [user];
}
final class LoginFailure extends LoginState {
const LoginFailure(this.message);
final String message;
@override
List<Object?> get props => [message];
}
在 UI 中穷尽处理所有状态:
switch (state) {
case LoginInitial(): ...
case LoginInProgress(): ...
case LoginSuccess(:final user): ...
case LoginFailure(:final message): ...
}
适合使用带状态枚举的单一类的情况
- 多种状态共享许多属性。
- 这种方式更简单、灵活;失败后必须保留之前的数据。
enum LoginStatus { initial, loading, success, failure }
@immutable
class LoginState extends Equatable {
const LoginState({
this.status = LoginStatus.initial,
this.user,
this.errorMessage,
});
final LoginStatus status;
final User? user;
final String? errorMessage;
LoginState copyWith({
LoginStatus? status,
User? user,
String? errorMessage,
}) {
return LoginState(
status: status ?? this.status,
user: user ?? this.user,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@override
List<Object?> get props => [status, user, errorMessage];
}
状态规则(适用于两种方案)
- 继承
Equatable,并将所有相关字段传入props。 - 在
props中使用List.of/Map.of复制List/Map属性。 - 使用
@immutable注解。 - 始终发出新实例;不要重复使用同一个状态对象。
- bloc 会忽略重复状态,因此请确保状态变化有意义。
4. Cubit 实现
class LoginCubit extends Cubit<LoginState> {
LoginCubit(this._authRepository) : super(const LoginState());
final AuthRepository _authRepository;
Future<void> login(String email, String password) async {
emit(state.copyWith(status: LoginStatus.loading));
try {
final user = await _authRepository.login(email, password);
emit(state.copyWith(status: LoginStatus.success, user: user));
} catch (e) {
emit(state.copyWith(status: LoginStatus.failure, errorMessage: e.toString()));
}
}
}
规则:
- 仅在 Cubit/Bloc 内调用
emit。 - 公共方法只返回
void或Future<void>。 - 不要将业务逻辑放在 UI 中。
- 在
HydratedCubit中重写storage时,将其作为命名参数传入:super(initialState, storage: storage)。
5. Bloc 实现
sealed class LoginEvent {}
final class LoginSubmitted extends LoginEvent {
LoginSubmitted({required this.email, required this.password});
final String email;
final String password;
}
class LoginBloc extends Bloc<LoginEvent, LoginState> {
LoginBloc(this._authRepository) : super(LoginInitial()) {
on<LoginSubmitted>(_onLoginSubmitted);
}
final AuthRepository _authRepository;
Future<void> _onLoginSubmitted(
LoginSubmitted event,
Emitter<LoginState> emit,
) async {
emit(LoginInProgress());
try {
final user = await _authRepository.login(event.email, event.password);
emit(LoginSuccess(user));
} catch (e) {
emit(LoginFailure(e.toString()));
}
}
}
规则:
- 通过
bloc.add(Event())触发状态变化,不要使用自定义公共方法。 - 将事件处理方法设为私有(
_onEventName)。 - 内部/仓库事件必须为私有,并且可以使用自定义转换器。
6. 架构
分为三层——每层都必须遵守各自的边界:
Presentation → Business Logic (Cubit/Bloc) → Data (Repository → DataProvider)
- 数据层:仓库封装数据提供方。数据提供方执行原始 CRUD 操作(HTTP、DB)。仓库提供简洁的领域对象。
- 业务逻辑层:Cubit/Bloc 接收仓库数据并发出状态。通过构造函数注入仓库。
- 表现层:根据状态渲染 UI。通过调用 cubit 方法或添加 bloc 事件来处理用户输入。
规则:
- Blocs 不得直接访问数据提供方,只能通过仓库访问。
- 不要在 bloc 之间直接通信。使用 UI 中的
BlocListener连接各个 bloc。 - 对于共享数据,将同一个仓库注入多个 bloc。
- 在
main.dart中初始化BlocObserver。
7. Flutter Bloc 组件
| 组件 | 用途 |
|---|---|
BlocProvider | 向子树提供一个 bloc |
MultiBlocProvider | 无需嵌套即可提供多个 bloc |
BlocBuilder | 状态变化时重建 UI |
BlocListener | 仅处理副作用(导航、对话框、SnackBar) |
MultiBlocListener | 无需嵌套即可监听多个 bloc |
BlocConsumer | 同时重建 UI 并处理副作用 |
BlocSelector | 仅在选定的状态部分发生变化时重建 |
RepositoryProvider | 向组件树提供一个仓库 |
MultiRepositoryProvider | 无需嵌套即可提供多个仓库 |
BlocProvider(
create: (context) => LoginCubit(context.read<AuthRepository>()),
child: LoginView(),
);
BlocBuilder<LoginCubit, LoginState>(
builder: (context, state) {
return switch (state.status) {
LoginStatus.loading => const CircularProgressIndicator(),
LoginStatus.success => const HomeView(),
LoginStatus.failure => Text(state.errorMessage ?? 'Error'),
LoginStatus.initial => const LoginForm(),
};
},
);
BlocListener<LoginCubit, LoginState>(
listener: (context, state) {
if (state.status == LoginStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.errorMessage ?? 'Login failed')),
);
}
},
child: LoginForm(),
);
规则:
- 在回调中使用
context.read<T>()(不要在build中使用)。 - 仅在必要时于
build中使用context.watch<T>();优先使用BlocBuilder。 - 切勿在
build的根部调用context.watch或context.select——使用Builder限定作用域。 - 在 UI 中处理所有可能的状态(初始、加载、成功、失败)。
8. 测试
使用 bloc_test 包。使用 mocktail 模拟仓库。
import 'package:bloc_test/bloc_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:test/test.dart';
class MockAuthRepository extends Mock implements AuthRepository {}
void main() {
group('LoginCubit', () {
late AuthRepository authRepository;
late LoginCubit loginCubit;
setUp(() {
authRepository = MockAuthRepository();
loginCubit = LoginCubit(authRepository);
});
tearDown(() => loginCubit.close());
test('initial state should be LoginState with status initial', () {
expect(loginCubit.state, const LoginState());
});
blocTest<LoginCubit, LoginState>(
'should emit [loading, success] when login succeeds',
build: () {
when(() => authRepository.login(any(), any()))
.thenAnswer((_) async => fakeUser);
return loginCubit;
},
act: (cubit) => cubit.login('[email protected]', 'password'),
expect: () => [
const LoginState(status: LoginStatus.loading),
LoginState(status: LoginStatus.success, user: fakeUser),
],
);
blocTest<LoginCubit, LoginState>(
'should emit [loading, failure] when login throws',
build: () {
when(() => authRepository.login(any(), any()))
.thenThrow(Exception('error'));
return loginCubit;
},
act: (cubit) => cubit.login('[email protected]', 'wrong'),
expect: () => [
const LoginState(status: LoginStatus.loading),
isA<LoginState>().having((s) => s.status, 'status', LoginStatus.failure),
],
);
});
}
规则:
- 始终调用
tearDown(() => cubit.close())。 - 使用
blocTest断言状态发射。 - 使用被测类的名称命名
group()。 - 使用 "should" 命名测试用例,以描述预期行为。
- 为自定义类型注册回退值:
registerFallbackValue(MyEvent())。
9. 常见陷阱
| 陷阱 | 修复方法 |
|---|---|
| 连续发射同一个状态实例 | 始终创建新的状态对象;bloc 会通过 == 忽略重复发射。 |
在回调中调用 context.watch | 在回调中使用 context.read;watch 仅在 build 中有效。 |
忘记添加 Equatable 属性 | 将每个字段添加到 props;遗漏字段会导致状态更新错误且不易察觉。 |
| 可变状态字段 | 保持状态为 @immutable;使用 copyWith 或新的密封子类实例。 |
| 在组件中编写业务逻辑 | 将所有逻辑移入 Cubit/Bloc;组件只负责分发事件或调用方法。 |
// BAD — mutating state in-place
state.items.add(newItem);
emit(state);
// GOOD — emit a new state with copied list
emit(state.copyWith(items: [...state.items, newItem]));
参考资料
评论
支持 Markdown