Flutter 基础体系 · 第 54/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。

Flutter BLoC:Event、State、转换、并发、持久化和测试

BLoC(Business Logic Component)是一种把业务逻辑从 Flutter Widget 中移出的状态管理模式。它的核心不是某个 Widget,而是一条受约束的数据流:

用户操作或外部输入
        │
        ▼
      Event
        │
        ▼
   BLoC 处理逻辑
        │
        ▼
      State
        │
        ▼
Flutter Widget 重建界面

在 Dart 生态中,通常使用以下几个包:

  • bloc:提供纯 Dart 的 BlocCubit、事件处理和状态流。
  • flutter_bloc:提供 Flutter 集成,例如 BlocProviderBlocBuilderBlocListener
  • bloc_concurrency:提供 sequentialconcurrentdroppablerestartable 等事件并发转换器。
  • bloc_test:提供针对 BLoC 的测试辅助工具。
  • hydrated_bloc:提供基于序列化数据的状态持久化。

这些包并不是 Flutter SDK 内置 API,而是由 Bloc 生态提供。项目应根据当前稳定版本的兼容性选择依赖版本,并通过 pub.dev 上对应包的 API 文档确认版本差异。


一、先区分三个概念:输入、转换和输出

BLoC 中最容易混淆的是 EventTransitionState

1. Event:描述“发生了什么”

Event 是输入,不是命令执行结果,也不是界面本身。

例如:

sealed class LoginEvent {}

final class EmailChanged extends LoginEvent {
  const EmailChanged(this.email);

  final String email;
}

final class PasswordChanged extends LoginEvent {
  const PasswordChanged(this.password);

  final String password;
}

final class LoginSubmitted extends LoginEvent {
  const LoginSubmitted();
}

EmailChanged 表示“邮箱输入发生变化”,并不表示邮箱已经合法;LoginSubmitted 表示“用户提交登录”,也不表示登录已经成功。

把事件设计成“发生的事实”有两个好处:

  1. BLoC 可以根据当前状态和事件决定下一步行为。
  2. 测试可以明确复现输入,而不必直接调用内部方法。

事件通常应满足以下条件:

  • 不可变;
  • 字段表达输入事实;
  • 不直接保存 Widget、BuildContext 或数据库连接;
  • 不把最终状态塞进事件中。

不推荐这样设计:

final class SetLoading extends LoginEvent {
  const SetLoading();
}

SetLoading 更像内部状态命令,而不是业务输入。通常应由 LoginSubmitted 触发加载状态,而不是由 Widget 手工发送一系列内部状态事件。


2. State:描述“当前可以被观察到的事实”

State 是 BLoC 对外暴露的结果。Widget 不应该猜测 BLoC 内部正在执行什么,而应根据 State 渲染。

enum LoginStatus {
  initial,
  editing,
  submitting,
  success,
  failure,
}

final class LoginState {
  const LoginState({
    this.email = '',
    this.password = '',
    this.status = LoginStatus.initial,
    this.errorMessage,
  });

  final String email;
  final String password;
  final LoginStatus status;
  final String? errorMessage;

  LoginState copyWith({
    String? email,
    String? password,
    LoginStatus? status,
    String? errorMessage,
    bool clearErrorMessage = false,
  }) {
    return LoginState(
      email: email ?? this.email,
      password: password ?? this.password,
      status: status ?? this.status,
      errorMessage: clearErrorMessage
          ? null
          : errorMessage ?? this.errorMessage,
    );
  }

  @override
  bool operator ==(Object other) {
    return other is LoginState &&
        other.email == email &&
        other.password == password &&
        other.status == status &&
        other.errorMessage == errorMessage;
  }

  @override
  int get hashCode => Object.hash(
        email,
        password,
        status,
        errorMessage,
      );
}

State 最好是不可变对象。不可变状态意味着:

旧 State 不再改变
新 Event 产生新 State
Widget 只观察新 State

这使状态变化可以被记录、比较、测试和重放。

copyWith 中的可空字段有一个常见陷阱:errorMessage: null 既可能表示“不修改错误”,也可能表示“清除错误”。上面的 clearErrorMessage 参数就是为了区分这两种意图。

在真实项目中可以使用 Equatable 简化值比较,也可以使用代码生成工具生成不可变对象;但核心要求不变:状态比较必须反映 UI 真正关心的字段。


3. Transition:描述一次状态变化

在 BLoC 中,一次状态转换可以形式化为:

T=(Scurrent,E,Snext)T = (S_{\text{current}}, E, S_{\text{next}})

其中:

  • ScurrentS_{\text{current}}:处理事件前的状态;
  • EE:当前事件;
  • SnextS_{\text{next}}:处理事件后发出的新状态。

例如登录流程:

S0 = initial(email: '', password: '')
E1 = EmailChanged('a@example.com')
S1 = editing(email: 'a@example.com', password: '')

E2 = PasswordChanged('secret')
S2 = editing(email: 'a@example.com', password: 'secret')

