大家好,我是[晚风依旧似温柔],新人一枚,欢迎大家关注~

前言

图片社区、商品推荐、内容卡片这类页面有一个共同特点:每个条目的高度并不固定。图片比例不同、标题行数不同、附加信息不同,如果仍然把所有内容塞进规则网格,就很容易出现大量留白,或者不得不提前把每个条目的高度“修”成一致。

ArkUI 提供的 WaterFlow 正是针对这类场景的瀑布流容器。本文不做复杂业务封装,而是围绕一个可以复现的图片墙,把数据构造、FlowItem、LazyForEach、触底分页、图片尺寸稳定和长列表性能几个问题串起来。

本文以 HarmonyOS 7 / API 26.0.0 为开发背景。华为当前升级适配文档明确说明,HarmonyOS 7.0 对应 API 26.0.0,并建议应用升级开发套件后检查 API 行为变化和兼容性。

一、为什么这里不直接用 Grid

Grid 很适合规则网格:例如九宫格入口、相册缩略图、固定规格商品卡片。它通过 rowsTemplate、columnsTemplate 等能力定义网格行列,也支持不规则 GridItem 等更复杂的网格布局。

但“支持不规则网格”不等于“所有不等高内容都应该用 Grid”。

图片流更常见的目标是:列宽基本一致,每个条目的主轴尺寸由内容决定,各列最终自然形成参差排列。 WaterFlow 的布局模型与这种需求更直接。华为当前的瀑布流最佳实践也把图片资讯、购物商品、直播视频等作为典型 WaterFlow 场景。

所以本文把场景缩小为:

两列图片内容流;图片拥有不同宽高比;数据按页增加;只按需创建当前需要显示的条目;滚动到底部继续追加下一页。

二、先把几个官方规则弄清楚

这次实践主要涉及 ArkUI 的 WaterFlow、FlowItem、LazyForEach 和 Image。

FlowItem 是 WaterFlow 的直接子组件。官方 API 参考明确说明,FlowItem 从 API version 9 开始支持,只能作为 WaterFlow 的子组件使用,而且一个 FlowItem 只支持一个直接子组件。

因此下面这种结构是本文的基础:

WaterFlow
 ├─ FlowItem
 │   └─ Column
 │       ├─ Image
 │       └─ Text
 ├─ FlowItem
 │   └─ Column
 └─ ...

注意“一个直接子组件”的限制并不意味着一张卡片只能放一张图片。正确方式是在 FlowItem 里面放一个 Column、Stack 等容器,再由这个容器组织图片、标题和其他信息。

HarmonyOS 7 对应 API 26.0.0,而本文使用的基础 WaterFlow/FlowItem 能力并不是 HarmonyOS 7 才新增的能力。换句话说,不能把 WaterFlow 写成“HarmonyOS 7 新特性”;这里只是在 HarmonyOS 7 的开发环境下使用已经存在的 ArkUI 布局能力。

本文示例使用应用本地 media 图片,因此不需要为了 WaterFlow 本身增加权限,也没有额外的 module.json5 权限配置。如果实际项目改成网络图片,请再根据实际网络访问方案核对对应配置,不要把网络权限和 WaterFlow 布局能力混在一起。

三、先构造真正“不等高”的数据

瀑布流 Demo 最容易写偏的地方,是组件用了 WaterFlow,数据却全部一样高。那样只能证明“它能排列”,看不出瀑布流真正解决的问题。

这里给每条数据保存一个图片宽高比:

interface PhotoItem {
  id: string;
  title: string;
  image: Resource;
  ratio: number;
}

ratio 表示宽高比。比如 1.0 是正方形,0.75 会形成偏高的图片,1.4 则更偏横向。

准备至少三张放在 resources/base/media 下的本地图片,例如:

photo_1.jpg
photo_2.jpg
photo_3.jpg

页面中按页构造数据:

private buildPage(page: number, count: number): PhotoItem[] {
  const ratios: number[] = [0.72, 1.0, 1.28, 0.82, 1.45, 0.92];
  const images: Resource[] = [
    $r('app.media.photo_1'),
    $r('app.media.photo_2'),
    $r('app.media.photo_3')
  ];

  let result: PhotoItem[] = [];
  for (let i = 0; i < count; i++) {
    const index = page * count + i;
    result.push({
      id: `photo-${index}`,
      title: `图片内容 ${index + 1}`,
      image: images[index % images.length],
      ratio: ratios[index % ratios.length]
    });
  }
  return result;
}

