Flutter 基础体系 · 第 11/80 篇。示例基于当前稳定 Flutter 与 Dart 3 语言能力;Android、iOS、桌面和 Web 差异会明确说明。
Flutter 状态管理:InheritedWidget、Provider、Riverpod、BLoC 和边界
状态管理不是“选一个包”的问题,而是要回答四个更基础的问题:
- 状态由谁拥有?
- 谁可以读取或修改它?
- 状态变化如何传播?
- 异步、生命周期、错误和平台差异由哪一层处理?
Flutter 的界面是 Widget 树,但状态不一定属于某个 Widget。一个文本框的焦点通常属于页面内部,一个登录用户可能属于整个应用,一个分页请求则更适合属于某个 Feature 或 Repository。不同状态的所有权不同,传播机制也不同。
本文从 Flutter 原生的 InheritedWidget 开始,逐步解释 Provider、Riverpod 和 BLoC 的机制、边界及取舍,并把 StatefulWidget、BuildContext、Key、生命周期和大型应用分层放回同一张图中。
一、先区分“状态”和“界面重建”
1. 状态是什么
状态是影响未来行为或界面结果、并且会随时间变化的数据。例如:
class CartState {
final List<String> productIds;
final bool submitting;
final String? errorMessage;
const CartState({
this.productIds = const [],
this.submitting = false,
this.errorMessage,
});
}
这里有三个不同维度:
productIds:业务数据;submitting:异步流程状态;errorMessage:错误状态。
它们不是 Widget。Widget 是某个时刻根据状态生成的不可变配置:
Widget build(BuildContext context) {
if (state.submitting) {
return const CircularProgressIndicator();
}
return Text('商品数:${state.productIds.length}');
}
可以把界面看成函数:
其中:
- 是时刻 的应用状态;
- 是环境,例如屏幕尺寸、平台、系统主题;
- 是该时刻应显示的 Widget 配置。
状态管理机制要解决的不是“如何画界面”,而是:
然后让受影响的界面重新计算。
2. 重建不等于重新创建所有对象
Flutter 的 Widget 通常是短生命周期、不可变的配置对象。Widget 重建时,Flutter 会尝试复用已有的 Element 和 RenderObject。
因此下面三件事不同:
| 行为 | 含义 |
|---|---|
build 被调用 |
某个 Element 需要重新生成 Widget 配置 |
| Widget 对象被重新创建 | 父级 build 返回了新的配置对象 |
State 被销毁 |
Flutter 没有继续匹配到原来的 StatefulElement |
状态管理主要影响第一件事,但 Key 和 Widget 树结构决定第三件事。
二、Flutter 的原生传播机制:InheritedWidget
1. InheritedWidget 的定义
InheritedWidget 是 Flutter 框架提供的一种沿 Widget 树向下共享数据的机制。
它具有几个关键性质:
- 数据通常通过构造函数传入,因此 Widget 本身是不可变的;
- 后代通过
BuildContext.dependOnInheritedWidgetOfExactType<T>()建立依赖; - 当祖先位置上的
InheritedWidget被更新时,Flutter 根据updateShouldNotify判断是否通知依赖者; - 依赖者收到通知后,通常会重新执行
build。
一个最小的计数器示例:
import 'package:flutter/material.dart';
class CounterScope extends InheritedWidget {
final int count;
final VoidCallback onIncrement;
const CounterScope({
super.key,
required this.count,
required this.onIncrement,
required super.child,
});
static CounterScope of(BuildContext context) {
final scope = context.dependOnInheritedWidgetOfExactType<CounterScope>();
assert(scope != null, 'CounterScope not found in the widget tree');
return scope!;
}
@override
bool updateShouldNotify(CounterScope oldWidget) {
return count != oldWidget.count;
}
}
class CounterPage extends StatefulWidget {
const CounterPage({super.key});
@override
State<CounterPage> createState() => _CounterPageState();
}
class _CounterPageState extends State<CounterPage> {
var _count = 0;
@override
Widget build(BuildContext context) {
return CounterScope(
count: _count,
onIncrement: () {
setState(() {
_count++;
});
},
child: const CounterView(),
);
}
}
class CounterView extends StatelessWidget {
const CounterView({super.key});
@override
Widget build(BuildContext context) {
final scope = CounterScope.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text('count = ${scope.count}'),
ElevatedButton(
onPressed: scope.onIncrement,
child: const Text('increment'),
),
],
);
}
}
完整应用入口:
void main() {
runApp(
const MaterialApp(
home: Scaffold(
body: Center(
child: CounterPage(),
),
),
),
);
}
运行后,点击按钮的因果链是:
CounterView读取CounterScope.of(context);- 这次读取建立了依赖关系;
- 点击回调调用
_CounterPageState.setState; CounterPage重新执行build;- 新的
CounterScope.count比旧值大; updateShouldNotify返回true;- 依赖
CounterScope的CounterView被标记为需要重建; CounterView读取新的count。
2. dependOn... 和 get... 的区别
常见的两个 API 语义不同:
context.dependOnInheritedWidgetOfExactType<MyScope>();
会建立依赖。祖先更新并且通知时,当前 Element 会被标记为需要重建。
context.getInheritedWidgetOfExactType<MyScope>();
只查找当前树中的对象,不建立依赖。它适合只想读取一次、并不希望因变化自动重建的场景,但不能把它误用为普通响应式读取。
因此,下面的代码不会自动响应后续变化:
final scope = context.getInheritedWidgetOfExactType<CounterScope>();
而下面的代码会:
final scope = context.dependOnInheritedWidgetOfExactType<CounterScope>();
3. updateShouldNotify 的形式化条件
设旧的 InheritedWidget 为 ,新的为 ,判断函数为:
updateShouldNotify 就是在实现这个 。
例如:
@override
bool updateShouldNotify(CounterScope oldWidget) {
return count != oldWidget.count;
}
如果旧值为 1,新值为 2:
N(1, 2) = true
依赖者会被通知。
如果旧值为 1,新值仍为 1:
N(1, 1) = false
依赖者不会因为这个 InheritedWidget 更新而重建。
一个危险的反例:可变对象没有变化引用
class UserScope extends InheritedWidget {
final User user;
const UserScope({
super.key,
required this.user,
required super.child,
});
@override
bool updateShouldNotify(UserScope oldWidget) {
return user != oldWidget.user;
}
}
如果外部代码这样修改:
user.name = 'new name';
setState(() {});
而新旧 UserScope 仍然持有同一个 user 实例,那么:
user == oldWidget.user
可能为 true,导致 updateShouldNotify 返回 false。依赖者看不到变化。
正确方向通常是使用不可变对象并创建新实例:
setState(() {
user = user.copyWith(name: 'new name');
});
这里不是因为“不可变对象一定更快”,而是因为状态变化具有明确的新旧边界,比较和通知才有可靠语义。
4. InheritedWidget 的实际边界
InheritedWidget 只负责:
- 沿 Widget 树提供数据;
- 建立依赖;
- 在祖先配置变化后通知后代。
它不负责:
- 网络请求;
- 状态持久化;
- 并发请求取消;
- 业务规则;
- 跨进程或跨平台通信;
- 自动生成不可变状态;
- 自动管理 Repository 生命周期。
因此,直接使用 InheritedWidget 时,状态的所有权仍然要由外层的 State、Controller 或其他对象承担。
三、BuildContext、生命周期和 Key:状态传播的三个边界
1. BuildContext 是 Element 的句柄
BuildContext 实际上是 Widget 在树中对应的 Element 的接口。它不是全局容器,也不是可以永久保存的“页面引用”。
以下代码有风险:
Future<void> load(BuildContext context) async {
await repository.fetch();
Navigator.of(context).pop();
}
异步等待期间,调用该方法的页面可能已经被移除。正确做法是检查上下文是否仍然挂载:
Future<void> load(BuildContext context) async {
await repository.fetch();
if (!context.mounted) {
return;
}
Navigator.of(context).pop();
}
在 State 中则使用:
if (!mounted) {
return;
}
这解决的是“异步完成后界面对象是否仍存在”的问题,不是状态管理框架特有的问题。
2. StatefulWidget 生命周期决定本地状态的有效期
典型生命周期如下:
createState
↓
initState
↓
didChangeDependencies
↓
build
↓
didUpdateWidget(父级用相同 Key 更新配置时可能发生)
↓
build
↓
deactivate
↓
dispose
主要边界:
initState只执行一次,适合初始化与订阅;didChangeDependencies适合处理依赖的 InheritedWidget 变化;build可能执行很多次,不能在其中启动每次都会重复的请求或订阅;dispose必须取消订阅、关闭 Controller、释放资源;didUpdateWidget用于响应同一个 State 收到的新 Widget 配置。
例如:
class SearchState extends State<SearchPage> {
late final TextEditingController controller;
StreamSubscription<String>? subscription;
@override
void initState() {
super.initState();
controller = TextEditingController();
}
@override
void dispose() {
subscription?.cancel();
controller.dispose();
super.dispose();
}
}
如果把订阅放进 build:
@override
Widget build(BuildContext context) {
stream.listen((event) {
// 错误风险:每次 build 都新增一个订阅
});
return const SizedBox();
}
页面每次重建都会产生新订阅,最终可能表现为重复请求、重复导航或内存泄漏。
3. Key 决定 State 是否继续属于同一个位置
Flutter 在父子关系、类型和 Key 等条件下匹配新旧 Widget。对于同类型、同位置且 Key 相同的 StatefulWidget,通常会复用原来的 State。
class Editor extends StatefulWidget {
final String documentId;
const Editor({
super.key,
required this.documentId,
});
@override
State<Editor> createState() => _EditorState();
}
列表中如果没有稳定 Key:
ListView(
children: documents.map((document) {
return Editor(documentId: document.id);
}).toList(),
)
当列表头部插入一项时,Flutter 可能把原来第一个位置的 State 复用给新的第一项。文本控制器、动画状态或展开状态就可能与错误的文档关联。
应使用稳定的业务身份:
ListView(
children: documents.map((document) {
return Editor(
key: ValueKey(document.id),
documentId: document.id,
);
}).toList(),
)
Key 解决的是 Widget 树中 State 的身份匹配;Provider、Riverpod、BLoC 解决的是 状态的访问、更新和传播。两者不能互相替代。
四、Provider:对 InheritedWidget 的结构化封装
Provider 是 Flutter 生态中常见的状态访问方案。它的核心思想仍然建立在 Widget 树和 InheritedWidget 之上,只是把:
- 创建对象;
- 向下提供对象;
- 监听变化;
- 自动销毁;
- 读取方式;
封装成了更统一的 API。
1. ChangeNotifierProvider 的完整示例
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class CounterModel extends ChangeNotifier {
int _count = 0;
int get count => _count;
void increment() {
_count++;
notifyListeners();
}
}
void main() {
runApp(
ChangeNotifierProvider(
create: (_) => CounterModel(),
child: const MaterialApp(
home: CounterPage(),
),
),
);
}
class CounterPage extends StatelessWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context) {
final count = context.watch<CounterModel>().count;
return Scaffold(
appBar: AppBar(title: const Text('Provider')),
body: Center(
child: Text('count = $count'),
),
floatingActionButton: FloatingActionButton(
onPressed: context.read<CounterModel>().increment,
child: const Icon(Icons.add),
),
);
}
}
需要在 pubspec.yaml 中添加 provider 依赖。示例的输入是点击加号,预期输出是文本依次显示 count = 1、count = 2 等。
调用链如下:
ChangeNotifierProvider创建CounterModel;context.watch建立对该 Provider 的监听;- 点击按钮通过
context.read获取模型,但不建立按钮自身的监听; increment修改_count;notifyListeners()通知监听者;- 使用
watch的CounterPage重建; - 使用
read的回调不会因为计数变化而重建。
2. watch、read 和 select
三种读取方式语义不同:
context.watch<CounterModel>()
读取并监听。Provider 更新时,当前 Widget 可能重建。
context.read<CounterModel>()
读取但不监听。适合按钮回调、事件处理等只需要调用方法的地方。
context.select<CounterModel, int>(
(model) => model.count,
)
只监听选择出的部分。假设模型还有一个与当前 Widget 无关的字段:
class CounterModel extends ChangeNotifier {
int _count = 0;
bool _busy = false;
int get count => _count;
bool get busy => _busy;
}
只选择 count 可以减少不必要的重建:
final count = context.select<CounterModel, int>(
(model) => model.count,
);
但这不应被理解为“任何情况下都只重建一个 Text”。Flutter 仍然以 Widget 子树为重建单位;选择器减少的是当前监听 Widget 因无关字段变化而重建的机会。
3. ChangeNotifier 的边界
ChangeNotifier 提供的是“有人修改后主动通知”的机制:
_count++;
notifyListeners();
它不会自动知道字段何时变化。因此下面的代码是一个逻辑错误:
void increment() {
_count++;
// 忘记 notifyListeners()
}
内存中的 _count 已经改变,但监听 UI 不会收到通知。
另一个问题是通知粒度。一个巨大模型包含整个应用状态时:
class AppModel extends ChangeNotifier {
User? user;
List<CartItem> cart = [];
ThemeMode themeMode = ThemeMode.system;
}
任何字段变化都可能通知大量监听者。可以通过 select 拆分读取,也可以按 Feature 拆分模型。Provider 本身并不阻止这种设计。
4. 创建和销毁规则
使用:
ChangeNotifierProvider(
create: (_) => CounterModel(),
child: ...,
)
Provider 通常负责创建并在离开树时销毁对象。
如果对象由外部拥有,应使用 .value:
final model = existingModel;
ChangeNotifierProvider.value(
value: model,
child: const SomePage(),
);
不能为了“方便”把新创建的对象放入 .value:
ChangeNotifierProvider.value(
value: CounterModel(),
child: const SomePage(),
);
这样会使所有权和销毁责任不清晰,尤其在列表复用或树结构变化时容易出现资源生命周期错误。
五、Riverpod:把依赖从 BuildContext 中分离出来
Riverpod 仍然提供响应式状态,但它的容器模型与 Flutter Widget 树不同:
- Provider 描述如何创建状态;
ProviderContainer保存并管理 Provider 实例;- Flutter 中通常由
ProviderScope持有容器; - Widget 通过
WidgetRef读取,而不是通过BuildContext查找; - Provider 可以在 Widget 树之外被测试或组合。
1. 一个可运行的 Riverpod 示例
以下使用 Dart 3 的类语法和 Riverpod 的 Notifier API:
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
final counterProvider =
NotifierProvider<CounterNotifier, int>(CounterNotifier.new);
class CounterNotifier extends Notifier<int> {
@override
int build() {
return 0;
}
void increment() {
state++;
}
}
void main() {
runApp(
const ProviderScope(
child: MaterialApp(
home: CounterPage(),
),
),
);
}
class CounterPage extends ConsumerWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider);
return Scaffold(
appBar: AppBar(title: const Text('Riverpod')),
body: Center(
child: Text('count = $count'),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
ref.read(counterProvider.notifier).increment();
},
child: const Icon(Icons.add),
),
);
}
}
需要在 pubspec.yaml 中添加 flutter_riverpod。运行条件是 Flutter 应用根部存在 ProviderScope。
这里的因果链是:
counterProvider描述CounterNotifier如何被创建;ProviderScope提供运行时容器;ref.watch(counterProvider)订阅状态;state++产生新的状态;- Riverpod 通知依赖该 Provider 的 Widget;
CounterPage重新构建并读取新的整数。
ref.read 只读取一次,不建立当前 Widget 的响应式监听。修改器可以通过:
ref.read(counterProvider.notifier)
取得。
2. Riverpod 与 BuildContext 的边界
Provider 方案通常通过:
context.watch<T>()
在 Widget 树中查找依赖。Riverpod 使用:
ref.watch(provider)
这使依赖描述不再绑定到某个具体的 BuildContext。
例如,同一个 Provider 可以在测试中直接使用容器:
final container = ProviderContainer();
final initial = container.read(counterProvider);
container.read(counterProvider.notifier).increment();
final updated = container.read(counterProvider);
测试结束时应销毁容器:
addTearDown(container.dispose);
在 Flutter 应用中则通常由 ProviderScope 管理容器生命周期。
这不意味着 Riverpod 完全不受 Widget 生命周期影响。使用 ConsumerWidget 时,Widget 仍然会被销毁;区别在于状态容器和 Widget 树之间的依赖关系更明确。
3. autoDispose 的真实语义
一个 Provider 的生命周期可能比单个页面长,也可能只在有监听者时存在。带 autoDispose 的 Provider 在不再被使用后可以释放状态:
final searchProvider = NotifierProvider.autoDispose<SearchNotifier, String>(
SearchNotifier.new,
);
对于页面搜索框,这通常符合“离开页面即丢弃”的所有权模型;对于登录用户,则可能不符合预期,因为用户状态应由更高层 Provider 持有。
生命周期选择可以形式化为:
如果状态生命周期 至少覆盖消费者生命周期 ,消费者销毁不会导致状态过早消失。反过来,如果页面状态错误地放在全局容器中,就会造成状态泄漏或页面间串状态。
4. AsyncValue 与异步状态
请求状态不能只用一个可空数据字段表示,否则“尚未请求”“请求中”“请求成功为空”“请求失败”会混在一起。可以显式建模:
final userProvider =
AsyncNotifierProvider<UserNotifier, User>(UserNotifier.new);
class UserNotifier extends AsyncNotifier<User> {
@override
Future<User> build() async {
return repository.fetchCurrentUser();
}
Future<void> reload() async {
state = const AsyncLoading();
try {
final user = await repository.fetchCurrentUser();
state = AsyncData(user);
} catch (error, stackTrace) {
state = AsyncError(error, stackTrace);
}
}
}
上例中的 repository 必须来自实际的依赖注入方式,例如另一个 Provider;不能把未定义的全局变量直接放进生产代码。完整的 UI 通常根据 AsyncValue 分支:
class UserView extends ConsumerWidget {
const UserView({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final value = ref.watch(userProvider);
return value.when(
loading: () => const CircularProgressIndicator(),
error: (error, stackTrace) => Text('加载失败:$error'),
data: (user) => Text(user.name),
);
}
}
5. 异步竞态:后发请求不一定先完成
假设用户连续输入两个关键词:
请求 A:flutter
请求 B:flutter riverpod
如果 B 先完成,UI 先显示 B;随后 A 才完成,旧结果可能覆盖新结果。这是请求竞态,不是 Riverpod 或 Provider 自动解决的问题。
需要为请求建立“代次”:
class SearchController {
int _generation = 0;
Future<void> search(String keyword) async {
final generation = ++_generation;
final result = await repository.search(keyword);
if (generation != _generation) {
return; // 旧请求的结果被丢弃
}
state = result;
}
// state 和 repository 省略于此类示例时,实际代码必须定义它们
}
生产实现还可以取消旧请求,但取消能力取决于 Repository 和 HTTP 客户端是否支持。状态管理框架可以承载并发策略,却不能凭空取消底层网络操作。
六、BLoC:通过事件和状态显式建模业务流
BLoC 通常指 Business Logic Component。它把业务逻辑放在独立对象中,并通过两个方向传递信息:
UI ── Event ──> BLoC ── State ──> UI
与直接调用 model.increment() 相比,BLoC 更强调:
- 输入是事件;
- 输出是状态;
- 业务逻辑不依赖 Flutter Widget;
- 状态变化可以被记录、测试和观察。
1. Dart 3 sealed 类型定义事件和状态
sealed class CounterEvent {}
final class IncrementPressed extends CounterEvent {}
final class DecrementPressed extends CounterEvent {}
sealed class CounterState {
const CounterState();
}
final class CounterValue extends CounterState {
final int value;
const CounterValue(this.value);
}
BLoC 实现:
import 'package:flutter_bloc/flutter_bloc.dart';
class CounterBloc extends Bloc<CounterEvent, CounterState> {
CounterBloc() : super(const CounterValue(0)) {
on<IncrementPressed>((event, emit) {
final current = (state as CounterValue).value;
emit(CounterValue(current + 1));
});
on<DecrementPressed>((event, emit) {
final current = (state as CounterValue).value;
emit(CounterValue(current - 1));
});
}
}
UI:
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
void main() {
runApp(
BlocProvider(
create: (_) => CounterBloc(),
child: const MaterialApp(
home: CounterPage(),
),
),
);
}
class CounterPage extends StatelessWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('BLoC')),
body: Center(
child: BlocBuilder<CounterBloc, CounterState>(
builder: (context, state) {
final value = (state as CounterValue).value;
return Text('count = $value');
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () {
context.read<CounterBloc>().add(IncrementPressed());
},
child: const Icon(Icons.add),
),
);
}
}
依赖为 flutter_bloc。完整链路为:
- UI 发送
IncrementPressed; CounterBloc的事件处理器被调用;- 处理器读取当前状态;
- 计算新状态;
emit发出新状态;BlocBuilder收到状态流事件;- UI 根据新状态重建。
2. BLoC 与 Cubit 的区别
Cubit 直接暴露方法:
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0);
void increment() => emit(state + 1);
}
BLoC 通常通过事件进入业务逻辑:
context.read<CounterBloc>().add(IncrementPressed());
两者都可以实现响应式状态。区别主要在输入建模:
Cubit:调用方法更直接,样板代码较少;BLoC:事件类型更明确,适合审计、日志、复杂流程和事件驱动业务。
不能仅凭“用了 BLoC”就得到更好的架构。若所有事件处理器只是把方法转发给一个巨大对象,复杂度可能只是转移了。
3. BLoC 的异步并发边界
事件处理器可能包含异步操作:
on<SearchSubmitted>((event, emit) async {
emit(const SearchLoading());
try {
final result = await repository.search(event.keyword);
emit(SearchSuccess(result));
} catch (error, stackTrace) {
emit(SearchFailure(error, stackTrace));
}
});
连续事件的处理顺序和并发策略必须明确。不能假设“后加入的事件一定后完成”或“事件处理器自动串行”。
例如:
t0: 收到 A
t1: 收到 B
t2: B 请求完成,emit(B)
t3: A 请求完成,emit(A)
最终状态可能错误地回到 A。解决方案包括:
- 在业务层为请求加代次;
- 使用可取消的请求;
- 对事件处理器配置串行、丢弃旧事件或只保留最新事件的策略;
- 让 Repository 提供明确的并发语义。
具体转换器 API 会受所使用的 bloc 版本影响,因此不能在没有锁定依赖版本时假设某个转换器的默认行为。生产代码应查看当前版本的 bloc API 和测试实际时序。
4. BlocBuilder、BlocListener 和 BlocSelector
三类用途不同:
BlocBuilder<MyBloc, MyState>(
builder: (context, state) {
return ...;
},
)
用于状态变化时生成 UI。
BlocListener<MyBloc, MyState>(
listener: (context, state) {
// 导航、SnackBar、Dialog 等一次性副作用
},
child: ...,
)
用于副作用,而不是把导航写进 builder。因为 builder 可能被多次调用,可能导致重复导航或重复弹窗。
BlocSelector<MyBloc, MyState, Selected>(
selector: (state) => ...,
builder: (context, selected) {
return ...;
},
)
用于只关注状态的一部分。
一个常见错误是:
BlocBuilder<LoginBloc, LoginState>(
builder: (context, state) {
if (state is LoginSuccess) {
Navigator.of(context).push(...);
}
return const SizedBox();
},
)
这里把渲染和副作用混在一起。应使用 BlocListener,并在异步流程后检查页面是否仍然挂载。
七、四种机制的共同模型与差异
它们都可以抽象为:
状态拥有者
↓
读取关系
↓
事件或方法修改状态
↓
变化判断
↓
通知订阅者
↓
局部重建或副作用
差异在于每一步的实现:
| 机制 | 状态容器 | 读取方式 | 变化通知 | 典型边界 |
|---|---|---|---|---|
InheritedWidget |
外部 State 或不可变配置 | BuildContext |
updateShouldNotify |
最底层,控制力强但样板多 |
| Provider | Provider 创建的对象 | watch/read/select |
ChangeNotifier、Listenable 等 |
易上手,生命周期依赖 Widget 树 |
| Riverpod | ProviderContainer | ref.watch/read |
Provider 状态变化 | 依赖可脱离具体 BuildContext |
| BLoC | Bloc/Cubit | context.read、Builder/Listener |
Stream 状态输出 | 事件、状态和业务流更显式 |
一个关键区别:状态变化与 Widget 变化
如果一个 InheritedWidget 的 updateShouldNotify 返回 false,依赖者不会因为该依赖被通知。
如果一个 ChangeNotifier 调用了 notifyListeners,监听者会收到通知,但 Provider 并不替你判断业务字段是否真的发生有意义的变化。
如果 Riverpod 的 Provider 发出了新状态,依赖者是否重建还会受到读取方式和选择机制影响。
如果 BLoC emit 了状态,BlocBuilder 是否重建还可能受 buildWhen 或选择器影响。
因此,“状态发生变化”至少有三层含义:
- 内存中的字段是否被修改;
- 状态容器是否发出了通知;
- 当前消费者是否订阅了该变化。
缺少任何一层,UI 都可能不更新。
八、状态分类决定状态管理边界
1. 本地短生命周期状态
适合放在 StatefulWidget:
- 输入框文本;
- 动画控制器;
- 当前 Tab;
- 页面内展开/折叠;
- 滚动位置;
- 当前焦点。
例如文本控制器属于页面资源:
class SearchField extends StatefulWidget {
const SearchField({super.key});
@override
State<SearchField> createState() => _SearchFieldState();
}
class _SearchFieldState extends State<SearchField> {
late final TextEditingController controller;
@override
void initState() {
super.initState();
controller = TextEditingController();
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return TextField(controller: controller);
}
}
把这类状态提升到全局 Provider,通常会扩大生命周期并增加无关依赖。
2. 页面或 Feature 状态
适合由 Provider、Riverpod Notifier、Cubit 或 BLoC 管理:
- 页面加载状态;
- 筛选条件;
- 分页数据;
- 表单提交状态;
- 当前 Feature 的业务状态。
它们通常需要被多个 Widget 使用,但不一定属于整个应用。
3. 应用级状态
例如:
- 当前认证会话;
- 用户权限;
- 全局配置;
- 主题模式;
- 多页面共享的购物车。
应用级并不等于“所有 Widget 都能随便修改”。应限制修改入口,例如只允许 SessionController 处理登录和登出,而不是让任意界面直接修改用户对象。
4. Repository 和外部数据源
网络、数据库、文件系统、平台插件不应直接散落在 build 方法中:
@override
Widget build(BuildContext context) {
final response = http.get(...); // 错误边界
...
}
更合理的数据流是:
Widget
↓ 用户事件
Controller / BLoC / Notifier
↓
Use Case(可选)
↓
Repository
↓
HTTP、数据库、平台 API
Repository 负责外部数据源和错误转换;状态控制器负责把结果转成可被 UI 消费的状态;Widget 负责渲染和发送用户意图。
九、大型应用中的分层与依赖注入
一个 Feature 可以组织成:
features/orders/
├── presentation/
│ ├── orders_page.dart
│ └── orders_controller.dart
├── domain/
│ ├── order.dart
│ └── load_orders.dart
└── data/
├── orders_repository.dart
└── orders_api.dart
依赖方向应尽量向内稳定:
flowchart LR
UI[Presentation UI] --> C[Controller / BLoC / Notifier]
C --> U[Use Case]
U --> R[Repository Interface]
R --> D[Data Source]
D --> P[HTTP / DB / Platform]
关键规则是:
- UI 不直接依赖 HTTP 客户端;
- Controller 不应该知道具体 Widget;
- Repository 不应该调用
BuildContext; - 平台插件通过边界接口进入 Data 层;
- 依赖注入负责组装对象,不负责替代业务逻辑。
Provider 和 Riverpod 都可以承担依赖注入;BLoC 也可以由 Provider 或 BlocProvider 创建。工具不同,但依赖方向不应随工具改变。
Riverpod 中的依赖声明示意
final ordersRepositoryProvider = Provider<OrdersRepository>((ref) {
return OrdersRepository(
api: OrdersApi(),
);
});
final ordersProvider =
AsyncNotifierProvider<OrdersNotifier, List<Order>>(
OrdersNotifier.new,
);
class OrdersNotifier extends AsyncNotifier<List<Order>> {
@override
Future<List<Order>> build() {
return ref.read(ordersRepositoryProvider).loadOrders();
}
}
这里 OrdersNotifier 依赖 Repository Provider,而不是在 build 方法中直接构造 HTTP 客户端。测试时可以覆盖 Repository:
final container = ProviderContainer(
overrides: [
ordersRepositoryProvider.overrideWithValue(FakeOrdersRepository()),
],
);
具体 override API 以当前 Riverpod 版本为准,但“通过容器替换依赖”的设计目标是稳定的。
十、错误处理必须沿数据流传播
错误不是打印日志后结束的副作用,而是状态的一部分。
一个加载过程至少有:
未开始 → 加载中 → 成功
↘
失败
如果支持重试,还可能有:
失败 → 加载中 → 成功
BLoC 可以用 sealed state 表达:
sealed class OrdersState {
const OrdersState();
}
final class OrdersInitial extends OrdersState {
const OrdersInitial();
}
final class OrdersLoading extends OrdersState {
const OrdersLoading();
}
final class OrdersLoaded extends OrdersState {
final List<Order> orders;
const OrdersLoaded(this.orders);
}
final class OrdersError extends OrdersState {
final Object error;
final StackTrace stackTrace;
const OrdersError(this.error, this.stackTrace);
}
这样 UI 可以明确处理每条路径,而不是依赖多个互相矛盾的字段:
if (loading && orders.isEmpty) {
// 首次加载
} else if (loading && orders.isNotEmpty) {
// 刷新已有内容
} else if (error != null) {
// 失败
}
后者并非一定错误,但字段之间的合法组合更多,状态机更难验证。
错误转换的边界
Repository 可以把底层错误转换成领域错误:
class NetworkFailure implements Exception {
final String message;
const NetworkFailure(this.message);
}
Controller 再把领域错误映射为 UI 状态。这样 Widget 不需要判断:
if (error is SocketException) ...
否则平台、网络库和 UI 会发生不必要的耦合。
十一、重建边界、性能和可观测性
1. 先测量,再优化
Flutter 的重建本身不等于性能问题。真正需要关注的是:
- 是否重建了过大的子树;
build中是否进行了昂贵计算;- 是否重复启动请求或订阅;
- 是否创建了大量短命对象;
- 是否因为错误的状态范围导致整页更新。
可以使用 Flutter DevTools 的 Performance、Widget rebuild 相关调试能力检查实际行为,而不是仅凭 Widget 数量判断性能。
2. 缩小监听范围
Provider:
final count = context.select<CounterModel, int>(
(model) => model.count,
);
Riverpod:
final count = ref.watch(counterProvider);
如果一个 Provider 暴露的是大对象,可以进一步拆分 Provider,或选择具体字段。BLoC 则可以使用 BlocSelector 或合理的 buildWhen。
但选择器也有边界:如果每次选择都生成一个新的、没有正确相等语义的对象,比较可能失去意义。例如:
final selected = ref.watch(provider.select((state) {
return [state.a, state.b]; // 每次创建新 List
}));
是否会导致额外更新取决于框架和比较语义;更稳妥的方式是返回具有明确值相等语义的不可变对象,或拆成更小的 Provider。
3. const 不是状态管理
const 可以减少相同配置的对象创建,并帮助 Flutter 判断 Widget 配置稳定,但它不能替代通知机制:
const Text('固定内容')
不会让动态状态自动更新。状态仍需通过 setState、InheritedWidget 通知、Provider 通知、Riverpod 状态变化或 BLoC 状态输出传播。
十二、常见失败表现与诊断路径
失败一:UI 不更新
检查顺序:
- 状态字段是否真的修改;
- 是否调用了通知方法,例如
notifyListeners; - Widget 是否使用了监听 API,而不是
read或getInherited...; updateShouldNotify是否错误地返回false;- 是否修改了可变对象的内部字段,却没有创建新状态;
- 是否读取的是另一个 Provider、另一个容器或另一个 BLoC 实例;
- 是否因为 Key 改变导致页面拿到的是新 State。
失败二:页面离开后仍然收到回调
通常说明存在未释放资源:
StreamSubscription未取消;AnimationController未释放;TextEditingController未释放;- BLoC 未关闭;
- 定时器未取消;
- 外部回调仍持有已销毁 State。
检查 dispose 和 Provider/BLoC 的创建位置,确认对象到底由谁拥有。
失败三:重复请求或重复导航
重点检查:
- 是否在
build中启动请求; - 是否每次 build 都
listen; - 是否把导航写进
BlocBuilder; - 是否因为父级重建重复创建了控制器;
- 是否使用了错误的 Key,导致 State 被反复创建;
- 是否存在异步竞态,旧请求覆盖了新请求。
失败四:ProviderNotFoundException 或找不到依赖
原因通常是:
- Provider 不在当前 Widget 的祖先路径上;
- 在创建 Provider 的同一个
build中使用了旧的BuildContext; - 路由使用了不同的树或 Overlay;
- 测试没有包裹
ProviderScope; - Provider 被放在错误的 Feature 边界。
修复重点不是“再包一层 Provider”,而是确认依赖的所有权和可见范围。
失败五:使用已经销毁的 Context
异步操作完成后执行:
Navigator.of(context).pop();
如果页面已销毁,可能出现异常或无效操作。应在异步边界后检查:
if (!context.mounted) return;
但更好的架构是让状态层发出“提交成功”状态,由仍然存在的 UI 监听并决定是否导航;状态层不应长期保存 BuildContext。
十三、平台差异:状态框架相同,生命周期环境不同
Provider、Riverpod 和 BLoC 本身主要是 Dart/Flutter 层机制,Android、iOS、桌面和 Web 通常可以共享同一套状态模型,但外部边界不同。
Android 与 iOS
系统可能暂停、恢复或终止应用。后台切换不等于 Dart 对象一定会被永久保留:
- 内存状态可能在进程被杀后丢失;
- 关键草稿、认证信息和同步游标需要持久化;
- 网络恢复后可能需要重新加载;
- 平台权限和通知能力需要通过平台 API 建模。
因此,不能把 Riverpod 容器或 BLoC 内存中的状态当成可靠持久化。
桌面平台
桌面应用可能有:
- 多窗口;
- 窗口尺寸和显示器变化;
- 文件系统访问;
- 原生菜单或托盘;
- 键盘快捷键。
“全局状态”需要重新定义:是整个进程共享,还是每个窗口独立?如果两个窗口应有不同的文档编辑状态,就不应把它们放进一个无边界的全局单例。
Web
Web 应用受到:
- 页面刷新;
- 浏览器前进后退;
- URL 路由;
- 标签页关闭;
- 浏览器存储限制;
- 网络断开和恢复;
等因素影响。刷新会重新创建 Dart 应用和状态容器,因此需要持久化的状态应放入合适的 Web 存储或通过服务端恢复。不能因为页面在桌面浏览器中看起来“长期打开”,就假设内存状态永久存在。
Isolate 和平台通道
Flutter UI 通常运行在主 isolate。后台 isolate 不会直接共享主 isolate 中的 Provider、Riverpod 容器或 BLoC 实例。跨 isolate 通信必须通过消息传递。
同样,平台插件调用可能是异步的,并且受平台权限、线程和生命周期影响。状态层应把这些调用封装在 Data 或 Platform 边界中,向上暴露稳定的 Dart 领域接口。
十四、如何选择:按问题而不是按流行度
直接使用 InheritedWidget
适合:
- 需要理解 Flutter 树依赖机制;
- 共享范围很小;
- 状态结构简单;
- 不希望引入外部包;
- 需要自定义通知逻辑。
不适合把网络、缓存、复杂业务流程全部塞进一个 Scope。
使用 Provider
适合:
- 团队熟悉 Widget 树依赖;
- 已有
ChangeNotifier、ValueNotifier或其他 Listenable; - 需要简单的依赖注入和生命周期管理;
- 应用状态规模中等,模型边界清楚。
需要特别注意 notifyListeners、对象所有权和可变数据。
使用 Riverpod
适合:
- 希望 Provider 脱离具体
BuildContext; - 需要更明确的依赖声明、测试覆盖和容器隔离;
- 需要处理参数化 Provider、异步状态或不同生命周期;
- 大量状态需要按依赖图组织。
仍然需要自己设计状态模型、错误处理和并发策略。Riverpod 不会自动替你解决业务层竞态。
使用 BLoC
适合:
- 业务事件和状态转换是核心;
- 需要清晰记录“用户做了什么”;
- 团队倾向于事件驱动和显式状态机;
- 复杂页面需要区分渲染、副作用和业务流程。
代价是事件、状态和处理器会带来更多类型和样板代码。简单的本地计数器不一定值得使用完整 BLoC。
混用时的边界
实际项目可能同时使用:
Riverpod:依赖注入、Repository、应用级 Provider
BLoC:复杂 Feature 的事件与状态流
StatefulWidget:输入控制器、焦点、动画
InheritedWidget:主题、局部树上下文或自定义底层机制
混用并非问题,同一份状态有多个权威来源 才是问题。
例如,既让 Riverpod 保存登录用户,又让一个全局 BLoC 保存另一份登录用户,最终必须处理:
哪个状态是事实来源?
什么时候同步?
冲突时谁覆盖谁?
登出时谁负责清理?
如果无法回答这些问题,说明边界已经失控。
十五、最终判断标准:状态的所有权是否清楚
一个可维护的状态设计通常能明确回答:
- 这个状态属于哪个 Feature?
- 它应在页面销毁时消失,还是跨页面保留?
- 谁可以修改它?
- 修改通过方法、事件还是命令?
- 哪些 Widget 监听它?
- 哪些变化不会导致当前 Widget 重建?
- 异步请求失败后状态是什么?
- 同时发起多个请求时,哪个结果有效?
- 页面销毁后,未完成操作如何处理?
- Android、iOS、桌面和 Web 是否需要不同的持久化或平台适配?
- 测试时能否替换外部依赖?
InheritedWidget 解释了 Flutter 如何沿树传播依赖;Provider 把这种机制封装为易用的对象监听;Riverpod 把依赖和状态容器进一步从 BuildContext 中分离;BLoC 则把业务输入和输出明确建模为事件与状态。
它们不是互相排斥的“等级”。真正的边界来自状态所有权、生命周期、依赖方向和并发语义。只要这些关系清楚,工具可以更换;如果这些关系不清楚,换成更复杂的框架也只会把问题隐藏得更深。
系列导航与关联阅读
- 系列入口:Flutter 完整学习路线:从 Dart 与 Widget 到多端架构和应用发布
- 上一篇:Flutter 主题与设计系统:Material、ColorScheme、Token 和组件规范
- 下一篇:Flutter 网络与数据层:HTTP、序列化、取消、缓存、分页和离线
- 延伸:Flutter 状态与生命周期:StatefulWidget、BuildContext、Key 和更新边界
- 延伸:Flutter 大型应用架构:分层、Feature、依赖注入和多端边界
官方资料
本文依据 Flutter 与 Dart 官方文档重新梳理;正文与示例由 WR BLOG 编写。

评论
0 条讨论