E3 = LoginSubmitted()
S3 = submitting(...)
S4 = success(...)

这里 LoginSubmitted 可能产生多个状态,因此一个事件不一定只对应一次转换。

需要区分:

  • Event transformer:决定多个事件如何排队、并发或取消;
  • Transition:记录某个事件导致的状态变化。

事件转换器改变的是处理时序,不直接改变状态定义。


二、用 Bloc 表达事件到状态的转换

一个完整的登录 BLoC 可以写成:

import 'package:bloc/bloc.dart';

abstract interface class AuthRepository {
  Future<void> login({
    required String email,
    required String password,
  });
}

sealed class LoginEvent {
  const LoginEvent();
}

final class EmailChanged extends LoginEvent {
  const EmailChanged(this.email);

  final String email;
}

final class PasswordChanged extends LoginEvent {
  const PasswordChanged(this.password);

  final String password;
}

final class LoginSubmitted extends LoginEvent {
  const LoginSubmitted();
}

class LoginBloc extends Bloc<LoginEvent, LoginState> {
  LoginBloc(this.repository) : super(const LoginState()) {
    on<EmailChanged>(_onEmailChanged);
    on<PasswordChanged>(_onPasswordChanged);
    on<LoginSubmitted>(_onLoginSubmitted);
  }

  final AuthRepository repository;

  void _onEmailChanged(
    EmailChanged event,
    Emitter<LoginState> emit,
  ) {
    emit(
      state.copyWith(
        email: event.email,
        status: LoginStatus.editing,
        clearErrorMessage: true,
      ),
    );
  }

  void _onPasswordChanged(
    PasswordChanged event,
    Emitter<LoginState> emit,
  ) {
    emit(
      state.copyWith(
        password: event.password,
        status: LoginStatus.editing,
        clearErrorMessage: true,
      ),
    );
  }

  Future<void> _onLoginSubmitted(
    LoginSubmitted event,
    Emitter<LoginState> emit,
  ) async {
    final email = state.email.trim();
    final password = state.password;

    if (!_isValidEmail(email) || password.length < 8) {
      emit(
        state.copyWith(
          status: LoginStatus.failure,
          errorMessage: '邮箱格式不正确,且密码至少需要 8 个字符。',
        ),
      );
      return;
    }

    emit(
      state.copyWith(
        status: LoginStatus.submitting,
        clearErrorMessage: true,
      ),
    );

    try {
      await repository.login(
        email: email,
        password: password,
      );

      emit(
        state.copyWith(
          status: LoginStatus.success,
          clearErrorMessage: true,
        ),
      );
    } catch (error) {
      emit(
        state.copyWith(
          status: LoginStatus.failure,
          errorMessage: _messageFor(error),
        ),
      );
    }
  }

  static bool _isValidEmail(String email) {
    return RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$').hasMatch(email);
  }

  static String _messageFor(Object error) {
    return '登录失败,请稍后重试。';
  }
}

这段代码的关键路径是:

  1. Widget 发送 EmailChangedPasswordChanged
  2. BLoC 从当前 state 生成一个新的编辑状态。
  3. Widget 观察状态并更新按钮、错误文本等 UI。
  4. 用户发送 LoginSubmitted
  5. BLoC 先验证本地输入。
  6. 验证通过后发出 submitting
  7. 仓储调用成功则发出 success,失败则发出 failure

repository 是依赖注入的边界。BLoC 不应该直接创建 HTTP Client、读取 SQLite 或访问平台 API,否则测试时无法替换这些外部依赖。

事件处理器中的状态读取

事件处理器通常通过 state 读取当前状态。这个读取发生在处理器执行时,而不是事件创建时:

final valueAtHandlingTime = state.email;

因此,如果多个事件并发处理,多个处理器读取到的状态可能不同。涉及多步异步操作时,必须明确并发策略,否则“最后一个请求最后完成”不一定等于“最后一次用户输入的结果”。


三、emit、状态序列与生命周期

一次异步事件处理通常包含多个中间状态:

LoginSubmitted
    │
    ├── submitting
    │
    ├── success
    │
    └── failure

emit 只能在事件处理器仍然有效时使用。不要在 BLoC 已经关闭后继续发出状态。

Future<void> _onSubmitted(
  LoginSubmitted event,
  Emitter<LoginState> emit,
) async {
  emit(state.copyWith(status: LoginStatus.submitting));

  final result = await repository.login(
    email: state.email,
    password: state.password,
  );

  if (emit.isDone) {
    return;
  }

  emit(
    state.copyWith(
      status: LoginStatus.success,
      // 根据 result 更新其他字段
    ),
  );
}

emit.isDone 在存在取消、替换或 BLoC 关闭的场景下很有用。它不能取消底层网络请求,但可以避免异步任务完成后继续更新无效的事件处理器。

BLoC 关闭时:

await bloc.close();

正常情况下,BlocProvider 创建的 BLoC 会在 Provider 移除时关闭;通过 BlocProvider.value 传入的已有实例通常不由该 Provider 创建,因此不应让 Provider 误承担其生命周期。