这里没有用随机高度,而是使用固定比例序列。这样每次打开页面得到的布局一致,更适合观察分页、重排和性能问题。

四、给 LazyForEach 准备数据源

如果直接用 ForEach 展示几百甚至几千条数据,所有条目的组件创建策略并不是我们想要的长列表方案。对于大量可滚动内容,应把数据按需渲染纳入设计。华为当前长列表及网格文档也持续强调懒加载、缓存、组件复用等优化方向。

下面实现一个最小 IDataSource:

class PhotoDataSource implements IDataSource {
  private data: PhotoItem[] = [];
  private listeners: DataChangeListener[] = [];

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

  getData(index: number): PhotoItem {
    return this.data[index];
  }

  registerDataChangeListener(listener: DataChangeListener): void {
    if (this.listeners.indexOf(listener) < 0) {
      this.listeners.push(listener);
    }
  }

  unregisterDataChangeListener(listener: DataChangeListener): void {
    const index = this.listeners.indexOf(listener);
    if (index >= 0) {
      this.listeners.splice(index, 1);
    }
  }

  append(items: PhotoItem[]): void {
    this.data.push(...items);

    // 最小示例采用整体刷新通知,重点放在 WaterFlow 与分页流程。
    this.listeners.forEach((listener: DataChangeListener) => {
      listener.onDataReloaded();
    });
  }
}

LazyForEach 数据源的关键不只是保存数组,还要通过 DataChangeListener 告诉框架数据发生了变化。这里为了让示例集中在瀑布流本身,分页追加后使用 onDataReloaded() 通知刷新。

如果业务需要频繁插入、删除、交换条目,可以进一步针对具体变化发送更细粒度的数据变更通知,而不是所有变化都整体 reload。

五、实现基础 WaterFlow 图片墙

有了数据之后,页面主体其实很短:

@Entry
@Component
struct WaterFlowPage {
  private dataSource: PhotoDataSource = new PhotoDataSource();
  private page: number = 0;
  private readonly pageSize: number = 20;
  private isLoading: boolean = false;

  aboutToAppear(): void {
    this.dataSource.append(this.buildPage(this.page, this.pageSize));
  }

  private buildPage(page: number, count: number): PhotoItem[] {
    const ratios: number[] = [0.72, 1.0, 1.28, 0.82, 1.45, 0.92];
    const images: Resource[] = [
      $r('app.media.photo_1'),
      $r('app.media.photo_2'),
      $r('app.media.photo_3')
    ];

    let result: PhotoItem[] = [];
    for (let i = 0; i < count; i++) {
      const index = page * count + i;
      result.push({
        id: `photo-${index}`,
        title: `图片内容 ${index + 1}`,
        image: images[index % images.length],
        ratio: ratios[index % ratios.length]
      });
    }
    return result;
  }

