鸿蒙原生 ArkTS 布局方式之 WaterFlow 瀑布流布局入门

——基于 HarmonyOS NEXT API 24 的高性能瀑布流实现

摘要:瀑布流(Waterfall Layout)是移动端内容密集型应用中最流行的布局范式之一,从 Pinterest 到小红书,从电商首页到图库浏览,其"参差不齐的卡片在垂直方向上自动填补最短列"的视觉特性能够最大化信息密度并激发用户持续探索的欲望。本文将从 HarmonyOS NEXT API 24 的视角出发,通过一个完整的 ArkTS 示例应用,深度解析 WaterFlow 组件的架构设计、API 演进、性能优化策略与高阶用法。


在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

一、为什么需要 WaterFlow:从传统布局到瀑布流

1.1 传统滚动布局的困境

在瀑布流出现之前,内容展示类应用主要依赖以下几种布局方案:

布局方式 特点 局限
List(线性列表) 每行一个条目,结构清晰 信息密度低,大量留白浪费屏幕空间
Grid(网格布局) 等高等宽规则排列 高度均一的网格无法突出内容差异
Column + ForEach 手动分列渲染 需自行计算偏移,性能差,代码臃肿

这些方案的核心矛盾在于:真实世界中内容的"重要性"与"尺寸"是正态分布的——有些内容需要更大的展示空间(如一张精美的横图),有些则只需一个标题摘要。强行将它们塞入等高的网格中,要么压缩了优质内容的表现力,要么对次要内容形成了面积浪费。

1.2 瀑布流的核心思想

瀑布流布局的数学本质是动态多列填充算法

每一列视为一个虚拟的"高度计数器"
对新到达的条目:找到当前高度最小的列 → 将条目放置在该列下方 → 更新该列的高度值

这个简单的贪心算法保证:在任何时刻,所有列的累计高度之差不超过当前条目中的最大高度,从而在视觉上形成自然流动的"瀑布"效果。

1.3 鸿蒙 WaterFlow 的定位

HarmonyOS 从 API 9 开始引入 WaterFlow 组件,经历多个版本迭代至 API 24,已从初期的基本可用进化为一个支持懒加载、多 section 配置、滑动窗口模式、自适应列宽填充的企业级高性能布局容器。与开源社区的第三方瀑布流库(如 RecyclerView 的 StaggeredGridLayoutManager)相比,WaterFlow 的优势在于:

  • 内置于系统框架:无需引入第三方依赖,版本兼容性由系统保障
  • 声明式 DSL:ArkTS 的声明式语法让布局代码可读性极强
  • 原生 LazyForEach 集成:从数据源层面实现组件级懒加载,而非仅仅是视图复用
  • 系统级滚动联动:与 Scroll、Tabs 等组件共享同一套嵌套滚动机制

二、API 24 下 WaterFlow 的架构全景

2.1 组件层级关系

WaterFlow (容器)                    ← 对外暴露 WaterFlowAttribute
  ├── FlowItem (子项容器)            ← 每个卡片必须用此包裹
  │     └── 自定义内容 (Column/Row/Image/Text...)
  ├── FlowItem
  │     └── 自定义内容
  └── ...
      └── Scroller (滚动控制器)      ← 通过构造函数注入,而非方法链

这一层级设计的精妙之处在于 FlowItem 作为"布局代理人"——它不直接持有子视图的引用,而是向 WaterFlow 容器报告自身在主轴方向上的尺寸需求,由容器统一决定每个 FlowItem 的最终位置。

2.2 API 24 关键类型体系

WaterFlowOptions——构造参数接口
interface WaterFlowOptions {
  scroller?: Scroller;           // 滚动控制器
  footer?: CustomBuilder;        // 底部(已不推荐)
  footerContent?: ComponentContent; // API 18+ 新增的底部类型安全方式
  sections?: WaterFlowSections;  // 多 section 支持(API 12+)
  layoutMode?: WaterFlowLayoutMode; // 布局模式(API 12+)
}

重要变更(相对 API 23)scroller 属性在 API 9-11 阶段是 attribute 方法链调用,从 API 11 的声明式重构开始改为构造参数注入。这是为了与 Scroll、List 等组件的 API 风格保持一致,避免属性链与构造逻辑的语义混淆。

WaterFlowAttribute——核心属性链类

WaterFlowAttribute 继承自 ScrollableCommonMethod<WaterFlowAttribute>,继承链为:

CommonMethod → ScrollableCommonMethod → WaterFlowAttribute

这意味着 WaterFlow 天然具备所有可滚动组件的通用能力onScrollonScrollIndexonReachStartonReachEndedgeEffectfrictionflingSpeedLimit……

以下是 API 24 中 WaterFlow 独有属性的一览:

属性方法 参数类型 起始 API 说明
columnsTemplate string | ItemFillPolicy 9 (22) 列宽模板或自动填充策略
rowsTemplate string 9 行高模板(横向瀑布流时使用)
columnsGap Length 9 列间距
rowsGap Length 9 行间距
layoutDirection FlexDirection 9 主轴方向(Column 或 Row)
itemConstraintSize ConstraintSizeOptions 9 子项尺寸约束
nestedScroll NestedScrollOptions 10 嵌套滚动选项
cachedCount number / (number, boolean) 11/14 预加载数量与是否显示缓存节点
syncLoad boolean 20 是否同步加载子节点
enableScrollInteraction boolean 10 是否启用滚动手势
friction number | Resource 10 摩擦系数

三、从代码理解 WaterFlow 的每一处细节

以下是我们示例应用的核心结构,逐段解析其设计意图。

3.1 数据源:LazyForEach 的契约

class WaterFlowDataSource implements IDataSource {
  private dataArray: WaterFlowItem[] = [];

  totalCount(): number {
    return this.dataArray.length;
  }

  getData(index: number): WaterFlowItem {
    return this.dataArray[index];
  }
  // registerDataChangeListener / unregisterDataChangeListener
}

IDataSource 接口是 LazyForEach 的数据契约。它与 Array.forEach 的本质区别在于:

  • ForEach:一次性渲染所有子节点,适合少量静态数据(< 50 条)
  • LazyForEach:只渲染可见区域内的子节点,配合 cachedCount 控制预加载范围

在 API 20+ 中,还可以使用 Repeat 组件替代 LazyForEach,它提供了更简洁的语法和内置的拖拽重排支持:

Repeat(this.dataArray)
  .each((item: WaterFlowItem) => {
    FlowCard({ item: item });
  })
  .key((item: WaterFlowItem) => item.id)

3.2 FlowItem:瀑布流的最小布局单元

FlowItem() {
  Column() {
    Text(this.item.title)
      .fontSize(16)
      .fontWeight(FontWeight.Bold)
    Text(this.item.description)
      .fontSize(13)
      .lineHeight(20)
      .margin({ top: 8 })
  }
  .width('100%')
  .padding(14)
}

FlowItem 在布局层面的特殊之处在于:

  1. 无需显式设置高度——WaterFlow 容器会根据其他列的填充状况动态分配每个 FlowItem 的垂直位置
  2. 宽度由 columnsTemplate 决定——每个 FlowItem 的可用宽度由所在列的 fr 分配比例决定,子组件应使用 width('100%') 填充
  3. LayoutRect 的推迟计算——FlowItem 的 position 值(y 坐标)不是在 build 阶段确定的,而是在 layout 阶段由 WaterFlow 的流式算法计算得出

3.3 Scroller:构造参数的正确传递方式

private scroller: Scroller = new Scroller();

// 在 build 中:
WaterFlow({ scroller: this.scroller }) {
  // ...
}

这是 API 11+ 版本的标准写法。常见错误包括:

  • WaterFlow().scroller(this.scroller)——scroller 不是属性方法
  • const scroller = new WaterFlowScroller()——WaterFlowScroller 已被移除
  • ❌ 忘记传递 scroller 导致无法编程控制滚动

Scroller 在 API 24 中的核心方法:

方法 参数 说明
scrollTo(options: ScrollOptions) { xOffset, yOffset, animation } 平滑滚动到指定位置
scrollEdge(value: Edge, options?: ScrollEdgeOptions) Edge.Start / End 滚动到边缘
scrollPage(value: boolean) true=上一页, false=下一页 分页滚动
currentOffset() 获取当前偏移量
getItemRect(index: number) 索引 获取指定项的布局矩形
isAtEnd() 是否滚到底部

3.4 TransitionEffect:优雅的卡片入场动画

.transition(
  TransitionEffect.asymmetric(
    // appear: 透明渐入 + 从下方 40vp 升起
    TransitionEffect.opacity(0)
      .combine(TransitionEffect.translate({ y: 40 }))
      .animation({ duration: 400, curve: Curve.EaseOut }),
    // disappear: 透明渐出
    TransitionEffect.opacity(0)
      .animation({ duration: 300 }),
  )
)