四、Event Transformer:事件如何被处理

4.1 默认并发模型

Bloc 的事件处理不是天然的顺序队列。当前 Bloc 生态中,注册的事件处理器默认允许并发处理。也就是说:

bloc.add(EventA());
bloc.add(EventB());

不应自动推断为:

EventA 完成后才处理 EventB

更准确的模型是:

EventA ─────── 异步操作 ─────── 产生 StateA
EventB ─── 异步操作 ─── 产生 StateB

哪个异步操作先完成,哪个状态可能先被发出。

这在只做纯同步字段更新时通常没有问题,但在写数据库、提交订单、修改服务端资源时可能造成错误结果。


4.2 四种常用并发策略

bloc_concurrency 提供常用的事件转换器:

import 'package:bloc_concurrency/bloc_concurrency.dart';

concurrent()

允许多个事件同时处理。适合互不影响的异步任务,但要求状态合并和副作用本身支持并发。

on<IndependentTaskRequested>(
  _onTaskRequested,
  transformer: concurrent(),
);

sequential()

按进入顺序排队,一个完成后再处理下一个:

on<SaveDraftRequested>(
  _onSaveDraftRequested,
  transformer: sequential(),
);

适合:

  • 本地写入需要保持顺序;
  • 事件表示必须依次完成的操作;
  • 后一个操作依赖前一个操作的结果。

droppable()

当前事件处理期间,忽略后来到达的事件:

on<LoadNextPageRequested>(
  _onLoadNextPageRequested,
  transformer: droppable(),
);

适合防止重复点击“加载下一页”或重复提交同一读取请求。它的语义是丢弃事件,不是排队。

restartable()

新事件到达时取消旧事件处理,开始处理最新事件:

on<SearchChanged>(
  _onSearchChanged,
  transformer: restartable(),
);

适合搜索框输入:

输入 f       → 请求 f
输入 fl      → 取消或替换 f
输入 flu     → 取消或替换 fl
输入 flut    → 取消或替换 flu

但需要注意:取消事件处理不一定能够取消底层 HTTP 请求。具体能否真正中止网络传输,取决于 HTTP Client 是否支持取消。转换器至少可以阻止旧处理结果继续成为有效的事件处理结果;如果底层资源昂贵,仍应在仓储层提供可取消操作。


4.3 用搜索功能说明并发差异

sealed class SearchEvent {
  const SearchEvent();
}

final class SearchChanged extends SearchEvent {
  const SearchChanged(this.query);

  final String query;
}

enum SearchStatus {
  initial,
  loading,
  success,
  failure,
}

final class SearchState {
  const SearchState({
    this.query = '',
    this.status = SearchStatus.initial,
    this.results = const <String>[],
    this.errorMessage,
  });

  final String query;
  final SearchStatus status;
  final List<String> results;
  final String? errorMessage;

  SearchState copyWith({
    String? query,
    SearchStatus? status,
    List<String>? results,
    String? errorMessage,
  }) {
    return SearchState(
      query: query ?? this.query,
      status: status ?? this.status,
      results: results ?? this.results,
      errorMessage: errorMessage ?? this.errorMessage,
    );
  }

  @override
  bool operator ==(Object other) {
    return other is SearchState &&
        other.query == query &&
        other.status == status &&
        _listEquals(other.results, results) &&
        other.errorMessage == errorMessage;
  }

  @override
  int get hashCode => Object.hash(
        query,
        status,
        Object.hashAll(results),
        errorMessage,
      );

  static bool _listEquals(List<String> a, List<String> b) {
    if (a.length != b.length) return false;

    for (var i = 0; i < a.length; i++) {
      if (a[i] != b[i]) return false;
    }

    return true;
  }
}

abstract interface class SearchRepository {
  Future<List<String>> search(String query);
}

class SearchBloc extends Bloc<SearchEvent, SearchState> {
  SearchBloc(this.repository) : super(const SearchState()) {
    on<SearchChanged>(
      _onSearchChanged,
      transformer: restartable(),
    );
  }

  final SearchRepository repository;

  Future<void> _onSearchChanged(
    SearchChanged event,
    Emitter<SearchState> emit,
  ) async {
    final query = event.query.trim();

    emit(
      state.copyWith(
        query: query,
        status: query.isEmpty
            ? SearchStatus.initial
            : SearchStatus.loading,
        results: query.isEmpty ? const <String>[] : state.results,
        errorMessage: null,
      ),
    );

    if (query.isEmpty) {
      return;
    }

    try {
      final results = await repository.search(query);

      if (emit.isDone) {
        return;
      }

      emit(
        state.copyWith(
          status: SearchStatus.success,
          results: results,
          errorMessage: null,
        ),
      );
    } catch (error) {
      if (emit.isDone) {
        return;
      }

      emit(
        state.copyWith(
          status: SearchStatus.failure,
          errorMessage: '搜索失败。',
        ),
      );
    }
  }
}

