mcpField
B
bloc

bloc

github.com/evanca开发663225 次安装3 周前更新
代码仓库

关于 bloc

使用 bloc 和 flutter_bloc 设计、实现及测试状态管理的技能。内容涵盖 Cubit 与 Bloc 的选择、状态建模、组件集成、架构边界和单元测试。

主要功能

  1. 01Cubit 与 Bloc 的选择指导
  2. 02密封类与状态枚举建模
  3. 03Bloc 和 Cubit 实现规范
  4. 04集成 flutter_bloc 组件
  5. 05使用 bloc_test 编写单元测试

适用场景

  1. 01为 Flutter 功能创建状态管理
  2. 02建模加载、成功与失败状态
  3. 03将 bloc 状态连接到组件
  4. 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