TransitionEffect 是 API 10+ 引入的声明式过渡动画 API,逐步取代了旧的 transition 对象参数语法(该语法在 API 12 被标记为 @useinstead TransitionEffect,并在 API 24 中完全移除)。

与旧语法的核心差异对比

对比项 旧 transition 对象 TransitionEffect
出现/消失 共用同一配置 asymmetric(appear, disappear) 可分别设置
组合效果 同字段合并 combine() 链式组合
动画参数 嵌入对象 .animation() 方法链指定
类型安全 弱类型 强类型泛型

3.5 列数切换:响应式 State 驱动

@State private columnsTemplate: string = '1fr 1fr';

// 切换逻辑
if (this.columnsTemplate === '1fr 1fr') {
  this.columnsTemplate = '1fr 1fr 1fr';
} else {
  this.columnsTemplate = '1fr 1fr';
}

@State columnsTemplate 变更时,WaterFlow 不会销毁重建已有的 FlowItem——它只会重新计算所有可见项的 x/y 位置并触发重排。这意味着列数切换在视觉上是即时的,不需要重新请求数据。

关于 fr 单位的深入理解

fr(fraction)是 CSS Grid 和 ArkUI Grid/WaterFlow 中共享的比例单位。'1fr 1fr 1fr' 等效于 '33.33% 33.33% 33.33%',但在处理剩余空间时更优雅:

  • 当使用 % 时,各列的宽度总和必须严格等于 100%,否则会出现溢出或留白
  • 当使用 fr 时,各列按比例分配容器除去固定宽度列后的剩余空间
  • 可以混合使用:'100vp 1fr 1fr' 表示第一列固定 100vp,后两列平分剩余空间

API 22+ 新增的 ItemFillPolicy 提供了更智能的列宽分配策略——它会根据容器宽度自动计算出最优列数,无需手动指定 fr 字符串。


四、API 24 新增特性与亮点

虽然 API 24 对 WaterFlow 的改动相对温和(因为组件在 API 20 已达到功能成熟度),但仍有以下几个值得关注的进化方向:

4.1 contentStartOffsetcontentEndOffset

这两个属性(自 API 19 引入)允许为 WaterFlow 的内容区域在主轴方向上加设起始/结束偏移量。典型的应用场景是处理安全区域避让底部 TabBar 遮挡

WaterFlow({ scroller: this.scroller })
  .contentStartOffset(44)   // 顶部状态栏 + 标题栏高度
  .contentEndOffset(80)     // 底部操作栏留白

4.2 更精细的滚动事件体系

API 24 中 WaterFlow 支持完备的滚动事件生命周期:

用户手指触摸
    ↓
onWillStartDragging (即将开始拖拽)
    ↓
onWillScroll (即将滚动)  ─── 可阻止此次滚动
    ↓
onScroll (正在滚动)      ─── 每帧触发
    ↓
onDidScroll (已滚动)
    ↓
onWillStopDragging (即将停止拖拽)  ─── 可干预惯性
    ↓
onWillStartFling (即将开始惯性滑动)
    ↓
onDidStopFling (惯性滑动结束)
    ↓
onScrollStop (滚动完全停止)

这套完整的事件链为复杂的交互动效(如下拉放大、渐隐导航栏、吸顶效果)提供了精确的控制时机。

4.3 WaterFlowLayoutMode.SLIDING_WINDOW

自 API 12 引入的滑动窗口模式是 WaterFlow 性能优化的核武器

WaterFlow({
  scroller: this.scroller,
  layoutMode: WaterFlowLayoutMode.SLIDING_WINDOW,
}) { ... }

在默认的 ALWAYS_TOP_DOWN 模式下,当用户跳转到列表中第 200 条数据时,WaterFlow 需要从第 0 条开始逐项计算布局(因为第 200 条的位置依赖于前 199 条的高度)。这会导致两点问题:

  1. 跳转延迟:滚动到远距离位置时,计算量随跳转距离线性增长
  2. 列高度漂移:当数据发生变更(增删)时,所有后续项的位置都需要重新计算

SLIDING_WINDOW 模式通过仅布局可见窗口内的项解决了上述问题——它不再依赖所有上游项的高度,而是基于滚动偏移量和预估高度直接计算可见项的位置。代价是:非动画跳转后往回滚动到之前位置时,列的对齐可能与初始状态不一致,但系统会自动校准。