输入 fflflu 时,restartable() 使“最新输入优先”。如果改成 sequential(),所有查询都会排队,用户可能先看到旧查询结果,再看到新结果;如果改成 droppable(),用户快速输入时中间输入可能直接被丢弃。

并发策略不是性能装饰,而是业务语义的一部分:

业务含义 通常选择
所有任务都必须完成,彼此独立 concurrent
操作必须按顺序完成 sequential
处理中重复触发没有意义 droppable
只关心最新输入 restartable

五、Event Transformer 与状态一致性

考虑一个库存更新场景:

初始库存:10

EventA:减 2
EventB:减 3

如果两个处理器都读取到库存 10,然后分别计算:

EventA → 8
EventB → 7

并发完成后,最后发出的状态可能是 8 或 7,而正确结果应是:

1023=510 - 2 - 3 = 5

sequential() 可以保证两个事件按顺序处理,但它只保证 BLoC 内事件处理的顺序。若服务端本身也存在并发写入,仍需要服务端事务、版本号或条件更新。

因此,状态一致性至少有三层:

  1. BLoC 层:事件是否按正确顺序处理;
  2. 仓储层:本地数据库或网络请求是否具有正确事务语义;
  3. 服务端层:多个客户端同时修改时是否有冲突控制。

仅仅给 on<Event> 加上 sequential(),不能替代数据库事务或服务端并发控制。


六、State 设计:状态机而不是布尔变量集合

简单页面常见这种设计:

final bool isLoading;
final bool hasError;
final bool isSuccess;

它允许产生非法组合:

isLoading = true
hasError = true
isSuccess = true

更稳妥的方式是使用互斥状态:

enum RequestStatus {
  initial,
  loading,
  success,
  failure,
}

状态转换可以表示为:

initial ──请求──> loading ──成功──> success
                    │
                    └─失败──> failure

但状态枚举并不自动保证所有转换合法。例如,业务可能要求登录成功后不能再次进入编辑状态;这些约束仍需由事件处理逻辑实现。

对于复杂流程,可以使用密封类表达不同状态携带的不同数据:

sealed class PaymentState {
  const PaymentState();
}

final class PaymentInitial extends PaymentState {
  const PaymentInitial();
}

final class PaymentSubmitting extends PaymentState {
  const PaymentSubmitting();
}

final class PaymentSuccess extends PaymentState {
  const PaymentSuccess(this.transactionId);

  final String transactionId;
}

final class PaymentFailure extends PaymentState {
  const PaymentFailure(this.message);

  final String message;
}

这种设计的优点是:成功状态必须携带交易号,失败状态必须携带错误信息,状态字段之间的关系更清晰。代价是 Widget 需要通过模式匹配或 is 判断处理不同子类型。


七、在 Flutter 中连接 BLoC

flutter_bloc 提供 Provider、Builder 和监听器。

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

class LoginPage extends StatelessWidget {
  const LoginPage({
    required this.repository,
    super.key,
  });

  final AuthRepository repository;

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (_) => LoginBloc(repository),
      child: const LoginView(),
    );
  }
}

class LoginView extends StatelessWidget {
  const LoginView({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('登录')),
      body: BlocListener<LoginBloc, LoginState>(
        listenWhen: (previous, current) {
          return previous.status != current.status &&
              current.status == LoginStatus.success;
        },
        listener: (context, state) {
          Navigator.of(context).pushReplacementNamed('/home');
        },
        child: BlocBuilder<LoginBloc, LoginState>(
          builder: (context, state) {
            final isSubmitting =
                state.status == LoginStatus.submitting;

            return Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                children: [
                  TextField(
                    onChanged: (value) {
                      context.read<LoginBloc>().add(
                            EmailChanged(value),
                          );
                    },
                  ),
                  TextField(
                    obscureText: true,
                    onChanged: (value) {
                      context.read<LoginBloc>().add(
                            PasswordChanged(value),
                          );
                    },
                  ),
                  if (state.errorMessage != null)
                    Text(
                      state.errorMessage!,
                      style: const TextStyle(color: Colors.red),
                    ),
                  FilledButton(
                    onPressed: isSubmitting
                        ? null
                        : () {
                            context.read<LoginBloc>().add(
                                  const LoginSubmitted(),
                                );
                          },
                    child: isSubmitting
                        ? const CircularProgressIndicator()
                        : const Text('登录'),
                  ),
                ],
              ),
            );
          },
        ),
      ),
    );
  }
}

三个常用 API 的职责不同:

  • context.read<LoginBloc>():读取实例,不建立重建关系,适合发送事件。
  • BlocBuilder<LoginBloc, LoginState>:监听状态并构建 UI。
  • BlocListener<LoginBloc, LoginState>:处理一次性副作用,例如导航、SnackBar、弹窗。

不应在 BlocBuilder 中直接执行导航或弹窗。Builder 可能因为多种原因重建,副作用会被重复执行;副作用应放在 BlocListener 中。

缩小重建范围

如果页面只关心一个字段,可以使用 BlocSelector

