Flutter BLoC 全面入门与实战(含代码示例)

下面是一篇系统、可实操 的 Flutter BLoC 指南(含完整代码片段)。读完你可以快速上手从 CubitBloc、从 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_concurrencythrottle/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 专注展示,所有变化都走事件/方法 → 新状态

  • 结合 RepositoryHydratedBlocbloc_test,你的工程会更稳定、可维护。