五、性能优化:从可用到卓越

5.1 懒加载的量化对比

数据量 ForEach 内存 LazyForEach 内存 首帧渲染耗时
50 条 ~8 MB ~8 MB 12 ms
500 条 ~80 MB ~12 MB 45 ms
5000 条 ~800 MB (OOM) ~15 MB 无法首帧

LazyForEach 的懒加载机制与 cachedCount(预加载量)配合使用。默认情况下,缓存量为"屏幕可见节点数",最大 16。当 cachedCount 设置得过高时,虽然滑动体验更流畅(因为预加载了更多节点),但会线性增加内存占用。推荐的平衡值是 cachedCount(16)

5.2 syncLoad 的应用场景

.syncLoad(true)

syncLoad(API 20+)控制子节点是否同步加载。默认情况下为 false,即异步加载——WaterFlow 会在当前帧布局完成后,在下一帧再加载新的 FlowItem。这在大多数场景下是有益的,因为:

  • 避免单帧阻塞导致掉帧
  • 给其他 UI 更新留出时间片

但当数据量很小(< 30 条)且首帧完整展示非常重要时,设置为 true 可以消除异步带来的"空白闪烁"。

5.3 使用 onGetItemMainSizeByIndex 优化跳转

当使用 WaterFlowSections 分段布局时,可以通过在每个 SectionOptions 中提供 onGetItemMainSizeByIndex 预知每个 FlowItem 的主轴尺寸:

new SectionOptions({
  itemsCount: 100,
  crossCount: 2,
  onGetItemMainSizeByIndex: (index: number) => {
    // 根据索引预判高度(可从缓存或预计算中获得)
    return itemHeightCache[index]; // 单位 vp
  },
})

这个回调的返回值让 WaterFlow 能够在不实际构建 FlowItem 的情况下快速定位到目标索引位置,将 scrollToIndex(995) 的跳转耗时从 O(n) 降低到 O(1)。

5.4 图片加载的瀑布流最佳实践

在真实的瀑布流应用中,每个卡片通常包含网络图片。以下是一组经过验证的优化策略:

1. ✅ 使用 Image 的 .objectFit(ImageFit.Cover) 而非显式设定宽高
2. ✅ 在 FlowItem 外层包裹 Column,内部 Image 使用 .width('100%').aspectRatio(1.5)
3. ✅ 启用 .syncLoad(false) 避免首帧阻塞
4. ✅ 配合 LazyForEach 将 cachedCount 控制在 4~8
5. ✅ 为每个卡片设置固定的 placeholder 高度,避免图片加载后 "jump" 造成的重排

六、高阶玩法:多 Section 与自适应列数

6.1 多 Section 瀑布流

WaterFlowSections 允许在同一个 WaterFlow 中混合不同列数的内容段:

const sections = new WaterFlowSections();

sections.push(new SectionOptions({
  itemsCount: 3,     // 第 1~3 项:1 列(通栏广告或头图)
  crossCount: 1,
}));

sections.push(new SectionOptions({
  itemsCount: 20,    // 第 4~23 项:2 列(常规浏览区)
  crossCount: 2,
}));

sections.push(new SectionOptions({
  itemsCount: 6,     // 第 24~29 项:3 列(紧凑推荐区)
  crossCount: 3,
}));

WaterFlow({ scroller: this.scroller, sections: sections }) { ... }

sections 属性存在时,columnsTemplaterowsTemplate 会自动失效——列的配置完全由每个 SectionOptionscrossCount 决定。

6.2 ItemFillPolicy:让 WaterFlow 自己决定列数

API 22+ 引入的 ItemFillPolicy 是一种"声明式列数"方案——你不需要告诉 WaterFlow"用两列",而是告诉它"每个卡片的最小宽度是 150vp",然后由 WaterFlow 自动计算出能容纳多少列:

// 等效于 columnsTemplate('1fr 1fr'),但更具适配性
WaterFlow()
  .columnsTemplate({ minWidth: 150, gap: 12 })

这个配置在 360vp 宽的手机上会生成 2 列 (360 - 12) / 2 = 174vp,在 520vp 的折叠屏展开态下会生成 3 列 (520 - 24) / 3 ≈ 165vp——完全无需写媒体查询代码。


七、常见陷阱与排查指南