BlocSelector<LoginBloc, LoginState, bool>(
  selector: (state) {
    return state.status == LoginStatus.submitting;
  },
  builder: (context, isSubmitting) {
    return FilledButton(
      onPressed: isSubmitting
          ? null
          : () {
              context.read<LoginBloc>().add(
                    const LoginSubmitted(),
                  );
            },
      child: Text(isSubmitting ? '提交中' : '登录'),
    );
  },
)

BlocBuilder 默认会在监听到状态变化时调用 builder。buildWhen 可以进一步过滤:

BlocBuilder<LoginBloc, LoginState>(
  buildWhen: (previous, current) {
    return previous.status != current.status;
  },
  builder: (context, state) {
    return Text(state.status.name);
  },
)

过滤条件必须与 UI 依赖一致。如果 Builder 使用了 state.email,却只比较 status,就会导致输入变化后界面不更新。


八、BLoC、Cubit 与 Flutter 状态的边界

Cubit 是不接收 Event、直接暴露方法的状态容器:

class ThemeCubit extends Cubit<ThemeMode> {
  ThemeCubit() : super(ThemeMode.system);

  void useDark() => emit(ThemeMode.dark);

  void useLight() => emit(ThemeMode.light);
}

区别可以概括为:

Cubit:方法调用 → State
Bloc :Event → 事件处理器 → State

适合使用 Cubit 的场景:

  • 状态变化路径很少;
  • 操作没有复杂的事件分类;
  • 不需要记录业务事件。

适合使用 Bloc 的场景:

  • 同一个状态可能由很多不同输入触发;
  • 需要明确记录用户行为;
  • 需要为不同事件设置不同并发策略;
  • 事件流本身需要测试和审计。

BLoC 也不应承载所有状态。以下状态通常直接放在 Widget 或 Flutter 自身机制中更合适:

  • 当前文本输入光标;
  • 单个 Widget 的展开或收起;
  • 动画控制器;
  • FocusNodeScrollController
  • 只影响局部布局的短暂 UI 状态。

如果一个状态不会被业务逻辑、多个页面或异步流程使用,就没有必要提升到全局 BLoC。


九、错误处理:区分业务失败、系统异常和取消

BLoC 中的错误通常至少分为三类。

1. 可预期的业务失败

例如密码错误、余额不足、表单校验失败。这类错误通常转换成正常 State:

emit(
  state.copyWith(
    status: LoginStatus.failure,
    errorMessage: '邮箱或密码错误。',
  ),
);

2. 系统异常

例如网络不可用、数据库损坏、序列化失败。应在仓储层将底层异常转换为有业务含义的错误类型,避免 Widget 直接依赖 HTTP 或数据库异常。

class NetworkFailure implements Exception {
  const NetworkFailure();
}

class UnauthorizedFailure implements Exception {
  const UnauthorizedFailure();
}

BLoC 可以根据错误类型决定显示信息、重试按钮或跳转登录。

3. 取消或过期结果

搜索请求被 restartable() 替换,不应显示成“搜索失败”。取消是控制流,不一定是用户可见错误。

错误处理的基本原则是:

try
  执行外部操作
catch
  转换为可观察的失败状态
finally
  确保加载状态不会永久卡住

不要在 BLoC 中无条件捕获所有错误后返回空结果。这样会把“网络失败”伪装成“没有数据”,诊断和用户反馈都会变差。


十、持久化:保存 State,不等于保存整个运行时对象

BLoC 本身通常是内存对象,应用进程被系统终止后会丢失。持久化的目标是把可恢复的数据序列化到存储介质:

State
  ↓ toJson
Map<String, dynamic>
  ↓
磁盘、Key-Value 存储或浏览器存储
  ↓ fromJson
State

不能直接持久化:

  • BuildContext
  • Stream
  • Future
  • Socket;
  • TextEditingController
  • 文件句柄;
  • 平台对象;
  • 正在执行的请求。

只能保存重新创建状态所需的数据,例如:

  • 用户偏好;
  • 购物车商品 ID 和数量;
  • 最近一次同步时间;
  • 草稿文本;
  • 分页游标。

10.1 使用 hydrated_bloc

hydrated_blocBlocCubit 提供自动恢复能力。示例:

import 'package:hydrated_bloc/hydrated_bloc.dart';

sealed class CartEvent {
  const CartEvent();
}

final class AddItem extends CartEvent {
  const AddItem(this.productId);

  final String productId;
}

final class RemoveItem extends CartEvent {
  const RemoveItem(this.productId);

  final String productId;
}

final class CartState {
  const CartState(this.quantities);

  final Map<String, int> quantities;

  factory CartState.fromJson(Map<String, dynamic> json) {
    final raw = json['quantities'];

    if (raw is! Map) {
      return const CartState({});
    }

    final quantities = <String, int>{};

    for (final entry in raw.entries) {
      final value = entry.value;
      if (value is int && value > 0) {
        quantities[entry.key.toString()] = value;
      }
    }

    return CartState(quantities);
  }

