mcpField
B
bloc

bloc

github.com/evanca開発663225 回インストール3 週間前に更新
リポジトリ

bloc について

bloc と flutter_bloc による状態管理の設計、実装、テストを支援するスキルです。Cubit と Bloc の選択、状態設計、ウィジェット連携、アーキテクチャ境界、ユニットテストを扱います。

主な機能

  1. 01Cubit と Bloc の選択指針
  2. 02sealed class と状態 enum によるモデル化
  3. 03Bloc と Cubit の実装規約
  4. 04flutter_bloc ウィジェットとの連携
  5. 05bloc_test によるユニットテスト

活用シーン

  1. 01Flutter 機能の状態管理を作成
  2. 02読み込み・成功・失敗状態をモデル化
  3. 03bloc の状態をウィジェットに接続
  4. 04状態遷移と動作をテスト

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/evanca/flutter-ai-rules --skill bloc

インストール後、Claude Code で/blocを入力して読み込まれたか確認します。

詳細

Bloc スキル

bloc および flutter_bloc ライブラリを使用した状態管理の設計、実装、テストを行います。

使用する場面

次のような場合にこのスキルを使用します。

  • 機能用の新しい Cubit または Bloc を作成するとき。
  • 状態をモデル化するとき(sealed クラスと、status enum を持つ単一の状態クラスのどちらを選ぶか)。
  • ウィジェットツリーに 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。
  • sealed サブクラス:BlocSubject + Initial | InProgress | Success | Failure。
    • 例:LoginInitial、LoginInProgress、LoginSuccess、LoginFailure。
  • 単一クラスの方法:BlocSubjectState + BlocSubjectStatus enum(initial、loading、success、failure)。

3. 状態のモデル化

サブクラスを持つ sealed クラスを使う場合
  • 状態が明確に定義され、相互に排他的です。
  • 型安全で網羅的な 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): ...
}
status enum を持つ単一クラスを使う場合
  • 複数の状態で共有するプロパティが多くあります。
  • よりシンプルで柔軟な方法であり、失敗後も以前のデータを保持する必要があります。
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 に渡します。
  • List/Map プロパティは、props 内で List.of/Map.of を使ってコピーします。
  • @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()));
    }
  }
}

ルール:

  • emit を呼び出すのは Cubit/Bloc の内部だけにします。
  • 公開メソッドの戻り値は 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. アーキテクチャ

3 つのレイヤーがあり、それぞれの境界を守る必要があります。

Presentation  →  Business Logic (Cubit/Bloc)  →  Data (Repository → DataProvider)
  • データレイヤー:リポジトリはデータプロバイダーをラップします。プロバイダーは HTTP や DB などの生の CRUD 操作を実行します。リポジトリはクリーンなドメインオブジェクトを公開します。
  • ビジネスロジックレイヤー:Cubit/Bloc はリポジトリのデータを受け取り、状態を出力します。リポジトリはコンストラクターを通じて注入します。
  • プレゼンテーションレイヤー:状態に基づいて UI を描画します。ユーザー入力は、Cubit メソッドの呼び出しまたは Bloc イベントの追加によって処理します。

ルール:

  • Bloc がデータプロバイダーに直接アクセスしてはいけません。必ずリポジトリを介してください。
  • Bloc 同士を直接通信させないでください。UI で BlocListener を使って Bloc 間をつなぎます。
  • データを共有する場合は、同じリポジトリを複数の Bloc に注入します。
  • main.dart で BlocObserver を初期化します。

7. Flutter Bloc ウィジェット

ウィジェット用途
BlocProviderサブツリーに bloc を提供します
MultiBlocProviderネストせずに複数の bloc を提供します
BlocBuilder状態の変化に応じて UI を再構築します
BlocListener副作用のみを処理します(ナビゲーション、ダイアログ、スナックバー)
MultiBlocListenerネストせずに複数の bloc を監視します
BlocConsumerUI の再構築と副作用の処理をまとめて行います
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. よくある落とし穴

落とし穴対処方法
同じ状態インスタンスを 2 回発行する常に新しい状態オブジェクトを作成します。bloc は == によって重複した発行を無視します。
コールバック内で context.watch を呼び出すコールバック内では context.read を使用します。watch は build 内でのみ有効です。
Equatable の props を設定し忘れるすべてのフィールドを props に追加します。フィールドが不足すると、状態更新のバグが気付かれないまま発生します。
状態フィールドが変更可能状態を @immutable に保ち、copyWith または新しい sealed サブクラスのインスタンスを使用します。
ウィジェット内にビジネスロジックを置くすべてのロジックを 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]));

参考資料

コメント