Chương 07 · flutter_bloc Deep Dive

flutter_bloc Deep Dive

flutter_bloc là 1 trong 3 thư viện state management lớn nhất ecosystem. Hợp cho app medium-large. 2 lý do chọn Bloc: (1) tách logic khỏi widget rõ ràng, (2) test logic dễ vì là pure Dart function của event/state.

Opinion: dùng Cubit cho 90% case, Bloc khi cần event chain rõ ràng.

Độ dài: ~1230 dòng Bài tập: 5 Quiz: 8 Prerequisites: Ch 6 + Dart Ch 7 (Stream)
🎯 Mục tiêu chương
  • Hiểu mô hình BLoC: Event in, State out qua Stream.
  • Phân biệt Cubit vs Bloc — khi nào dùng cái nào.
  • Sử dụng BlocProvider, BlocBuilder, BlocListener, BlocConsumer.
  • Thiết kế State class theo pattern: sealed class với multiple state types.
  • Test Bloc/Cubit độc lập với widget.
  • MultiBlocProvider cho nested provider.
  • BlocSelector cho rebuild optimization.
  • Pattern repository → bloc → view.

1. Pattern BLoC là gì?

Business Logic Component. Tách logic ra khỏi UI:

  • Input: Event (Stream input).
  • Output: State (Stream output).
  • UI: subscribe state stream + dispatch event.
Bloc Pattern UI (Widget) Bloc / Cubit (logic) Repository (data) event state fetch data UI dispatch event → Bloc process → emit new state → UI rebuild

2. Cubit vs Bloc

CubitBloc
InputMethod call trực tiếp (cubit.increment())Event class (add(IncrementPressed()))
Outputemit(newState)emit(newState) trong handler on<E>
Verbose?Ít hơnNhiều hơn (event class)
Trace historyKhông event logCó (Bloc observer log event/state)
Khi dùng90% caseChain event phức tạp, analytics, time-travel debug

3. Setup

flutter pub add flutter_bloc
flutter pub add --dev bloc_test    # cho test chương 14

4. Counter Cubit — đơn giản nhất

import 'package:flutter_bloc/flutter_bloc.dart';

class CounterCubit extends Cubit<int> {
  CounterCubit() : super(0);   // initial state = 0

  void increment() => emit(state + 1);
  void decrement() => emit(state - 1);
  void reset() => emit(0);
}

Wire vào UI:

BlocProvider(
  create: (_) => CounterCubit(),
  child: CounterPage(),
)

class CounterPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Counter')),
      body: Center(
        child: BlocBuilder<CounterCubit, int>(
          builder: (context, count) => Text('$count', style: Theme.of(context).textTheme.displayLarge),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => context.read<CounterCubit>().increment(),
        child: const Icon(Icons.add),
      ),
    );
  }
}

5. Bloc — variant đầy đủ với Event

// Events
sealed class CounterEvent {}
class IncrementPressed extends CounterEvent {}
class DecrementPressed extends CounterEvent {}
class ResetPressed extends CounterEvent {}

// Bloc
class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0) {
    on<IncrementPressed>((event, emit) => emit(state + 1));
    on<DecrementPressed>((event, emit) => emit(state - 1));
    on<ResetPressed>((event, emit) => emit(0));
  }
}

// Dispatch
context.read<CounterBloc>().add(IncrementPressed());
🧠 Khi nào dùng Cubit vs Bloc?
  • Cubit — simple action: counter, toggle, form field. Method clear hơn event.
  • Bloc — complex flow: auth (LoginPressed, LogoutPressed, TokenExpired, BiometricUnlocked), shopping (AddItem, RemoveItem, ApplyCoupon, Checkout).
  • Bloc cho time-travel debug + analytics — log mọi event.

6. BlocBuilder

BlocBuilder<CounterCubit, int>(
  builder: (context, count) => Text('$count'),
)

// Với buildWhen — control khi nào rebuild
BlocBuilder<CounterCubit, int>(
  buildWhen: (prev, curr) => curr % 10 == 0,   // chỉ rebuild khi % 10
  builder: (context, count) => Text('$count'),
)

7. BlocListener — không rebuild, chỉ side effect

BlocListener<AuthCubit, AuthState>(
  listenWhen: (prev, curr) => curr is Authenticated && prev is! Authenticated,
  listener: (context, state) {
    context.go('/home');   // navigate
  },
  child: LoginForm(),
)

Khác BlocBuilder: không rebuild UI, chỉ trigger callback. Dùng cho:

  • Navigate khi state đổi.
  • Show snackbar / dialog.
  • Trigger analytics event.

8. BlocConsumer — combine Builder + Listener

BlocConsumer<LoginCubit, LoginState>(
  listener: (context, state) {
    if (state is LoginError) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(state.message)),
      );
    }
    if (state is LoginSuccess) context.go('/home');
  },
  builder: (context, state) => switch (state) {
    LoginInitial() => const LoginForm(),
    LoginLoading() => const CircularProgressIndicator(),
    LoginError() => LoginForm(error: state.message),
    LoginSuccess() => const SizedBox(),
  },
)

9. BlocSelector — optimization