  Map<String, dynamic> toJson() {
    return <String, dynamic>{
      'quantities': quantities,
    };
  }

  CartState copyWith({
    Map<String, int>? quantities,
  }) {
    return CartState(quantities ?? this.quantities);
  }

  @override
  bool operator ==(Object other) {
    return other is CartState &&
        _mapEquals(other.quantities, quantities);
  }

  @override
  int get hashCode => Object.hashAll(
        quantities.entries.map(
          (entry) => Object.hash(entry.key, entry.value),
        ),
      );

  static bool _mapEquals(
    Map<String, int> a,
    Map<String, int> b,
  ) {
    if (a.length != b.length) return false;

    for (final entry in a.entries) {
      if (b[entry.key] != entry.value) return false;
    }

    return true;
  }
}

class CartBloc extends HydratedBloc<CartEvent, CartState> {
  CartBloc() : super(const CartState({})) {
    on<AddItem>((event, emit) {
      final next = Map<String, int>.from(state.quantities);
      next[event.productId] = (next[event.productId] ?? 0) + 1;
      emit(state.copyWith(quantities: next));
    });

    on<RemoveItem>((event, emit) {
      final next = Map<String, int>.from(state.quantities);
      final current = next[event.productId] ?? 0;

      if (current <= 1) {
        next.remove(event.productId);
      } else {
        next[event.productId] = current - 1;
      }

      emit(state.copyWith(quantities: next));
    });
  }

  @override
  CartState? fromJson(Map<String, dynamic> json) {
    try {
      return CartState.fromJson(json);
    } catch (_) {
      return const CartState({});
    }
  }

  @override
  Map<String, dynamic>? toJson(CartState state) {
    return state.toJson();
  }
}

应用启动时需要初始化 Hydrated 存储。具体目录 API 会随 hydrated_blocpath_provider 版本变化,应按所使用版本的官方 API 配置。例如当前常见流程是:

  1. 初始化 Flutter binding;
  2. 获取平台可用的应用文档目录;
  3. 构造 HydratedStorage
  4. 设置 HydratedBloc.storage
  5. 再创建需要恢复的 BLoC。

初始化必须发生在创建 HydratedBloc 之前,否则第一个实例可能在存储系统准备好之前读取状态。


10.2 持久化的恢复时序

恢复过程不是“从磁盘同步读取后再构造对象”,而通常包含异步初始化:

应用启动
  ↓
初始化存储
  ↓
创建 HydratedBloc
  ↓
读取旧 JSON
  ↓
fromJson
  ↓
得到恢复后的 State
  ↓
Widget 监听并显示

因此界面可能先短暂显示默认状态,再显示恢复状态,具体行为由实现和初始化时机决定。若页面不能接受这一短暂差异,应在应用根部完成持久化初始化后再构建业务页面。


10.3 Web、移动端和桌面的差异

持久化存储的底层实现受平台影响:

  • Android、iOS 和桌面通常写入应用可访问的本地目录;
  • Web 通常使用浏览器提供的存储能力,受到浏览器隐私策略、清理策略和容量限制;
  • Web 的存储不应被当作跨设备同步;
  • 卸载移动应用后,本地数据通常会被删除;
  • 桌面应用可能面临用户手工删除配置目录、多个应用版本共用目录等问题。

敏感数据不应直接以普通 JSON 明文保存。访问令牌、刷新令牌、支付信息等应根据平台使用安全存储能力,例如 Android Keystore、iOS Keychain 或对应桌面平台的安全凭据存储。hydrated_bloc 解决的是状态恢复,不自动提供机密数据保护。

还应考虑版本迁移:

{
  "version": 2,
  "quantities": {
    "p100": 2
  }
}

恢复时先判断版本,再把旧结构转换为当前结构。不能假设历史 JSON 永远与当前 State 字段完全一致。


十一、状态持久化与服务端真相的冲突

本地恢复的购物车可能已经过期:

昨天本地数量:3
今天服务端库存:1

因此,持久化状态通常只是缓存,不一定是真相源。应用恢复后需要根据业务决定:

  • 直接使用本地状态;
  • 与服务端重新同步;
  • 以服务端库存覆盖本地数量;
  • 对冲突项显示需要用户确认;
  • 让本地状态进入 syncingconflict 状态。

持久化不能替代同步协议。尤其是订单、支付、库存和权限数据,恢复本地 State 后仍必须重新验证服务端状态。


十二、测试 BLoC:测试事件、状态和副作用边界

BLoC 测试的重点不是测试私有方法,而是验证:

给定初始状态
发送一组 Event
得到规定的 State 序列

12.1 测试仓储

class FakeAuthRepository implements AuthRepository {
  FakeAuthRepository({
    this.shouldFail = false,
  });

  final bool shouldFail;

  @override
  Future<void> login({
    required String email,
    required String password,
  }) async {
    if (shouldFail) {
      throw Exception('network');
    }
  }
}

12.2 使用 bloc_test

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

