状态管理与架构篇-Provider select 与局部刷新性能优化

3 阅读5分钟

Provider 的 select 与局部刷新:别用错粒度

系列:状态管理与架构篇(3/6)

Riverpod 里最容易产生错觉的一句话是:「我用了 Riverpod,性能就不会差。」
实际上 监听的是哪一层状态、用什么方式监听,比在 Widget 里手写 setState 更决定会不会整页重绘。

这篇只抠一件事:select 在什么场景奏效、什么时候是摆设、列表和详情怎么拆监听。


1. 问题背景

业务页面里常见一块 StatecopyWith 一改,下面三个区域一起闪:

  • 顶部用户信息
  • 中间 Tab / 筛选条件
  • 列表或九宫格内容

你明明只改了列表某一页的 pageNum,或者只改了某个 Tab 的选中下标,结果 build 跑了一整块子树。划列表时掉帧,Profiler 里全是 build,但网络并不忙。

另一种情况:你听说要用 select,写了:

ref.watch(fooProvider.select((s) => s));

等于还是监听整个 s和直接 watch(fooProvider) 没区别


2. 原因分析

Riverpod 的刷新规则可以压成一句话:Provider 触发重建,是因为「你 watch 的那段值」在上一次和这一次之间被认为发生了变化。

要点有三条:

(1)ref.watch(provider) 绑的是整个暴露的值。
StateNotifierstate 一换引用,所有单纯 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
异步页 AsyncValuedata 里真正显示的字段做 select,避免 loading 一闪全树跟着抖
整个列表引用频繁变优先稳定「列表身份」:用不可变更新,或把「列表」和「选中项」拆开

和代码生成的关系
你们用片段生成 Providerriverpod_generator / @riverpod)也好,手写 StateNotifierProvider 也好,select 的用法完全一样,挂在 WidgetRef / Ref 上对同一个 provider 做投影即可。生成器解决的是样板代码,不改变「监听粒度」这条规律。


4. 关键代码

4.1 同一状态,多区块只监听各自字段

假设 HomeUiState 里同时有 tabIndexbannerLoadingroomList(示意,名字随意):

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 时,只 watchtabIndex / bannerLoading 的控件 不会 跟着 build

前提是:tabIndexbannerLoadingrooms 这几项的类型 == 可信(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),
);

AsyncLoadingAsyncData 时只有关心 followerCount 的叶子会动;如果其它字段在 User 里变了但 followerCount 相同,也不必重建。

4.4 listen 也是一样要选

不仅 watchref.listen 也可以在第二个参数里用 select 的等价思路:只对「要弹 Toast / 要导航」的那几个字段建立监听,避免无关字段抖动触发回调。


5. 效果验证

改完不要先看「感觉自己优化了」,直接开 Flutter DevTools → Performance,录一段你反复触发「只改局部状态」的操作(例如切换 Tab、加载下一页)。

  • 改前:单次操作里整页 build 次数多、深度大。
  • 改后build 集中在真正依赖变化的那几个 Element 上;列表滑动时 CPU 曲线会舒服一截。

如果你在 select 里投影了一个每次都是新实例的对象,DevTools 会诚实告诉你:改和没改一样。这时先修 == 或缩小投影类型,比再加一层 Consumer 有意义。


6. 可复用结论

  1. select 不是语法糖,是「相等性边界」:投影结果必须能稳定比较,否则白写。
  2. 大 State 可以留,大 watch 不能留:要么 select,要么拆 Provider / family。
  3. 列表性能经常是「身份与切片」问题:整表 watch + itemBuilder 里读全局,是最常见的隐性全局刷新。
  4. 生成 Provider 不改变这条规则@riverpod 生成的是注入和生命周期,select 仍然决定了谁在 rebuild。

下一篇准备写异步状态:加载、空态、错误态怎么收口,减少每页各写一套 try/catch + loading 三目