WaterFlow 瀑布流是菜谱应用最合适的列表形态:菜谱图片高度不一,瀑布流按列排布自动错落,视觉密度远高于等高网格。但它也是调试成本最高的容器------列宽怎么定、跨列怎么做、滚动位置怎么保持、卡片高度不一带来的复用问题,每个都有讲究。这篇记录健康菜谱助手瀑布流从粗糙到成熟的完整过程。

一、基础结构:columnsTemplate 与 FOOTER/HEADER
WaterFlow 用 columnsTemplate 定义列,如 '1fr 1fr' 双列。要点是 fr 单位的分配按剩余空间均分,gutter(列间距)和 margin(行间距)分开设置。首尾的加载区用 Footer/Header 组件声明,加载更多时 Footer 显示 loading。基础结构十分钟搭完,真正的坑都在后面。
ts
WaterFlow({ footer: this.itemFoot.bind(this) }) {
ForEach(this.recipes, (r) => {
FlowItem() { RecipeCard({ item: r }) }
}, (r) => r.id)
}
.columnsTemplate('1fr 1fr')
.columnsGap(8)
.rowsGap(8)
.onReachEnd(() => this.loadMore())
二、卡片高度:自适应与固定比率的取舍
瀑布流的灵魂是卡片高度错落。最初给图片固定高度,瀑布流退化成等高网格;改成按图片原始比例自适应,又遇到加载前高度未知、布局跳变的问题。最终方案:数据层为每个菜谱存一张"裁剪后宽高比"(上传时算好),渲染时按比率预占位,图片加载完成后无跳变。自适应的前提是"高度可预知"------数据侧先算好,渲染侧不猜。
| 方案 | 视觉效果 | 布局稳定性 |
|---|---|---|
| 固定高度 | 退化为网格 | 稳定 |
| 加载后自适应 | 错落 | 跳变严重 |
| 预存宽高比占位 | 错落 | 稳定 |
三、跨列卡片的实现:sectionsTemplate
运营位需要一张横跨双列的大卡片。WaterFlow 从 API 11 起支持 sectionsTemplate 分组配置:每组指定 itemsCount、crossCount、onGetItemMainSizeByIndex(该项主轴尺寸)。大卡片单独一个 section,crossCount 1,卡片内大图;普通菜谱一个 section,crossCount 2。分组让混合布局有了声明式写法,但要注意 section 边界处滚动位置的计算会复杂一些------切换 section 时 onScrollIndex 回调的索引是全局的,业务里做好映射。
ts
WaterFlow() { ... }
.sectionsTemplate([
{ itemsCount: 1, crossCount: 1 }, // 运营大卡
{ itemsCount: this.recipes.length - 1, crossCount: 2 },
])
四、滚动位置保持:瀑布流的特有难点
瀑布流跳转详情后返回,滚动位置要恢复。等高网格可以用索引×行高估算,瀑布流不行------每列高度独立,索引无法直接换算偏移。实测发现 WaterFlow 自己维护了滚动位置,页面返回时**只要数据源不变,位置自动恢复**;会破坏位置的操作是"返回途中刷新数据"(列表重建)。规范因此定为:返回场景数据源懒刷新(先恢复滚动,再静默更新差异),刷新用 diff 更新而不是整体替换------前面 LazyForEach 篇的通知方法语义在这里再次派上用场。
五、onScrollIndex 与预加载:触底的时机
onReachEnd 是最简单的触底加载,但它在滚到**绝对底部**才触发,弱网下用户会等。更平滑的方案是 onScrollIndex 回调:滚动过程中当前首尾索引实时回调,当尾部索引接近总数(如剩 6 条)时提前触发加载。双方案并存:onReachEnd 兜底,onScrollIndex 预加载。配合 loading 状态去重(加载中不再触发),瀑布流的无限滚动体验就顺滑了。
ts
.onScrollIndex((start, end) => {
if (end >= this.recipes.length - 6 && !this.loading) {
this.loadMore()
}
})


