文章

BLoC 模式深度解析

BLoC 用 Stream 把事件与状态解耦:讲清 Event→Bloc→State 的单向数据流与 Stream 中转。 掌握后能说清「BLoC 和 Provider 的核心差异」及适用场景。

BLoC 模式深度解析

一句话概括

BLoC = Events In, States Out。每一次状态变化都有明确的事件来源——这在 Flutter 状态管理方案里是独一份。Provider 告诉你 “状态变了”,BLoC 告诉你 “谁触发的变化、从哪个状态变到了哪个状态”。

核心知识点

1. 三层模型:Event → Bloc → State

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// 事件:描述"发生了什么"
class LoginButtonPressed {
  final String email, password;
  const LoginButtonPressed(this.email, this.password);
}

// 状态:描述"界面长什么样"
abstract class LoginState {}
class LoginInitial extends LoginState {}
class LoginLoading extends LoginState {}
class LoginSuccess extends LoginState { final String token; LoginSuccess(this.token); }
class LoginFailure extends LoginState { final String msg; LoginFailure(this.msg); }

// BLoC:事件→状态的转换逻辑(纯 Dart,零 UI 依赖)
class LoginBloc extends Bloc<LoginButtonPressed, LoginState> {
  final AuthRepo repo;
  LoginBloc(this.repo) : super(LoginInitial()) {
    on<LoginButtonPressed>((event, emit) async {
      emit(LoginLoading());
      try {
        final token = await repo.login(event.email, event.password);
        emit(LoginSuccess(token));
      } catch (e) {
        emit(LoginFailure(e.toString()));
      }
    });
  }
}

关键:事件和状态都用类来表示,而不是原始类型。这是 BLoC 的最大设计原则——类型系统帮你区分 10 种不同的 “loading” 状态。

2. BlocBuilder / BlocListener / BlocConsumer 三兄弟

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// BlocBuilder:状态映射到 UI(会重建)
BlocBuilder<LoginBloc, LoginState>(
  builder: (context, state) => switch (state) {
    LoginLoading() => const CircularProgressIndicator(),
    LoginSuccess(:final token) => Text('登录成功, token: $token'),
    LoginFailure(:final msg) => Text('出错: $msg'),
    _ => const LoginForm(),
  },
);

// BlocListener:一次性副作用(不重建 UI)
BlocListener<LoginBloc, LoginState>(
  listener: (context, state) {
    if (state is LoginSuccess) Navigator.pushReplacementNamed(context, '/home');
    if (state is LoginFailure) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.msg)));
  },
  child: const LoginForm(),
);

// BlocConsumer:既要重建 UI 又要副作用,合二为一
BlocConsumer<LoginBloc, LoginState>(
  listener: /* 同上 */,
  builder: /* 同上 */,
);

记法:Builder 管 UI,Listener 管动作。两者互斥但 BlocConsumer 把它俩装一块了。

3. 事件转换器:用 debounce 避免浪费请求

1
2
3
4
5
6
7
class SearchBloc extends Bloc<SearchEvent, SearchState> {
  SearchBloc() : super(SearchInitial()) {
    on<QueryChanged>(_onQueryChanged,
      transformer: debounce(const Duration(milliseconds: 300)), // BLoC 内置
    );
  }
}

debounce transformer 直接消灭了搜索时 “每按一个键发一次请求” 的问题。比在 UI 层手动写 Timer 清爽太多。

4. BLoC 最大的卖点:测试不需要 Widget

1
2
3
4
5
6
7
8
9
10
11
12
13
blocTest<LoginBloc, LoginState>(
  '登录成功: Initial → Loading → Success',
  build: () => LoginBloc(MockAuthRepo()..mockLogin(returns: 'token123')),
  act: (bloc) => bloc.add(LoginButtonPressed('a@b.com', '123456')),
  expect: () => [LoginLoading(), LoginSuccess('token123')],
);

blocTest<LoginBloc, LoginState>(
  '登录失败: Initial → Loading → Failure',
  build: () => LoginBloc(MockAuthRepo()..mockLogin(throws: AuthException('密码错误'))),
  act: (bloc) => bloc.add(LoginButtonPressed('a@b.com', 'wrong')),
  expect: () => [LoginLoading(), LoginFailure('密码错误')],
);

blocTest 接收一个 BLoC 构造函数、一个事件、然后断言状态序列。整个过程不碰 Widget 树,不碰 pumpAndSettle,毫秒级跑完。

5. BLoC 底层就是两个 StreamController

1
2
3
4
5
6
7
8
9
10
11
// BLoC 内部伪代码
abstract class Bloc<Event, State> {
  final _eventController = StreamController<Event>.broadcast();
  final _stateController = StreamController<State>.broadcast();

  Bloc() { _eventController.stream.listen(_handleEvent); }

  void add(Event e) => _eventController.add(e);
  void emit(State s) => _stateController.add(s);
  Stream<State> get stream => _stateController.stream;
}

一个 StreamController 收事件,一个 StreamController 发状态。中间是你的 on<T>(handler) 注册的函数。

其实你每天都在用

  • 搜索框的实时补全 — 一边打字一边发请求 → BLoC debounce transformer 让只有最后一次敲键才触发请求
  • 登录/注册流程 — Initial → Loading → Success/Failure 三态切换,BLoC 的标准模板
  • 分页加载更多 — 滚动到底部发 LoadMore 事件,BLoC 追加新数据到列表状态
  • 多步骤表单 — 每个步骤一个事件,状态里记录步骤序号和数据,BLoC 天然适合 wizard 流程
  • WebSocket 实时推送 — Stream 直接对接,价格变动事件 → BLoC → UI 刷新

常见误解(FAQ)

  • ❌ 误区:「BLoC 模板代码太多,写起来很累」 确实比 Provider 多。但这堆 “多余代码” 换来的是:事件回溯、测试不需要 Widget、状态不会在背后被修改——三个字:可维护。小型 CRUD 项目别上 BLoC,中大型多人协作项目别不上 BLoC。

  • **❌ 误区:「on 里可以在 await 之后 emit 任意多次」** BLoC 关闭后 emit 会抛异常。比如用户在 loading 期间关闭了页面,BlocProvider 自动 dispose BLoC,你的 `await repo.login()` 回来再 `emit(LoginSuccess(...))` —— booom。正确做法:`if (!isClosed) emit(...)`。

  • ❌ 误区:「BlocBuilder 只是一个更啰嗦的 setState」 setState 重建整个 StatefulWidget 的 build,BlocBuilder 只重建 builder 回调的内容。前者像全屏刷新,后者像精准更新一个 cell。

  • ❌ 误区:「BLoC 和 Provider 互斥,不能一起用」 它们可以共存。Provider 管配置类、主题、简单的值;BLoC 管复杂业务流程。MultiBlocProvider 旁边放个 Provider<bool>.value(value: isDark) —— 完全没问题。

一句话总结

BLoC 的强项不是好用,是好查——当产品问 “为什么用户页面显示了这个错误状态”,你能顺着事件日志找到哪个事件触发的哪个状态。Provider 给了你速度,BLoC 给了你审计。

本文由作者按照 CC BY 4.0 进行授权