在最近发布的 Flutter 3.43.0-0.1.pre 这个 Beta 版本里,官方在 Framework 层面对 ScrollView / Viewport / ShrinkWrappingViewport 做了一个比较有意思的修改:
- 引入
ScrollCacheExtent,废弃cacheExtent + cacheExtentStyle - 修复
RenderShrinkWrappingViewport在无约束下 cacheExtent 可能变成 NaN 的问题 - 重构 Viewport cache 计算路径
这次修改涉及 rendering 层核心代码,属于 Viewport 底层重构,暂时看来修改的作用是正向的,应该不至于引起类似之前《Flutter 3.41 iOS 键盘负优化:一个代码洁癖引发的负优化》 的问题。
根据 #181092 的修改内容,这次修改范围主要涉及:
rendering/viewport.dart
widgets/scroll_view.dart
widgets/page_view.dart
widgets/list_view.dart
widgets/grid_view.dart
对应源码的影响有:
RenderViewportBase
RenderViewport
RenderShrinkWrappingViewport
Viewport
ShrinkWrappingViewport
ScrollView
ListView
PageView
所以,虽然看起来只是一个小 feature 和一个 bug fix,但是其实这个调整并不是 Widget 层的小改动,而是 Viewport 渲染路径修改。
所以才会需要挑出来聊一聊。
首先是 ScrollCacheExtent ,在之前的实现里,Viewport cache 主要由这两个字段控制:
double cacheExtent
CacheExtentStyle cacheExtentStyle
相关逻辑为:
switch (cacheExtentStyle) {
case CacheExtentStyle.pixel:
calculatedCacheExtent = cacheExtent;
case CacheExtentStyle.viewport:
calculatedCacheExtent = mainAxisExtent * cacheExtent;
}
涉及的关键变量是:
mainAxisExtent = viewport size
而问题也就出现在这里,因为 ShrinkWrappingViewport 的特殊性,当 ScrollView 设置 shrinkWrap = true 的时候,ScrollView.buildViewport 就会会创建 ShrinkWrappingViewport :
ScrollView.buildViewport
-> ShrinkWrappingViewport
-> RenderShrinkWrappingViewport
而 ShrinkWrappingViewport 的特点就是 viewport size 由子节点决定,而不是通过父约束,这就意味着mainAxisExtent 可能不是y一个有限的值 ,也就是类似以下的场景:
SingleChildScrollView
-> ListView(shrinkWrap: true)
Column
-> ListView(shrinkWrap: true)
这些情况下父布局在主轴方向是 unbounded ,所以 ShrinkWrappingViewport 会得到 constraints.maxExtent = infinity 的情况,也就是最终:
mainAxisExtent = infinity
这乍一看没什么问题,但 cacheExtent 逻辑没有考虑这个情况,因为在旧逻辑里:
viewport cache mode
= cacheExtentStyle.viewport
也就是
calculatedCacheExtent = mainAxisExtent * cacheExtent
如果这时候 mainAxisExtent = infinity ,那就会 infinity * 0.5 = infinity ,以至于在后续布局计算里paintExtent \ layoutOffset \ scrollOffset 都可能出现 infinity - infinity ,也就是结果为 NaN ,比如:
SingleChildScrollView(
child: ListView.builder(
shrinkWrap: true,
cacheExtent: 0.5,
cacheExtentStyle: CacheExtentStyle.viewport,
itemBuilder: ...
),
)
而在新 API 下,cacheExtent 和 cacheExtentStyle 现在变成 ScrollCacheExtent ,并且内部做了适配,所以这种情况现在不会再报错了:
SingleChildScrollView(
child: ListView.builder(
shrinkWrap: true,
scrollCacheExtent: ScrollCacheExtent.viewport(0.5),
),
)
所以这里的 ScrollCacheExtent 不是简单的把两个参数编程一个,而是内部做了重构,首先是在 viewport.dart 内部提供了:
ScrollCacheExtent.pixels()
ScrollCacheExtent.viewport()
对应内部实现了新的 Viewport 计算逻辑:
_calculateCacheOffset(mainAxisExtent)
_calculatedCacheExtent =
_scrollCacheExtent._calculateCacheOffset(mainAxisExtent)
这个情况下 cache 集中计算,并且避免 style + value 分离,其中「NaN 修复」的关键在于 RenderShrinkWrappingViewport ,对应核心修改为:
if (!mainAxisExtent.isFinite)
cacheExtent = 0
因为对于 infinite viewport 来说,实际上 already builds all children ,所以根本不需要 Cache ,而这个修改也会涉及 PageView \ ListView \ GridView \ CustomScrollView 等常用控件。
所以这也是一个相对昂贵的性能配置选项。
所以这个 ScrollCacheExtent 的修改,本质上是:
- 重构 Viewport cache API
- 修复
ShrinkWrappingViewport在无约束下cacheExtent计算 NaN 的问题 - 统一
ScrollView/Viewport/RenderViewport的缓存逻辑
虽然逻辑改动看起来好像改的不多,但是涉及的文件和地方还是挺多的,从长远来看,这个修改还是比较有意义的,至少之前经常遇到的 NaN 问题终于不要自己处理了。