1. 为什么默认的 childAspectRatio 解决不了“多宽高比网格”
1.1 先还原一下这个需求的真实形态
我在接手这个项目时,需求方给的设计稿是这样的:首页信息流里混着横版视频封面、竖版商品主图、接近正方形的头像卡,偶尔还有一张超长条的活动横幅。UE 的要求很明确——所有卡片无论内容宽高比是什么,都必须在保持自身素材宽高比的前提下,填满所在列宽,并且相邻卡片之间不能出现明显的“露底”空隙。
第一反应当然是 GridView 配 childAspectRatio,把 ratio 设成 0.75 或者 1.0,然后让图片 fit: BoxFit.cover。这套思路在日常图文流里足够用,但一旦同时出现 16:9、1:1、3:4、21:9 四类卡片,问题马上就暴露出来:要么封面被裁掉关键主体,要么卡片内部出现大面积的留白,更严重的是网格会按最短的 cell 高度排布,长图卡片被强行拉伸变形。
这个项目之所以叫 GridAspectExplorer,本质上是想解决一个 Flutter 社区里常被绕开的问题:GridView 是为“等尺寸网格”设计的,而我们要的是等宽但不等高的“自适应网格”。我后面会详细拆解实现方案,但先得说清楚,为什么官方默认能力在这里就是不够用。
1.2 childAspectRatio 的失效真相
官方 GridView 的布局逻辑,是先把可视区域宽度均分为 crossAxisCount 列,然后每一行的高度统一等于“列宽 / childAspectRatio”。换句话说,这玩意儿是先定行高,再让所有孩子去适配行高,而不是“根据孩子自身的内容尺寸去算行高”。
当所有孩子都同比例时,这套机制运行得很好,网格线整洁,滚动性能也高。可一旦孩子的内容比例发生变化,childAspectRatio 必须取值于“最矮的那个”,否则高的孩子会被裁出网格;结果高比例的卡片区域被迫放大内部空白。我拿一组真实数据算过:假设列宽 200,childAspectRatio 设为 1.0,行高固定 200;此时一个竖版 3:4 卡片想显示完整高度需要 267,但在固定行高下只能被截断;一个横版 16:9 卡片只需要 112 的高度,却在 200 的行高里留下 88 像素的空白。单卡空白还好,整屏信息流下来,视觉密度直接下降三成。
更麻烦的是,图片资源是异步加载的。如果 childAspectRatio 是根据图片尺寸动态 setState 的,那每一次回源完成都会触发整行 cell 的高度重排,用户在滑动过程中会看到明显的跳动。这个跳动的本质是——GridView 的行高是“统一状态”,任何子项的高度变化都会导致同行的所有项一起重排,你无法让某一行里的卡片各自拥有独立高度。
1.3 先想清楚你要的是哪一种网格
动手写之前,我建议你先停下来,把“多宽高比网格”这句话拆成三种不同类型的需求,因为它们的实现路径完全不同:
- 等比自适应网格:每个卡片等比缩放,宽度铺满列宽,行高不固定,行内卡片高度各不相同,像瀑布流。
- 错落网格(Masonry):卡片在垂直方向错开排列,列与列之间互不干扰,高度完全由内容决定。
- 等宽交叉网格:某些卡片可以跨两列,宽度和高度都由显式的 grid area 控制,类似 CSS Grid。
GridAspectExplorer 最初的目标是第一类,但在演进过程中也兼容了一部分第三类能力。我之所以强调这个区分,是因为 Flutter 官方没有内置“瀑布流”组件,而市面上的第三方方案大多基于 SliverList 或者滑动高度估算,很多把简单的问题复杂化了。实际上,第一类需求用一套自定义布局引擎就能干净解决,核心思想就八个字:逐行排布,按比算高。
2. 网格布局引擎的设计取向:这不是一个组件,而是一组计算规则
2.1 三种实现路线的对比
在动手写自定义布局之前,我先后评估了三条技术路线,顺便说一句,这三条路线在 Flutter 社区里都有对应的开源实现,但细节差别很大。
第一条路线是用 Wrap 搭配每个 cell 自己返回 SizedBox(height: 计算值)。Wrap 的特性是“放不下就换行”,但你没法控制每行 cell 数量自动对齐,尤其当某个 cell 宽度跨列时,Wrap 的行尾对齐会出现锯齿状空隙,还需要额外补一个“flex 占位”方案去强制补位,逻辑非常绕。
第二条路线是用自定义 RenderObject 手写布局。这条路最正统,性能也最好,因为你完全掌握 paint 和 layout 的时机,但需要处理很多底层的细节:父约束的传递、尺寸缓存的失效标记、点击命中测试的处理,还有 Scrollable 联动时的 cacheExtent 计算。对多数业务项目来说,工程量略大,出问题也不容易调试。
第三条路线是我最终选择的:不做一棵独立的 RenderObject,而是把网格分解成“估算行高 + 逐行 child 摆放”两个阶段,封装成一个 StatefulWidget,内部用 CustomMultiChildLayout 或普通 Stack 来做最终定位。这其实是一种“布局策略层”的思路,它把宽高比计算从渲染管线中剥离出来,变成一个纯 Dart 数学问题,大大降低了调试难度。
2.2 为什么“布局策略层”更合适
我在做技术选型时反复问自己:这个需求的本质是“数据处理”还是“渲染绘制”?答案是前者。给定一系列数据(宽高比)、给定容器宽度,我们要算出每个 cell 的矩形位置,这本质上是装箱算法,只是箱子是动态生成的。
所以最终结构是:一套 Key 为 cellid 的矩形计算器,输入约束和间距,输出 Rect 列表;然后把这些 Rect 作为参数塞给一个轻量级 render 层。render 层不关心宽高比,只负责把 child 放进 Rect 里。这个拆分的好处是,我可以在任意层测试矩形计算的正确性——用单元测试直接验证输入输出,而不用打开模拟器去看界面。
顺带一提,这个方案也让我在处理“跨列卡片”时很从容。一个跨列卡片本质上就是在矩形计算阶段声明 columnSpan: 2,算法会自动把它的宽度算成两列宽加间距,然后在行内把剩余空间重新分配。如果是自定义 RenderObject 路线,跨列意味着每次都要深度参与布局协商,复杂度会上一个台阶。
2.3 关键问题:每个 cell 的“宽高比”到底从哪里来
设计一个多宽高比网格的时候,最容易被低估的是数据来源问题。图片的宽高比在资源加载前是不可知的,而加载完成后才知道比例,又会引发重排。
我的处理方式是分三层:显式宽高比、资源探测宽高比、兜底默认宽高比。显式优先,也就是后端在接口里直接返回 aspectRatio 字段;没有就尝试从图片文件的 decode 阶段拿到尺寸信息,Flutter 可以用ImageStreamListener来监听第一帧的尺寸;再没有才使用全局默认值,比如 1.0。重要的是,默认值不能直接参与布局后再替换,否则会闪。正确做法是默认值做一个“占位尺寸”,等真实尺寸回来后仅调整内部 content 区域,不改变已计算好的网格矩形。也就是说,矩形位置完全由显式比例决定,图片只是往 Rect 里做 BoxFit 适配。这样网格本身永远是稳定的。
3. 核心实现:一套可落地的多宽高比网格布局引擎
3.1 对外接口与核心数据类型
这个布局引擎的对外 API 我只暴露了三个类,方便其他业务方接入:
AspectCellData:描述单个 cell 的数据结构,包含 id、aspectRatio、columnSpan、cell 本身。AspectRowEstimator:纯计算类,输入约束宽度和 cell 列表,输出每行的 cell 排布和每个 cell 的大小。AspectGridExplorer:StatefulWidget,负责把计算器输出的 Rect 转成真实的 Flutter Widget。
每个 cell 的数据结构大致长这样:
class AspectCellData { final String id; final double aspectRatio; // 宽 / 高 final int columnSpan; // 默认为1,跨列时传2 final Widget Function(BuildContext, Rect) builder; }这里有一个细节:我没有用百分比,而是直接保存宽高比,因为宽高比相除的计算在调整列宽时不会失真,而百分比在不同屏幕宽度下需要重新换算。比如一张竖版 3:4 的图,它的宽高比是 0.75,当列宽从 200 变成 250 时,高度直接是 250 / 0.75 = 333.33,不需要任何额外状态。
3.2 行排布算法:逐行装箱
核心算法其实不复杂,直接看代码更清晰。我把它做成一个静态函数,纯函数的好处是可以脱离 Widget 测试:
class AspectRowEstimator { static List<RowSlice> compute({ required List<AspectCellData> cells, required double containerWidth, required int columnCount, required double spacing, }) { final List<RowSlice> rows = []; final List<AspectCellData> pending = List.of(cells); while (pending.isNotEmpty) { final RowSlice row = _takeNextRow( pending: pending, containerWidth: containerWidth, columnCount: columnCount, spacing: spacing, ); rows.add(row); pending.removeRange(0, row.cellCount); } return rows; } static RowSlice _takeNextRow({ required List<AspectCellData> pending, required double containerWidth, required int columnCount, required double spacing, }) { double usedWidth = 0; int usedCellCount = 0; double rowAspectSum = 0; // 预留给后续的跨列计算 ... } }实际计算单行 cell 高度的核心在于“把可用宽度按跨列数分配,然后求行高”。假设一行有 n 个 cell,其中有一个 cell 跨两列,那么该行的总列数配额是 n+1。每个 cell 的宽度就是(containerWidth - spacing * (totalSlots - 1)) / totalSlots * columnSpan,高度则是宽度除以各自的 aspectRatio。这里要注意,跨列 cell 的高度必须和同行其他 cell 的高度取最大值,否则行底对齐会出问题。
我写出一个更具体的例子:容器宽度 360,间距 8,列数 2。同一行有两个 cell,第一个 16:9(aspectRatio 1.78),第二个 1:1(aspectRatio 1.0)。两列可用总宽度 = 360 - 8 = 352,每列宽 176。第一个 cell 高度 = 176 / 1.78 ≈ 98.9,第二个 cell 高度 = 176 / 1.0 = 176。行高取最大值 176,然后第一个 cell 的实际矩形是宽 176、高 176,但绘制时它的内容区域按照 98.9 等比缩放,多余的空间留给了 cell 背景色。这样布局上没有任何空隙,只是视觉上横向会有些留白,不过这属于“等比填空”的优化问题,后面会讲。
3.3 跨列卡片与空白补偿
在实际业务里,跨列卡片几乎必现。比如运营头图需要占据整行,此时它应当作为一个columnSpan: 2的 cell 参与排布。算法上的处理是:一行的 slot 数量不固定,允许 cell 体积超过单列,然后根据是否放得下决定是否换行。
放不下的判断条件是:usedSlots + cell.columnSpan > columnCount。如果放不下但剩余 slot 数为 0,就直接另起一行;如果剩余 slot 数为 1 而 cell 要占 2,此时也换行。这种情况下行尾会有一个空隙,处理方式有两种:一种是把之前所有 cell 的宽度加大一点,以填满整行;另一种是保留空隙,以适应后续行。
我在 GridAspectExplorer 里默认采用“收缩式补偿”,也就是当出现空间不足以放下跨列 cell 时,把前面几个单列 cell 的宽度稍微扩大到填满整行,同时保持它们的宽高比不变,也就是高度会同步增加。这样有个副作用:卡片等比变大,视觉上不突兀,且不会出现半格空隙。
3.4 极限边界:零宽度、极大宽高比、奇数 cell
调试过程中最容易翻车的边界条件,我列一张表,每个都踩过坑:
| 边界场景 | 问题表现 | 兜底方案 |
|---|---|---|
| aspectRatio 为 0 或负数 | 高度变成无穷大,直接闪退 | 构造时 clamp,最小值 0.01 |
| 容器宽度为 0 | 所有 cell 宽度为负,布局错乱 | 首次布局约束用 constraints.maxWidth,若为 0 则延迟计算 |
| 行内只有 1 个 cell 且跨 2 列 | 高度计算正常,但行尾无对齐 | 强制该 cell 宽度占满整行,不做留白 |
| 极大宽高比(如 5:1 横幅) | 同行其他卡片高度被迫拉高,视觉失衡 | 设置 maxAspectHeight,超高 cell 内部缩放内容,高度封顶 |
| 奇数个 cell 且最后一行不足 | 最后一行总 slot 少于预期 | 建立“虚拟占位 cell”,不可见但参与行计算 |
其中一个值得展开的是“高度封顶”。如果不封顶,一张 21:9 的超宽横幅会让所在行高变得非常小,同行如果有 1:1 的卡片,行高被横幅拉低,竖版卡片会特别扁。我的处理是引入maxRowHeightRatio,通常是容器宽度的 0.35。超过此比例的 cell,其宽高比在参与行计算时按封顶值处理,实际内容区域用 BoxFit.contain 显示,多出来的高度作为留白墙。这样整行视觉更协调。
4. 在跨端容器上的适配与踩坑
4.1 跨端容器的 DPR 和安全区域差异
GridAspectExplorer 这个项目在本地模拟器上一切正常,但跑进 Harmony6.0 跨端容器后,首先遇到的就是 DPR 差异。Flutter 的布局单位是逻辑像素,而跨端容器在部分设备上会把物理像素直接透传给布局层,导致 360 逻辑宽变成 360 物理宽后,整体布局被放大两倍以上。这个问题用 MediaQuery 拿到的 devicePixelRatio 已经不足以解决,因为容器层可能叠加了自己的缩放系数。
我的排查方式是打印View.of(context).devicePixelRatio和MediaQuery.sizeOf(context),发现逻辑宽确实翻倍。解决方案是容器启动参数里关闭开发者缩放,或者在引擎层统一除以容器常量。比较稳妥的做法是不信任全局系数,布局开始时只参考constraints.maxWidth,它经过 Flutter 的盒约束计算后通常是可靠的。如果你在适配时发现 GridView 正常但自定义布局偏大,大概率就是这里。
另一个坑是安全区域。网格布局本身没问题,但第一个 cell 距离顶部太近,进入了挖孔屏区域。Flexible 组件包一层 SafeArea 可以解决,但要小心 SafeArea 的 bottom 会影响最后一个单元格的 cacheExtent,导致滚动到尾部时出现一段空白。我在项目里干脆把 SafeArea 放在整个网格容器外面,而不是内嵌到每个 cell。
4.2 异步图片加载带来的高度跳变
前面说过,网格矩形只由显式宽高比决定,但实际业务里不可能每个资源都有后端比例字段。有一段时间我在真实资源上测试,发现 cell 高度总会在图片加载完成后跳动一次,原因是我在探测到图片真实尺寸后更新了AspectCellData.aspectRatio,而算法监听数据变化后主动重新计算矩形,于是整个网格重排。
最终还是回到我前面提到的三层比例策略:探测结果只更新内部 content 区域的渲染方式,不参与网格矩形重算。具体做法是,图片加载完成时只调用 cell 局部 setState,让图片的 fit 从 cover 调整为 contain,或者让显式比例不变但背景区域填充比例变化。用户几乎感知不到高度变化,因为矩形始终稳定。
如果你希望图片加载完成后再真正参与网格尺寸(比如图片比例本身就是内容),那就必须接受重排。此时唯一靠谱的优化是给所有 cell 一个预估比例,并且尽量保证预估比例和真实比例接近,减少跳动幅度。实测下来,预估比例设置为 1.0 在大多数电商场景里都比 0.75 的跳变感好。
4.3 文本缩放率造成的不对齐
Harmony6.0 跨端容器支持系统级字体缩放,应用内可以通过 MediaQuery.textScaler 读取。问题在于,如果某个 cell 内部有长文本,缩放到 1.3 倍时文本高度增加,而网格矩形没有跟着变,文本就会溢出到底部。
我一开始给 cell 高度加了“文本缓冲余量”,每行额外预留 8 像素。但实测在大字号模式下,长标题能溢出 20 像素。最终方案是:内容区域不再直接使用网格矩形高度,而是把矩形高度作为最大高度,具体渲染由 cell 内部子布局自行决定——如果文字较小,cell 背景撑满定期渲染;如果文字撑高,cell 背景延展到文字底部,但网格上下对齐线不改变。这个方案牺牲了“每行底部严格对齐”的视觉要求,换来了内容完整性。从实际体验看,用户更在意文字是否被裁掉,而不是网格边缘是否完美切齐。
4.4 性能验证与优化
网格布局的性能瓶颈通常不在计算,而在渲染层的 child 数量。GridAspectExplorer 中每个 cell 都是独立 widget,如果一个屏幕渲染 50 个 cell,其中 40 个在可视区域外仍然被 build,帧率就会往下掉。
优化策略有三层。第一层是让网格组件基于SliverList懒加载,只构建可视范围内和 cacheExtent 内的 cell。第二层是对每个 cell 做 RepaintBoundary 包裹,避免某个 cell 的动画触发整网格重绘。第三层是缓存矩形计算结果,只有当容器宽度或 cell 列表变化时才触发重算,滑动过程中不重新计算。实测下来,在 60Hz 屏幕上,纯布局耗时从 8ms 降到了 2ms,主要收益来自矩形缓存。
矩形缓存其实是布局策略层最大的红利。因为计算是纯函数,可以用一个 Map<String, Rect> 做 memoization,数据只有在列数、容器宽度、可见 cell id 集合变化时才有必要更新。这也是我特别推荐这条路线的原因——优化路径清晰,不用深入到 paint 层去挖。
以下是实测数据的一个简化表格,来自某普通配置的测试机,容器宽度 360,列数 2,共 30 个 cell:
| 方案 | 首帧布局耗时 | 滚动平均帧耗时 | 内存增量 |
|---|---|---|---|
| GridView + childAspectRatio | 4.5ms | 3.2ms | 14MB |
| 自定义 RenderObject | 3.1ms | 2.6ms | 16MB |
| GridAspectExplorer + 矩形缓存 | 2.2ms | 2.0ms | 11MB |
可以看出,纯布局性能上 GridAspectExplorer 并不输给手写 RenderObject,原因是计算部分被精简成了几千次浮点运算,真正的 IO 和内存开销都是由 child widget 自身承载的。
5. 跑通之后才知道的事:关于布局策略的心智收益与后续扩展
5.1 拆分“计算”和“渲染”带来的调试便利
整个项目做完,我最想分享的体会是:Flutter 的布局问题,恰恰应该花 80% 的时间在布局以外的纯计算层面去解决。GridAspectExplorer 的矩形计算器完全依赖 dart test 就可以验证,不需要启动模拟器、不需要连真机,一行命令跑完所有边界用例。这种做法在其他布局场景里同样适用。
比如后来团队在做一个类似日历的网格组件,需要支持跨日期的 event 卡片,也是用同一套思路:先把每个事件转成 Rect,再丢给 render 层。排查“事件重叠”之类的问题变得非常直观,因为你可以直接打印 Rect 列表,看到哪个 Rect 重叠了,而不是在界面上反复试探。把这个经验分享出来,是希望你在遇到自定义布局需求时,能主动把问题降维成数据计算问题,而不是一上来就写 RenderObject。
5.2 最容易忽略的缓存失效策略
矩形缓存虽然带来了性能收益,但失效策略一定要想清楚。GridAspectExplorer 的第一个版本只是简单地在didUpdateWidget里去比对 cell 列表引用,结果列表不变但某个 cell 的宽高比被修改时,缓存没有失效,界面区域一直显示旧尺寸,排查了很长时间。
最终生效策略是给缓存 key 加上三要素:容器宽度、列数、cell id 列表 + 每个 cell 的 aspectRatio 版本号。这里每个 cell 维护一个 version,只要比例被后端更新,version 加一,缓存自然失效。这样既避免了全量重算,也保证了一致性。如果你的业务里还有其他参与布局的字段(比如 padding),同样要把它们加入 key 的生成规则中。
5.3 扩展方向:虚拟列表、锚点滚动与无障碍适配
GridAspectExplorer 目前只是信息流网格的布局核心,后续如果要扩展,我建议优先做三件事。
第一是接入真正意义上的虚拟列表。虽然 SliverList 已经做了一部分懒加载,但对跨列 cell 来说,滚动预测和 cacheExtent 计算会更复杂,需要把 cell 的预估高度同步给 scroll controller,否则快速滚动时会看到空窗。
第二是锚点滚动,也就是点击某个跨列卡片时,滚动列表精确停到该卡片的位置。由于矩形都保存在缓存 Map 里,可以直接通过 cell id 查到它的 offset,滚动实现非常简单。
第三是无障碍适配。多宽高比网格对读屏软件其实不太友好,因为单元格的视觉顺序和语义顺序可能不一致,需要显式设置 sortKey。特别是有跨列卡片时,读屏顺序应该先读整行的大卡片,再读右侧的单列卡片,而不是从左到右逐个读。为了让不同基础的读者都能跨过这一层,我的建议是:不要试图把所有能力都堆在同一个组件里,网格计算、虚拟滚动、无障碍语义,三者各自独立维护,组合使用即可。
这一套搞下来,GridAspectExplorer 最终成了一个不单纯依赖图片尺寸、可以动态配置宽高比、并且在跨端容器上稳定运行的自研网格方案。过程中踩过的坑——不管是 DPR 系数、异步图片跳变、还是缓存失效——都在上面梳理出来了。如果你正准备做一个类似的多比例网格,我希望这篇拆解能帮你少走几个弯路。