ข้ามไปยังเนื้อหา

BLoC & Patterns

BLoC (Business Logic Component) คือ pattern ที่สร้างบนกฎเข้ม: data ไหลไป ทิศทางเดียว UI ส่ง event (หรือเรียก method) เข้า; BLoC emit state ออก; UI rebuild จาก state เหล่านั้น UI ไม่เคย mutate state ตรง ๆ และ business logic ไม่เคยแตะ widget

sequenceDiagram
  participant UI
  participant Bloc
  UI->>Bloc: add(Event) / เรียก method
  Bloc->>Bloc: รัน business logic
  Bloc->>UI: emit(State ใหม่)
  UI->>UI: rebuild จาก State
Unidirectional data flow — ข้อมูลไหลทิศทางเดียว

วินัยทางเดียวนี้คือคุณค่าทั้งหมด เพราะ state เปลี่ยนได้ที่เดียวเท่านั้น ตอบสนองต่อ input ที่ชัดเจน app จึงคาดเดาได้และ test ได้ — คุณ feed event ให้ BLoC ใน test แล้ว assert ลำดับ state ที่แน่นอนได้ โดยไม่ต้องมี widget เลย

package flutter_bloc (ปัจจุบัน: 9.1.1 บน pub.dev) มาพร้อมทั้งสองแบบที่บทนี้ครอบคลุม บวก bloc_test (10.0.0) สำหรับ helper blocTest และ equatable (3.0.0) สำหรับ value equality ที่ถูก ๆ บน event/state — ยืนยันทั้งหมดบน pub.dev และใช้เป็นเวอร์ชัน pin ข้างล่าง

Cubit คือแบบที่ง่ายกว่า — ไม่มี event class คุณแค่เรียก method ที่ emit state ใหม่ เริ่มที่นี่

lib/counter_cubit.dart
import 'package:flutter_bloc/flutter_bloc.dart';
/// Cubit: the simpler half of the library — no event classes, methods emit
/// states directly.
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0);
void increment() => emit(state + 1);
void reset() => emit(0);
}
// In the UI: BlocBuilder rebuilds when a new state is emitted.
BlocBuilder<CounterCubit, int>(
builder: (context, count) => Text('$count'),
);
// Trigger an input:
context.read<CounterCubit>().increment();

BLoC เต็มรูปเพิ่ม Event class ที่ชัดเจนระหว่าง UI กับ logic (add(Increment()) แทน increment()) การอ้อมนั้นแลกมาด้วย list ของทุก input ที่เป็นไปได้ที่ document และ log ได้ — มีค่าใน app ใหญ่ เกินจำเป็นใน app เล็ก

BLoC code จริงจะ model event และ state เป็น sealed class union (Dart 3) — variant ทุกตัวที่เป็นไปได้ของ event/state คือ set ที่รู้และปิดแล้ว และ switch ที่ครอบมันถูกเช็คความครบถ้วนตอน compile time ไม่ใช่มาเจอตอน runtime ทีหลัง

lib/counter_bloc.dart
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
sealed class CounterEvent {
const CounterEvent();
}
final class Increment extends CounterEvent {
const Increment();
}
final class Decrement extends CounterEvent {
const Decrement();
}
final class Reset extends CounterEvent {
const Reset();
}
sealed class CounterState extends Equatable {
const CounterState(this.value);
final int value;
@override
List<Object?> get props => [value];
}
final class CounterValue extends CounterState {
const CounterValue(super.value);
}
/// A distinct state variant — not just "CounterValue(5)" — so UI can branch
/// on the TYPE, not re-check a magic number everywhere.
final class CounterAtMax extends CounterState {
const CounterAtMax(super.value);
}
const _max = 5;
class CounterBloc extends Bloc<CounterEvent, CounterState> {
CounterBloc() : super(const CounterValue(0)) {
on<CounterEvent>((event, emit) {
final next = switch (event) {
Increment() => state.value + 1,
Decrement() => state.value - 1,
Reset() => 0,
};
emit(next >= _max ? CounterAtMax(_max) : CounterValue(next));
});
}
}

CounterState extends Equatable พร้อม props = [value] คือสิ่งที่ทำให้ expect: () => [...] ของ bloc_test (ข้างล่าง) เทียบ state ที่ emit ตามค่า ไม่ใช่ตาม identity — ถ้าไม่มีมัน CounterValue(1) สอง instance ที่สร้างแยกกันจะไม่เท่ากัน และทุก assertion ของ blocTest จะพังด้วยเรื่องเทคนิคเปล่า ๆ

