심층적인 BLoC 패턴: 이벤트, 상태 및 큐빗
BLoC(Business Logic Component) 패턴은 Google이 2018년에 만들었습니다. Flutter의 UI에서 비즈니스 로직을 분리하는 문제를 해결합니다. 오늘은 flutter_bloc 9 Dart 3는 더욱 강력해졌습니다. 르 봉인된 수업 상태에 대한 완전한 유형 안전성을 제공합니다. 는 완척 명시적인 이벤트 없이 사용 사례를 단순화합니다. hydrated_bloc 자동 상태 지속성을 추가합니다.
이 가이드는 Cubit과 완전한 BLoC, 관리 간의 실질적인 차이점을 다룹니다.
관용적 오류, 복잡한 기능에 대한 여러 BLoC 구성,
및 테스트 전략 bloc_test.
무엇을 배울 것인가
- Cubit vs BLoC: 단순함이 구조를 능가하는 경우
- 철저한 유형 안전 상태를 위한 밀봉 클래스 Dart 3
- 입력된 오류 상태를 사용한 오류 처리
- BLoC 구성: 다른 BLoC를 듣는 BLoC
- hydrated_bloc: 세션 간 자동 상태 지속성
- bloc_test: 깔끔한 테스트를 위한 패턴을 내보내고, 실행하고, 예상합니다.
- BlocObserver: 모든 BLoC의 글로벌 로깅
큐빗: BLoC 단순화
Il 완척 BLoC의 축소 버전: 명시적인 이벤트가 없습니다. 상태를 출력하는 메서드만 해당됩니다. 상태 전환이 간단한 경우 사용 그리고 상태의 "원인"을 추적할 필요가 없습니다(불필요한 이벤트 소싱).
// Cubit: ideale per UI semplice (counter, toggle, selezione)
// State
class ThemeState {
final bool isDarkMode;
const ThemeState({required this.isDarkMode});
ThemeState copyWith({bool? isDarkMode}) =>
ThemeState(isDarkMode: isDarkMode ?? this.isDarkMode);
}
// Cubit
class ThemeCubit extends Cubit<ThemeState> {
ThemeCubit() : super(const ThemeState(isDarkMode: false));
void toggleTheme() => emit(
state.copyWith(isDarkMode: !state.isDarkMode),
);
void setDarkMode(bool value) => emit(
state.copyWith(isDarkMode: value),
);
}
// Widget consumer del Cubit
class ThemeToggle extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<ThemeCubit, ThemeState>(
builder: (context, state) {
return Switch(
value: state.isDarkMode,
onChanged: (value) =>
context.read<ThemeCubit>().setDarkMode(value),
);
},
);
}
}
봉인된 클래스가 있는 BLoC: 유형이 안전한 상태
// Sealed classes Dart 3: enumerazione chiusa degli stati possibili
// Il compilatore verifica che tutti i casi siano gestiti (exhaustive)
sealed class CartState {}
// Stati concreti
class CartInitial extends CartState {}
class CartLoading extends CartState {}
class CartLoaded extends CartState {
final List<CartItem> items;
final double total;
CartLoaded({required this.items, required this.total});
// Copia immutabile per aggiornamenti parziali
CartLoaded copyWith({
List<CartItem>? items,
double? total,
}) =>
CartLoaded(
items: items ?? this.items,
total: total ?? this.total,
);
}
class CartError extends CartState {
final String message;
final CartErrorType errorType;
CartError({required this.message, required this.errorType});
}
enum CartErrorType {
network,
itemOutOfStock,
sessionExpired,
unknown,
}
// Events
sealed class CartEvent {}
class CartItemAdded extends CartEvent {
final String productId;
final int quantity;
CartItemAdded(this.productId, this.quantity);
}
class CartItemRemoved extends CartEvent {
final String itemId;
CartItemRemoved(this.itemId);
}
class CartCleared extends CartEvent {}
class CartRefreshRequested extends CartEvent {}
// BLoC con sealed classes
class CartBloc extends Bloc<CartEvent, CartState> {
final CartRepository _repo;
CartBloc(this._repo) : super(CartInitial()) {
on<CartItemAdded>(_onItemAdded);
on<CartItemRemoved>(_onItemRemoved);
on<CartCleared>(_onCleared);
on<CartRefreshRequested>(_onRefreshRequested);
}
Future<void> _onItemAdded(
CartItemAdded event,
Emitter<CartState> emit,
) async {
// Pattern: preserva i dati correnti durante il loading
if (state is CartLoaded) {
final currentState = state as CartLoaded;
// Emetti loading con i dati vecchi visibili
// (non mostrare spinner che copre il carrello)
}
emit(CartLoading());
try {
final cart = await _repo.addItem(event.productId, event.quantity);
emit(CartLoaded(items: cart.items, total: cart.total));
} on OutOfStockException catch (e) {
emit(CartError(
message: 'Prodotto non disponibile: ${e.productName}',
errorType: CartErrorType.itemOutOfStock,
));
} on NetworkException {
emit(CartError(
message: 'Errore di connessione. Riprova.',
errorType: CartErrorType.network,
));
} catch (e) {
emit(CartError(
message: 'Si e verificato un errore imprevisto.',
errorType: CartErrorType.unknown,
));
}
}
Future<void> _onItemRemoved(
CartItemRemoved event,
Emitter<CartState> emit,
) async {
if (state is! CartLoaded) return;
final current = state as CartLoaded;
// Update ottimistico: rimuovi immediatamente dalla UI
final optimisticItems = current.items
.where((item) => item.id != event.itemId)
.toList();
emit(current.copyWith(items: optimisticItems));
try {
final cart = await _repo.removeItem(event.itemId);
emit(CartLoaded(items: cart.items, total: cart.total));
} catch (e) {
// Rollback al precedente stato
emit(current);
}
}
Future<void> _onCleared(
CartCleared event,
Emitter<CartState> emit,
) async {
emit(CartLoading());
try {
await _repo.clearCart();
emit(CartLoaded(items: [], total: 0));
} catch (e) {
emit(CartError(message: 'Impossibile svuotare il carrello', errorType: CartErrorType.unknown));
}
}
Future<void> _onRefreshRequested(
CartRefreshRequested event,
Emitter<CartState> emit,
) async {
final cart = await _repo.getCart();
emit(CartLoaded(items: cart.items, total: cart.total));
}
}
BLoC 구성: 다른 BLoC를 듣는 BLoC
// Scenario: OrderBloc deve reagire ai cambiamenti di CartBloc
class OrderBloc extends Bloc<OrderEvent, OrderState> {
final CartBloc _cartBloc;
late final StreamSubscription<CartState> _cartSubscription;
OrderBloc({required CartBloc cartBloc})
: _cartBloc = cartBloc,
super(OrderInitial()) {
on<OrderCartUpdated>(_onCartUpdated);
on<OrderSubmitted>(_onOrderSubmitted);
// Ascolta i cambiamenti del CartBloc
_cartSubscription = _cartBloc.stream.listen((cartState) {
if (cartState is CartLoaded) {
add(OrderCartUpdated(items: cartState.items, total: cartState.total));
}
});
}
Future<void> _onCartUpdated(
OrderCartUpdated event,
Emitter<OrderState> emit,
) async {
if (state is OrderDraft || state is OrderInitial) {
emit(OrderDraft(
items: event.items,
total: event.total,
canSubmit: event.items.isNotEmpty,
));
}
}
@override
Future<void> close() {
// IMPORTANTE: cancella la subscription per evitare memory leak
_cartSubscription.cancel();
return super.close();
}
}
bloc_test로 테스트하기
// test/cart_bloc_test.dart
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
class MockCartRepository extends Mock implements CartRepository {}
void main() {
late MockCartRepository mockRepo;
late CartBloc cartBloc;
setUp(() {
mockRepo = MockCartRepository();
cartBloc = CartBloc(mockRepo);
});
tearDown(() => cartBloc.close());
// blocTest: il pattern piu pulito per testare BLoC
blocTest<CartBloc, CartState>(
'emette [CartLoading, CartLoaded] quando un item viene aggiunto con successo',
build: () => CartBloc(mockRepo),
setUp: () {
when(mockRepo.addItem('product-1', 1)).thenAnswer(
(_) async => Cart(
items: [CartItem(id: 'item-1', productId: 'product-1', quantity: 1)],
total: 29.99,
),
);
},
act: (bloc) => bloc.add(CartItemAdded('product-1', 1)),
expect: () => [
isA<CartLoading>(),
isA<CartLoaded>().having((s) => s.items.length, 'items count', 1),
],
verify: (_) {
verify(mockRepo.addItem('product-1', 1)).called(1);
},
);
blocTest<CartBloc, CartState>(
'emette [CartLoading, CartError] per OutOfStockException',
build: () => CartBloc(mockRepo),
setUp: () {
when(mockRepo.addItem(any, any)).thenThrow(
OutOfStockException(productName: 'Test Product'),
);
},
act: (bloc) => bloc.add(CartItemAdded('product-99', 1)),
expect: () => [
isA<CartLoading>(),
isA<CartError>().having(
(s) => s.errorType,
'errorType',
CartErrorType.itemOutOfStock,
),
],
);
// Test per exhausitiveness dei sealed state nel widget
testWidgets('CartView mostra errore per CartError state', (tester) async {
await tester.pumpWidget(
MaterialApp(
home: BlocProvider.value(
value: cartBloc,
child: const CartView(),
),
),
);
cartBloc.emit(CartError(
message: 'Test error',
errorType: CartErrorType.network,
));
await tester.pump();
expect(find.text('Test error'), findsOneWidget);
});
}
hydrated_bloc: 자동 지속성
// hydrated_bloc: lo stato sopravvive al riavvio dell'app
// pubspec.yaml: aggiungi hydrated_bloc
// hydrated_bloc: ^9.0.0
// main.dart: inizializza HydratedBloc
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Configura lo storage per la piattaforma corrente
HydratedBloc.storage = await HydratedStorage.build(
storageDirectory: await getApplicationDocumentsDirectory(),
);
runApp(const MyApp());
}
// Cart BLoC con persistenza
class PersistentCartBloc extends HydratedBloc<CartEvent, CartState> {
PersistentCartBloc() : super(CartInitial()) {
on<CartItemAdded>(_onItemAdded);
}
// Serializzazione: CartState -> JSON
@override
Map<String, dynamic>? toJson(CartState state) {
if (state is CartLoaded) {
return {
'items': state.items.map((i) => i.toJson()).toList(),
'total': state.total,
};
}
return null; // Non persistere loading/error/initial
}
// Deserializzazione: JSON -> CartState
@override
CartState? fromJson(Map<String, dynamic> json) {
try {
final items = (json['items'] as List)
.map((i) => CartItem.fromJson(i as Map<String, dynamic>))
.toList();
return CartLoaded(
items: items,
total: (json['total'] as num).toDouble(),
);
} catch (_) {
return null; // Ritorna null per usare lo stato iniziale
}
}
}
결론
봉인된 클래스를 갖춘 BLoC는 상태 관리의 표준입니다. Flutter 엔터프라이즈 앱에서. 엄격한 이벤트/상태 구조는 모든 것을 보장합니다. 전환은 대규모 팀에서도 추적 가능하고 테스트 가능하며 이해할 수 있습니다. hydrated_bloc은 중요한 추가 코드 없이 지속성을 추가합니다. 새로운 기능: 전환이 간단한 경우 Cubit으로 시작하고 BLoC로 확장 이벤트 추적성이 필요할 때 완료하세요.







