本节我们将系统学习鸿蒙不等高瀑布流核心组件WaterFlow,以小红书首页为例搞懂瀑布流布局的核心规则、LazyForEach懒加载、无限滚动、性能优化等核心能力。

【学习目标】

  • 掌握WaterFlow/FlowItem核心架构,明确与Grid/List组件的选型边界,理解瀑布流布局的核心填充规则
  • 吃透WaterFlow六大核心能力:基础布局、无限滚动、动态列数、滚动控制、下拉刷新、性能优化
  • 实现小红书首页核心交互:双列不等高瀑布流、下拉刷新、触底预加载、笔记卡片自适应

一、工程目录结构

WaterFlowDemo/
├── entry/
│   └── src/
│       └── main/
│           ├── ets/
│           │   ├── entryability/
│           │   │   └── EntryAbility.ets       // 应用入口
│           │   ├── pages/
│           │   │   └── Index.ets    // 小红书首页
│           │   ├── model/
│           │   │   └── NoteCardModel.ets        // 数据模型
│           │   ├── components/
│           │   │   └── NoteCard.ets        // 笔记卡片组件
│           │   └── datasource/
│           │       ├── BaseDataSource.ets  // 通用列表数据源基类
│           │       └── NoteDataSource.ets // 懒加载业务数据源
│           ├── resources/ 
│           └── module.json5                    
└── build-profile.json5                          

二、瀑布流布局核心基础

2.1 什么是瀑布流布局

瀑布流布局是内容社区、电商类应用最主流的不等高自适应滚动布局方案,核心由WaterFlow容器组件和FlowItem子组件组成:

  • WaterFlow:瀑布流父容器,定义列数、布局模式、滚动规则等全局配置
  • FlowItem:瀑布流子项容器,必须作为WaterFlow的直接子组件使用

核心特点:列数固定、高度自适应、自动填充最短列,完美适配小红书这类以图片为主、内容高度不固定的场景。

核心铁则:WaterFlow的直接子组件只能是FlowItem,FlowItem必须嵌套在WaterFlow容器内。

2.2 小红书瀑布流核心填充规则

  1. 第一行2个卡片,从左到右填充左右两列
  2. 后续卡片自动填入总高度更小的列
  3. 高度相同时,优先左列
  4. 垂直方向天然支持无限滚动
  5. 卡片宽度自动均分,高度由内容自适应

2.3 WaterFlow 核心选型规则

组件 核心优势
WaterFlow 不等高自适应、自动填充、原生无限滚动、滑动窗口优化
Grid 固定行列布局
List 单列滚动、性能最优

三、WaterFlow 核心配置详解

3.1 核心构造函数

WaterFlow(value?: {
  footer?: CustomBuilder;          // 可选:瀑布流底部自定义组件,用于加载更多、无更多数据提示
  layoutMode?: WaterFlowLayoutMode;// 可选:布局模式,SLIDING_WINDOW 性能最优,长列表必须使用
  sections?: WaterFlowSections;    // 可选:多区域/多分组瀑布流,实现复杂分区布局
  scroller?: Scroller;             // 可选:滚动控制器,支持滚动到顶、滚动监听、精准定位
})

3.2 核心布局属性

属性 作用 配置示例
columnsTemplate 定义列数 '1fr 1fr' 双列
columnsGap 列间距 8vp
rowsGap 行间距 12vp
layoutMode 渲染模式 SLIDING_WINDOW 滑动窗口

3.3 核心布局模式

  • SLIDING_WINDOW:仅渲染可见区域,组件自动回收,长列表必选
  • ALIGN_ITEMS:一次性渲染所有项,禁止用于无限滚动

3.4 核心事件

  • onScrollIndex:滚动时触发,用于预加载下一页
  • onReachEnd:滚动到底部触发,兜底加载
  • onReachStart:滚动到顶部,用于下拉刷新

四、核心:LazyForEach + DataSource

4.1 核心优势

  1. 真正懒加载:只渲染屏幕可见区域,大幅降低内存占用
  2. 滑动丝滑:配合SLIDING_WINDOW,万级数据无卡顿
  3. 数据解耦:数据源与UI分离,便于维护和扩展

五、完整代码实现

5.1 数据模型:NoteCardModel.ets

@Observed
export class NoteCardModel {
  noteId: string;
  coverUrl: string;
  authorName: string;
  avatarUrl: string;
  likeCount: number;
  title: string;
  isLike: boolean = false

