下面是一篇系统、可实操 的 Flutter BLoC 指南(含完整代码片段)。读完你可以快速上手从 Cubit
到 Bloc
、从 UI 到持久化与测试的全链路用法。
1. BLoC 是什么?为什么用?
BLoC(Business Logic Component)是一种把业务逻辑 与视图 解耦的状态管理模式。
核心收益:
-
逻辑可复用、可测试(Stream 驱动,输入事件 → 输出状态)
-
UI 更"傻",只负责渲染;复杂度下沉到 Bloc/Cubit
-
适合中大型项目(模块化、多人协作、依赖清晰)
快速对比:
Cubit:只有 State,没有 Event,API 更轻、更直接。
Bloc:Event + State,适合多来源事件、复杂流转。
2. 关键概念(很重要)
-
State :UI 渲染的数据快照,必须不可变(推荐配合
equatable
)。 -
Event(仅 Bloc):触发状态变化的意图(用户点击、网络返回等)。
-
Cubit/Bloc:接收输入(事件/方法调用),输出新状态(Stream)。
-
BlocProvider:把 Bloc/Cubit 注入到 Widget 树。
-
BlocBuilder / BlocListener / BlocConsumer:
-
Builder:根据状态重建 UI
-
Listener:根据状态执行一次性副作用(如弹 Toast)
-
Consumer:两者合体(慎用,通常拆开更清晰)
-
-
BlocSelector:只选状态的一部分,减少无关重建。
3. 准备工作(pubspec)
dependencies:
flutter:
sdk: flutter
flutter_bloc: ^8.1.0 # 选择最新稳定版
equatable: ^2.0.5
rxdart: ^0.27.0 # 可选:做防抖/节流更方便
# bloc_concurrency: ^0.2.0 # 可选:节流/丢弃等并发策略
dev_dependencies:
bloc_test: ^9.1.0
flutter_test:
sdk: flutter
4. 示例一:用 Cubit 实现最小计数器
适合:简单状态,无需 Event
counter_cubit.dart
import 'package:flutter_bloc/flutter_bloc.dart';
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0);
void increment() => emit(state + 1);
void decrement() => emit(state - 1);
}
ui.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'counter_cubit.dart';
class CounterPage extends StatelessWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => CounterCubit(),
child: Scaffold(
appBar: AppBar(title: const Text('Cubit 计数器')),
body: Center(
child: BlocBuilder<CounterCubit, int>(
builder: (_, count) => Text('$count', style: const TextStyle(fontSize: 48)),
),
),
floatingActionButton: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
FloatingActionButton(onPressed: () => context.read<CounterCubit>().increment(), child: const Icon(Icons.add)),
const SizedBox(height: 12),
FloatingActionButton(onPressed: () => context.read<CounterCubit>().decrement(), child: const Icon(Icons.remove)),
],
),
),
);
}
}
5. 示例二:用 Bloc 做一个 Todo(含 Repository、事件/状态)
5.1 模型与仓库
todo.dart
import 'package:equatable/equatable.dart';
class Todo extends Equatable {
final String id;
final String title;
final bool completed;
const Todo({required this.id, required this.title, this.completed = false});
Todo copyWith({String? title, bool? completed}) =>
Todo(id: id, title: title ?? this.title, completed: completed ?? this.completed);
@override
List<Object?> get props => [id, title, completed];
}
todo_repository.dart
import 'dart:async';
import 'todo.dart';
class TodoRepository {
final List<Todo> _data = [];
Future<List<Todo>> fetchTodos() async {
await Future.delayed(const Duration(milliseconds: 300)); // 模拟网络
return List.unmodifiable(_data);
}
Future<void> add(String title) async {
_data.add(Todo(id: DateTime.now().microsecondsSinceEpoch.toString(), title: title));
}
Future<void> toggle(String id) async {
final idx = _data.indexWhere((e) => e.id == id);
if (idx != -1) {
_data[idx] = _data[idx].copyWith(completed: !_data[idx].completed);
}
}
Future<void> remove(String id) async {
_data.removeWhere((e) => e.id == id);
}
}
5.2 事件与状态
todo_event.dart
import 'package:equatable/equatable.dart';
abstract class TodoEvent extends Equatable {
const TodoEvent();
@override
List<Object?> get props => [];
}
class TodosRequested extends TodoEvent {}
class TodoAdded extends TodoEvent {
final String title;
const TodoAdded(this.title);
@override
List<Object?> get props => [title];
}
class TodoToggled extends TodoEvent {
final String id;
const TodoToggled(this.id);
@override
List<Object?> get props => [id];
}
class TodoRemoved extends TodoEvent {
final String id;
const TodoRemoved(this.id);
@override
List<Object?> get props => [id];
}
todo_state.dart
import 'package:equatable/equatable.dart';
import 'todo.dart';
enum TodosStatus { initial, loading, success, failure }
class TodosState extends Equatable {
final TodosStatus status;
final List<Todo> items;
final String? message;
const TodosState({
this.status = TodosStatus.initial,
this.items = const [],
this.message,
});
TodosState copyWith({TodosStatus? status, List<Todo>? items, String? message}) =>
TodosState(status: status ?? this.status, items: items ?? this.items, message: message);
@override
List<Object?> get props => [status, items, message];
}
5.3 Bloc 实现
todo_bloc.dart
import 'package:flutter_bloc/flutter_bloc.dart';
import 'todo_event.dart';
import 'todo_state.dart';
import 'todo_repository.dart';
class TodoBloc extends Bloc<TodoEvent, TodosState> {
final TodoRepository repo;
TodoBloc({required this.repo}) : super(const TodosState()) {
on<TodosRequested>(_onRequested);
on<TodoAdded>(_onAdded);
on<TodoToggled>(_onToggled);
on<TodoRemoved>(_onRemoved);
}
Future<void> _onRequested(TodosRequested e, Emitter<TodosState> emit) async {
emit(state.copyWith(status: TodosStatus.loading));
try {
final data = await repo.fetchTodos();
emit(state.copyWith(status: TodosStatus.success, items: data));
} catch (err) {
emit(state.copyWith(status: TodosStatus.failure, message: '$err'));
}
}
Future<void> _onAdded(TodoAdded e, Emitter<TodosState> emit) async {
await repo.add(e.title);
add(TodosRequested());
}
Future<void> _onToggled(TodoToggled e, Emitter<TodosState> emit) async {
await repo.toggle(e.id);
add(TodosRequested());
}
Future<void> _onRemoved(TodoRemoved e, Emitter<TodosState> emit) async {
await repo.remove(e.id);
add(TodosRequested());
}
}
5.4 UI 绑定
todo_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'todo_bloc.dart';
import 'todo_state.dart';
import 'todo_event.dart';
import 'todo_repository.dart';
import 'todo.dart';
class TodoPage extends StatelessWidget {
const TodoPage({super.key});
@override
Widget build(BuildContext context) {
return RepositoryProvider(
create: (_) => TodoRepository(),
child: BlocProvider(
create: (ctx) => TodoBloc(repo: ctx.read<TodoRepository>())..add(TodosRequested()),
child: const _TodoView(),
),
);
}
}
class _TodoView extends StatefulWidget {
const _TodoView();
@override
State<_TodoView> createState() => _TodoViewState();
}
class _TodoViewState extends State<_TodoView> {
final _controller = TextEditingController();
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: BlocSelector<TodoBloc, TodosState, int>(
selector: (state) => state.items.where((e) => !e.completed).length,
builder: (_, count) => Text('Todos(未完成:$count)'),
),
),
body: BlocConsumer<TodoBloc, TodosState>(
listenWhen: (prev, curr) => prev.status != curr.status,
listener: (context, state) {
if (state.status == TodosStatus.failure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message ?? '加载失败')),
);
}
},
builder: (context, state) {
if (state.status == TodosStatus.loading) {
return const Center(child: CircularProgressIndicator());
}
if (state.status == TodosStatus.failure) {
return Center(child: Text(state.message ?? '出错了'));
}
final items = state.items;
if (items.isEmpty) {
return const Center(child: Text('暂无数据'));
}
return ListView.separated(
itemCount: items.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (_, i) {
final Todo(:id, :title, :completed) = items[i];
return ListTile(
title: Text(title, style: TextStyle(decoration: completed ? TextDecoration.lineThrough : null)),
leading: Checkbox(value: completed, onChanged: (_) => context.read<TodoBloc>().add(TodoToggled(id))),
trailing: IconButton(icon: const Icon(Icons.delete), onPressed: () => context.read<TodoBloc>().add(TodoRemoved(id))),
);
},
);
},
),
bottomNavigationBar: SafeArea(
minimum: const EdgeInsets.all(12),
child: Row(
children: [
Expanded(
child: TextField(
controller: _controller,
decoration: const InputDecoration(hintText: '输入待办事项'),
),
),
const SizedBox(width: 8),
ElevatedButton(
onPressed: () {
final text = _controller.text.trim();
if (text.isNotEmpty) {
context.read<TodoBloc>().add(TodoAdded(text));
_controller.clear();
}
},
child: const Text('添加'),
),
],
),
),
);
}
}
6. 示例三:搜索输入的防抖(RxDart)
适合:联想搜索、输入框频繁变化,减少无谓请求。
search_bloc.dart
import 'dart:async';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:rxdart/rxdart.dart';
class SearchEvent extends Equatable {
final String query;
const SearchEvent(this.query);
@override
List<Object?> get props => [query];
}
enum SearchStatus { idle, loading, success, failure }
class SearchState extends Equatable {
final SearchStatus status;
final String query;
final List<String> results;
final String? message;
const SearchState({
this.status = SearchStatus.idle,
this.query = '',
this.results = const [],
this.message,
});
SearchState copyWith({SearchStatus? status, String? query, List<String>? results, String? message}) =>
SearchState(status: status ?? this.status, query: query ?? this.query, results: results ?? this.results, message: message);
@override
List<Object?> get props => [status, query, results, message];
}
EventTransformer<T> debounce<T>(Duration d) {
return (events, mapper) => events.debounceTime(d).switchMap(mapper);
}
class SearchBloc extends Bloc<SearchEvent, SearchState> {
SearchBloc() : super(const SearchState()) {
on<SearchEvent>(_onQueryChanged, transformer: debounce(const Duration(milliseconds: 350)));
}
Future<void> _onQueryChanged(SearchEvent e, Emitter<SearchState> emit) async {
if (e.query.isEmpty) {
emit(state.copyWith(status: SearchStatus.idle, results: []));
return;
}
emit(state.copyWith(status: SearchStatus.loading, query: e.query));
try {
// 模拟请求
await Future.delayed(const Duration(milliseconds: 300));
final fake = List<String>.generate(5, (i) => '${e.query}_结果_$i');
emit(state.copyWith(status: SearchStatus.success, results: fake));
} catch (err) {
emit(state.copyWith(status: SearchStatus.failure, message: '$err'));
}
}
}
search_ui.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'search_bloc.dart';
class SearchPage extends StatelessWidget {
const SearchPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => SearchBloc(),
child: Scaffold(
appBar: AppBar(title: const Text('搜索(防抖)')),
body: Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
TextField(
decoration: const InputDecoration(hintText: '输入关键字...'),
onChanged: (q) => context.read<SearchBloc>().add(SearchEvent(q)),
),
const SizedBox(height: 12),
Expanded(
child: BlocBuilder<SearchBloc, SearchState>(
builder: (_, state) {
switch (state.status) {
case SearchStatus.loading:
return const Center(child: CircularProgressIndicator());
case SearchStatus.success:
return ListView.builder(
itemCount: state.results.length,
itemBuilder: (_, i) => ListTile(title: Text(state.results[i])),
);
case SearchStatus.failure:
return Center(child: Text(state.message ?? '出错了'));
case SearchStatus.idle:
default:
return const Center(child: Text('开始输入进行搜索'));
}
},
),
),
],
),
),
),
);
}
}
不想引入 RxDart?可以改用
bloc_concurrency
的throttle
/droppable
实现「节流」。
7. 示例四:表单校验 + BlocListener
做副作用
login_cubit.dart
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class LoginState extends Equatable {
final String email;
final String password;
final bool submitting;
final bool success;
final String? error;
const LoginState({
this.email = '',
this.password = '',
this.submitting = false,
this.success = false,
this.error,
});
bool get valid => email.contains('@') && password.length >= 6;
LoginState copyWith({
String? email,
String? password,
bool? submitting,
bool? success,
String? error,
}) =>
LoginState(
email: email ?? this.email,
password: password ?? this.password,
submitting: submitting ?? this.submitting,
success: success ?? this.success,
error: error,
);
@override
List<Object?> get props => [email, password, submitting, success, error];
}
class LoginCubit extends Cubit<LoginState> {
LoginCubit() : super(const LoginState());
void emailChanged(String v) => emit(state.copyWith(email: v, success: false, error: null));
void passwordChanged(String v) => emit(state.copyWith(password: v, success: false, error: null));
Future<void> submit() async {
if (!state.valid) return;
emit(state.copyWith(submitting: true, error: null));
await Future.delayed(const Duration(milliseconds: 500)); // 模拟登录
if (state.email == 'user@test.com' && state.password == '123456') {
emit(state.copyWith(submitting: false, success: true));
} else {
emit(state.copyWith(submitting: false, success: false, error: '账号或密码错误'));
}
}
}
login_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'login_cubit.dart';
class LoginPage extends StatelessWidget {
const LoginPage({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => LoginCubit(),
child: Scaffold(
appBar: AppBar(title: const Text('登录')),
body: BlocListener<LoginCubit, LoginState>(
listenWhen: (p, c) => p.success != c.success || p.error != c.error,
listener: (context, state) {
if (state.success) {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('登录成功')));
} else if (state.error != null) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(state.error!)));
}
},
child: Padding(
padding: const EdgeInsets.all(16),
child: BlocBuilder<LoginCubit, LoginState>(
builder: (context, state) {
return Column(
children: [
TextField(
onChanged: context.read<LoginCubit>().emailChanged,
decoration: const InputDecoration(labelText: '邮箱'),
),
TextField(
onChanged: context.read<LoginCubit>().passwordChanged,
obscureText: true,
decoration: const InputDecoration(labelText: '密码(≥6位)'),
),
const SizedBox(height: 12),
ElevatedButton(
onPressed: state.valid && !state.submitting ? context.read<LoginCubit>().submit : null,
child: state.submitting ? const CircularProgressIndicator() : const Text('登录'),
)
],
);
},
),
),
),
),
);
}
}
8. 持久化:HydratedBloc(如:记住计数)
counter_hydrated_cubit.dart
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:path_provider/path_provider.dart';
class CounterHydratedCubit extends HydratedCubit<int> {
CounterHydratedCubit() : super(0);
void inc() => emit(state + 1);
@override
int? fromJson(Map<String, dynamic> json) => json['value'] as int?;
@override
Map<String, dynamic>? toJson(int state) => {'value': state};
}
main.dart(初始化)
import 'package:flutter/material.dart';
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:path_provider/path_provider.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
final storage = await HydratedStorage.build(storageDirectory: await getApplicationDocumentsDirectory());
HydratedBlocOverrides.runZoned(
() => runApp(const MyApp()),
storage: storage,
);
}
注意:iOS/Android 需要
path_provider
权限/配置正常。
9. 测试:bloc_test
编写行为用例
todo_bloc_test.dart
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'todo_bloc.dart';
import 'todo_event.dart';
import 'todo_state.dart';
import 'todo_repository.dart';
void main() {
group('TodoBloc', () {
late TodoRepository repo;
setUp(() => repo = TodoRepository());
blocTest<TodoBloc, TodosState>(
'添加后应能拉取到数据',
build: () => TodoBloc(repo: repo),
act: (bloc) async {
bloc.add(const TodoAdded('learn bloc'));
await Future<void>.delayed(const Duration(milliseconds: 10));
bloc.add(TodosRequested());
},
wait: const Duration(milliseconds: 400),
expect: () => [
// 请求列表 -> loading
isA<TodosState>().having((s) => s.status, 'status', TodosStatus.loading),
// 请求成功
isA<TodosState>().having((s) => s.status, 'status', TodosStatus.success)
.having((s) => s.items.isNotEmpty, 'has item', true),
],
);
});
}
10. 目录组织建议(可参考)
lib/
app.dart
main.dart
core/
widgets/ ...
utils/ ...
data/
models/
repositories/
features/
todos/
bloc/
todo_bloc.dart
todo_event.dart
todo_state.dart
view/
todo_page.dart
data/
todo_repository.dart
auth/
cubit/
view/
11. 最佳实践与常见坑
-
状态不可变 :配合
equatable
,避免手误导致比较失效。 -
尽量薄 UI:把逻辑放到 Bloc/Cubit;UI 只读 State、发 Event。
-
精准重建 :用
BlocSelector
/context.select
,不要让整页频繁重建。 -
副作用用 Listener :SnackBar/导航/弹框等用
BlocListener
,不要放 Builder 里。 -
事件风暴 :输入频繁变更时防抖/节流(RxDart 或 bloc_concurrency)。
-
Bloc 间通信 :A 需要 B 的状态?用
context.read<B>()
或在构造参数中传入 B 的 stream;避免循环依赖。 -
资源释放 :自己 new 的 Stream/Controller 要在 Bloc/Cubit 的
close()
里释放。 -
错误处理 :状态里留
message
字段,UI 层友好提示。 -
可测试性 :Repository 抽象 +
bloc_test
,单测能跑就敢重构。
12. 小结
-
小状态用 Cubit ,复杂交互用 Bloc;
-
UI 专注展示,所有变化都走事件/方法 → 新状态;
-
结合 Repository 、HydratedBloc 、bloc_test,你的工程会更稳定、可维护。