ทีนี้พิสูจน์ exhaustiveness: ลบ case Reset() ออกจาก switch เดียวกันนั้นแล้วรัน flutter analyze — diagnostic จริงไม่แก้ไข:

// @expect-error missing `Reset()` case — real non_exhaustive_switch_expression, proven below
int handle(CounterEvent event, int current) {
return switch (event) {
Increment() => current + 1,
Decrement() => current - 1,
};
}
error • The type 'CounterEvent' isn't exhaustively matched by the switch cases since it doesn't match
the pattern 'Reset()'. Try adding a wildcard pattern or cases that match 'Reset()'
• non_exhaustive_switch_expression

นี่คือผลตอบแทนจริงของ sealed: เพิ่ม event class ตัวที่สี่ทีหลัง (สมมติ SetTo(int value)) แล้ว switch ทุกตัวที่ครอบ CounterEvent ที่ไหนก็ตามใน codebase ที่ไม่ได้จัดการมันจะ fail flutter analyze ทันที — ไม่ใช่ “มีคนมาสังเกตทีหลังว่า UI ไม่ทำอะไรเลยสำหรับ event ใหม่บน production”

BlocProvider วาง BLoC/Cubit ไว้ใน widget tree (มันสร้างบน InheritedWidget กลไกเดียวกับบท InheritedWidget) เพื่อให้ context.read/BlocBuilder/BlocListener ข้างล่างหามันเจอ BlocBuilder rebuild ทุกครั้งที่มี state ใหม่โดย default — buildWhen ทำให้แคบลง เหมือน aspect ของ InheritedModel หรือ .select() ของ Riverpod เป๊ะ: คืน false เพื่อข้าม rebuild สำหรับการเปลี่ยน state ที่ UI ไม่ได้สนใจจริง ๆ