  constructor(
    noteId: string,
    coverUrl: string,
    authorName: string,
    avatarUrl: string,
    likeCount: number,
    title: string
  ) {
    this.noteId = noteId;
    this.coverUrl = coverUrl;
    this.authorName = authorName;
    this.avatarUrl = avatarUrl;
    this.likeCount = likeCount;
    this.title = title;
  }
}

5.2 通用数据源:BaseDataSource.ets

/**
 * 通用数据源基类
 * 基于鸿蒙官方 IDataSource 接口实现,封装列表数据的增删改查及刷新通知逻辑
 * 用于减少 List / Grid / WaterFlow 等列表组件的重复代码
 * @template T 列表数据泛型类型
 */
export class BaseDataSource<T> implements IDataSource {
  /**
   * 列表数据源数组
   * @protected
   */
  protected dataArray: T[] = [];

  /**
   * 数据变更监听器集合
   * @private
   */
  private listeners: DataChangeListener[] = [];

  /**
   * 获取列表数据总条数
   * @returns 数据数量
   */
  totalCount(): number {
    return this.dataArray.length;
  }

  /**
   * 根据索引获取对应数据项
   * @param index 数据索引
   * @returns 对应索引的数据项
   */
  getData(index: number): T {
    return this.dataArray[index];
  }
  /**
   * 获取全部数据
   * @returns 返回全部数据
   */
  getAllData(): T[] {
    return this.dataArray;
  }

  /**
   * 通知列表:指定索引数据新增
   * @param index 新增数据的索引
   */
  notifyDataAdd(index: number): void {
    this.listeners.forEach(listener => {
      listener.onDataAdd(index);
    });
  }

  /**
   * 通知列表:指定索引数据发生变化
   * @param index 变更数据的索引
   */
  notifyDataChange(index: number): void {
    this.listeners.forEach(listener => {
      listener.onDataChange(index);
    });
  }

  /**
   * 通知列表:指定索引数据被删除
   * @param index 删除数据的索引
   */
  notifyDataDelete(index: number): void {
    this.listeners.forEach(listener => {
      listener.onDataDelete(index);
    });
  }

  /**
   * 通知列表:数据位置发生移动
   * @param from 原索引
   * @param to 目标索引
   */
  notifyDataMove(from: number, to: number): void {
    this.listeners.forEach(listener => {
      listener.onDataMove(from, to);
    });
  }

  /**
   * 通知列表:执行批量数据操作
   * @param operations 操作集合
   */
  notifyDatasetChange(operations: DataOperation[]): void {
    this.listeners.forEach(listener => {
      listener.onDatasetChange(operations);
    });
  }

  /**
   * 通知列表:全局数据重新加载
   */
  notifyDataReload(): void {
    this.listeners.forEach(listener => {
      listener.onDataReloaded();
    });
  }

  /**
   * 注册数据变更监听器
   * @param listener 数据监听器
   */

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

  /**
   * 注销数据变更监听器
   * @param listener 数据监听器
   */
  unregisterDataChangeListener(listener: DataChangeListener): void {
    const pos = this.listeners.indexOf(listener);
    if (pos >= 0) {
      this.listeners.splice(pos, 1);
    }
  }

  /**
   * 向列表末尾添加单条数据
   * @param data 待添加的数据项
   */
  pushData(data: T): void {
    this.dataArray.push(data);
    const addIndex = this.dataArray.length - 1;
    this.notifyDataAdd(addIndex);
  }

  /**
   * 批量添加数据到列表末尾
   * @param dataList 待添加的数据集合
   */
  pushDataList(dataList: T[]): void {
    const startIndex = this.dataArray.length;
    this.dataArray.push(...dataList);
    this.notifyDatasetChange([{
      type: DataOperationType.ADD,
      index: startIndex,
      count: dataList.length
    }]);
  }

  /**
   * 根据索引删除单条数据
   * @param index 待删除数据的索引
   */
  deleteData(index: number): void {
    if (index < 0 || index >= this.dataArray.length) return;
    this.dataArray.splice(index, 1);
    this.notifyDataDelete(index)
  }

  /**
   * 更新指定索引的数据
   * @param index 待更新的数据索引
   * @param newData 新数据
   */
  updateData(index: number, newData: T): void {
    if (index < 0 || index >= this.dataArray.length) return;
    this.dataArray[index] = newData;
    this.notifyDataChange(index);
  }

  /**
   * 移动数据位置
   * @param from 原索引
   * @param to 目标索引
   */
  moveData(from: number, to: number): void {
    if (from < 0 || from >= this.dataArray.length || to < 0 || to >= this.dataArray.length) return;
    const items = this.dataArray.splice(from, 1);
    this.dataArray.splice(to, 0, ...items);
    this.notifyDataMove(from, to);
  }

