写 Flutter 列表页的同学,大概率都见过这类让人血压升高的报错:is not finite。我印象最深的一次,是一个放在Column里的ListView,为了让它正常布局老老实实加了shrinkWrap: true,结果数据一刷新,控制台直接冒出一串unhandled exception,定位进去发现是滚动偏移量算出了NaN。这个问题在社区里挂了很多年,相关 issue 从 2020 年一路叠到 2023 年,期间 Flutter 团队修过几次边缘 case,但总是按下葫芦浮起瓢。最近 Flutter Beta 分支一次性放出了两个关键改动:新增了ScrollCacheExtent这个可配置参数,并从数学层面修掉了shrinkWrap场景下长期存在的NaN问题。这篇文章我就把这两个改动拆开揉碎,讲清楚它们各自解决了什么、是怎么解决的,以及升级之后你该怎么用、该注意什么。
1. 先说结论:这个 Beta 到底改了什么
1.1 两个改动各自的定位
先说ScrollCacheExtent。它解决的是滚动缓存范围不可控的问题。以前 Flutter 的滚动视图在构建列表项时,为了让用户快速滑动时来不及白屏,会额外地预构建一部分"看不见但即将出现"的内容,这部分范围叫 cache extent。问题是这个范围的大小是框架根据视口尺寸按比例算的,开发者很难精确控制,在某些场景下要么缓存太少掉帧,要么缓存太多浪费内存。这次新引入的scrollCacheExtent参数,允许你直接以像素值指定这个缓存范围,从"框架说了算"变成"开发者说了算"。
另一个改动,就是修复shrinkWrap模式下滚动范围计算产生的NaN问题。这个NaN(Not a Number,即非数值)一旦出现,会像病毒一样通过运算传播:任何和它做加减乘除比较的数值都会变成NaN,最终导致布局断言失败、渲染异常甚至直接崩溃。它不是什么概率极低的玄学 bug,在特定的布局组合下是 100% 必现的。
1.2 核心收益一览
shrinkWrap: true配合空列表(itemCount: 0)时,不再抛NaN断言异常。shrinkWrap与itemExtent组合使用的数学计算变得健壮,负数、零值、无穷大都有了兜底。- 滚动缓存区可精确调控,为列表性能调优提供了新的抓手。
- 影响范围覆盖所有基于
Scrollable的组件:ListView、GridView、CustomScrollView、PageView等。
1.3 影响范围与升级建议
这两个改动看着小,影响的却是 Flutter 最核心的滚动体系。只要是使用ScrollView家族的组件,底层都会经过RenderViewport和ScrollPosition的计算逻辑。尤其项目里用了NestedScrollView、TabBarView嵌列表、RefreshIndicator包shrinkWrap列表、或者通过flutter aar方式把 Flutter 模块集成进 Android 主工程的老项目,这次升级都值得专门做一轮回归验证。
我的建议是:如果是个人项目或者预研项目,可以直接切到 Beta 分支尝鲜;如果是线上业务,先在测试分支验证完再决定是否合入,毕竟 Beta 分支的特性到 Stable 还需要一段观察期。
2. shrinkWrap 的 NaN 是怎么一步步算出来的
2.1 shrinkWrap 的布局模式切换
要理解这个 bug,得先搞清楚shrinkWrap做了什么。默认情况下(shrinkWrap: false),ListView这类滚动组件采用viewport 模式:视口占据父约束给的确定尺寸,子组件懒加载,滚动位置在一个明确的范围内变化。
打开shrinkWrap: true后,模式变了:滚动组件会先测量所有子组件的尺寸,把自己的尺寸收缩到"刚好包住所有内容"的大小,同时仍然保留滚动能力。简单说,默认模式是"我固定个窗口,你往里看";shrinkWrap模式是"我把自己变成内容那么长,但在受限的空间里滚动"。
这种模式下,内部需要把"内容总长度"和"可滚动范围"做一次换算。问题恰恰出在换算过程里。
2.2 滚动范围归一化里的数学死角
ScrollPosition内部维护着minScrollExtent和maxScrollExtent两个边界值,滚动就是一个在这两个值之间移动的过程。很多逻辑(比如计算滚动进度、判断是否到顶/到底、更新滚动通知)都会对一个归一化公式做运算:
(当前偏移量 - min) / (max - min)这个公式本身没问题,问题出在边界条件:如果max和min恰好相等,分母就是 0,0 / 0在浮点运算里直接得到NaN。
什么时候max和min会相等?最典型的就是shrinkWrap: true且列表内容总高度为 0 的时候。内容高度为 0,意味着没有任何可滚动的余地,minScrollExtent = 0,maxScrollExtent = 0。此时一旦有代码触发上面的归一化计算,NaN就诞生了。常见触发方式包括:ScrollController的offset监听、Scrollbar的滑杆位置计算、自定义NotificationListener里读取进度等。
2.3 空列表、itemExtent、嵌套滚动三大高危场景
我实测下来,以下三种组合最容易把问题逼出来。
场景一:空数据 + shrinkWrap
ListView.builder( shrinkWrap: true, itemCount: 0, itemBuilder: (_, __) => const SizedBox(height: 50), )数据为空时,内容高度为 0,配合shrinkWrap,很容易触发上面的0/0分母问题。如果你的页面用了FutureBuilder加载数据,数据回来前列表为空,setState刷新的一瞬间就可能崩。
场景二:shrinkWrap + itemExtent = 非有限值
当开发者显式指定itemExtent,框架会跳过测量子组件这一步,直接用"item 数量 × itemExtent"估算内容总长度。如果这个估算值出现负数、0、或者与约束冲突导致无穷大,maxScrollExtent的计算就会产生非有限数。尤其在shrinkWrap模式下,框架既要收缩尺寸又要估算滚动范围,两套模型叠加在一起,数值边界更容易被击穿。
场景三:嵌套滚动容器
在NestedScrollView、CustomScrollView或TabBarView内部,每个滚动视图都会独立计算自己的滚动范围。当外层容器给出的是无限约束(比如把ListView放到另一个ListView里),内层shrinkWrap列表测量时会拿到无穷大的最大约束,某些数学运算(infinity - infinity、infinity / infinity)同样会产生NaN。这也是很多"列表嵌列表"的写法在某些机型上偶发崩溃的根源。
2.4 为什么这个 bug 能活这么久
这个 bug 生命周期长,不是团队懒,而是触发条件太依赖组合场景。单测里不太好构造 "shrinkWrap 且内容为 0 且恰好有人读滚动进度" 这类全链路条件;加上 Flutter 的修复往往动一发而牵全身,稍不注意就会影响正常列表的性能。比如之前有修过shrinkWrap空列表的问题,但只堵住了 UI 层,Scrollbar或者RefreshIndicator内部的进度计算还是会踩雷。这次 Beta 的修复直接从数学层面做兜底,把所有非有限值挡在源头,才是真正治本。
3. ScrollCacheExtent 参数详解:从缓存机制到实战配置
3.1 cacheExtent 和 scrollCacheExtent 的前世今生
聊新参数之前,必须先理解老缓存机制是怎么回事。在RenderViewportBase中,滚动视图会在可见区域之外额外布局一块内容,这就是 cache extent。以前这个值由框架计算,默认逻辑是"视口在滚动方向上的尺寸 × 系数"。好处是省心,坏处是:
- 视口很大的时候(比如横屏平板、可折叠设备),按比例算出来的缓存区会非常大,首帧构建一大堆不可见的 item,白白消耗 CPU 和内存。
- 视口很小的场景(比如小窗模式、分屏),按比例算出的缓存区又可能不够,快速滑动时内容来不及构建,出现白屏闪烁。
- 列表项非常重(比如包含图片、
PlatformView、复杂自定义布局)时,缓存范围越大,滑动越容易掉帧。
scrollCacheExtent的出现就是要解决这种"一刀切"的问题。它是一个绝对值,单位是逻辑像素,由开发者直接指定:我需要额外缓存 400 像素的内容,就写 400。这样不管视口多大,缓存范围都可预期。
3.2 新参数在源码里的落点
在 Beta 分支中,ScrollView构造函数新增了可选的scrollCacheExtent参数。通过阅读源码 diff 可以确认,它最终会传递给Scrollable,再注入ScrollPosition和RenderViewport,替代原本按比例计算的默认缓存值。同时,修复NaN的补丁也集中在RenderViewport和ScrollPosition的数学计算上,对所有涉及minScrollExtent、maxScrollExtent的非有限数值做了统一兜底处理。你一不看实现,二不影响现有 API,老代码不传这个参数,行为默认和以前保持一致。
3.3 三种场景下的配置建议
// 场景一:常规长列表,想保证快速滑动不白屏 ListView.builder( scrollCacheExtent: 600, itemCount: 1000, itemBuilder: (_, index) => Text('Item $index'), ) // 场景二:列表项较重(含图片/PlatformView),缓存范围不宜过大 ListView.builder( scrollCacheExtent: 300, itemCount: 100, itemBuilder: (_, index) => buildHeavyItem(index), ) // 场景三:需要精确控制性能的无限滚动 feed 流 CustomScrollView( scrollCacheExtent: 500, slivers: [ SliverList.builder( itemCount: null, itemBuilder: (_, index) => buildFeedItem(index), ), ], )我个人的调参经验是:先给一个保守值(300~500),再通过性能工具观察滑动帧率和构建耗时,逐步加码。对重 item 列表,缓存给大了反而掉帧;对轻 item 列表,给个 600~800 完全没有压力。如果你的列表是图片流,配合CachedNetworkImage之类的图片缓存库,缓存的逻辑像素值可以适当小一些,因为图片有内存缓存兜底,真正需要预构建的是 item 骨架。
3.4 新参数与 Impeller 渲染器的配合关系
最近很多人聊 Flutter 的 Impeller 渲染引擎,顺带在这里多说一句。Impeller 把很多绘制工作移到 GPU 侧,渲染阶段的 CPU 开销下降了,但布局和构建阶段的 CPU 开销依然是 UI 线程的瓶颈。scrollCacheExtent控制的就是布局构建的提前量,它和 Impeller 是互补关系:前者优化"要不要提前构建",后者优化"构建完怎么高效画出来"。两件事都做好,列表滑动才能既顺又不浪费资源。
4. 我把应用切到 Beta 分支:升级实录与实测对比
4.1 升级步骤与回滚方案
先说明一下,我不建议直接照搬我的操作,因为 Beta 分支版本迭代很快,一切以你自己电脑上的实际情况为准。我的操作如下:
# 1. 查看当前 stable 版本 flutter --version # 2. 切换到 beta 分支 flutter channel beta # 3. 升级到最新 beta 版 flutter upgrade # 4. 验证版本 flutter --version切换完成后,flutter pub get重新拉依赖,然后命令行跑flutter doctor检查一下环境。这里有个细节:如果你的项目里有些第三方包只声明了sdk: ^3.x.x,切到 Beta 后一般不受影响,依赖照常解析;但如果某个包用了临时的分析 API 或者 Dart 私有特性,可能不兼容,编译期就会暴露出来。
如果测试之后想回滚:
flutter channel stable flutter upgrade注意,flutter upgrade会同时升级 Dart SDK 和引擎,回滚也是一样,操作上是可逆的。唯一的坑是本地缓存的编译产物可能需要清理:flutter clean之后再打一次包。
4.2 触发 NaN 的场景全部回归验证
我拿之前最容易出问题的几段代码做了回归,列表如下。
| 测试场景 | Stable 3.10 | Beta(含修复) |
|---|---|---|
shrinkWrap: true+itemCount: 0 | 崩,控制台报 is not finite | 正常,无异常 |
shrinkWrap: true+itemCount: 0+ 手动ScrollController监听 | 崩,监听回调里读到 NaN | 正常,offset 为 0 |
shrinkWrap: true+itemExtent: 0 | 崩,断言失败 | 正常,自动回退到测量模式 |
ListView嵌套ListView(shrinkWrap: true) | 偶发 NaN | 正常 |
NestedScrollView头部 + 底部ListView(shrinkWrap: true) | 快速切换 tab 偶发异常 | 正常 |
RefreshIndicator包shrinkWrap: true空列表 | 下拉刷新时崩 | 正常 |
说白了,之前能稳定复现的几条路,现在都被堵上了。特别是itemCount: 0配合shrinkWrap这种组合,以前是必现,现在跑几十次都没动静,修复粒度是可靠的。
4.3 滚动性能前后对比
我也顺手对比了scrollCacheExtent对滑动性能的影响。测试机型是一台骁龙 8 系的中端机,列表项为包含一张网络图和两行文字的卡片,共 500 条。结果如下(数据来自 Flutter DevTools 的帧时间统计):
| 配置 | 首帧构建耗时 | 快速滑动帧率 | 内存增量 |
|---|---|---|---|
| 默认(不传 scrollCacheExtent) | 约 120ms | 约 55fps,偶有掉帧 | 约 45MB |
scrollCacheExtent: 200 | 约 90ms | 约 58fps,较稳定 | 约 40MB |
scrollCacheExtent: 800 | 约 150ms | 约 53fps,掉帧增多 | 约 62MB |
这个结果印证了我的猜测:缓存给太大,首帧构建和内存都会上去,滑动反而因为要维护更多 item 而变卡。所以,调这个参数不是越大越好,是"够用就好"。
4.4 兼容性提醒:Android 集成项目尤其注意
如果你是通过flutter aar把 Flutter 模块集成进 Android 主工程的,有两个点必须注意:
- 切换 Flutter 版本后,AAR 包需要重新构建,主工程的 Gradle 配置里如果写死了旧版本号,记得同步更新。
- Beta 分支构建出的 AAR 在原生工程里的集成方式和 Stable 略有差异,特别是依赖的 Android Embedding API 版本。建议先在 Android 主工程里跑一遍最小集成验证,再做功能回归。
5. 排查滚动布局异常的通用方法论
5.1 遇到布局数值异常先查这三处
就算这次NaN修好了,日常开发里你还是可能遇到类似布局异常。我的排查顺序一般是:
第一步:看是不是自己的 Scrollable 出了问题。打开 DevTools 的 Widget Inspector,找到出问题的滚动组件,确认它的ScrollPosition的minScrollExtent和maxScrollExtent是否有限。如果出现NaN,先隔离这段组件,用最简单的数据跑去复现,缩小范围。
第二步:检查约束是不是传了无限值。无限约束是导致infinity和NaN的最大温床。常见写法问题:
Column( children: [ Expanded( child: ListView(), // 这里没问题 ), ListView(shrinkWrap: true), // 和上面的 Column 放在一起,这里要注意 ], )原则很简单:Column给子组件的是有限约束,滚动组件可以正常用;ListView嵌套ListView时,内层会拿到无限约束,必须用shrinkWrap+ 固定physics或者改造为CustomScrollView方案。
第三步:打印所有涉及尺寸的数值。在itemExtent、cacheExtent这类显式数字传入处,用assert断言它们是isFinite且大于等于 0。
assert(itemExtent == null || itemExtent! > 0); assert(scrollCacheExtent == null || scrollCacheExtent!.isFinite);5.2 自定义 RenderObject 时防 NaN 的几条建议
如果你写了自己的RenderObject或Sliver,注意浮点运算的边界。以下是我踩过坑之后沉淀的几条保命规则:
- 除法之前先做分母检查,分母为 0 或为 NaN 直接返回 0。
min、max、clamp操作之前,先把输入值用isFinite过滤一遍。- 尺寸计算最后对结果做一次兜底:
finite ? result : 0.0。 - 考虑使用 Flutter 内置的
math.min/max和double.infinity交互时要非常小心,infinity - infinity也是 NaN。
一个很实用的小技巧是写一个封装函数:
double safeDiv(double a, double b) { if (!b.isFinite || b == 0) return 0; return a / b; }所有除法替换成这个安全版本,从源头杜绝NaN产生。
5.3 相关错误信息的快速定位
| 报错特征 | 常见原因 | 处理方向 |
|---|---|---|
is not finite | 滚动范围计算出 NaN/infinity | 检查 shrinkWrap 空列表、约束传参 |
RenderBox was not laid out | 布局顺序错误、列表项过早被访问 | 检查 itemBuilder 中是否立即访问 RenderObject |
Failed assertion: maxScrollExtent >= minScrollExtent | 滚动范围边界反转 | 检查 itemExtent 是否为负值/0 |
unhandled exception in ScrollController | 控制器关联多个滚动视图 | 检查是否复用了同一个 controller |
遇到报错先别慌,把它当线索,先定位到具体是哪一层滚动组件算错了,再去看约束和数值来源,比在 UI 代码里瞎试快得多。
6. 我的一些使用体会和后续建议
实际用下来,这次的 Beta 改动让我比较安心的一点是:修复不是靠打补丁堵洞,而是把滚动范围计算里所有产生非有限值的路径都做了收敛。对于长期被shrinkWrap空列表问题困扰的项目,升级之后基本可以删掉之前那些 workaround(比如手动判断itemCount == 0时渲染SizedBox.shrink代替列表),代码少一坨,心智负担也小一截。
至于scrollCacheExtent,我给还在 Stable 分支观望的朋友一个建议:不必急着用,等它沉淀进 Stable 之后,再把它纳入列表性能调优的常规工具箱。到时候可以建立一个简单的压测流程:固定机型、固定数据量,逐步调整缓存值,记录帧率和内存,形成一份自己的调参表。
最后分享一个写作时想到的小技巧:如果你在滚动物理行为上做了调优(比如用了ClampingScrollPhysics、BouncingScrollPhysics),记得把scrollCacheExtent的调参也纳入测试范围。因为不同的滚动物理效果,对滑动两侧的惯性缓存需求是不一样的,物理效果越"弹",越需要合理的缓存区来保证回弹时不白屏。这个点很多人容易忽略。