Rebuild chỉ khi subset state đổi:

// Cart có 10 field. UI chỉ care totalItems.
BlocSelector<CartBloc, CartState, int>(
  selector: (state) => state.totalItems,
  builder: (context, totalItems) => Badge(label: Text('$totalItems')),
)
// Rebuild chỉ khi totalItems thay đổi, không khi item detail / total price đổi.

10. read vs watch

// read — lấy reference, KHÔNG subscribe rebuild
ElevatedButton(
  onPressed: () => context.read<CounterCubit>().increment(),
  child: const Text('+1'),
)

// watch — subscribe rebuild khi state đổi
@override
Widget build(BuildContext context) {
  final count = context.watch<CounterCubit>().state;
  return Text('$count');
}
💡 Rule of thumb
  • onPressed callback: read — không cần rebuild khi user tap.
  • Trong build(): watch hoặc BlocBuilder — cần rebuild.

11. Sealed state class — pattern chuẩn

sealed class AuthState {
  const AuthState();
}

class AuthInitial extends AuthState {
  const AuthInitial();
}

class AuthLoading extends AuthState {
  const AuthLoading();
}

class Authenticated extends AuthState {
  final User user;
  const Authenticated(this.user);
}

class Unauthenticated extends AuthState {
  final String? error;
  const Unauthenticated({this.error});
}

// Trong BlocBuilder — switch exhaustive
BlocBuilder<AuthCubit, AuthState>(
  builder: (context, state) => switch (state) {
    AuthInitial() => const SplashScreen(),
    AuthLoading() => const LoadingScreen(),
    Authenticated(:var user) => HomeScreen(user: user),
    Unauthenticated(:var error) => LoginScreen(error: error),
  },
)
🧠 Pattern này là gold standard

Sealed class (Dart 3) + exhaustive switch → compiler ép cover hết case. Thêm state mới → quên handle UI → compile fail. Đây là type-safety mạnh nhất bạn có cho state machine.

12. Async event trong Bloc

class UserBloc extends Bloc<UserEvent, UserState> {
  final UserRepository _repo;

  UserBloc(this._repo) : super(UserInitial()) {
    on<LoadUser>(_onLoad);
  }

  Future<void> _onLoad(LoadUser event, Emitter<UserState> emit) async {
    emit(UserLoading());
    try {
      final user = await _repo.fetchUser(event.id);
      emit(UserLoaded(user));
    } on ApiException catch (e) {
      emit(UserError(e.message));
    }
  }
}

13. MultiBlocProvider — nested

MultiBlocProvider(
  providers: [
    BlocProvider(create: (_) => AuthCubit(AuthRepository())),
    BlocProvider(create: (_) => CartBloc()),
    BlocProvider(create: (_) => ThemeCubit()),
  ],
  child: MyApp(),
)

14. Repository pattern

// Interface
abstract class UserRepository {
  Future<User> fetchUser(int id);
}

// Concrete impl
class UserRepositoryImpl implements UserRepository {
  final Dio _dio;
  UserRepositoryImpl(this._dio);

  @override
  Future<User> fetchUser(int id) async {
    final r = await _dio.get('/users/$id');
    return User.fromJson(r.data);
  }
}

// Inject vào Bloc
BlocProvider<UserBloc>(
  create: (ctx) => UserBloc(ctx.read<UserRepository>()),
  child: UserPage(),
)

// Wrap với RepositoryProvider ở root
RepositoryProvider<UserRepository>(
  create: (_) => UserRepositoryImpl(Dio()),
  child: MyApp(),
)

15. Test Bloc — basic

import 'package:test/test.dart';
import 'package:bloc_test/bloc_test.dart';

void main() {
  group('CounterCubit', () {
    test('initial state is 0', () {
      expect(CounterCubit().state, 0);
    });

    blocTest<CounterCubit, int>(
      'emits [1] when increment is called',
      build: () => CounterCubit(),
      act: (cubit) => cubit.increment(),
      expect: () => [1],
    );

    blocTest<CounterCubit, int>(
      'emits [1, 2] when increment called twice',
      build: () => CounterCubit(),
      act: (cubit) {
        cubit.increment();
        cubit.increment();
      },
      expect: () => [1, 2],
    );
  });
}

Chương 14 (Testing) sẽ deep dive thêm.

16. Pitfall: closure capture stale state

// ❌ Sai
on<IncrementPressed>((event, emit) async {
  final current = state;   // capture tại đây
  await Future.delayed(Duration(seconds: 1));
  emit(current + 1);  // có thể stale nếu state đã đổi trong khi await
});

// ✅ Đúng — luôn read state mới qua getter
on<IncrementPressed>((event, emit) async {
  await Future.delayed(Duration(seconds: 1));
  emit(state + 1);   // state là getter, luôn current
});

17. Bài tập

CounterCubit + reset + test

Counter app dùng Cubit. Add reset button. Test với bloc_test (3 case: initial, increment, reset).

💡 Gợi ý đáp án

Tham khảo section 4 (Counter Cubit) + section 15 (test). Test reset: act: (c) { c.increment(); c.reset(); }, expect: () => [1, 0].