六、卡片复用:高度不一的复用问题
FlowItem 的组件复用在瀑布流里有个特有问题:卡片高度不同,复用池按组件类型区分,同类型不同高度的复用没有问题(布局重新计算),但卡片内部有按高度做的逻辑分支时(比如大图卡与文字卡的模板差异),复用会导致状态错乱。解法是把"视觉形态"做成卡片的数据属性(每条菜谱声明自己的形态),复用键里带上形态值------形态不同的项不复用,形态相同的正常复用。复用键的设计比复用本身更重要。
七、列数响应断点:与 GridRow 的协作
多设备适配时瀑布流的列数要变:手机双列、平板三列。方案一:WaterFlow 的 columnsTemplate 动态切换,断点从 MediaQuery 或外层 GridRow 的 onBreakpointChange 获取;方案二:平板直接换 GridRow 方案。实测动态切 columnsTemplate 会重建整个列表(滚动位置丢失),平板场景用户能接受回顶。方案定为:手机双列 WaterFlow,平板用 columnsTemplate('1fr 1fr 1fr') 并接受回顶------瀑布流的断点切换没有完美方案,选用户体验损失最小的那个。
八、图片策略:瀑布流的内存大头
瀑布流的内存大头是图片。卡片宽度约 170vp,图按 2 倍密度准备 340px 宽即可;高度按宽高比可到 500px,解码后单张约 0.6MB。屏幕内可见卡片约 8 张,加预加载 12 张,图片内存约 8MB,可控。关键措施:Image 组件用 objectFit Cover 配合预占位比率;加载失败的占位图复用同一宽高比,避免布局跳变。瀑布流的图片策略本质是"预知尺寸+按需解码",与列表复用配合才完整。
九、下拉刷新与触底加载的组合
瀑布流的刷新交互用 Refresh 组件包裹,触底加载用第五节的 onScrollIndex。两者的数据合并逻辑要小心:刷新是替换(重置列表+回到顶部),加载是追加(保留位置)。追加时用 DataSource 的 onDataAdd 逐条通知,不要整体替换数组------否则滚动位置丢失,前面的经验直接复用。刷新与加载的状态机用三个状态管理:idle、refreshing、loadingMore,互斥防抖。
十、总结:瀑布流的五个要点
收束成本篇的五点:高度预存宽高比(布局稳定的前提)、sectionsTemplate 做混合布局、onScrollIndex 提前预加载、复用键带形态值、图片按列宽准备。瀑布流比等高列表复杂,但它的视觉密度对内容型应用值得投入------按这五个要点搭,瀑布流可以从"能跑"到"好用"。
参考资料:
- 华为开发者:WaterFlow 瀑布流
- 华为开发者:sectionsTemplate 分组配置
- 华为开发者:FlowItem 与组件复用
#鸿蒙 #HarmonyOS 7(API 26)
十一、真实Bug复盘:卡片重叠与列错乱
瀑布流上线第一周抓到两个渲染 Bug,都和卡片高度有关。第一个:偶发两张卡片重叠,排查发现是某个卡片内的图片加载完成后撑开了容器,但 WaterFlow 的位置缓计算还用的是旧高度,下一帧图片布局覆盖了邻卡。修复就是把宽高比预占位做彻底------图片容器的高度永远由数据侧的比率决定,Image 只做填充,不参与布局计算。第二个:切换筛选条件后左列明显比右列长出一截,像瀑布流断流。原因是筛选后数组整体替换,WaterFlow 对新数据的列分配是从头开始的贪心算法,数据里恰好前几条都是高度相近的卡片,导致分配不均。解法是在数据源里对卡片高度做一次混排,让长短卡片交替进入,视觉密度立刻均匀。两个 Bug 的共性:瀑布流对卡片高度的真实性极其敏感,任何高度与内容的偏差都会在布局上放大。
十二、骨架屏:加载态的瀑布流版本
首屏加载时的骨架屏也有瀑布流特有的细节:骨架卡片如果全部等高,用户会预期一个规整布局,真实内容加载出来却是错落的,视觉上有跳变。骨架屏按真实数据的宽高比分布生成------从缓存的上次数据里取宽高比序列,新用户用预设的随机比率分布。骨架与真实内容的过渡用透明度交叉,不用位移,错落位置的卡片做位移动画会显得乱。这套骨架屏让首屏加载的感知时间缩短了一半以上------不是加载变快了,而是等待变得可预期。
十三、数据分页与去重的工程细节
无限滚动的分页加载有个隐蔽问题:翻页期间如果有新菜谱入库,第二页请求返回的数据可能与第一页尾部重叠,列表出现重复卡片。去重逻辑放在数据源层:追加前按 ID 过滤掉已存在的项,过滤后为零条时提前停止翻页,说明到底了。另一个细节是分页游标的稳定性:用页码做游标在数据插入时会跳数据,改用最后一条的 ID 加时间戳做游标,保证翻页期间的插入不影响遍历完整性。这些细节用户感知不到,但决定了无限滚动是否经得起反复刷新。
十五、下拉刷新与触底加载的组合状态机
瀑布流的刷新交互用 Refresh 组件包裹,触底加载用 onScrollIndex 提前触发,两路数据变更共用一个数据源,状态机设计就成了关键。我的实现是三个状态:idle(空闲)、refreshing(下拉刷新中)、loadingMore(触底加载中),三态互斥。下拉刷新时重置列表并回到顶部;触底加载时追加并保持位置。最容易出错的交叉场景:用户正在下拉刷新,手指没松开又上滑触发触底判断------状态机里 refreshing 状态会直接忽略 loadMore 请求,防抖生效。另一个交叉:加载更多失败后的重试,失败时状态回到 idle 并在底部显示重试按钮,而不是无限自动重试打扰用户。状态机虽然只有三态,却把瀑布流交互里九成的边界场景管住了,剩下的10%是断网与弱网,交给统一的错误处理层。
ts
type WfState = 'idle' | 'refreshing' | 'loadingMore'
let state: WfState = 'idle'
async function loadMore() {
if (state !== 'idle') return
state = 'loadingMore'
try { await fetchNextPage() } finally { state = 'idle' }
}
async function refresh() {
if (state !== 'idle') return
state = 'refreshing'
try { await reloadFirstPage() } finally { state = 'idle' }
}
十六、从瀑布流到内容矩阵:下一步的演进方向
瀑布流稳定运行后,内容运营提出了新需求:顶部要固定一个不可滚动的运营横幅,中间穿插专题分组标题,分组之间还要有"查看全部"入口。这些需求 WaterFlow 本身不直接支持,我的演进方案是退回分层结构:外层用 List 做纵向分区(横幅区、分组区、瀑布流区的组合),瀑布流作为 List 里的一个高个子 ListItem 存在,内部保持独立的滚动。这个方案牺牲了一点嵌套复杂度,换来了内容组织的完全自由。它也印证了布局选型的一个规律:容器能力不够用时,不硬扩容器,而是回到分层组合------每一层只解决一个问题,组合起来解决全部问题。瀑布流篇至此收笔,后续如果分组交互有新变化,再补充实战记录。
十七、性能账本与选型总结
把瀑布流的性能账本公开:一千二百条菜谱,WaterFlow 实际创建的 FlowItem 保持在一百个以内,内存占用约二十兆,滚动帧率满帧。对比同一数据量下 ForEach 全量渲染的版本------内存八十兆、首帧超秒,差距就是容器选型的差距。选型总结成一句话:图片高度错落的内容流用 WaterFlow,等高规整的卡片流用 Grid 加 LazyForEach,轻量少量用 ForEach。三个容器各有最舒适的场景,选对了容器,后面一半的性能问题自然消失。瀑布流篇至此收笔,从基础结构、高度策略、混合布局、滚动保持、复用设计、骨架屏、分页去重到分层演进,十六个章节记录了它从能跑到好用的完整过程。
十八、读者问答:三个高频问题的补充回复
文章草稿阶段收到了几个重复出现的问题,在此统一补充。问得最多的是宽高比数据从哪来:上传端裁剪时顺手计算并入库,历史数据用一次性脚本回填,两端都不是成本大头。第二个问题是 WaterFlow 能不能做横向瀑布流:可以,rowsTemplate 换方向即可,但横向瀑布流的滚动认知成本高,除非场景强需求,否则不建议。第三个问题是瀑布流里能否插广告位:用 sectionsTemplate 分组即可,广告位单独一组,间隔 N 个自然卡插入一组广告,频率控制在每一屏最多一个,超出这个密度用户的滑动意愿会明显下降。三个回答的共同点:技术都可行,决策要看场景与体验的平衡,这大概也是布局选型的通用答案。