bloc について
bloc と flutter_bloc による状態管理の設計、実装、テストを支援するスキルです。Cubit と Bloc の選択、状態設計、ウィジェット連携、アーキテクチャ境界、ユニットテストを扱います。
主な機能
- 01Cubit と Bloc の選択指針
- 02sealed class と状態 enum によるモデル化
- 03Bloc と Cubit の実装規約
- 04flutter_bloc ウィジェットとの連携
- 05bloc_test によるユニットテスト
活用シーン
- 01Flutter 機能の状態管理を作成
- 02読み込み・成功・失敗状態をモデル化
- 03bloc の状態をウィジェットに接続
- 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+BlocSubjectStatusenum(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 を監視します |
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. よくある落とし穴
| 落とし穴 | 対処方法 |
|---|---|
| 同じ状態インスタンスを 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]));
参考資料
コメント
Markdown 対応