资讯详情

资讯详情

Flutter性能优化:用Sliver缩小rebuild影响面的原理与实践

做 Flutter 性能优化的人迟早会遇到“rebuild 影响面”这个词。我当初接手一个接近成型的项目首页是一个巨大的消息流每次下拉刷新、每发一条新消息页面都明显有掉帧用 DevTools 看 build 次数整个列表的 item 几乎全量 rebuild 了一遍。后来我把列表区域逐步改造成 CustomScrollView SliverList并调整了数据更新粒度rebuild 影响面直接从“全列表”缩小到了“可见区域里的几行”低端机上的滚动帧率肉眼可见地稳了。这篇文章就把“Sliver 为什么能天然缩小 rebuild 影响面”这件事一次说透。如果你是 Flutter 中级开发者正准备优化长列表、复杂滚动页或者对 Element 树的更新机制感兴趣建议认真看下去。我会从框架的运行机制讲起再给出一套能直接落地的实践方案和调优参数最后把我踩过的一些坑也一并列出来。1. 别把“Rebuild 影响面”理解成一条直线1.1 从 setState 到 RenderObject中间隔着三层要理解 Sliver 的优势得先把 Flutter 的三棵树关系理清Widget 树、Element 树、RenderObject 树。日常开发写的 Widget 只是配置描述真正参与界面计算的是 Element 和 RenderObject。rebuild 这个概念说的其实是“当状态变化后框架需要重新执行哪些 Widget 的 build 方法并且让对应的 Element 和 RenderObject 进行同步更新”。很多新手以为 setState 只更新调用它的那个 Widget这是一个严重误解。setState 会标记当前 Element 为 dirty然后在其父级 rebuild 时这个 Element 的 build 方法会被重新执行。而 build 一旦重新执行它返回的新 Widget 树会被框架拿去和旧 Element 树做 diff。diff 通过的地方会继续向下递归不能通过的地方会销毁旧 Element 并创建新 Element。换句话说setState 的影响面是一个“向下扩散的子树”扩散到什么程度取决于这颗子树里面有多少节点需要变更。拿生活类比Widget 树是施工图纸Element 树是现场施工队RenderObject 树是已经盖好的建筑。图纸改了施工队要逐张图纸核对核对到哪一张哪里就要重新施工。如果你把整个页面的图纸全塞进一个 Auto 里那么图纸任何一个小改动施工队都得把全部图纸翻一遍。而 Sliver 做的事情相当于把图纸分成若干卷宗只把当前视野相关的卷宗摆在桌上其余部分连翻都不翻。1.2 影响面的真正来源不只“build 方法”我在和团队新人讨论时经常强调rebuild 影响面不是单纯看 build 方法执行了多少次而是要看三个层面的叠加。第一是 Widget 层。父级 rebuild 后所有非 const 的子 Widget 都会因为构造函数重新执行而变成“新实例”。哪怕构建出来的 Widget 和之前长得完全一样只要不是同一个实例diff 就要继续向下走。第二是 Element 层。如果 diff 判断 runtimeType 和 key 相同Element 会复用并调用 update 方法继续和子级 diff一旦 runtimeType 或 key 改变旧的 Element 整个销毁子树的 State 全部丢失然后重新创建 Element。这一层是状态丢失的高发区。第三是 RenderObject 层。Element 更新后如果布局约束或绘制属性变化RenderObject 还要重新 layout 甚至 paint。这是真正消耗帧预算的部分。三者叠加影响面就成了一个“递归扩散”的过程。普通列表之所以容易被全量重建是因为它把全部子项都作为 children 传给了一个前端的容器diff 时必须逐个检查而 Sliver 改变了这个前提。1.3 为什么列表页面最容易把影响面扩大到全屏不用 Sliver 的典型写法是 ListView(children: [...])比如循环一个数组直接 build 出几百个 ListTile。这种写法的问题不只是初始化慢更在于状态变更时非常被动。一旦外层 setStateListView 会拿着新旧两个 children 列表从头开始比对。如果 item 没有 key框架就按 index 逐个配对index 0 的内容是老的还新的index 1 的内容是否对应同一个逻辑数据框架根本不关心只认位置。这时只要列表中间发生插入、删除或重排序从那个 index 开始的所有 item 都会被视为“位置错位”要么整个 Element 重建要么被迫把后面的 item 全部 reposition 一遍。其实很多看起来是“业务数据变了所以刷新”的场景本质上都是“key 缺失导致的错位重建”这就是影响面扩大的最大来源。2. Sliver 凭什么能把影响面“锁死”在小范围2.1 视口驱动构建没有进视口就不参与 rebuildSliver 是 Flutter 滚动体系里最底层的滚动单元概念。平时用到的 ListView、GridView 本质上都是对若干 Sliver 的封装比如 ListView 内部就是一个 SliverListGridView 内部就是一个 SliverGrid。绕开这些封装直接用 CustomScrollView 管理 Sliver才能理解 Sliver 独特的构建逻辑。Sliver 的布局不是传统意义上的“自上而下全部排一遍”而是由 Viewport 驱动的。Viewport 只负责一个窗口当前可见区域加上前后各一段缓存区域这段区域的专业术语是 cacheExtent默认大约 250 逻辑像素。Viewport 会告诉每个 Sliver“我需要你在这一段范围内给出子节点”Sliver 再按需创建对应的子 Widget、Element 和 RenderObject。关键在于那些不在视口和缓存区域内的 item根本没有对应的 Element连参与 diff 的机会都没有。再直白一点影响面是“视口里有什么才有资格被影响”。这就是我听很多人说的“Sliver 天然缩小 rebuild 影响面”的第一层含义。2.2 SliverMultiBoxAdaptorElement一套专门管子节点的 Element一般容器节点比如 Column、Stack它们的 Element 是 MultiChildRenderObjectElement会平等管理所有子 Element。而 SliverList 这类组件内部使用的是 SliverMultiBoxAdaptorElement这是一个专门的适配器元素它维护的不是“全部孩子”而是“活跃孩子”。活跃孩子怎么定义很简单就是上一次布局完成后仍然在视口和缓存区域内的那批 item 对应的 Element。滚动过程中滑出区域的 item 会被回收滑入区域的 item 才会被创建。因此 SliverMultiBoxAdaptorElement 里保存的孩子数量长期保持在几十个以内哪怕数据源有几万条。这套机制的好处是当父级因为 setState 触发 rebuild 时Sliver 的 Element 更新逻辑也遵循“活跃孩子优先”的规则。它不会尝试去比较 100 万个 item 有没有变化而是只关心当前活跃的几十个孩子是否需要更新。影响面积的复杂度从数据总量 O(n) 降到了可见数量 O(viewport)。2.3 同一个 delegate 实例跳过整批 child 重建的关键SliverList 自身在 rebuild 时也要经过 Widget diff 的流程。很多人会以为既然 CustomScrollView rebuild 了SliverList 的 Widget 实例变了那 child 肯定全部重建。其实这里有个关键判断SliverMultiBoxAdaptorElement 的 update 方法会检查新旧 SliverList 的 delegate 是否相同。我们平时写的 SliverChildBuilderDelegate本质是“给一个 index返回一个 Widget”的委托对象。如果新 build 出来的 SliverList 和旧 SliverList 使用同一个 delegate 实例比如把 delegate 定义成 State 的成员变量那么 Element 会认为子项构建策略没有变化活跃的 item Element 会原样保留根本不会重新调用 builder。只有 delegate 实例变化时才会触发一轮活跃孩子的重新构建。这个细节对优化影响面极其重要。很多性能问题的根源其实不是 Sliver 本身慢而是每次 build 都新建了一个 delegate导致 Sliver 被迫“怀疑”所有子项都变了。后面实操部分我会给出正确的用法。2.4 Key 让 Element 定位做到“精确打击”Sliver 的另一个天然优势是“孩子少key 匹配成本低”。普通列表如果有 10000 个 itemkey 匹配要遍历的基数也是 10000而 SliverList 的活跃孩子只有几十个哪怕全部 key 因子都遍历一遍也就几十次比较。在这个小范围里只要 item Widget 带上 ValueKey 之类稳定标识框架就可以精确地把“数据变化”对应到“某几行 Element 更新”其他活跃项即使 build 方法被调用也能通过 identical 或 canUpdate 判断快速跳过。Key 的意义在重排序时最明显。同样是 10000 条消息里把第 100 条和第 101 条互换普通 ListView 没有 key 时从第 100 条到第 10000 条全部错位影响面几乎等于全列表。而 SliverList 活跃孩子有限加上 key 做身份锚定影响面就只是这几十个活跃 children 里的两行。这种“精确打击”的能力是缩小影响面最直接的工程抓手。3. 从普通列表到 Sliver影响面一步步缩小的实战对比3.1 反面案例一次性 children 全屏 setState先看一个我在项目里接手时遇到的真实写法功能上没问题但性能隐患很大Widget build(BuildContext context) { return ListView( children: messages .map((m) _MessageItem(message: m)) .toList(), ); }这个写法有三层问题。第一messages 一变化整个 messages.map 会重新执行生成一批全新的 _MessageItem Widget 实例。第二ListView 的 children 没有 keydiff 按 index 硬配对中间任何位置变化都会引发后续 item 的状态丢失。第三只要 setState 发生所有 item 的 build 都会重新执行一遍哪怕是完全没受影响的 item。实际症状就是用户只是把一条消息标记为已读结果整个页面卡一下DevTools 里能看到所有 item 都 rebuild 了。这种影响面是“全量”的。3.2 为什么 ListView.builder 已经算“半个 Sliver”了后来改为 ListView.builder会好很多ListView.builder( itemCount: messages.length, itemBuilder: (context, index) _MessageItem(message: messages[index]), )很多人以为这是“另一种列表写法”其实 ListView.builder 内部就是包的 SliverListitemBuilder 也被封装成了 SliverChildBuilderDelegate。所以它天然具备视口驱动构建的能力滑出区域的 item 会被销毁未进入视口的 item 不会被构建。从这一点来说ListView.builder 已经具备 Sliver 缩小影响面的第一层优势。但注意它仍有局限因为你拿到的只是 ListView 这个封装层不方便对 Sliver 做一些更细的、按区块划分的处理。比如页面顶部有一个常驻 header中间有一段根据网络状态决定显隐的数据面板底部才是长列表。如果都用 ListView.builder 包起来要么 header 被当成 item 0要么需要嵌套多个 ScrollView影响面的控制粒度始终比较粗。3.3 CustomScrollView SliverList把页面拆成多个独立区块想要进一步缩小影响面就得把“一个列表”升级为“多个 Sliver 区块”。每个 Sliver 都有自己的 Element 更新逻辑互不干扰。直接看代码class ChatPageState extends StateChatPage { final SliverChildBuilderDelegate _messageDelegate SliverChildBuilderDelegate( (context, index) _MessageItem( key: ValueKey(messages[index].id), message: messages[index], ), ); bool _showChart false; void _toggleChart() { setState(() _showChart !_showChart); } override Widget build(BuildContext context) { return CustomScrollView( slivers: [ SliverToBoxAdapter( child: _PageHeader(title: 消息列表), ), SliverVisibility( visible: _showChart, sliver: SliverToBoxAdapter( child: _DataSummaryChart(), ), ), SliverList( delegate: _messageDelegate, ), ], ); } }这个结构里有三个值得注意的设计。第一用于长列表的 SliverChildBuilderDelegate 被提升为 State 的成员变量在多次 build 之间保持同一实例这样消息列表的 Element 不会因为 delegate 变化被整批怀疑影响面只停留在真正变化的 item 上。第二header、图表、消息列表分别是三个独立 Sliver。当 _showChart 变化时SliverVisibility 这段会被重建而 _messageDelegate 和它的活跃 item Element 完全不受牵连。第三消息 item 上挂了 ValueKey当某条消息内容变化时framework 可以在活跃孩子小集合里迅速定位到该 id 对应的那一个 Element精确更新那一行。对比最开始的 ListView(children: [...]) 写法这里的影响面从“整个页面全部 item”降到了“header 图表 活跃 item 中真正有变化的几行”。SetState 消息已读状态时实际 rebuild 的 item 数量肉眼可见地少了一个量级。提示SliverChildBuilderDelegate 里 itemBuilder 返回的 Widget 需要带 key 吗需要。因为 delegate 负责生成活跃区的 itemkey 是 framework 在活跃集合中识别的身份凭据。即使没有 key受限列表也能工作但精确定位会退化成按 index 匹配影响面可能在小范围里扩散。3.4 再进一步用 SliverVisibility 做条件区块SliverVisibility 是一个容易被低估的组件。它并不是普通 Visibility 的简单封装而是直接控制“该 Sliver 在布局阶段是否需要分配区域”。当 visible 为 false 时对应 Sliver 会被当成一个零尺寸区块不会布局任何子节点子节点的 Element 也不会被创建或保留。这意味着不只是“不显示”而是“不参与布局、不参与 rebuild 候选”。比如数据面板是否需要展示由某个状态决定传统写法里通常要给整个面板做 if else然后塞到某个容器里。用 SliverVisibility 后隐藏状态下面板对应的整个子树都不存在于 Element 树中setState 刷新其他区域时这个区块连 diff 都不会进入。我做过一个数据面板显隐场景面板内部有几十个图表节点。改造前每次列表刷新面板底下的图表也全部跟着 rebuild非常浪费改造后面板隐藏时影响面是零显示时也只在自身状态变化时才 rebuild。这就是“按区块消灭影响面”的思路。4. 实际工程中的调优参数与避坑记录4.1 cacheExtent影响面扩大的隐藏开关有一个参数很容易被忽略ScrollView 的 cacheExtent。它决定了视口前后额外保留多少区域的孩子。默认值 250.0 逻辑像素是在“滚动体验”和“构建成本”之间的平衡。如果把这个值调大比如设为 1000那前后 1000 像素范围内的 item 都会提前构建并保留。好处是滚动到那边时不需要临时构建卡顿更少坏处是“活跃孩子”变多setState 时参与 diff 的 item 数量也变多影响面随之扩大。内存和初始化耗时同样上升。如果把这个值调小比如设为 0影响面理论上最小但滚动时容易看到新 item 临时构建的白屏或卡顿。所以调优时不要盲目追求小影响面要根据列表项的高度、构建成本、滚动速度综合权衡。下面是我常用的一张参考表cacheExtent 设置影响面滚动表现推荐场景0最小仅当前可见 item滚出区域立刻销毁可能临时构建每项构建成本很低、列表高度很大默认 250中等覆盖可见区域外一小段滚动衔接较平滑大多数消息流、信息流页面500~1000较大提前构建更多 item进入新区域时体验最稳每项构建成本高、用户频繁往复滚动全局无限大极大列表等于全部构建无动态构建但内存和首帧很差几乎不推荐除非列表项总数极少4.2 KeepAlive 不是免费的Sliver 的默认行为是“滑出视口就销毁 Element”。但有些场景不希望销毁比如表单列表项里有输入框滑出去再滑回来要保留输入内容。这时候很多人会用 AutomaticKeepAlive。KeepAlive 会把 item 标为“需要存活”即使滑出视口也不销毁 Element。这确实解决了状态保留问题但也意味着这些 item 会一直留在 Element 树中。如果列表里挂了 50 个 KeepAlive item那影响面就从“可见区几十个”变成“累积的 50 个”。KeepAlive 数量越多rebuild 时参与 diff 的 item 就越多影响面越接近全量。我的经验是KeepAlive 只用在确实需要保留状态的少量 item 上比如视口附近正在编辑的输入框、需要维持播放状态的视频卡片。普通只读的文本消息、图片卡片完全没必要 KeepAlive让它随滚动销毁反而更健康。4.3 Key 用错的三个典型症状key 是把影响面做小的核心工具但用错了会适得其反。三个典型症状我全都遇到过。第一个症状列表重排序后全部重建。原因是 key 用的是 index 而不是稳定 id。比如 ValueKey(index)等于没 key排序后位置一变它仍然按 index 配对所有 item 全面错位。正解是用业务唯一 id比如 ValueKey(message.id)。第二个症状item 状态频繁丢失。这是因为 key 放在了错误层级比如给 _MessageItem 内部的某个 Text 加 key而不是给 item 根 Widget 加 key。frame 定位身份时找的是活跃子节点集合里的第一层 keykey 得挂在 SliverChildBuilderDelegate 构建返回的最外层。第三个症状动画异常某个 item 删掉后其他 item 的进入/退出动画全部触发。这也和 key 缺失有关框架无法区分“原来的 item 去了哪里”只能把所有活跃项都当成新对象处理。检查方式很简单给 item 根 Widget 加上稳定 ValueKey看动画是否只发生在真正变化的 item 上。4.4 如何确认影响面真的变小了优化完不能靠感觉必须拿工具确认。Flutter 里有两个非常实用的入口。第一个是 DevTools 的性能面板。打开后启用跟踪操作目标页面观察 rebuild count 的变化。如果 Sliver 方案生效你操作的 item 数量变化时其他 item 的 build count 应该基本不动。这个方法在调试“到底哪个 widget 在乱 rebuild”时极其好用。第二个是代码里的 profile 标记。在关键 item 的 build 方法里临时加一行debugProfileBuildsEnabled(true);配合 Performance Overlay 或 Timeline 工具可以看到 build 事件的火焰图分布。如果你从火焰图里看到大量 _MessageItem.build 事件就说明影响面没有收敛需要继续检查 delegate 实例、key 和 cacheExtent。我在实际项目里还用过另一个土办法在 item 的 build 方法里打日志打印 index 和时间戳。setState 之后翻日志看哪些 index 被 build 了。这个方式不优雅但在真机上快速验证影响面是否真的缩小到局部非常直观。跑几次就能发现哪些无意义的 rebuild 是 delegate 实例不稳定造成的。结尾文章写到这里没有额外总结了。我只想说两个个人体会。Sliver 的“天然”不是银弹。它只是在机制上给了你足够小的活动空间但能不能用起来取决于 delegate 的稳定性、key 的正确性、cacheExtent 和 KeepAlive 的取舍。我见过太多项目只是把 ListView 换成 CustomScrollView 就以为优化完成结果影响面一点没缩因为该犯的 key 错误一个没少。最后分享一个我自己常用的检查习惯每次改完列表性能先打开 DevTools 看 rebuild count再回归一遍“插入、删除、滚动回顶”三个动作。这三个动作能覆盖影响面扩张的绝大多数场景跑完不出问题基本可以放心上线。如果还想继续深挖从 Viewport 的 cacheExtent 和 SliverMultiBoxAdaptorElement 源码入手会很有意思毕竟只有弄清楚框架内部怎么维护活跃孩子才能在遇到怪问题时一眼看穿根因。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →