Provider 的 select 与局部刷新:别用错粒度
系列:状态管理与架构篇(3/6)
Riverpod 里最容易产生错觉的一句话是:「我用了 Riverpod,性能就不会差。」
实际上 监听的是哪一层状态、用什么方式监听,比在 Widget 里手写 setState 更决定会不会整页重绘。
这篇只抠一件事:select 在什么场景奏效、什么时候是摆设、列表和详情怎么拆监听。
1. 问题背景
业务页面里常见一块 State:copyWith 一改,下面三个区域一起闪:
- 顶部用户信息
- 中间 Tab / 筛选条件
- 列表或九宫格内容
你明明只改了列表某一页的 pageNum,或者只改了某个 Tab 的选中下标,结果 build 跑了一整块子树。划列表时掉帧,Profiler 里全是 build,但网络并不忙。
另一种情况:你听说要用 select,写了:
ref.watch(fooProvider.select((s) => s));
等于还是监听整个 s,和直接 watch(fooProvider) 没区别。
2. 原因分析
Riverpod 的刷新规则可以压成一句话:Provider 触发重建,是因为「你 watch 的那段值」在上一次和这一次之间被认为发生了变化。
要点有三条:
(1)ref.watch(provider) 绑的是整个暴露的值。
StateNotifier 的 state 一换引用,所有单纯 watch 它的消费者都会收到通知。
所以「状态模型太大」本身不一定是错,但 不加选择地 watch 一定吃亏。
(2)select 是在「投影」之后再比相等性。
ref.watch(provider.select((s) => s.onlyWhatINeed)) 只有在投影结果 != 上一次 时才往下通知。
投影函数每次都会跑,所以 投影要便宜;比较要可靠,所以投影出来的类型最好 有意义地实现 ==(例如 freezed、手写 == 的不可变小对象)。
(3)投影结果如果每次都是新对象且 == 为 false,会一直重建。
典型坑:select((s) => s.items.toList()) 每次新 List;
或 select((s) => SomeDto(a: s.a, b: s.b)) 但 SomeDto 没实现按字段的 ==。
这时候 select 写在代码里,性能和心理安慰是同步的——都等于零。
3. 解决方案
思路不是「到处 select」,而是「watch 的粒度 ≤ 界面真正关心的粒度」。
可以按场景选工具:
| 场景 | 做法 |
|---|---|
| 同页多区块,共享一个 Notifier | 各区块 watch(...select) 只取自己那几字段;或拆成多个 Provider 组合 |
| 列表一行里多种子控件 | 行级用 select 取该行 id 对应的数据切片;或给行单独 family / 派生 Provider |
异步页 AsyncValue | 对 data 里真正显示的字段做 select,避免 loading 一闪全树跟着抖 |
| 整个列表引用频繁变 | 优先稳定「列表身份」:用不可变更新,或把「列表」和「选中项」拆开 |
和代码生成的关系:
你们用片段生成 Provider(riverpod_generator / @riverpod)也好,手写 StateNotifierProvider 也好,select 的用法完全一样,挂在 WidgetRef / Ref 上对同一个 provider 做投影即可。生成器解决的是样板代码,不改变「监听粒度」这条规律。
4. 关键代码
4.1 同一状态,多区块只监听各自字段
假设 HomeUiState 里同时有 tabIndex、bannerLoading、roomList(示意,名字随意):
final tabIndex = ref.watch(homeProvider.select((s) => s.tabIndex));
final bannerLoading = ref.watch(homeProvider.select((s) => s.bannerLoading));
final rooms = ref.watch(homeProvider.select((s) => s.rooms));
copyWith 只改 rooms 时,只 watch 了 tabIndex / bannerLoading 的控件 不会 跟着 build。
前提是:tabIndex、bannerLoading、rooms 这几项的类型 == 可信(bool、int、以及实现了逐元素比较的集合/模型)。
4.2 列表项:避免整表 watch
差写法(示意):整页 watch 一个大 list,在 itemBuilder 里用下标取值——任何一行数据变化,依赖整个 list 引用的区域都会更新。
好一点:每一行对自己关心的数据做 select,且投影最好是「标量或可稳定比较的小对象」:
// 在 cell 的 build 里
final title = ref.watch(
catalogProvider.select((s) => s.itemTitleOf(itemId)),
);
itemTitleOf 返回 String 或实现了 == 的不可变 view model,避免每次 new 一个「永远不相等」的 DTO。
再好一点(数据量大时):catalogProvider.family(或派生 Provider)按 id 暴露该项状态,cell 只 watch 自己的 family,列表父级只 watch「id 列表」或 count。
4.3 AsyncValue 上剪枝
final count = ref.watch(
profileProvider.select((async) => async.valueOrNull?.followerCount),
);
AsyncLoading → AsyncData 时只有关心 followerCount 的叶子会动;如果其它字段在 User 里变了但 followerCount 相同,也不必重建。
4.4 listen 也是一样要选
不仅 watch,ref.listen 也可以在第二个参数里用 select 的等价思路:只对「要弹 Toast / 要导航」的那几个字段建立监听,避免无关字段抖动触发回调。
5. 效果验证
改完不要先看「感觉自己优化了」,直接开 Flutter DevTools → Performance,录一段你反复触发「只改局部状态」的操作(例如切换 Tab、加载下一页)。
- 改前:单次操作里整页
build次数多、深度大。 - 改后:
build集中在真正依赖变化的那几个Element上;列表滑动时 CPU 曲线会舒服一截。
如果你在 select 里投影了一个每次都是新实例的对象,DevTools 会诚实告诉你:改和没改一样。这时先修 == 或缩小投影类型,比再加一层 Consumer 有意义。
6. 可复用结论
select不是语法糖,是「相等性边界」:投影结果必须能稳定比较,否则白写。- 大 State 可以留,大 watch 不能留:要么
select,要么拆 Provider / family。 - 列表性能经常是「身份与切片」问题:整表 watch + itemBuilder 里读全局,是最常见的隐性全局刷新。
- 生成 Provider 不改变这条规则:
@riverpod生成的是注入和生命周期,select仍然决定了谁在 rebuild。
下一篇准备写异步状态:加载、空态、错误态怎么收口,减少每页各写一套 try/catch + loading 三目。