  /**
   * 全量替换数据源并刷新列表
   * @param newDataList 新数据源
   */
  reloadData(newDataList: T[]): void {
    this.dataArray = newDataList;
    this.notifyDataReload();
  }
}

5.3 懒加载业务数据源:NoteDataSource.ets

import { NoteCardModel } from '../model/NoteCardModel';
import { BaseDataSource } from './BaseDataSource';

// 继承BaseDataSource
export class NoteDataSource extends BaseDataSource<NoteCardModel> {
  private data: NoteCardModel[] = [];

  // 获取数据总数
  override totalCount(): number {
    return this.data.length;
  }

  // 根据索引获取数据
  override getData(index: number): NoteCardModel {
    return this.data[index];
  }

  // 初始化数据
  initializeData(data: NoteCardModel[]): void {
    this.data = [...data];
    // 通知UI刷新
    this.notifyDataReload();
  }

  // 追加数据(下拉加载更多)
  addData(data: NoteCardModel[]): void {
    this.data.push(...data);
    // 通知UI追加数据
    this.notifyDatasetChange([{type:DataOperationType.ADD,index:this.data.length - data.length,count:data.length}]);
  }

  // 清空数据(下拉刷新)
  clearData(): void {
    this.data = [];
    this.notifyDataReload();
  }
}

5.4 笔记卡片组件:NoteCard.ets

import { NoteCardModel } from "../model/NoteCardModel";

@Reusable
@Component
export struct NoteCard {
  @ObjectLink note: NoteCardModel;
   
  aboutToReuse(params: Record<string, Object | null | undefined>): void {
    
  }

  build() {

    Column({ space: 6 }) {
      // 笔记封面图
      Image(this.note.coverUrl)
        .width('100%')
        .backgroundColor('#F0F0F0')
        .constraintSize({minHeight:150,maxHeight:250})
        .borderRadius({ topLeft: 8, topRight: 8 })

      Text(this.note.title)
        .fontSize(14)
        .fontColor('#666')
        .textOverflow({overflow:TextOverflow.Clip})
        .maxLines(2)
        .padding(5)

      // 作者信息 + 点赞
      Row() {
        // 左侧:头像 + 昵称
        Row() {
          Text(this.note.authorName[0] || '')
            .fontSize(12)
            .fontColor($r('sys.color.comp_background_list_card'))
            .width(20)
            .height(20)
            .borderRadius(10)
            .backgroundColor('#FF2442')
            .textAlign(TextAlign.Center)

          Text(this.note.authorName)
            .fontSize(12)
            .fontColor('#666')
            .margin({ left: 15 })
        }
        .layoutWeight(1)

        // 右侧:点赞按钮
        Button({stateEffect:true,type: ButtonType.Normal }) {
          Row({ space: 2 }) {
            Image(this.note.isLike ? $r('app.media.like_selected') : $r('app.media.like_normal'))
              .objectFit(ImageFit.Contain)
              .width(22)
              .height(22)

            Text(this.formatLikeCount(this.note.likeCount))
              .fontSize(12)
              .fontColor('#666')
          }
        }
        .backgroundColor(Color.Transparent)
        .border({ width: 0 })
        .onClick(() => {
          this.note.isLike = !this.note.isLike;
          this.note.likeCount += this.note.isLike ? 1 : -1;
        })
      }
      .width('100%')
      .padding({ left: 8, right: 8, bottom: 8 })
    }
    .width('100%')
    .backgroundColor($r('sys.color.comp_background_list_card'))
    .borderRadius(8)
    .alignItems(HorizontalAlign.Start)
    .justifyContent(FlexAlign.Start)
  }

  // 格式化点赞数
  private formatLikeCount(count: number): string {
    if (count >= 10000) {
      return (count / 10000).toFixed(1) + 'w';
    }
    return count.toString();
  }
}

5.5 小红书首页:Index.ets

import { NoteCard } from '../components/NoteCard';
import { NoteCardModel } from '../model/NoteCardModel';
import { NoteDataSource } from '../datasource/NoteDataSource';
import { util } from '@kit.ArkTS';

@Entry
@Component
struct Index {
  // 加载状态
  @State isLoading: boolean = false;
  // 滚动控制器
  private scroller: Scroller = new Scroller();
  // 懒加载数据源
  private dataSource: NoteDataSource = new NoteDataSource();
  // 页码
  private page: number = 1;

  aboutToAppear(): void {
    this.initData();
  }

