BLoC パターンの詳細: イベント、状態、キュービット
BLoC (ビジネス ロジック コンポーネント) パターンは 2018 年に Google によって作成されました Flutter でビジネス ロジックを UI から分離するという問題を解決します。 今日は、 フラッターブロック9 そして Dart 3 はさらに強力になりました。 ル シールドされたクラス 状態に対して完全な型安全性を提供します。 の キュービット 明示的なイベントを使用せずにユースケースを簡素化します。 水和ブロック 自動状態永続性を追加します。
このガイドでは、Cubit と完全な BLoC の実際的な違い、管理について説明します。
慣用的なエラー、複雑な機能に対する複数の BLoC の構成、
そして戦略をテストする bloc_test.
何を学ぶか
- Cubit 対 BLoC: シンプルさが構造に勝つとき
- 徹底したタイプセーフ状態を実現するシールド クラス Dart 3
- 型指定されたエラー状態によるエラー処理
- BLoC の構成: BLoC が他の BLoC をリッスンする
- quantum_bloc: セッション間での自動状態永続化
- block_test: クリーンなテストの出力、動作、期待パターン
- BlocObserver: すべての BLoC のグローバル ロギング
Cubit: 簡略化された 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();
}
}
block_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);
});
}
quantum_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 エンタープライズ アプリで。厳格なイベント/状態構造により、すべての 移行は追跡可能で、テスト可能で、大規模なチームであっても理解可能です。 水分子ブロックは、大幅な追加コードなしで永続性を追加します。 新機能の場合: トランジションが単純な場合は Cubit から開始し、BLoC にスケールします イベントのトレーサビリティが必要な場合に完了します。