void main() {
  group('LoginBloc', () {
    blocTest<LoginBloc, LoginState>(
      '输入合法信息并登录成功',
      build: () => LoginBloc(FakeAuthRepository()),
      act: (bloc) {
        bloc
          ..add(const EmailChanged('a@example.com'))
          ..add(const PasswordChanged('password'))
          ..add(const LoginSubmitted());
      },
      expect: () => [
        const LoginState(
          email: 'a@example.com',
          password: '',
          status: LoginStatus.editing,
        ),
        const LoginState(
          email: 'a@example.com',
          password: 'password',
          status: LoginStatus.editing,
        ),
        const LoginState(
          email: 'a@example.com',
          password: 'password',
          status: LoginStatus.submitting,
        ),
        const LoginState(
          email: 'a@example.com',
          password: 'password',
          status: LoginStatus.success,
        ),
      ],
    );

    blocTest<LoginBloc, LoginState>(
      '输入不合法时不调用仓储并直接失败',
      build: () => LoginBloc(FakeAuthRepository()),
      act: (bloc) {
        bloc
          ..add(const EmailChanged('invalid'))
          ..add(const PasswordChanged('123'))
          ..add(const LoginSubmitted());
      },
      expect: () => [
        const LoginState(
          email: 'invalid',
          password: '',
          status: LoginStatus.editing,
        ),
        const LoginState(
          email: 'invalid',
          password: '123',
          status: LoginStatus.editing,
        ),
        const LoginState(
          email: 'invalid',
          password: '123',
          status: LoginStatus.failure,
          errorMessage: '邮箱格式不正确,且密码至少需要 8 个字符。',
        ),
      ],
    );
  });
}

这段测试依赖两个条件:

  1. LoginState 正确实现相等性,否则列表中的状态即使字段相同也可能比较失败;
  2. 事件处理器的状态序列稳定,否则测试会暴露并发或异步设计问题。

密码明文出现在测试状态中是为了展示状态序列。生产代码中要谨慎记录日志,不能把密码、令牌或其他敏感信息写入调试日志和持久化数据。


12.3 测试异常路径

blocTest<LoginBloc, LoginState>(
  '仓储失败时发出 submitting 和 failure',
  build: () => LoginBloc(
    FakeAuthRepository(shouldFail: true),
  ),
  act: (bloc) {
    bloc
      ..add(const EmailChanged('a@example.com'))
      ..add(const PasswordChanged('password'))
      ..add(const LoginSubmitted());
  },
  expect: () => [
    const LoginState(
      email: 'a@example.com',
      password: '',
      status: LoginStatus.editing,
    ),
    const LoginState(
      email: 'a@example.com',
      password: 'password',
      status: LoginStatus.editing,
    ),
    const LoginState(
      email: 'a@example.com',
      password: 'password',
      status: LoginStatus.submitting,
    ),
    const LoginState(
      email: 'a@example.com',
      password: 'password',
      status: LoginStatus.failure,
      errorMessage: '登录失败,请稍后重试。',
    ),
  ],
);

测试失败路径时,应验证:

  • 是否发出了加载状态;
  • 异常是否被转换成 State;
  • 加载状态是否不会永久保留;
  • 是否保留了允许保留的输入内容;
  • 是否没有泄露底层异常中的敏感数据。

十三、测试并发转换器

并发策略本身也需要测试。搜索仓储可以控制每个查询的完成顺序:

class ControlledSearchRepository implements SearchRepository {
  final completers = <String, Completer<List<String>>>{};

  @override
  Future<List<String>> search(String query) {
    final completer = Completer<List<String>>();
    completers[query] = completer;
    return completer.future;
  }
}

测试 restartable() 时:

  1. 发送 SearchChanged('a')
  2. 发送 SearchChanged('ab')
  3. 先完成 a 的请求;
  4. 再完成 ab 的请求;
  5. 验证最终状态是 ab 的结果,而不是旧查询结果。

这个测试验证的不是“请求函数被调用了几次”,而是过期结果是否还能改变可见状态。

对于 sequential(),则应验证第二个事件只有在第一个事件完成后才开始;对于 droppable(),应验证处理期间到达的事件不会启动新的任务。异步测试必须避免任意长的 Future.delayed,优先使用 Completer 或可控的 Fake Clock,以减少时序抖动。


十四、测试 Flutter Widget 与 BLoC 的连接

BLoC 单元测试无法发现所有 Widget 集成问题。例如:

  • Provider 是否放在正确的树层级;
  • Widget 是否读取了错误的 BLoC;
  • BlocListener 是否重复导航;
  • buildWhen 是否过滤掉了必要更新。

这些需要 Widget Test:

testWidgets('提交中禁用登录按钮', (tester) async {
  final bloc = LoginBloc(FakeAuthRepository());

  await tester.pumpWidget(
    MaterialApp(
      home: BlocProvider.value(
        value: bloc,
        child: const LoginView(),
      ),
    ),
  );

  bloc.add(const EmailChanged('a@example.com'));
  bloc.add(const PasswordChanged('password'));
  bloc.add(const LoginSubmitted());

  await tester.pump();

  expect(find.text('提交中'), findsOneWidget);

  await bloc.close();
});