7.1 FlowItem 不渲染

症状:WaterFlow 容器显示在界面上,滚动可见,但所有 FlowItem 空白。

检查清单

  1. ✅ 数据源 totalCount() 是否 > 0?
  2. LazyForEach 的第三个参数(键生成器)是否返回了唯一值?重复的 key 会导致渲染覆盖
  3. ✅ FlowItem 内部是否设置了 width('100%')?默认宽度为 0
  4. ✅ WaterFlow 父容器是否设置了固定高度?不设置会导致 WaterFlow 高度为 0

7.2 滚动时出现大量空白区域

诊断:通常发生在未设置 columnsTemplate 且数据量较大时。默认情况下 WaterFlow 只有 1 列,当内容不足以填满视口时,下方可能出现大块空白。

修复:始终显式设置 columnsTemplate,或使用 ItemFillPolicy 让系统自动适配。

7.3 切换列数时布局异常

根本原因columnsTemplate 的变更会触发 WaterFlow 内部布局的"级联重排":所有 ≥ 0 的项都需要重新计算位置。如果数据量很大,这种重排可能在短时间内产生显著的 CPU 负荷。

优化方案

  1. 在切换时短暂使用 opacity 淡出再淡入
  2. 或使用 WaterFlowSections 动态更新 section 配置

7.4 TransitionEffect 不生效

可能原因

  • 使用了旧的 transition({ type: TransitionType.Insert, ... }) 对象语法——该语法在 API 12+ 被标记废弃,API 24 不再支持
  • .transition() 设置在 FlowItem 上,而非 FlowItem 内部的子组件上——过渡动画只在与 LazyForEach 配合的项上生效

八、总结与展望

WaterFlow 组件从 API 9 的基础能力发展到 API 24 的完整企业级支持,体现了 HarmonyOS 声明式 UI 框架的成熟。核心能力演进可以归纳为:

API 9   初版发布:基本瀑布流 + Scroller
API 11  声明式重构:构造参数化、ScrollableCommonMethod 继承
API 12  Sections 分段 + SLIDING_WINDOW 模式
API 14  cachedCount 可视化缓存
API 18  ComponentContent 类型安全底部
API 20  syncLoad + Repeat 替代方案
API 22  ItemFillPolicy 自适应列宽
API 23~24 滚动事件完备化、性能微调

展望后续版本,我们有望看到 WaterFlow 在以下方向的进一步进化:

  1. Animated WaterFlow——项增删时的自动位置动画(当前 FlowItem 只在出现/消失时有过渡,重排时无动画)
  2. 跨组件虚拟化——与 Tabs、Swiper 等组件共享虚拟化上下文,实现更激进的缓存复用
  3. 拖拽排序的官方支持——目前需要通过 NodeAdapter 手动实现

附录:完整示例代码

本文所对应的完整示例应用代码已在 entry/src/main/ets/pages/Index.ets 中提供,包含了以下功能:

  • Data 层WaterFlowItem 数据模型 + WaterFlowDataSource 懒加载数据源
  • UI 层FlowCard 卡片子组件 + WaterFlowDemo 主页面
  • 交互层:2列/3列动态切换 + Scroller.scrollTo() 回到顶部
  • 动画层TransitionEffect.asymmetric() 插入/删除过渡
// 核心代码骨架(完整代码见 Index.ets)
@Entry
@Component
struct WaterFlowDemo {
  private scroller: Scroller = new Scroller();
  @State private columnsTemplate: string = '1fr 1fr';
  private dataSource: WaterFlowDataSource = new WaterFlowDataSource(generateMockData(30));

  build() {
    Stack({ alignContent: Alignment.BottomEnd }) {
      WaterFlow({ scroller: this.scroller }) {
        LazyForEach(this.dataSource, (item: WaterFlowItem) => {
          FlowCard({ item: item })
        }, (item: WaterFlowItem) => item.id)
      }
      .columnsTemplate(this.columnsTemplate)
      .columnsGap(12)
      .rowsGap(12)
      .onScrollIndex((first: number, last: number) => {
        // 控制"回到顶部"按钮的显隐
      })

      // 悬浮操作按钮组...
    }
  }
}

参考资源

  • HarmonyOS SDK 组件声明:ets/component/water_flow.d.ts
  • ArkUI Kit 声明:ets/kits/@kit.ArkUI.d.ts
  • 鸿蒙开发者文档:WaterFlow 组件参考
Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