lib/counter_bloc_widgets.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:my_app/counter_bloc.dart';
int capBannerBuildCount = 0;
final capReachedLog = <int>[];
/// Only cares about whether we've hit the cap, not the running count — so
/// buildWhen skips every intermediate increment and rebuilds ONLY when the
/// state's runtime TYPE actually changes (CounterValue <-> CounterAtMax).
class CapBanner extends StatelessWidget {
const CapBanner({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<CounterBloc, CounterState>(
buildWhen: (previous, current) => previous.runtimeType != current.runtimeType,
builder: (context, state) {
capBannerBuildCount++;
return state is CounterAtMax ? const Text('capped') : const SizedBox.shrink();
},
);
}
}
/// A side effect (logging here; in a real app: SnackBar, navigation) that
/// fires once when the cap is reached, without itself rebuilding anything.
class CapListener extends StatelessWidget {
const CapListener({super.key, required this.child});
final Widget child;
@override
Widget build(BuildContext context) {
return BlocListener<CounterBloc, CounterState>(
listener: (context, state) {
if (state is CounterAtMax) capReachedLog.add(state.value);
},
child: child,
);
}
}
test/counter_bloc_widgets_test.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/counter_bloc.dart';
import 'package:my_app/counter_bloc_widgets.dart';
void main() {
testWidgets('buildWhen skips rebuilds while the state TYPE stays the same', (tester) async {
capBannerBuildCount = 0;
final bloc = CounterBloc();
addTearDown(bloc.close);
await tester.pumpWidget(
MaterialApp(
home: BlocProvider.value(value: bloc, child: const CapBanner()),
),
);
expect(capBannerBuildCount, 1, reason: 'initial build always happens');
// 0 -> 1 -> 2 -> 3 -> 4: all stay CounterValue, buildWhen returns false —
// no rebuild for any of these four.
for (var i = 0; i < 4; i++) {
bloc.add(const Increment());
await tester.pump();
}
expect(capBannerBuildCount, 1, reason: 'runtimeType never changed — buildWhen skipped every rebuild');
expect(find.text('capped'), findsNothing);
// 4 -> 5: CounterValue -> CounterAtMax, runtimeType changes — rebuilds.
bloc.add(const Increment());
await tester.pump();
expect(capBannerBuildCount, 2, reason: 'runtimeType changed — buildWhen allowed exactly one rebuild');
expect(find.text('capped'), findsOneWidget);
});
testWidgets('BlocListener fires exactly once when the cap is reached, without rebuilding', (tester) async {
capReachedLog.clear();
final bloc = CounterBloc();
addTearDown(bloc.close);
await tester.pumpWidget(
MaterialApp(
home: BlocProvider.value(
value: bloc,
child: CapListener(child: const SizedBox.shrink()),
),
),
);
expect(capReachedLog, isEmpty);
for (var i = 0; i < 5; i++) {
bloc.add(const Increment());
await tester.pump();
}
expect(capReachedLog, [5], reason: 'listener fired exactly once, with the capped value');
});
}
✓ buildWhen skips rebuilds while the state TYPE stays the same
✓ BlocListener fires exactly once when the cap is reached, without rebuilding
00:00 +2: All tests passed!

event Increment สี่ครั้งได้ CounterValue ใหม่สี่ตัว และ capBannerBuildCount ไม่ขยับเลย — พิสูจน์ว่า buildWhen กัน rebuild ได้จริง ไม่ใช่แค่ “output บังเอิญหน้าตาเหมือนเดิม” มีแค่ event ที่ห้าเท่านั้น ตัวที่เปลี่ยน runtime type ของ state จริง ๆ ที่ทำให้ build ครั้งที่สองเกิดขึ้น

blocTest สร้าง bloc ขับเคลื่อนมันด้วย act แล้ว assert list ของ state ที่ emit ทั้งหมดตามลำดับด้วย expect — ไม่มี widget เกี่ยวข้องเลย ตรงกับผลตอบแทน “คาดเดาได้และ test ได้” จากกฎทางเดียวเป๊ะ ๆ

test/counter_bloc_actions_test.dart
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:my_app/counter_bloc.dart';
void main() {
group('CounterBloc', () {
blocTest<CounterBloc, CounterState>(
'emits [CounterValue(1)] when Increment is added once',
build: CounterBloc.new,
act: (bloc) => bloc.add(const Increment()),
expect: () => [const CounterValue(1)],
);
blocTest<CounterBloc, CounterState>(
'emits CounterAtMax once the count reaches 5',
build: CounterBloc.new,
act: (bloc) {
for (var i = 0; i < 5; i++) {
bloc.add(const Increment());
}
},
expect: () => [
const CounterValue(1),
const CounterValue(2),
const CounterValue(3),
const CounterValue(4),
const CounterAtMax(5),
],
);
blocTest<CounterBloc, CounterState>(
'Reset emits CounterValue(0) regardless of prior state',
build: CounterBloc.new,
seed: () => const CounterAtMax(5),
act: (bloc) => bloc.add(const Reset()),
expect: () => [const CounterValue(0)],
);
});
}
✓ CounterBloc emits [CounterValue(1)] when Increment is added once
✓ CounterBloc emits CounterAtMax once the count reaches 5
✓ CounterBloc Reset emits CounterValue(0) regardless of prior state
00:00 +3: All tests passed!

seed: เริ่ม bloc จาก state ที่กำหนดโดยตรง โดยไม่ต้อง replay ทุก event ที่ปกติจะนำไปสู่ state นั้น — จำเป็นมากสำหรับ test “จะเกิดอะไรต่อจาก state X” แบบแยกโดด ๆ expect: () => [...] เทียบตามค่าได้เพราะ Equatable — ถ้าไม่มีมัน ทั้งไฟล์นี้จะ fail ทั้งที่ logic ถูกต้องเป๊ะ

pubspec.yaml
dependencies:
flutter_bloc: ^9.1.1
equatable: ^3.0.0
dev_dependencies:
bloc_test: ^10.0.0

BLoC มีโครงสร้างมากกว่า setState หรือ Provider และโครงสร้างนั้นคือต้นทุนที่คุณจ่ายล่วงหน้า ซึ่งคืนทุนเมื่อ:

  • app ใหญ่ และคุณอยากได้ business logic เป็น unit ที่ test ได้ แยกจาก UI อย่างสมบูรณ์ — พิสูจน์ข้างบน: CounterBloc state machine ทั้งตัวถูก assert โดยไม่มี widget เลย
  • state transition ซับซ้อน — event เยอะ, async flow, state variant ที่แยกจากกันจริง ๆ (ไม่ใช่แค่ field เปลี่ยน) — และคุณอยากให้มันชัดเจน เช็ค exhaustiveness ได้ และ trace ได้
  • ทีม ได้ประโยชน์จาก pattern เดียวที่ enforce ทั่ว codebase ที่ทุก input ที่เป็นไปได้คือ class ที่มีชื่อและค้นเจอได้ ไม่ใช่การเรียก method แบบ ad hoc

สำหรับ app เล็กหรือ local state ของหน้าเดียว BLoC คือ over-engineering — setState หรือ ChangeNotifier ง่าย ๆ ชัดกว่า ทักษะคือการจับคู่ ceremony กับความซับซ้อน

BLoC คือ เครื่องฟังก์ชันบริสุทธิ์ที่สวม stream: (currentState, event) -> newState โดยมี emit เป็นทางออกทางเดียว และ add เป็นทางเข้าทางเดียว Cubit ถอด layer ของ event ออกจากเครื่องนั้น — “input” คือแค่ “method ไหนถูกเรียก” — แลก traceability กับ boilerplate ที่น้อยลง sealed บน hierarchy ของ event/state คือสิ่งที่เปลี่ยน “เราคิดว่าเราจัดการทุกเคสแล้ว” ให้กลายเป็น “compiler ปฏิเสธไม่ build จนกว่าจะทำจริง” buildWhen/listenWhen คือไอเดียเดียวกับ aspect ของ InheritedModel หรือ .select() ของ Riverpod แค่นำมาใช้กับ stream ของ state ของ BLoC แทนค่าของ provider

ตัวเลือกข้อดีต้นทุน
Cubitceremony น้อยที่สุด — method ที่ emitไม่มี event log/traceability; เห็น “input ที่เป็นไปได้อะไรบ้าง” จาก type อย่างเดียวยากกว่า
BLoC เต็มรูปพร้อม sealed eventทุก input คือ class ที่มีชื่อและถูกเช็ค exhaustivenessboilerplate ต่อ feature มากกว่า — event class, switch/on<T> handler
sealed state union (หลาย variant)UI แยกตาม type ไม่ใช่ค่ามหัศจรรย์ที่ซ่อนใน field เดียวclass เยอะกว่า “state class เดียวที่มี nullable field เพียบ”
Equatable บน state/eventblocTest/BlocBuilder เทียบตามค่า ไม่ใช่ identityclass base เพิ่มอีกตัว กับ list props ที่ต้อง sync กับ field เอง
buildWhen/listenWhenข้าม rebuild/side effect ที่ UI ไม่สนใจ พิสูจน์ข้างบนpredicate อีกตัวที่ต้องทำให้ถูก — bug ตรงนี้จะกด update จริงให้เงียบไปเฉย ๆ
  • “Cubit กับ Bloc คือ API ที่ไม่เกี่ยวกัน” Bloc<Event, State> คือ Cubit<State> ที่เพิ่มรับ stream ของ event ที่ typed แทนการเรียก method ตรง ๆ — กลไกการ emit state ข้างใต้เหมือนกัน
  • “switch ที่ครอบ sealed class exhaustive อัตโนมัติ ไม่ว่าจะเขียนยังไง” พิสูจน์ผิดข้างบน: analyzer ปฏิเสธ compile switch expression ที่ขาด case จริง ๆ พร้อม diagnostic จริงเจาะจง (non_exhaustive_switch_expression) — แต่การป้องกันนี้มีอยู่ก็เพราะ class hierarchy เป็น sealed เท่านั้น; abstract class ธรรมดาไม่มีการเช็คแบบนี้
  • “buildWhen แค่ optimize performance UI ทำงานเหมือนเดิมไม่ว่ายังไง” พิสูจน์ข้างบน: capBannerBuildCount นิ่งตลอดสี่ event คือประเด็นเชิงฟังก์ชันทั้งหมด ไม่ใช่ micro-optimization — ถ้า builder มี side effect buildWhen: false จะข้ามมันจริง ๆ
  • “ไม่มี Equatable state ก็แค่เทียบช้าลงนิดหน่อย” พิสูจน์โดยนัยข้างบน: ไม่มี Equatable แล้ว expect: () => [CounterValue(1)] ของ blocTest จะ fail ทั้งที่ state ที่ emit มีค่าที่ถูกต้องเป๊ะ — นี่คือปัญหาเรื่อง correctness ของ test ไม่ใช่ performance
  • “BLoC คือตัวเลือกที่ถูกเสมอสำหรับ state management แบบ ‘จริงจัง’” ตาราง trade-off กับหัวข้อ “เมื่อไร ceremony ถึงคุ้ม” ของบทนี้บอกตรงข้ามเลย: สำหรับ state ง่าย ๆ ของหน้าเดียว class เพิ่มเติมคือ overhead ล้วน ๆ ไม่มีผลตอบแทน

BLoC code ที่ AI generate มามักได้รูปทรงถูกต้อง (sealed event, Bloc<Event, State>) แต่ข้ามสองอย่างที่สำคัญเฉพาะตอน feature โตขึ้น: Equatable (ทำให้ equality-based test และการเทียบของ buildWhen/listenWhen พังเงียบ ๆ) และการทำให้ switch กว้างครอบจริง ๆ แทนที่จะเป็นสาย if (event is X) ที่ analyzer เช็คความครบถ้วนให้ไม่ได้

// AI-generated: "handle the counter events"
class CounterBloc extends Bloc<CounterEvent, CounterState> {
CounterBloc() : super(const CounterValue(0)) {
on<CounterEvent>((event, emit) {
if (event is Increment) {
emit(CounterValue(state.value + 1));
} else if (event is Decrement) {
emit(CounterValue(state.value - 1));
}
// Reset silently does nothing — no compiler warning, because this is
// an if/else chain, not a switch over a sealed type.
});
}
}

ก่อนเชื่อ BLoC/Cubit code ที่ generate มา เช็คสิ่งเหล่านี้:

  • มี switch (event) { ... } (หรือ switch (state) { ... }) ที่ครอบ sealed hierarchy ไหม ไม่ใช่สาย if (event is X) ... else if (event is Y) ... — มีแค่รูปแบบ switch เท่านั้นที่ได้การเช็ค exhaustiveness จริงจาก analyzer พิสูจน์ข้างบนแล้ว
  • มี Equatable (หรือ ==/hashCode แบบเขียนมือ) บนทุก state และ event class ที่จะถูกเทียบไหม — ใน expect: () => [...] ของ test, ใน buildWhen, หรือใน dedup แบบ equality-based default ของ BlocListener
  • predicate ของ buildWhen/listenWhen อ้างอิง field/type ที่มันอ้างว่าเป็นเงื่อนไขจริงไหม ไม่ใช่ stub ที่คืน true เสมอ (เสียจุดประสงค์) หรือ false เสมอ (UI ไม่เคย update เลย)
  • ทุก event class ที่ sealed hierarchy ประกาศไว้ถูกจัดการที่ไหนสักแห่งจริงไหม — analyzer จะจับตรงนี้ได้ก็ต่อเมื่ออยู่ใน switch จริง ดังนั้น handler ที่ generate มาแล้วทิ้ง event ไปเงียบ ๆ (เหมือน Reset ข้างบน) จะ ship ออกไปสะอาดถ้ามันไม่เคยใช้ switch ตั้งแต่แรก

🐛 หา bug ในโค้ดนี้

sealed class AuthState {}
final class Unauthenticated extends AuthState {}
final class Authenticated extends AuthState {
Authenticated(this.userId);
final String userId;
}
final class AuthError extends AuthState {
AuthError(this.message);
final String message;
}
class AuthCubit extends Cubit<AuthState> {
AuthCubit() : super(Unauthenticated());
String statusLabel() => switch (state) {
Authenticated(:final userId) => 'Signed in as $userId',
Unauthenticated() => 'Signed out',
};
}
เฉลย

โค้ดนี้ compile ไม่ผ่าน: AuthState มี sealed subtype สามตัว (Unauthenticated, Authenticated, AuthError) แต่ switch expression ใน statusLabel() ครอบแค่สองตัว — analyzer error จริงคือ non_exhaustive_switch_expression บอกตรง ๆ ว่าขาดอะไร: "doesn't match the pattern 'AuthError()'" นี่คือ bug ประเภทเดียวกับที่พิสูจน์ไว้ก่อนหน้าในบทนี้ด้วย CounterEvent เป๊ะ วิธีแก้คือเพิ่ม case ที่ขาด (AuthError(:final message) => 'Error: \$message') — และเพราะ hierarchy เป็น sealed compiler จะบังคับให้แก้แบบเดียวกันนี้กับทุก switch อื่นที่ครอบ AuthState ใน codebase ด้วย ไม่ใช่แค่ตัวนี้

กฎที่นิยาม pattern ของ BLoC คืออะไร?
Cubit ต่างจาก BLoC เต็มรูปอย่างไร?
switch expression ที่ครอบ sealed CounterEvent hierarchy ขาด case Reset() ผลลัพธ์จริงที่พิสูจน์คืออะไร?
พิสูจน์ด้วย widget test จริงในบทนี้ buildWhen ของ BlocBuilder ทำอะไรจริง ๆ?
ทำไม CounterState ของบทนี้ extend Equatable ตามที่พิสูจน์เกี่ยวข้องจริงจาก assertion ของ blocTest?
parameter seed: ของ blocTest ที่ใช้ใน test ของ Reset ในบทนี้ทำอะไร?
ตามตาราง trade-off ของบทนี้ BLoC เป็น over-engineering เมื่อไร?