  build() {
    Column() {
      Text('WaterFlow 图片墙')
        .fontSize(24)
        .fontWeight(FontWeight.Bold)
        .width('100%')
        .padding(16)

      WaterFlow() {
        LazyForEach(
          this.dataSource,
          (item: PhotoItem) => {
            FlowItem() {
              Column() {
                Image(item.image)
                  .width('100%')
                  .aspectRatio(item.ratio)
                  .objectFit(ImageFit.Cover)

                Text(item.title)
                  .fontSize(14)
                  .width('100%')
                  .padding(10)
              }
              .width('100%')
              .backgroundColor('#FFFFFF')
              .borderRadius(12)
              .clip(true)
            }
          },
          (item: PhotoItem) => item.id
        )
      }
      .columnsTemplate('1fr 1fr')
      .columnsGap(10)
      .rowsGap(10)
      .padding({ left: 12, right: 12, bottom: 12 })
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }
}

真正需要关注的是三个地方。

第一,columnsTemplate('1fr 1fr') 把交叉轴分成两列;官方当前瀑布流实践和 WaterFlow FAQ 都展示了 columnsTemplate、rowsTemplate、间距等布局配置。

第二,每个 FlowItem 的高度没有统一写死,而是由内部图片和文本共同决定。

第三,LazyForEach 的 key 使用业务唯一的 id,不要直接拿不断变化的数组位置冒充稳定业务标识。

六、滚动到底部加载下一页

WaterFlow 可以通过 onReachEnd 处理到达内容末尾后的逻辑。华为 2026 年更新的 WaterFlow FAQ 直接给出了通过 onReachEnd 在触底时增加数据的示例;当前瀑布流最佳实践的“上拉加载”场景同样采用这一事件。

在前面的 WaterFlow 后继续添加:

.onReachEnd(() => {
  if (this.isLoading) {
    return;
  }

  this.isLoading = true;
  this.page++;

  const nextPage = this.buildPage(this.page, this.pageSize);
  this.dataSource.append(nextPage);

  this.isLoading = false;
})

这里的 isLoading 很重要。触底事件只是“加载下一页”的触发点,不应该等价于“无条件发起一次请求”。

真实项目通常是:

onReachEnd
    ↓
检查 loading / hasMore
    ↓
请求下一页
    ↓
成功:追加数据
失败:保留当前数据并恢复状态
    ↓
更新 loading / hasMore

本文没有伪造网络接口,所以示例直接构造下一页本地数据。迁移到真实业务时,只需要把 buildPage() 换成自己的分页数据请求,WaterFlow 布局部分不需要跟着改。

七、图片加载完成后再改变高度,为什么要谨慎

图片瀑布流还有一个经常被忽略的问题:布局第一次测量时不知道图片最终高度怎么办?

一种直觉写法是先给图片一个临时高度,等 Image.onComplete 后再根据图片结果修改高度。ArkUI 的 Image 确实提供图片加载完成事件,华为当前 FAQ 中也有 Image(...).onComplete(...) 的实际用法。

但对于 WaterFlow,这意味着图片完成加载后,FlowItem 的主轴尺寸发生变化,布局需要重新计算。大量网络图片在不同时间完成加载时,就可能形成连续的尺寸变化。

所以对于图片墙、商品流这类服务端通常已经掌握素材尺寸的场景,更稳妥的方案是:数据接口同时返回原图宽高,在图片真正下载之前就确定比例。

例如服务端返回:

interface NetworkPhoto {
  id: string;
  url: string;
  imageWidth: number;
  imageHeight: number;
}

页面计算:

const ratio: number = item.imageWidth / item.imageHeight;

再直接:

Image(item.url)
  .width('100%')
  .aspectRatio(ratio)
  .objectFit(ImageFit.Cover)

这样图片从占位状态变成真实内容时,外层卡片的几何尺寸可以保持稳定。

如果确实只能在加载完成后才能获得业务所需信息,onComplete 可以处理加载状态,但建议把“图片加载完成”和“必须修改整个 FlowItem 高度”分开考虑。例如仅切换占位视觉状态:

Image(this.item.image)
  .width('100%')
  .aspectRatio(this.item.ratio)
  .objectFit(ImageFit.Cover)
  .onComplete(() => {
    this.loaded = true;
  })

核心思路不是禁止动态高度,而是尽可能避免大量图片完成加载时才第一次确定布局高度。

八、LazyForEach 只是第一层优化

把 ForEach 换成 LazyForEach 后,并不意味着长列表性能问题已经结束。

图片瀑布流的成本至少还有几类:图片解码和资源加载、FlowItem 测量、复杂卡片组件创建、快速滚动时的新节点准备,以及数据分页本身的耗时。

因此性能验证建议分成下面几个维度,而不是只看“页面能不能滑”:

检查项重点观察
首屏首次进入时是否集中创建过多组件
连续滚动快速滑动时是否出现明显白块或卡顿
图片加载图片完成加载是否频繁改变卡片几何尺寸
分页onReachEnd 是否重复触发业务请求
长时间滚动数据持续增长后内存和组件创建是否异常
卡片复杂度FlowItem 内是否存在大量不必要的嵌套和计算

对于真正的大规模图片流,华为目前还提供了一条更进一步的官方最佳实践路线:ScrollComponents。

这里必须区分清楚:WaterFlow、FlowItem 是 ArkUI 系统组件;最佳实践文档中的 @hadss/scroll_components 则被官方文档明确称为三方库,它在系统 NodeAdapter、BuilderNode、FrameNode、Prefetcher 等能力之上封装了组件复用、预创建、预加载等方案。不能把 WaterFlowManager 写成 WaterFlow 自带的系统 API。

当前官方最佳实践还专门覆盖了瀑布流首屏、无限滑动、上拉加载、组件复用和资源预取等场景。对于图片很多、卡片复杂、快速滚动白块明显的页面,可以在完成基础 WaterFlow 实现后,再评估这套方案,而不是一开始就把最小 Demo 堆成复杂框架。

另外,HarmonyOS 当前的组件复用迁移文档已经给出了 WaterFlow 配合 Repeat(...).virtualScroll() 与 @ReusableV2 的示例。这说明面向新工程继续做性能深化时,也值得关注 V2 状态管理和新的虚拟滚动/复用路线,而不是把历史写法当成唯一方案。

九、几个容易理解错的地方

WaterFlow 不等于“随机高度”。 高度应该来自真实内容约束。Demo 可以构造不同高度,但业务里最好来自图片比例、文本内容或明确的数据模型。

FlowItem 不能随便塞多个直接子组件。 官方规定它只支持一个子组件,因此图片、标题、价格等内容应该先放进 Column、Stack 等容器。

LazyForEach 和分页是两件事。 LazyForEach 解决的是 UI 子组件按需生成;onReachEnd 分页解决的是业务数据什么时候继续增加。用了懒加载,并不会自动帮应用请求下一页。

触底回调里必须考虑防重入。 官方瀑布流上拉加载实践同样使用 isLoadMore 一类状态避免重复加载。

图片加载和布局高度最好解耦。 如果服务端能够提供原图宽高,优先在进入布局前算出 aspectRatio,而不是等图片下载结束再决定 FlowItem 到底有多高。

不要把 ScrollComponents 当成系统 WaterFlow API。 它是华为官方最佳实践文档介绍的三方库方案,底层利用系统节点和懒加载能力进一步处理复杂长列表性能问题。

十、实际项目可以按这个顺序排查

遇到“瀑布流错位、加载重复、越滑越卡”时,可以按一条比较固定的链路检查:先确认目标 HarmonyOS/API 版本以及项目 SDK 配置;再确认 WaterFlow → FlowItem → 单一直接子容器 的结构;检查每个 item 的高度是否有稳定来源;确认 LazyForEach 的 key 是否真正唯一;检查数据变化后 DataSource 是否正确通知;再看 onReachEnd 有没有 loading/hasMore 防护;随后观察图片加载是否导致大面积重新测量;如果基础实现已经正确但长列表仍有明显性能压力,再评估组件复用、预创建、资源预取或 ScrollComponents。

HarmonyOS 7 升级时还应通过 DevEco Studio 的 API Change Assistant 等工具检查所用 API 在 SDK 版本变化之间是否存在行为调整,并在目标新旧设备环境进行兼容性验证。华为的 26.0.0 升级指南对此给出了明确流程。

开发经验总结

WaterFlow 真正有价值的地方,并不是“能做出两列高低不一样的卡片”,而是它把不等高内容的布局模型直接表达出来了。

一个可维护的图片瀑布流,可以把问题拆成四层:数据层保存稳定 ID 和图片比例;WaterFlow 负责瀑布布局;LazyForEach 负责按需生成 UI;onReachEnd 只负责决定什么时候继续取数据。图片加载、分页请求和布局测量尽量不要互相绑死。

对于普通图片墙,这套基础结构已经足够清楚;当数据量、图片资源和卡片复杂度继续增加,再把关注点转到复用、预加载、预创建和资源预取。这样性能优化才是沿着实际瓶颈逐层增加,而不是一开始就把页面做得很重。

如果正在做商品流或内容流,可以重点检查一个问题:接口是否已经提供图片原始宽高? 如果答案是否定的,瀑布流页面很可能会把本可以在数据层解决的尺寸问题,拖到图片加载和 UI 重排阶段处理。

如果觉得有帮助,别忘了点个赞+关注支持一下~
喜欢记得关注,别让好内容被埋没~

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