ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Flutter Beta修复shrinkWrap NaN问题并新增ScrollCacheExtent

Flutter Beta修复shrinkWrap NaN问题并新增ScrollCacheExtent 写 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问题。这个NaNNot 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: falseListView这类滚动组件采用viewport 模式视口占据父约束给的确定尺寸子组件懒加载滚动位置在一个明确的范围内变化。打开shrinkWrap: true后模式变了滚动组件会先测量所有子组件的尺寸把自己的尺寸收缩到刚好包住所有内容的大小同时仍然保留滚动能力。简单说默认模式是我固定个窗口你往里看shrinkWrap模式是我把自己变成内容那么长但在受限的空间里滚动。这种模式下内部需要把内容总长度和可滚动范围做一次换算。问题恰恰出在换算过程里。2.2 滚动范围归一化里的数学死角ScrollPosition内部维护着minScrollExtent和maxScrollExtent两个边界值滚动就是一个在这两个值之间移动的过程。很多逻辑比如计算滚动进度、判断是否到顶/到底、更新滚动通知都会对一个归一化公式做运算(当前偏移量 - min) / (max - min)这个公式本身没问题问题出在边界条件如果max和min恰好相等分母就是 00 / 0在浮点运算里直接得到NaN。什么时候max和min会相等最典型的就是shrinkWrap: true且列表内容总高度为 0 的时候。内容高度为 0意味着没有任何可滚动的余地minScrollExtent 0maxScrollExtent 0。此时一旦有代码触发上面的归一化计算NaN就诞生了。常见触发方式包括ScrollController的offset监听、Scrollbar的滑杆位置计算、自定义NotificationListener里读取进度等。2.3 空列表、itemExtent、嵌套滚动三大高危场景我实测下来以下三种组合最容易把问题逼出来。场景一空数据 shrinkWrapListView.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.10Beta含修复shrinkWrap: trueitemCount: 0崩控制台报 is not finite正常无异常shrinkWrap: trueitemCount: 0 手动ScrollController监听崩监听回调里读到 NaN正常offset 为 0shrinkWrap: trueitemExtent: 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偶有掉帧约 45MBscrollCacheExtent: 200约 90ms约 58fps较稳定约 40MBscrollCacheExtent: 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 中是否立即访问 RenderObjectFailed assertion: maxScrollExtent minScrollExtent滚动范围边界反转检查 itemExtent 是否为负值/0unhandled exception in ScrollController控制器关联多个滚动视图检查是否复用了同一个 controller遇到报错先别慌把它当线索先定位到具体是哪一层滚动组件算错了再去看约束和数值来源比在 UI 代码里瞎试快得多。6. 我的一些使用体会和后续建议实际用下来这次的 Beta 改动让我比较安心的一点是修复不是靠打补丁堵洞而是把滚动范围计算里所有产生非有限值的路径都做了收敛。对于长期被shrinkWrap空列表问题困扰的项目升级之后基本可以删掉之前那些 workaround比如手动判断itemCount 0时渲染SizedBox.shrink代替列表代码少一坨心智负担也小一截。至于scrollCacheExtent我给还在 Stable 分支观望的朋友一个建议不必急着用等它沉淀进 Stable 之后再把它纳入列表性能调优的常规工具箱。到时候可以建立一个简单的压测流程固定机型、固定数据量逐步调整缓存值记录帧率和内存形成一份自己的调参表。最后分享一个写作时想到的小技巧如果你在滚动物理行为上做了调优比如用了ClampingScrollPhysics、BouncingScrollPhysics记得把scrollCacheExtent的调参也纳入测试范围。因为不同的滚动物理效果对滑动两侧的惯性缓存需求是不一样的物理效果越弹越需要合理的缓存区来保证回弹时不白屏。这个点很多人容易忽略。
返回列表