LoginCubit flow

Login form 2 field (email, password) → Cubit emit Loading → mock delay 1s → emit Success hoặc Error. UI show loading spinner, error snackbar, navigate khi success.

💡 Gợi ý đáp án
sealed class LoginState { const LoginState(); }
class LoginInitial extends LoginState { const LoginInitial(); }
class LoginLoading extends LoginState { const LoginLoading(); }
class LoginSuccess extends LoginState { const LoginSuccess(); }
class LoginError extends LoginState {
  final String message;
  const LoginError(this.message);
}

class LoginCubit extends Cubit<LoginState> {
  LoginCubit() : super(const LoginInitial());

  Future<void> submit(String email, String password) async {
    emit(const LoginLoading());
    await Future.delayed(const Duration(seconds: 1));
    if (email == 'admin' && password == '1234') {
      emit(const LoginSuccess());
    } else {
      emit(const LoginError('Sai email/password'));
    }
  }
}

Refactor Cubit → Bloc

Refactor bài 2 từ Cubit sang Bloc (explicit Event class). So sánh code length + clarity.

💡 Gợi ý đáp án
sealed class LoginEvent {}
class LoginSubmitted extends LoginEvent {
  final String email;
  final String password;
  LoginSubmitted(this.email, this.password);
}

class LoginBloc extends Bloc<LoginEvent, LoginState> {
  LoginBloc() : super(const LoginInitial()) {
    on<LoginSubmitted>((e, emit) async {
      emit(const LoginLoading());
      await Future.delayed(const Duration(seconds: 1));
      if (e.email == 'admin' && e.password == '1234') {
        emit(const LoginSuccess());
      } else {
        emit(const LoginError('Sai'));
      }
    });
  }
}

Bloc dài hơn nhưng explicit. Cubit ngắn hơn — recommend cho most case.

CartBloc với 5 scenarios

Cart Bloc với event AddItem/RemoveItem/Clear. State có items list + total. Test với 5 scenario.

💡 Gợi ý đáp án

State class với List<Item> + computed total getter. Test: add 1 item, remove, clear, add same item twice, add+remove combo.

AuthBloc với MultiBlocProvider

Auth global: MultiBlocProvider wrap app. AuthBloc emit Authenticated/Unauthenticated. BlocListener navigate khi state đổi. Logout button qua bất kỳ widget.

💡 Gợi ý đáp án

Tham khảo section 11 (sealed AuthState) + section 7 (BlocListener navigate) + section 13 (MultiBlocProvider).

18. Quiz

Q1

Cubit và Bloc khác chính ở điểm?

Xem đáp án

Đáp án: Cubit emit trực tiếp qua method. Bloc qua event class + on<E> handler. Bloc verbose hơn nhưng explicit cho event chain.

Q2

context.read rebuild widget không?

Xem đáp án

Đáp án: Không. Chỉ lấy reference. watch mới subscribe rebuild.

Q3

BlocSelector vs BlocBuilder?

Xem đáp án

Đáp án: Selector rebuild chỉ khi selector output (subset state) đổi. Builder rebuild mỗi state change. Selector cho perf optimization khi state class lớn.

Q4

State class nên là immutable hay mutable?

Xem đáp án

Đáp án: Immutable. Bloc compare oldState vs newState qua == để decide rebuild. Mutable thì == always true → không rebuild.

Q5

emit(state) (same instance) có rebuild không?

Xem đáp án

Đáp án: Không, nếu == trả true. Bloc skip emit cùng giá trị (performance optimization).

Q6

Sealed state + switch exhaustive — analyzer guarantee gì?

Xem đáp án

Đáp án: Mọi state case có UI handle. Thêm state mới mà quên handle ở UI → compile lỗi. Type-safety mạnh nhất cho state machine.

Q7

Bloc nên dispose không?

Xem đáp án

Đáp án: BlocProvider tự close() Bloc khi unmount. Tự tạo qua BlocProvider.value phải close thủ công.

Q8

Test Bloc cần Flutter context không?

Xem đáp án

Đáp án: Không. Bloc/Cubit pure Dart, test trong unit test thuần. Không cần widget tree, không cần WidgetTester.

19. Tổng kết

  • ✅ Pattern BLoC: Event in → Bloc process → State out.
  • ✅ Cubit (simple) vs Bloc (event class).
  • BlocProvider, BlocBuilder, BlocListener, BlocConsumer, BlocSelector.
  • context.read (no rebuild) vs watch (rebuild).
  • ✅ Sealed state + exhaustive switch = robust UI.
  • ✅ Async event với on<E>((e, emit) async {...}).
  • ✅ MultiBlocProvider, RepositoryProvider.
  • ✅ Repository pattern: tách HTTP/DB khỏi Bloc.
  • bloc_test — pure Dart test, không cần Flutter.
  • ✅ Pitfall: state getter luôn current, không capture stale.

20. Kết nối

  • Chương 8: so sánh Bloc vs Provider/Riverpod/GetX.
  • Chương 10 — Network: Bloc gọi repository → HTTP.
  • Chương 14 — Testing: test Bloc + widget test.