  // 初始化数据
  initData(): void {
    const mockData = [
      new NoteCardModel(util.generateRandomUUID(true), "https://s41.ax1x.com/2026/04/01/peGZWhn.png", "奇趣玩家", "", 13, "春日氛围感穿搭指南,带你一起走进三月"),
      new NoteCardModel(util.generateRandomUUID(true), "https://s41.ax1x.com/2026/04/01/peGZRts.png", "爱吃小丸子.", "", 8, "零失败家常美食教程"),
      new NoteCardModel(util.generateRandomUUID(true), "https://s41.ax1x.com/2026/04/01/peGZ60g.png", "反派不探险", "", 4399, "超实用小众旅行攻略"),
      new NoteCardModel(util.generateRandomUUID(true), "https://s41.ax1x.com/2026/04/01/peGZhpq.png", "Andme", "", 50, "高效实用学习笔记分享"),
    ];
    this.dataSource.initializeData(mockData);
  }

  // 加载更多数据
  loadMoreData(): void {
    if (this.isLoading) return;
    this.isLoading = true;

    setTimeout(() => {
      const newData: NoteCardModel[] = [];
      for (let i = 0; i < 4; i++) {
        newData.push(new NoteCardModel(
          util.generateRandomUUID(true),
          `https://picsum.photos/300/${Math.floor(Math.random() * 300) + 400}`,
          `用户${this.page++}`,
          "",
          Math.floor(Math.random() * 1000),
          `LazyForEach懒加载笔记 ${this.page}`
        ));
      }
      // 追加数据
      this.dataSource.addData(newData);
      this.isLoading = false;
    }, 1500);
  }

  // 底部加载组件
  @Builder
  loadFooter() {
    if (this.isLoading) {
      Row({ space: 8 }) {
        LoadingProgress().color('#FF2442').width(24).height(24)
        Text('正在加载更多笔记...')
          .fontSize(14)
          .fontColor('#999')
      }
      .width('100%')
      .height(60)
      .justifyContent(FlexAlign.Center)
    }
  }

  build() {
    Column() {
      // 顶部导航栏
      Row() {
        Text("小红书")
          .fontSize(20)
          .fontWeight(FontWeight.Bold)
          .fontColor('#FF2442')
      }
      .width('100%')
      .height(50)
      .justifyContent(FlexAlign.Center)
      .backgroundColor(Color.White)

      WaterFlow({
        footer: this.loadFooter,
        scroller: this.scroller,
        layoutMode: WaterFlowLayoutMode.SLIDING_WINDOW
      }) {
        LazyForEach(this.dataSource, (note: NoteCardModel) => {
          FlowItem() {
            NoteCard({ note: note })
          }.width('100%') // 这里必须设置宽度'100%',否则笔记奇数情况下 宽度会自适应到屏幕宽度。
        }, (note: NoteCardModel) => note.noteId)
      }
      .columnsTemplate('1fr 1fr')
      .columnsGap(8)
      .rowsGap(12)
      .width('100%')
      .layoutWeight(1)
      .cachedCount(5)
      .edgeEffect(EdgeEffect.Spring)// 弹性滚动
      .backgroundColor('#F5F5F5')
      .padding(8)
      // 滚动预加载
      .onScrollIndex((first: number, last: number) => {
        if (last >= this.dataSource.totalCount() - 3) {
          this.loadMoreData();
        }
      })
      // 触底兜底加载
      .onReachEnd(() => {
        this.loadMoreData();
      })
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F5F5')
  }
}

运行效果

小红书瀑布流

六、最佳实践

  1. LazyForEach:长列表推荐DataSource+LazyForEach
  2. 滑动窗口模式layoutMode(WaterFlowLayoutMode.SLIDING_WINDOW) 必开
  3. 组件复用:卡片添加@Reusable,性能提升50%以上
  4. FlowItem 不设置宽度导致撑满屏幕 必须设置 width: 100% 让其自适应列宽可根据具体需求设置。

七、仓库代码

  • 工程名称:WaterFlowDemo
  • 仓库地址:https://gitee.com/HarmonyOS-UI-Basics/harmony-os-ui-basics.git

八、下节预告

下一节我们将正式学习鸿蒙轮播组件 Swiper 全解析,掌握页面轮播、广告Banner、卡片滑动的标准化开发:

  • 掌握 Swiper 基础用法、尺寸约束与父子布局规则
  • 实现循环播放、自动轮播、轮播方向、每页多子页等核心配置
  • 自定义导航点样式、箭头样式、间距与位置,打造高质感轮播效果
  • 学习 Swiper 控制器与页面切换动画,实现丝滑滑动体验
  • 掌握 Swiper 与 Tabs 联动、数据懒加载兼容、视图位置保持等高级特性
Logo

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

更多推荐