ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Flutter 滚动布局探秘:从 shrinkWrap 与 NaN 到 ScrollCacheExtent 的根治实践

Flutter 滚动布局探秘:从 shrinkWrap 与 NaN 到 ScrollCacheExtent 的根治实践 聊一个最近才消停的问题shrinkWrap 和 NaN 这对组合曾经在 Flutter 圈子里几乎成了“说到滚动布局就头疼”的代名词。我负责的消息流页面里这个 NaN 报错断断续续躺了大半年各种 workaround 都试过最后反而是因为把基础框架升到 Beta 分支看到 ScrollCacheExtent 这个新东西登场问题才真正从根上解决。这篇就把整个来龙去脉、技术原理、落地步骤和我们踩过的坑写清楚给还在被 shrinkWrap 折腾的工程师一个完整参考。先说清楚这篇文章适合谁正在用 Flutter 做嵌套滚动、消息列表、动态高度卡片这类场景的人被 “shrinkWrap NaN”“Viewport dimension is not finite” 这类错误反复折磨过的人还有那些想了解 Beta 新特性、但不太确定 ScrollCacheExtent 到底改了什么的人。这篇不是官方文档翻译是我在实际项目里从复现到定位、再到升级验证的全程复盘。1. 一次升级引发的“意外相遇”ScrollCacheExtent 和 NaN 的关联1.1 我们实际遇到的滚动场景长什么样先交代项目背景。我们做的是一款带即时通讯属性的 App消息流页面用的是典型的“外层下拉刷新 内层消息列表”结构。消息列表本身是一个 ListView但因为整个页面还有其他模块话题标签、置顶公告、输入框占位所以消息列表没法直接占满全屏而是被放在一个 Column 里通过 shrinkWrap: true 让列表跟着内容自适应高度。这个结构在早期很简单但随着消息类型越来越多——文本、图片、引用回复、合并转发卡片——列表 item 的高度开始变得非常不稳定。图片还没加载完的时候item 高度是 0图片加载完了又瞬间撑开。就在这种“两帧高度不同”的间隙里NaN 开始出现了。最经典的表现是列表滚动到某个位置后突然“卡死”手指怎么滑都没反应控制台刷出一行A RenderViewport computed a viewport dimension that is not finite再严重一点的直接白屏或者 list 消失。我们的崩溃监控平台里这个问题长期占据“未知异常”的前几名因为它不是必现的偶发率大概在 1% 左右但每次出现都发生在用户正在往上翻历史消息的时候体感非常差。1.2 为什么我会对这个新名字格外敏感ScrollCacheExtent 这个名字第一次出现是在一次例行查看 Beta 版本 Release Notes 的时候。当时笔记里写得很简略大意是“对滚动缓存范围的计算方式进行了重构并修复了 shrinkWrap 场景下可能产生非法布局值的问题”。看到“scroll”和“cacheExtent”这两个词组合在一起我下意识就觉得这和我们的 NaN 问题有关。因为在此之前Flutter 里与滚动预加载相关的核心参数只有一个cacheExtent它决定了视口之外还要预构建多少像素的内容。而 NaN 问题恰恰出在“视口尺寸都不确定”的场景里——shrinkWrap 模式下视口高度要被子内容决定子内容又要靠 cacheExtent 来决定要不要预加载这个循环一旦出现中间状态的非法值NaN 就来了。当时我第一反应就是这可能是官方终于对滚动布局的计算链路下手了。于是我们团队做了一个决定——不等 Stable直接在 Beta 分支上把依赖升上去用真实项目去验证 ScrollCacheExtent 到底能不能解决我们那个老大难问题。2. shrinkWrap NaN 的完整根因复盘这个“老问题”到底卡在哪2.1 从 Flutter 布局三阶段看 shrinkWrap 的生存环境要理解 NaN 为什么会产生得先把 Flutter 布局的基本逻辑捋一遍。Flutter 的布局过程本质上可以概括为三个阶段父节点向下传递约束Constraints、子节点向上返回尺寸Size、父节点根据尺寸确定位置Offset。对普通组件来说这个过程很简单父给一个“最大宽度/最大高度”子说自己要多大父再把它放好。但可滚动组件是特殊的存在。ScrollView 的默认行为是“填满视口”——不管里面有多少内容它自身占据的尺寸是固定的内容超过视口就滚。这个过程里“视口尺寸”是已知的就是父约束给的范围。shrinkWrap: true 的语义则完全不同它让 ScrollView 不再填满视口而是像普通组件一样只包裹内容所需的高度。问题也就出在这里——shrinkWrap 模式下ScrollView 需要先“算出内容有多高”才能决定自己有多高。但算内容高度又必须遵循“先有视口尺寸才能进行滚动布局”这一套逻辑。两个环节互相依赖Flutter 的解法是在布局过程中对内容高度进行预估把预估结果当作视口高度继续往下传。问题在于预估不是一个稳定的值。图片没加载完时预估 0加载完预估 300动态文本在字体加载前后测出来的宽度不同行数也随之变化更别说无限滚动加载中列表长度本身还在不停变。这些变化叠在一起极容易在某一次约束传递里算出 Infinity 或 0 这样的中间值再经过减法和除法最终变成一个无穷扩散的 NaN。2.2 三种我们复现过的稳定触发方式在把问题上报给 Flutter 团队之前我们自己在内部做了好几轮复现实验最终稳定复现出三种场景。第一种shrinkWrap 嵌在 shrinkWrap 里。外层 Column 设置了 shrinkWrap内层 ListView 也是 shrinkWrap并且消息 item 里还有一层嵌套的 GridView。这种“三明治”结构在嵌套深度超过两层后约束传递链路已经非常脆弱只要某个 item 的高度出现一帧抖动NaN 就出现了。报错信息是The getter maxScrollExtent was called on null但顺着堆栈看真正的源头是某个 ScrollPosition 在初始化时接收到了非法的尺寸。第二种与 ConstrainedBox 组合时把最大高度压到 0。我们在某个页面上用ConstrainedBox(constraints: BoxConstraints(maxHeight: 0))配合动画做折叠效果内部正好是 shrinkWrap ListView。当高度从正常值动画过渡到 0 的过程中Viewport 会计算出0 - something如果 something 是 Infinity结果就会变成 NaN。第三种是最常见的动态高度 item 在异步数据到达后重新布局。典型场景是消息里嵌入一张网络图片Image 组件先以 0 高度布局然后图片加载完成高度突变。如果这个过程恰好发生在用户快速滚动中Viewport 的 cacheExtent 计算就会在重建过程中读到尚未更新的旧尺寸出现mainAxisExtent cacheExtent NaN。三种情况的共同点很明显shrinkWrap 让“视口尺寸”变成了动态变量而 Flutter 老的滚动缓存计算又默认“视口尺寸已经确定”这个假设不成立的时候NaN 就找上门了。2.3 为什么官方拖了这么久才动手这个问题在 GitHub 上被反复讨论了很多年一直处于“有人报、有人修、修不完”的状态。我以前不太理解为什么一个偶发 NaN 就这么难根除直到我们自己尝试去修才明白其中的复杂程度。根因牵涉到三个核心类RenderViewport负责排版、SliverConstraints负责约束传递、ScrollPosition负责滚动数值计算。要彻底修好不是把某个比较运算换成math.max(0, )那么简单而是要把“滚动范围”和“缓存范围”这两个概念的计算时机重新设计。在 stable 分支上做这种程度的改动回归风险极高稍有不慎就会影响所有非 shrinkWrap 场景的性能和滑动手感。所以官方在 Beta 分支上引入 ScrollCacheExtent本质上不是修一个 bug而是换一套计算逻辑。从我们的实际测试来看它确实做到了“从根上修”而不是继续打补丁。3. 我理解的 ScrollCacheExtent从固定 cacheExtent 到关联式缓存范围3.1 新机制到底在算什么先回顾老的 cacheExtent。Flutter 默认的 cacheExtent 是 250 逻辑像素意思是视口之外上下各 250 像素范围内的内容会被提前布局和绘制这样用户滑到这个区域的时候不用现等。这个机制在没有 shrinkWrap 的时候很好用因为视口尺寸是固定不变的250 就是一个纯粹的偏移量。可一旦开启 shrinkWrap视口本身的高度就等于内容高度内容高度又在动态变化这时候再叠加一个固定 cacheExtent计算路径就会变成“内容高度 视口高度 250”而这个“内容高度”正好又回到了“视口高度”的输入参数里——自引用不出错才怪。我对 ScrollCacheExtent 的理解是它在新的计算逻辑里把“缓存范围”从一个固定常数改成了一个与布局结果联动的计算项。具体来说新的逻辑会先通过一次轻量的内容范围估算确定“在当前布局阶段最小的合理缓存范围是多少”然后用这个值和实际内容高度一起计算可滚动范围。这样一来视口高度的计算路径里不会再出现“拿一个尚不确定的尺寸去减另一个尺寸”的情况NaN 自然就没有了扩散的土壤。3.2 前后行为对照与配置方式光说概念太抽象我画个表对比一下新旧机制在 shrinkWrap 场景下的行为差异计算项旧 cacheExtent 模式ScrollCacheExtent 后的新机制视口尺寸来源父约束固定值shrinkWrap 下由内容估算结果决定缓存范围固定 250 逻辑像素跟随布局阶段动态调整与内容估算联动内容高度计算视口 固定缓存基于估算内容和缓存范围的单次布局决定非法中间值处理直接参与加减可能产生 NaN先做范围归一化避免 Infinity 参与后续计算性能影响无额外开销新增一次轻量估算通常可忽略在 Beta 版本的实现形态里ScrollCacheExtent 是作为一个可配置参数暴露出来的你可以按页面特点调整缓存范围的大小。我们当时的配置思路是普通文本消息页用默认值即可图片较多的消息页可以适当调大缓存范围因为图片解码本身需要时间提前多缓存一点能减少滚动过程中的白块感。实际配置方法很简单在 scroll behavior 或具体的 ScrollView 上指定缓存范围即可。要注意的是新版对 cacheExtent 的语义做了一些调整——老代码里如果手动设置过很大的 cacheExtent升级后可能会发现内存占用有变化这个后面细说。3.3 新机制的边界条件设计ScrollCacheExtent 不是单纯把公式改得更“宽容”它在边界条件上也做了不少工作。从我们的测试来看至少有三个方面明显比旧逻辑严谨。首先是“内容为空”的情况。shrinkWrap 空列表这个场景在旧版里偶尔会算出负的滚动范围新逻辑会把空内容的滚动范围直接收敛为 0。其次是在构建过程中同时存在“已加载 item”和“未加载 item”的混合状态新版会在布局中间态强制把参与运算的数值限定在有效范围内。第三点是和 ScrollPhysics 的配合。旧版 NaN 经常在 overscroll 弹性效果中放大——因为物理效果本身要做衰减计算一旦输入 NaN整个动画系统就会崩掉。新版在滚动位置更新的入口处做了守卫从源头截断了非法值的传播路径这让我们在 iOS 上那种“滑到边缘弹一下”的场景从偶发崩溃变成了完全稳定。4. 落地过程切换 Beta、清理 workaround 与搭起回归验证4.1 切换 Beta 分支的完整操作这个环节看起来简单实际坑不少。我们的项目依赖了十几个第三方包直接切分支之前得先确认这些包在 Beta 上的兼容性。当时的操作顺序是这样的先查看当前 Flutter 版本和可用分支flutter --version flutter channel然后切换到 beta 并升级flutter channel beta flutter upgrade flutter pub upgrade当时我们踩的第一个坑就是 pub 依赖冲突。有个老版本的路由库对 Flutter SDK 约束比较严格迁移后发现这个库直接报错无法编译。好在维护者隔天就发了新版但这件事也提醒了我们上车 Beta 之前一定要先跑一遍flutter pub outdated看看哪几个包可能存在兼容风险。升级完成后还要特别检查 Android 构建配置。新版 SDK 对 Gradle 的依赖方式做过调整如果你在配置文件里用老式的apply方式引入 Flutter 的 Gradle 插件会出现一个很吓人的报错就是网上常说的You are applying Flutters main Gradle plugin imperatively using the apply那个。解决办法是改用标准的pluginsDSL 写法。4.2 把所有 NaN 救火代码全部删掉这一步是我特别想强调的。我们项目里因为之前的 NaN 问题积累了大量临时补丁。比如在计算列表高度的工具类里加了一堆math.max(0, value)的钳制代码比如给 ScrollController 设置了自定义的position处理又比如在某些页面上强制把 ListView 替换成SliverList来绕开 shrinkWrap。升级到 Beta 之前我们其实经历了一段尴尬期新机制和旧补丁同时在跑结果反而出现了新的异常。因为老补丁会在数据源层面把一些合法的高度值强行截断导致新机制拿到的输入不对。后来我们决定把项目中所有与 NaN 相关的 workaround 全部清理干净回到“原生的 shrinkWrap ListView”状态再在 Beta 上验证新机制是否真的能独立扛住。清理范围包括自定义 ScrollPosition 里的 assert、列表高度估算工具类里的三处 clamp、以及几个页面里为了“防 NaN”而硬编码的最小高度。删完之后跑一轮全量回归发现不但 NaN 消失了列表的构建逻辑还变得更清晰了——以前为了绕体检而做的很多特殊分支都不需要了。4.3 回归测试怎么搭才有说服力代码改完验证体系必须跟上。我们做的第一件事是把之前三种稳定复现场景全部写成 Flutter test。重点不是“能跑通”而是“能测出 NaN”。测试里用一个自定义的 ScrollPosition 子类在每次滚动范围更新时检查数值是否为 NaNclass NaNDetectingScrollPosition extends ScrollPositionWithSingleContext { NaNDetectingScrollPosition({ required super.physics, required super.context, super.oldPosition, }); override void applyNewDimensions() { assert( pixels.isFinite maxScrollExtent.isFinite minScrollExtent.isFinite, Scroll position contains non-finite values, ); super.applyNewDimensions(); } }然后在 widget test 里构造一个 shrinkWrap ListView塞入动态高度 item手动触发多次布局重建只要有任何一次出现 NaN测试就会立即失败。我们把之前复现的三个场景都放进去全部通过。线上验证也不能少。我们走了逐级灰度先让 5% 的用户试用观察崩溃率和新机制下的滚动性能确认稳定后再逐步扩大到 50%、100%。两天后平台上的相关崩溃率直接从峰值的 0.8% 降到了 0而且没有出现新的因为缓存范围调整引起的内存问题。5. 给不同处境的人三条路径升级、过渡、重构5.1 暂时不想升级 Beta 时的过渡方案虽然我们最终靠升级解决了问题但不是所有团队都有条件立刻切 Beta 分支——大型项目里第三方依赖的兼容性、CI/CD 流程的适配、团队对 beta 稳定性的接受度都是现实的门槛。如果暂时留在 stable这里有几个过渡方案可以显著降低 NaN 的出现概率。方案一尽量避免深层嵌套下的 shrinkWrap。多层 shrinkWrap 叠加是 NaN 的高发区能用 CustomScrollView SliverList 重构的话尽量把“外层滚动”和“内层列表”合并成同一个滚动视图。消息流这种长列表本质上是单一滚动维度的需求不需要内层再开一个可滚动容器。方案二给动态高度 item 预设最小高度。网络图片加载造成的“0 高度 → N 高度”突变是 NaN 的常见来源在图片加载完成前给它一个固定高度占位可以大幅减少布局重建的抖动。缺点是不能根治只是降低触发概率。方案三用本地钳制把 NaN 挡在滚动位置更新之外。比如在 ScrollPosition 的 applyUserOffset 入口做一次数值校验发现非有限值就忽略该次更新。这是纯防御型方案不会改变布局结果但至少能避免 NaN 扩散到手势系统里导致整个列表卡死。5.2 已经跟随升级的人还要注意什么如果你决定和我们一样上 Beta 体验 ScrollCacheExtent有几件事必须先做好心理准备。第一Impeller 渲染引擎的适配。Beta 分支默认开启了新渲染引擎如果你的项目里还有旧的自定义 Shader 或者对 Skia 特性有依赖可能会遇到渲染异常。我们这边主要是部分老机型上出现少量纹理渲染毛边通过关闭 Impeller 回退到 Skia 后恢复正常但这需要在灰度前就明确开关策略。第二缓存范围增大后对内存的影响。ScrollCacheExtent 允许你把缓存范围调得比默认 250 大很多但如果 item 里包含大量高清图片缓存范围的增加会直接反映在内存占用上。我们曾经在调试模式下把缓存调到 1000 测试手感结果在低端 Android 机型上内存飙升了 30%。生产环境建议保持默认或者按页面复杂度单独覆盖。第三老项目里的 AAR 集成方式需要检查。新版 SDK 在发布期间做了不少构建链路的调整如果你是把 Flutter 作为 AAR 集成进原生工程升级后一定要重新生成 AAR 并检查原生侧的依赖版本否则容易出现编译期符号找不到的问题。5.3 一点个人体会回头再看这半年多的折腾最大的感受是框架层面的底层修复确实能消解掉一堆应用层的“灵异现象”。以前我们为了躲开 NaN在业务代码里写了很多复杂的边界判断这些代码表面上解决了问题实际上只是把问题的表现形式从崩溃变成了隐藏的异常。升级到 ScrollCacheExtent 之后我们把所有 workaround 删掉发现原生的 shrinkWrap 根本不需要那些额外的兜底。另一个体会是Beta 分支值得在团队里保持一个“尝鲜成员”。如果一个新特性可能影响你的核心场景与其等 Stable 出来后手忙脚乱地适配不如提前派一个小组在 Beta 上跑通验证。我们这次从发现 ScrollCacheExtent 到完成全量验证前后不到两周避免了很多反复。最后再分享一个调参与检查的小技巧升级后想确认自定义缓存范围是否真的生效可以用WidgetsBinding.instance.renderViewElement绕到实际俯视图上打印出当前视口的cacheExtent和estimatedCacheExtent两个字段对比一下。如果两者不一致说明你的配置还没有被框架完全接受这时候要优先检查是不是有布局缓存把旧配置给“记住”了。这个细节我们当时找了整整半天希望对你有用。
返回列表