这里 BlocProvider.value 传入的是测试中手动创建的实例,因此测试结束后明确关闭 bloc。若使用 BlocProvider(create: ...) 创建实例,则 Provider 通常负责其关闭时机。


十五、观察 Transition 和诊断状态流

BLoC 支持通过覆盖生命周期方法观察状态变化:

class ObservableLoginBloc extends LoginBloc {
  ObservableLoginBloc(super.repository);

  @override
  void onTransition(
    Transition<LoginEvent, LoginState> transition,
  ) {
    super.onTransition(transition);

    // 生产环境避免记录密码、令牌等敏感字段。
    debugPrint(
      '${transition.event.runtimeType}: '
      '${transition.currentState.status} -> '
      '${transition.nextState.status}',
    );
  }
}

在整个应用层面,也可以使用 BlocObserver 观察多个 BLoC 的事件、变化和错误。诊断时应记录:

  • BLoC 类型;
  • Event 类型;
  • 当前状态类型;
  • 下一状态类型;
  • 请求或业务操作的关联 ID;
  • 错误分类。

不要无条件打印完整 State。State 可能包含用户信息、访问令牌、密码、支付数据或大量列表。

常见故障与诊断方向如下:

表现 常见原因 检查方向
UI 不更新 State 被原地修改或相等性错误 检查不可变性和 ==
旧搜索结果覆盖新结果 使用了并发处理 检查 restartable() 或请求版本号
重复导航 在 Builder 中执行副作用 移到 BlocListener
页面退出后仍报错 异步任务未检查生命周期 检查 emit.isDone 和 close
加载状态卡住 异常路径没有发出失败状态 检查 try/catch 和所有返回路径
恢复数据崩溃 历史 JSON 结构变化 fromJson 中做版本和类型校验
Web 刷新后数据消失 浏览器存储被清理或不可用 检查存储策略,不把本地持久化当作同步

十六、平台差异和 API 边界

BLoC 核心位于 Dart 层,本身不区分 Android、iOS、桌面和 Web。平台差异主要来自 BLoC 调用的外部能力:

  • 网络栈和请求取消能力;
  • 文件系统路径;
  • 本地数据库;
  • 安全存储;
  • 浏览器生命周期和存储限制;
  • 应用进入后台、被挂起或被系统终止;
  • 桌面窗口关闭和多实例行为。

因此,不应让 BLoC 中出现大量平台判断:

if (Platform.isAndroid) {
  // ...
}

更合适的结构是:

BLoC
  ↓
抽象 Repository
  ↓
Android / iOS / Desktop / Web 实现

BLoC 负责事件、状态和业务流程;平台实现负责文件、网络、安全存储和插件 API。这样既能隔离平台差异,也能在测试中注入 Fake 实现。

Flutter 官方文档和 API Reference 主要描述 Flutter 的 Widget、生命周期、平台集成和测试 API;blocflutter_blocbloc_concurrencyhydrated_bloc 的具体 API 则应以各自版本文档为准。尤其是持久化目录初始化和包间兼容性,不能仅凭旧文章中的代码推断。


十七、从一个事件流检查 BLoC 设计是否正确

可以用以下问题验证一个 BLoC:

Event 是否表达了真实输入

如果事件名称是 SetLoadingSetSuccess,通常说明调用方正在操纵 BLoC 内部状态;应重新检查是否可以改成业务事件,例如 SubmitRequested

State 是否能表达所有可见事实

如果 Widget 需要通过多个额外变量判断“是否加载”“是否失败”“是否可以重试”,说明状态模型可能不完整。

每个异步事件的并发语义是什么

必须明确回答:

旧任务需要完成吗?
新事件是否可以覆盖旧事件?
重复事件是否应忽略?
事件之间是否必须保持顺序?

答案决定 concurrentsequentialdroppablerestartable

外部副作用是否可替换

如果 BLoC 内部直接创建 HTTP Client、数据库连接或平台插件,测试会变得困难,也很难控制异常、延迟和返回顺序。应通过构造函数注入抽象依赖。

状态是否可以安全恢复

如果启用持久化,必须确认:

  • JSON 字段类型不可信;
  • 旧版本数据仍可能存在;
  • 敏感数据没有被明文保存;
  • 恢复状态不会绕过服务端授权或库存校验;
  • 应用关闭和恢复期间的未完成任务不会被误认为已成功。

BLoC 的完整工作模型可以归纳为:

Event 是输入事实
State 是可观察结果
Transition 是一次状态变化记录
Transformer 是事件处理时序策略
Repository 是外部副作用边界
Persistence 是可序列化状态的恢复机制
Test 是对 Event → State 契约和时序的验证

当这几个概念保持分离时,Flutter Widget 只负责展示和派发事件,BLoC 负责状态机与流程,仓储负责平台和数据源,测试则可以独立验证每条状态转换和并发故障路径。


系列导航与关联阅读

官方资料

本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。