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 的Bloc、Cubit、事件处理和状态流。flutter_bloc:提供 Flutter 集成,例如BlocProvider、BlocBuilder、BlocListener。bloc_concurrency:提供sequential、concurrent、droppable、restartable等事件并发转换器。bloc_test:提供针对 BLoC 的测试辅助工具。hydrated_bloc:提供基于序列化数据的状态持久化。
这些包并不是 Flutter SDK 内置 API,而是由 Bloc 生态提供。项目应根据当前稳定版本的兼容性选择依赖版本,并通过 pub.dev 上对应包的 API 文档确认版本差异。
一、先区分三个概念:输入、转换和输出
BLoC 中最容易混淆的是 Event、Transition 和 State。
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 表示“用户提交登录”,也不表示登录已经成功。
把事件设计成“发生的事实”有两个好处:
- BLoC 可以根据当前状态和事件决定下一步行为。
- 测试可以明确复现输入,而不必直接调用内部方法。
事件通常应满足以下条件:
- 不可变;
- 字段表达输入事实;
- 不直接保存 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 中,一次状态转换可以形式化为:
其中:
- :处理事件前的状态;
- :当前事件;
- :处理事件后发出的新状态。
例如登录流程:
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 '登录失败,请稍后重试。';
}
}
这段代码的关键路径是:
- Widget 发送
EmailChanged或PasswordChanged。 - BLoC 从当前
state生成一个新的编辑状态。 - Widget 观察状态并更新按钮、错误文本等 UI。
- 用户发送
LoginSubmitted。 - BLoC 先验证本地输入。
- 验证通过后发出
submitting。 - 仓储调用成功则发出
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: '搜索失败。',
),
);
}
}
}
输入 f、fl、flu 时,restartable() 使“最新输入优先”。如果改成 sequential(),所有查询都会排队,用户可能先看到旧查询结果,再看到新结果;如果改成 droppable(),用户快速输入时中间输入可能直接被丢弃。
并发策略不是性能装饰,而是业务语义的一部分:
| 业务含义 | 通常选择 |
|---|---|
| 所有任务都必须完成,彼此独立 | concurrent |
| 操作必须按顺序完成 | sequential |
| 处理中重复触发没有意义 | droppable |
| 只关心最新输入 | restartable |
五、Event Transformer 与状态一致性
考虑一个库存更新场景:
初始库存:10
EventA:减 2
EventB:减 3
如果两个处理器都读取到库存 10,然后分别计算:
EventA → 8
EventB → 7
并发完成后,最后发出的状态可能是 8 或 7,而正确结果应是:
sequential() 可以保证两个事件按顺序处理,但它只保证 BLoC 内事件处理的顺序。若服务端本身也存在并发写入,仍需要服务端事务、版本号或条件更新。
因此,状态一致性至少有三层:
- BLoC 层:事件是否按正确顺序处理;
- 仓储层:本地数据库或网络请求是否具有正确事务语义;
- 服务端层:多个客户端同时修改时是否有冲突控制。
仅仅给 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 的展开或收起;
- 动画控制器;
FocusNode、ScrollController;- 只影响局部布局的短暂 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_bloc 为 Bloc 和 Cubit 提供自动恢复能力。示例:
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_bloc 和 path_provider 版本变化,应按所使用版本的官方 API 配置。例如当前常见流程是:
- 初始化 Flutter binding;
- 获取平台可用的应用文档目录;
- 构造
HydratedStorage; - 设置
HydratedBloc.storage; - 再创建需要恢复的 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
因此,持久化状态通常只是缓存,不一定是真相源。应用恢复后需要根据业务决定:
- 直接使用本地状态;
- 与服务端重新同步;
- 以服务端库存覆盖本地数量;
- 对冲突项显示需要用户确认;
- 让本地状态进入
syncing或conflict状态。
持久化不能替代同步协议。尤其是订单、支付、库存和权限数据,恢复本地 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 个字符。',
),
],
);
});
}
这段测试依赖两个条件:
LoginState正确实现相等性,否则列表中的状态即使字段相同也可能比较失败;- 事件处理器的状态序列稳定,否则测试会暴露并发或异步设计问题。
密码明文出现在测试状态中是为了展示状态序列。生产代码中要谨慎记录日志,不能把密码、令牌或其他敏感信息写入调试日志和持久化数据。
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() 时:
- 发送
SearchChanged('a'); - 发送
SearchChanged('ab'); - 先完成
a的请求; - 再完成
ab的请求; - 验证最终状态是
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;bloc、flutter_bloc、bloc_concurrency、hydrated_bloc 的具体 API 则应以各自版本文档为准。尤其是持久化目录初始化和包间兼容性,不能仅凭旧文章中的代码推断。
十七、从一个事件流检查 BLoC 设计是否正确
可以用以下问题验证一个 BLoC:
Event 是否表达了真实输入
如果事件名称是 SetLoading、SetSuccess,通常说明调用方正在操纵 BLoC 内部状态;应重新检查是否可以改成业务事件,例如 SubmitRequested。
State 是否能表达所有可见事实
如果 Widget 需要通过多个额外变量判断“是否加载”“是否失败”“是否可以重试”,说明状态模型可能不完整。
每个异步事件的并发语义是什么
必须明确回答:
旧任务需要完成吗?
新事件是否可以覆盖旧事件?
重复事件是否应忽略?
事件之间是否必须保持顺序?
答案决定 concurrent、sequential、droppable 或 restartable。
外部副作用是否可替换
如果 BLoC 内部直接创建 HTTP Client、数据库连接或平台插件,测试会变得困难,也很难控制异常、延迟和返回顺序。应通过构造函数注入抽象依赖。
状态是否可以安全恢复
如果启用持久化,必须确认:
- JSON 字段类型不可信;
- 旧版本数据仍可能存在;
- 敏感数据没有被明文保存;
- 恢复状态不会绕过服务端授权或库存校验;
- 应用关闭和恢复期间的未完成任务不会被误认为已成功。
BLoC 的完整工作模型可以归纳为:
Event 是输入事实
State 是可观察结果
Transition 是一次状态变化记录
Transformer 是事件处理时序策略
Repository 是外部副作用边界
Persistence 是可序列化状态的恢复机制
Test 是对 Event → State 契约和时序的验证
当这几个概念保持分离时,Flutter Widget 只负责展示和派发事件,BLoC 负责状态机与流程,仓储负责平台和数据源,测试则可以独立验证每条状态转换和并发故障路径。
系列导航与关联阅读
- 系列入口:Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布
- 上一篇:Flutter Riverpod:Provider、Notifier、异步状态、生命周期和测试
- 下一篇:Flutter 依赖注入:构造器、GetIt、作用域、生命周期和测试
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论