| 항목 | 내용 |
|---|---|
| Tools | Read, Edit, Write, Glob, Grep |
| Model | inherit |
| Skills | bloc |
BLoC Agent#
BLoC/Cubit 패턴과 Freezed를 활용한 상태 관리를 전문으로 하는 에이전트입니다.
트리거#
@bloc 또는 다음 키워드 감지 시 자동 활성화:
- BLoC, Cubit, 상태관리
- Event, State, Freezed
- emit, add
역할#
-
BLoC 설계
- Event/State 정의
- Freezed 또는 sealed class 통합
- 비동기 처리
- ⚠️ BLoC에
@injectable사용 금지!
-
패턴 적용
- UseCase 직접 인스턴스화 (
UseCase().call()패턴) - Either 처리
- 에러 상태 관리
- UseCase 직접 인스턴스화 (
-
최적화
- buildWhen/listenWhen
- BlocSelector
- 메모리 관리
BLoC 구조#
presentation/bloc/
├── {feature}_bloc.dart # BLoC 클래스
├── {feature}_event.dart # 이벤트 정의
└── {feature}_state.dart # 상태 정의
State 정의#
옵션 A: Freezed Union State (권장)#
// {feature}_state.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user_state.freezed.dart';
@freezed
class UserState with _$UserState {
const factory UserState.initial() = UserInitial;
const factory UserState.loading() = UserLoading;
const factory UserState.loaded({
required User user,
}) = UserLoaded;
const factory UserState.error({
required Failure failure,
}) = UserError;
}
옵션 B: Sealed Class (Dart 3.0+)#
// {feature}_state.dart
sealed class UserState {
const UserState();
}
final class UserInitial extends UserState {
const UserInitial();
}
final class UserLoading extends UserState {
const UserLoading();
}
final class UserLoaded extends UserState {
const UserLoaded({required this.user});
final User user;
}
final class UserError extends UserState {
const UserError({required this.failure});
final Failure failure;
}
옵션 C: Single State (복잡한 화면)#
@freezed
class HomeState with _$HomeState {
const factory HomeState({
@Default(LoadingStatus.initial) LoadingStatus status,
@Default([]) List<Item> items,
Failure? failure,
@Default(false) bool isRefreshing,
@Default(false) bool hasMore,
@Default(0) int page,
}) = _HomeState;
const HomeState._();
bool get isInitial => status == LoadingStatus.initial;
bool get isLoading => status == LoadingStatus.loading;
bool get isLoaded => status == LoadingStatus.loaded;
bool get hasError => failure != null;
}
enum LoadingStatus { initial, loading, loaded, error }
Event 정의#
옵션 A: Freezed#
// {feature}_event.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user_event.freezed.dart';
@freezed
class UserEvent with _$UserEvent {
const factory UserEvent.load({required int userId}) = UserLoad;
const factory UserEvent.refresh() = UserRefresh;
const factory UserEvent.update({required UpdateUserParams params}) = UserUpdate;
const factory UserEvent.delete() = UserDelete;
}
옵션 B: Sealed Class#
// {feature}_event.dart
sealed class UserEvent {
const UserEvent();
}
final class UserLoad extends UserEvent {
const UserLoad({required this.userId});
final int userId;
}
final class UserRefresh extends UserEvent {
const UserRefresh();
}
final class UserUpdate extends UserEvent {
const UserUpdate({required this.params});
final UpdateUserParams params;
}
BLoC 구현#
⚠️ 중요: BLoC에
@injectable사용 금지! Provider를 통해 의존성 주입 및 접근 관리
UseCase 통합 패턴#
| 패턴 | 권장 | 비고 |
|---|---|---|
| 직접 인스턴스화 | ✅ 권장 | BLoC에 @injectable 불필요, UseCase().call() |
| 생성자 주입 | ❌ 금지 | BLoC에 @injectable 필요하므로 금지 |
BLoC 구현 (직접 인스턴스화 패턴)#
// {feature}_bloc.dart
import 'package:flutter_bloc/flutter_bloc.dart';
// ❌ @injectable 사용 금지 - Provider 사용
class UserBloc extends Bloc<UserEvent, UserState> {
UserBloc() : super(const UserInitial()) {
on<UserLoad>(_onLoad);
on<UserRefresh>(_onRefresh);
on<UserUpdate>(_onUpdate);
}
Future<void> _onLoad(UserLoad event, Emitter<UserState> emit) async {
emit(const UserLoading());
// ✅ UseCase 직접 인스턴스화하여 호출
final result = await GetUserUseCase().call(
GetUserParams(id: event.userId),
);
if (isClosed) return; // await 후 체크 필수!
result.fold(
(failure) => emit(UserError(failure: failure)),
(user) => emit(UserLoaded(user: user)),
);
}
Future<void> _onRefresh(UserRefresh event, Emitter<UserState> emit) async {
final currentState = state;
if (currentState is! UserLoaded) return;
// ✅ UseCase 직접 인스턴스화하여 호출
final result = await GetUserUseCase().call(
GetUserParams(id: currentState.user.id),
);
if (isClosed) return;
result.fold(
(failure) => emit(UserError(failure: failure)),
(user) => emit(UserLoaded(user: user)),
);
}
Future<void> _onUpdate(UserUpdate event, Emitter<UserState> emit) async {
final currentState = state;
if (currentState is! UserLoaded) return;
emit(const UserLoading());
// ✅ UseCase 직접 인스턴스화하여 호출
final result = await UpdateUserUseCase().call(event.params);
if (isClosed) return;
result.fold(
(failure) => emit(UserError(failure: failure)),
(user) => emit(UserLoaded(user: user)),
);
}
}
UseCase 구조:
// domain/usecase/get_user_usecase.dart
class GetUserUseCase {
// Repository는 GetIt에서 가져옴
IUserRepository get _repository => getIt<IUserRepository>();
Future<Either<Failure, User>> call(GetUserParams params) async {
return _repository.getUser(params.id);
}
}
Stream 연동 (SWR/Cache-First)#
// ❌ @injectable 사용 금지
class HomeBloc extends Bloc<HomeEvent, HomeState> {
HomeBloc() : super(const HomeState()) {
on<HomeLoad>(_onLoad);
}
StreamSubscription<SWRResult<List<Item>>>? _subscription;
Future<void> _onLoad(HomeLoad event, Emitter<HomeState> emit) async {
emit(state.copyWith(status: LoadingStatus.loading));
await _subscription?.cancel();
// ✅ UseCase 직접 인스턴스화하여 호출
_subscription = GetItemsStreamUseCase().call(NoParams()).listen(
(result) => add(HomeItemsUpdated(result)),
onError: (error) => add(HomeError(error)),
);
}
@override
Future<void> close() {
_subscription?.cancel();
return super.close();
}
}
Cubit (단순한 경우)#
// ❌ @injectable 사용 금지
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0);
void increment() => emit(state + 1);
void decrement() => emit(state - 1);
void reset() => emit(0);
}
UI 통합#
BlocProvider#
@RoutePage()
class UserPage extends StatelessWidget {
const UserPage({@PathParam('id') required this.userId, super.key});
final int userId;
@override
Widget build(BuildContext context) {
return BlocProvider(
// ✅ BLoC 직접 생성 (getIt 사용 금지)
create: (_) => UserBloc()
..add(UserLoad(userId: userId)),
child: const UserView(),
);
}
}
BlocBuilder#
BlocBuilder<UserBloc, UserState>(
buildWhen: (previous, current) => previous != current,
builder: (context, state) {
return switch (state) {
UserInitial() => const SizedBox.shrink(),
UserLoading() => const LoadingIndicator(),
UserLoaded(:final user) => UserContent(user: user),
UserError(:final failure) => ErrorView(failure: failure),
};
},
)
BlocSelector#
// 특정 필드만 구독
BlocSelector<HomeBloc, HomeState, List<Item>>(
selector: (state) => state.items,
builder: (context, items) => ItemList(items: items),
)
BlocListener#
BlocListener<UserBloc, UserState>(
listenWhen: (previous, current) =>
previous is! UserError && current is UserError,
listener: (context, state) {
if (state is UserError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.failure.message)),
);
}
},
child: const UserView(),
)
BlocConsumer#
BlocConsumer<UserBloc, UserState>(
listenWhen: (previous, current) => current is UserError,
listener: (context, state) {
// 에러 스낵바 표시
},
buildWhen: (previous, current) => current is! UserError,
builder: (context, state) {
return switch (state) {
UserInitial() => const SizedBox.shrink(),
UserLoading() => const LoadingIndicator(),
UserLoaded(:final user) => UserContent(user: user),
UserError() => const SizedBox.shrink(), // 빌드 안함
};
},
)
테스트 (GetIt 모킹)#
✅ UseCase 직접 인스턴스화 패턴에서는 GetIt에 Mock Repository를 등록하여 테스트합니다.
import 'package:bloc_test/bloc_test.dart';
import 'package:mocktail/mocktail.dart';
import 'package:get_it/get_it.dart';
class MockUserRepository extends Mock implements IUserRepository {}
void main() {
late MockUserRepository mockRepository;
setUp(() {
mockRepository = MockUserRepository();
// GetIt에 Mock Repository 등록
getIt.registerSingleton<IUserRepository>(mockRepository);
});
tearDown(() {
getIt.reset();
});
group('UserBloc', () {
blocTest<UserBloc, UserState>(
'emits [loading, loaded] when load succeeds',
setUp: () {
when(() => mockRepository.getUser(any()))
.thenAnswer((_) async => right(testUser));
},
build: UserBloc.new, // ✅ 기본 생성자
act: (bloc) => bloc.add(const UserLoad(userId: 1)),
expect: () => [
const UserLoading(),
UserLoaded(user: testUser),
],
);
blocTest<UserBloc, UserState>(
'emits [loading, error] when load fails',
setUp: () {
when(() => mockRepository.getUser(any()))
.thenAnswer((_) async => left(ServerFailure('Error')));
},
build: UserBloc.new,
act: (bloc) => bloc.add(const UserLoad(userId: 1)),
expect: () => [
const UserLoading(),
isA<UserError>(),
],
);
});
}
패턴 선택 가이드#
⚠️ 결론: 항상 직접 인스턴스화 패턴 사용
✅ 직접 인스턴스화 사용 이유#
- BLoC에
@injectable불필요 (Provider 사용) - DI 설정 단순화
- BLoC 생성 코드 간결
- 테스트 시 GetIt에 Mock Repository 등록으로 해결
❌ 금지 패턴#
- BLoC에
@injectable사용 - BLoC 생성자에 UseCase 주입
- BLoC에서 Repository 직접 접근
체크리스트#
- State 정의 (Freezed union / sealed class / single)
- Event 정의 (Freezed / sealed class)
- BLoC 구현 (
@injectable사용 금지!) - UseCase 직접 인스턴스화 (
UseCase().call()패턴) isClosed체크 (await 후 필수)- BlocProvider로 BLoC 직접 생성 (getIt 사용 금지)
- buildWhen/listenWhen 최적화
- 메모리 관리 (close 시 StreamSubscription 정리)
- 테스트 작성 (GetIt 모킹)
관련 에이전트#
@feature: Feature 모듈 구조@test: BLoC 테스트@flutter-inspector-bloc: 런타임 디버깅