Flutter 性能优化实战:用 ConsumerWidget + select 做到真正的局部刷新
导语
很多 Flutter 开发者都知道"局部刷新"这个概念,但真正落地时往往会遇到一个问题:Provider 的一个字段变了,整个页面都 rebuild 了。本文将从一个商业项目的真实架构出发,介绍如何用 Riverpod 的 select 做到指向性依赖,让拥有 20+ 字段的状态对象在更新时,只有真正需要刷新的卡片才重建。
背景:为什么要死磕局部刷新
先看一个典型场景。假设你有一个详情页,顶部展示头像和名称,中间是进度条,底部是操作面板。这三个区域的数据都来自同一个 Provider。
传统做法:
dart
// 不好的做法:整个页面监听整个状态
final state = ref.watch(detailProvider);
// state 任何一个字段变化,整个 build 都会重新执行
问题很明显:进度条数值变化时,头像部分没必要重建;名称更新时,底部面板也不需要重绘。随着页面复杂度增加,这种"牵一发动全身"的刷新方式会造成严重的性能浪费和视觉抖动。
核心方案:select 实现指向性依赖
Riverpod 的 select 允许你从一个大的状态对象中"摘"出你关心的字段,只有当该字段变化时,当前 Widget 才 rebuild。
基础用法:语义化提取
dart
// 从有十几个字段的 UserModel 中只取 id
final currentUserId = ref.watch(
userProvider.select((e) => e?.id ?? '')
);
// 只取余额,其他字段变化不触发 rebuild
final balance = ref.watch(
walletProvider.select((e) => e.valueOrNull?.totalAmount ?? 0)
);
// 只关心是否是游客身份
final isVisitor = ref.watch(
userProvider.select((e) => e?.isVisitor)
) ?? false;
这里 userProvider 背后可能是一个包含十几个字段的 UserModel,但每个 Widget 只关心自己需要的那一个字段。
进阶用法:多组件各取所需
dart
// 同一个 Provider,三个独立组件
// 组件 A:只关心 id
final userId = ref.watch(userProvider.select((e) => e?.id)) ?? '';
// 组件 B:只关心登录方式
final loginType = ref.watch(userProvider.select((e) => e?.loginType)) ?? '';
// 组件 C:只关心昵称
final nickName = ref.watch(userProvider.select((e) => e?.nickName ?? '')).trim();
一个 userProvider,四处监听,互不干扰。每个 ConsumerWidget 只订阅自己真正需要的那一个字段。
实战案例:详情页的数据流设计
以一个复杂的详情页为例,页面包含 4 个独立区域:
| 区域 | 组件 | 依赖字段 | 来源 Provider |
|---|---|---|---|
| 顶部 | DetailHeader |
levelData + userData |
detailProvider(id) + userInfoProvider(id) |
| 背景 | DetailBackground |
阶段样式 | 由 stage 计算,无额外依赖 |
| 进度区 | ProgressPanel |
levelData |
detailProvider(id) |
| 功能面板 | ActionPanel |
阶段样式 | 由 stage 计算,无额外依赖 |
关键代码示例:
dart
@override
Widget build(BuildContext context, WidgetRef ref) {
// 每个区域独立监听自己的数据
final levelData = ref.watch(detailProvider(id)).valueOrNull;
final userData = ref.watch(userInfoProvider(id)).valueOrNull;
final stage = levelData?.levels
.firstOrNullWhere((e) => e.level == levelData.currentLevel)
?.stage ?? 1;
return Stack(
children: [
DetailBackground(stage: stage),
DetailHeader(data: levelData, user: userData),
ProgressPanel(data: levelData, stage: stage),
ActionPanel(stage: stage),
],
);
}
其中 DetailHeader 接收到 UserModel? user 后,在 build 内部自行计算子字段,避免外部传一堆零散参数:
dart
class DetailHeader extends StatelessWidget {
final LevelData data;
final UserModel? user;
@override
Widget build(BuildContext context) {
// 在组件内部自行计算需要的子字段
final isBuiltIn = user?.type == 0;
final avatarUrl = user == null
? ''
: (user!.type == 0 ? user!.avatarV2 : user!.avatar);
final nickname = user?.displayName ?? data.defaultName;
return Row(
children: [
AvatarCard(name: nickname, isBuiltIn: isBuiltIn, avatarUrl: avatarUrl),
],
);
}
}
这种做法把字段计算逻辑封装在组件内部,外部只需关心"传入了什么数据",不需要知道内部怎么拆解。
关键细节与踩坑点
1. select 的比较是 ==,不是 identity
Riverpod 的 select 使用 == 比较两次提取结果。这意味着:
dart
// 每次生成新对象 → select 每次都认为变了 → 每次都会 rebuild
final list = ref.watch(provider.select((e) =>
e.items.where((x) => x.active).toList() // 每次都产生新 List!
));
// 正确做法:在 Provider 内部过滤,确保引用稳定
2. select 回调中不要有副作用
select 回调会被频繁调用(每次 Provider notify 都会执行一次),不要在里面做网络请求、数据库读写等操作。它应该只是纯粹的字段提取。
3. ConsumerWidget 并非银弹
并不是所有组件都需要提取为 ConsumerWidget。如果一个组件本身很小(比如一个纯展示的 Text),提取反而增加代码复杂度。判断标准是:如果组件的 build 方法超过 30 行,或包含多个独立更新的子区域,就应该拆分。
4. 传对象而非传零散字段
当多个子字段需要从同一对象上获取时,把整个对象传入组件内部,由组件自行拆解,比在外部拆好再传入更干净:
dart
// 不好:外部拆字段,调用方需要知道内部实现细节
DetailHeader(
isBuiltIn: user?.type == 0,
avatarUrl: user?.type == 0 ? user?.avatarV2 : user?.avatar,
name: user?.displayName ?? data.defaultName,
)
// 更好:传整个对象,封装在组件内部
DetailHeader(data: levelData, user: user)
5. 和 Flutter Hooks 配合时注意时序
当在 HookWidget 中对 scrollOffset 等做动画插值,同时配合 select 监听数据时,select 变化导致的 rebuild 不会丢失 Hook 的状态。Flutter Hooks 是通过 useRef 在 Widget 生命周期内保持引用的,多次 rebuild 不会重新创建 Hook。
总结
ConsumerWidget + select 不是高深的黑魔法,而是一种纪律 :每次写 ref.watch 时,都问自己一句"我真的需要整个状态对象吗?"如果能用一个字段解决问题,就不要引入整个对象。
在实际项目中,这套纪律带来了立竿见影的效果:详情页的头像更新不再闪烁,余额变动不会导致整个页面 rebuild,消息列表的高频刷新只影响列表本身。
标签 :#Flutter #Riverpod #性能优化 #局部刷新 #ConsumerWidget