Derinlikli BLoC Modeli: Olaylar, Durumlar ve Cubit
BLoC (İş Mantığı Bileşeni) modeli 2018 yılında Google tarafından oluşturuldu Flutter'da iş mantığını kullanıcı arayüzünden ayırma sorununu çözmek için. Bugün, flutter_bloc 9 ve Dart 3 daha da güçlü hale geldi: le mühürlü sınıflar Durumlarda tam tip güvenliği sağlamak, the Arşın açık olaylar olmadan kullanım örneklerini basitleştirir, e hidratlı_bloc otomatik durum kalıcılığı ekler.
Bu kılavuz, Cubit ile tam BLoC yönetimi arasındaki pratik farkı kapsar.
deyimsel hataların sayısı, karmaşık özellikler için birden fazla BLoC'nin bileşimi,
ve stratejileri test etmek bloc_test.
Ne Öğreneceksiniz
- Cubit vs BLoC: basitlik yapıyı yendiğinde
- Kapsamlı tür güvenliği durumları için mühürlü sınıflar Dart 3
- Yazılan hata durumlarıyla hata yönetimi
- BLoC kompozisyonu: BLoC diğer BLoC'leri dinliyor
- hidratlı_bloc: Oturumlar arasında otomatik durum kalıcılığı
- bloc_test: temiz testler için kalıp yay, harekete geç, bekle
- BlocObserver: tüm BLoC'lerin küresel kaydı
Cubit: Basitleştirilmiş BLoC
Il Arşın ve BLoC'nin küçültülmüş bir versiyonu: açık olay yok, yalnızca durum çıktısı veren yöntemler. Durum geçişleri basit olduğunda kullanın ve duruma "neden olduğunu" izlemenize gerek yoktur (gereksiz olay kaynağı).
// 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),
);
},
);
}
}
Mühürlü Sınıflara Sahip BLoC: Güvenli Tip Durumları
// 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 Bileşimi: BLoC'ler Diğer BLoC'leri Dinliyor
// 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 ile test etme
// 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);
});
}
hidratlı_bloc: Otomatik Kalıcılık
// 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
}
}
}
Sonuçlar
Mühürlü sınıflara sahip BLoC, durum yönetimi için altın standarttır Flutter kurumsal uygulamalarında. Katı olay/durum yapısı her şeyin geçiş izlenebilir, test edilebilir ve büyük ekiplerde bile anlaşılabilirdir. hidratlı_bloc önemli bir ek kod olmadan kalıcılık sağlar. Yeni özellikler için: Geçişler basitse Cubit ile başlayın, BLoC'ye ölçeklendirin Olay izlenebilirliğine ihtiyaç duyduğunuzda tamamlayın.







