摘要:分组列表是移动端开发中最高频的交互形态之一——通讯录按字母索引、订单按日期归类、商品按分类浏览、消息按时间聚合,几乎每个 App 都绕不开。在 HarmonyOS NEXT(API 24)中,鸿蒙原生提供了 List + ListItemGroup + sticky 三件套,以声明式语法即可零成本实现"分组头部吸顶 + 滚动替换"的丝滑交互。本文从核心概念入手,全面梳理 API 24 版本下 List 与 ListItemGroup 的全量接口与枚举,结合通讯录电商订单两大完整实战代码,深入讲解 LazyForEach 懒加载、divider 定制、cachedCount 预加载、childrenMainSize、多列 lanes、嵌套滚动、Scroller 编程式跳转等进阶能力,最后汇总 10 大常见踩坑点与 8 条性能优化最佳实践,帮助你一次性吃透分组列表的所有玩法。


项目演示

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

在这里插入图片描述

一、为什么分组列表是 ArkUI 的必修课?

1.1 无处不在的分组场景

当你打开手机,映入眼帘的大多数长列表都是「分组」的:

应用场景 分组维度 头部吸顶作用
手机通讯录 / 联系人 拼音首字母 A~Z 快速知道当前浏览到哪个字母段
微信聊天列表 时间(今天 / 昨天 / 星期X) 感知消息的时间跨度
淘宝「我的订单」 下单日期 快速定位某一天的历史订单
设置页 功能分类(网络 / 显示 / 隐私) 区分不同设置域
城市选择器 省份 / 热门 / 定位 兼顾效率与浏览体验
音乐歌单 歌手 / 专辑 / 年代 聚合同类音频资源

在这些场景里,如果不做分组,长列表的浏览效率会急剧下降——用户上下滑动时完全不知道自己处于数据的什么位置,就像在没有路牌的公路上开车。而"分组头部吸顶(Sticky Header)“交互,本质上就是列表的"实时路牌”:它告诉用户当前可视区域属于哪个分组,当滚动到下一组时,新分组的头部会像"推土机"一样把上一组的头部顶出屏幕,这个微妙的过渡动画已经成为移动 OS 的设计共识(iOS、Android、HarmonyOS 均原生提供)。

1.2 鸿蒙 ArkUI 的解决方案:三件套

在早期的开发中(如 Android 的 RecyclerView + ItemDecoration),实现分组吸顶需要开发者自己计算滚动距离、绘制头部分割线、处理头部替换动画,代码量几百行起,边界 bug 层出不穷。鸿蒙 ArkUI 用声明式组件把这件事彻底简化了,你只需要写对三层结构,一行属性即可开启吸顶:

List                                  // ① 外层滚动容器,挂 sticky 属性
  └─ ListItemGroup                    // ② 分组容器,配置 header / footer
       └─ ListItem                    // ③ 具体列表项

这就是全部。不需要计算坐标,不需要手写动画——框架会自动识别分组边界,并在滚动过程中渲染吸顶、过渡、替换效果。你唯一要做的就是:把数据按业务维度组织成"组 + 组员"的结构。


二、三件套核心概念与 API 24 全量接口详解

2.1 List:外层滚动容器

List 是分组列表的根容器,负责三个职责:全局滚动、全局样式(如分割线/滚动条/回弹)、吸顶/吸底行为开关

2.1.1 构造函数(API 24)
// API 24 完整签名
interface ListOptions {                // API 18+ 新增对象式参数
  space?: number | string;             // 主轴方向子组件间距(VP)
  initialIndex?: number;               // 首次渲染默认显示的 item 索引
  scroller?: Scroller;                 // 滚动控制器,用于编程式滚动
  initialOffset?: number | string;     // API 22+ 初始偏移量
}

List(value?: ListOptions)              // 推荐写法(API 18+)
List(value?:{                          // 传统写法(兼容 API 7~17)
  space?: number | string,
  initialIndex?: number,
  scroller?: Scroller
})

注意点

  • initialIndex 如果设为负数或超过最大索引,会自动回退为 0;
  • space 作用于 List 的直接子组件(即 ListItemGroup 之间、或 ListItem 之间),不作用于 ListItemGroup 内部的 ListItem(内部间距用 ListItemGroup 的 space 属性单独控制);
  • 一个 Scroller 只能绑定一个可滚动组件,不能在 List 和 Scroll 之间复用。
2.1.2 List 核心属性(API 24 全量关键项)
属性名 参数类型 起始API 作用说明
listDirection Axis 7 主轴方向。Vertical(默认,上下滚)/ Horizontal(左右滚)
divider ListDividerOptions | null 7 全局分割线。含 strokeWidth(必填)/color/startMargin/endMargin
scrollBar BarState 7 滚动条显示策略:Auto/Off/On
cachedCount number 7 / 14+增强 / 22+重定义 预加载项数。API 22 后支持跨 ListItemGroup 计算
edgeEffect EdgeEffect 7 边缘回弹:Spring弹性 / None无 / Fade渐变
sticky StickyStyle 9 分组吸顶/吸底开关。核心属性
lanes number | LanesOptions 9 / 22+增强 交叉轴列数,实现多列列表
alignListItem ListItemAlign 9 交叉轴对齐:Start/Center/End
scrollSnapAlign ScrollSnapAlign 10 滚动对齐:分页吸附效果
nestedScroll NestedScrollOptions 10 与父/子滚动容器的嵌套滚动策略
friction number 10 滚动摩擦系数(0~1,默认 0.6)
contentStartOffset LengthMetrics 11 / 22+增强 内容头部偏移(用于吸顶时不被头部遮挡)
contentEndOffset LengthMetrics 11 / 22+增强 内容尾部偏移
childrenMainSize ChildrenMainSize 12 预定义子组件主轴尺寸数组(提升首帧性能)
maintainVisibleContentPosition IVisibleContentPositionOptions 12 数据插入时保持可视位置不变(聊天列表典型)
stackFromEnd boolean 19 从尾部开始堆叠(聊天/评论场景)
focusWrapMode FocusWrapMode 20 焦点环绕模式
syncLoad boolean 20 是否同步加载子组件(默认异步)
editModeOptions EditModeOptions 23 API 23+ 编辑模式配置(替代废弃的 editMode)
supportEmptyBranchInLazyLoading boolean 23 懒加载时空分支支持
chainAnimation boolean 7 子组件插入/删除时是否链式动画
multiSelectable boolean 8 是否开启多选模式
scrollSnapAnimationSpeed number 22 滚动吸附动画速度
2.1.3 StickyStyle 枚举(API 9+,吸顶核心)
enum StickyStyle {
  None,           // 0:不吸顶也不吸底(默认)
  Header,         // 1:仅分组头部吸顶 ★ 最常用
  Footer,         // 2:仅分组尾部吸底
  BOTH = Header | Footer  // 3:同时吸顶+吸底(API 20+可用 BOTH 简写)
}

使用姿势

// 开启头部吸顶
.sticky(StickyStyle.Header)

// 同时开启头部+尾部(两种等价写法)
.sticky(StickyStyle.Header | StickyStyle.Footer)
.sticky(StickyStyle.BOTH)  // API 20+ 简写

⚠️ 必记约束sticky 属性必须配合 ListItemGroup 使用。如果 List 下只有 ListItem 而没有 ListItemGroup,设置 sticky 无任何效果。吸顶的对象是 ListItemGroupheader 参数传入的构建器;吸底的对象是 footer 参数传入的构建器。


2.2 ListItemGroup:分组容器(API 9+)

ListItemGroup 是"分组"这个抽象概念在 ArkUI 中的具象化实体。它把"一组头部 + N 个列表项 +(可选)一组尾部"打包成一个逻辑单元,并作为 List 的直接子组件参与滚动。

2.2.1 构造函数与 ListItemGroupOptions
// 完整签名(API 24)
interface ListItemGroupOptions {
  header?: CustomBuilder;              // 分组头部(吸顶载体)API 9+
  footer?: CustomBuilder;              // 分组尾部(吸底载体)API 9+
  space?: number | string;             // 组内 ListItem 之间的间距 API 9+
  style?: ListItemGroupStyle;          // 卡片样式 API 10+:Default / Card
  divider?: ListDividerOptions | null; // 组内分割线(独立于 List.divider)
  headerComponent?: ComponentContent;  // 动态头部(API 13+,比 header 优先级高)
  footerComponent?: ComponentContent;  // 动态尾部(API 13+)
  initialLayoutCount?: number;         // 初次布局数量(懒加载优化)
}

ListItemGroup(options?: ListItemGroupOptions)

两种 Header/Footer 配置方式对比

维度 header / footer(传统) headerComponent / footerComponent(新版)
API 版本 9+ 13+
类型 @Builder 函数引用(CustomBuilder) ComponentContent 实例
动态更新 ❌ 不支持(闭包值变化后 UI 不同步) ✅ 支持 .update(newParams)
传参方式 闭包捕获 / Builder 参数 构造时传参 + 后需 update
同时配置时 低优先级(被新版覆盖) 高优先级
适用场景 静态头部、简单只读 动态标题、计数、状态徽章等实时变化场景
2.2.2 ListItemGroup 核心属性
属性 版本 作用
divider 9+ 组内分割线(覆盖 List 全局 divider 对本组的影响)
childrenMainSize 12+ 本组内部各 ListItem 的主轴尺寸预定义数组
style 10+ ListItemGroupStyle.Default 普通样式 / ListItemGroupStyle.Card 卡片样式(圆角+阴影自动附加)
2.2.3 懒加载行为差异(重要,关系性能)

ListItemGroup 的懒加载策略取决于它搭配的是 ForEach / LazyForEach / Repeat,三者行为差异极大:

搭配循环 创建时机 滑出屏幕是否销毁 配合 cachedCount
ForEach 首次渲染一次性创建所有子组件 ❌ 不销毁(仅隐藏) 空闲时隙预布局(不创建)
LazyForEach 按需创建可视区域节点 ✅ 销毁(省内存) 空闲时隙预创建+布局+渲染
Repeat (virtualScroll=true) 同 LazyForEach 同 LazyForEach 同 LazyForEach
Repeat (无 virtualScroll) 同 ForEach 同 ForEach 同 ForEach

💡 生产建议:数据量超过 3 屏(≈30 项)一律用 LazyForEach + IDataSource,对于联系人/订单这种几千条的场景,内存节省可达 80% 以上。


2.3 ListItem:列表项(API 7+)

ListItem 是列表的最小承载单元。必须作为 List 或 ListItemGroup 的直接子组件。ListItem 内部只能有一个根组件(通常用 Row/Column 包裹多个子元素)。

interface ListItemOptions {
  sticky?: StickyStyle;     // 单项目级吸顶(API 20+新增,不依赖组)
  editable?: boolean | EditMode;  // 是否可编辑(API 23+更新)
  selectable?: boolean;     // 是否可被多选(配合 List.multiSelectable)
}

ListItem(value?: ListItemOptions)

三、场景一实战:通讯录按字母分组 + 吸顶 + 快速索引

实战目标:仿微信/系统通讯录,A~Z 字母分组,头部吸顶,点击联系人弹出拨号提示,总计 10 个字母组、40+ 联系人。

3.1 第一步:数据建模(先有分组数据才有分组 UI)

建模原则是"两层数组嵌套":外层数组每个元素 = 一个分组对象;分组对象内部的 contacts 数组 = 该组所有列表项。

/** 联系人实体 */
class Contact {
  name: string;
  phone: string;
  avatarColor: string;  // 头像底色,避免引入图片资源
  constructor(name: string, phone: string, avatarColor: string) {
    this.name = name;
    this.phone = phone;
    this.avatarColor = avatarColor;
  }
}

/** 联系人分组(按首字母) */
class ContactGroup {
  letter: string;        // 分组标题:A/B/C...
  contacts: Contact[];   // 该组所有联系人
  constructor(letter: string, contacts: Contact[]) {
    this.letter = letter;
    this.contacts = contacts;
  }
}

3.2 第二步:用 @Builder 拆解头部 / 条目(降低嵌套复杂度)

@Builder 是 ArkUI 提炼可复用 UI 片段的标准手段,尤其分组列表这种"头部×N + 条目×M"的场景,能显著减少 build() 函数内的嵌套层级。

@Builder
ContactHeader(letter: string) {
  // 头部样式:蓝色实心条 + 白字加粗
  Row() {
    Text(letter)
      .fontSize(16)
      .fontWeight(FontWeight.Bold)
      .fontColor('#FFFFFF')
      .margin({ left: 16 });
  }
  .width('100%')
  .height(36)
  .backgroundColor('#007DFF')
  .alignItems(VerticalAlign.Center);
}

@Builder
ContactItem(contact: Contact, onItemClick: () => void) {
  Row() {
    // 圆形头像:取首字显示
    Text(contact.name.charAt(0))
      .fontSize(18)
      .fontColor('#FFFFFF')
      .width(44)
      .height(44)
      .borderRadius(22)
      .textAlign(TextAlign.Center)
      .backgroundColor(contact.avatarColor);

    Column() {
      Text(contact.name)
        .fontSize(16)
        .fontColor('#1A1A1A')
        .fontWeight(FontWeight.Medium);
      Text(contact.phone)
        .fontSize(13)
        .fontColor('#999999')
        .margin({ top: 4 });
    }
    .margin({ left: 12 })
    .alignItems(HorizontalAlign.Start);
  }
  .width('100%')
  .height(72)
  .padding({ left: 16, right: 16 })
  .alignItems(VerticalAlign.Center)
  .onClick(onItemClick);
}

3.3 第三步:组装三层结构 + 开 sticky(核心仅 1 行)

import promptAction from '@ohos.promptAction';

@Entry
@Component
struct ContactListPage {
  // 模拟数据:10 个分组,40+ 联系人
  private contactGroups: ContactGroup[] = [
    new ContactGroup('A', [
      new Contact('艾瑞克', '138****1234', '#FF6B6B'),
      new Contact('安娜', '139****5678', '#4ECDC4'),
      new Contact('爱丽丝', '137****9012', '#45B7D1'),
    ]),
    new ContactGroup('B', [
      new Contact('白展堂', '136****3456', '#96CEB4'),
      new Contact('包拯', '135****7890', '#FFEAA7'),
      new Contact('包青天', '134****2345', '#DDA0DD'),
      new Contact('白素贞', '133****6789', '#98D8C8'),
    ]),
    new ContactGroup('C', [
      new Contact('陈友谅', '132****0123', '#F7DC6F'),
      new Contact('程咬金', '131****4567', '#BB8FCE'),
      new Contact('曹操', '130****8901', '#85C1E9'),
      new Contact('楚留香', '159****2345', '#F8B500'),
    ]),
    // ... D/G/H/L/S/W/Z 组此处省略,完整代码见 Index.ets
  ];

  build() {
    Column() {
      // 顶部标题栏
      Row() {
        Text('📇 通讯录')
          .fontSize(20)
          .fontWeight(FontWeight.Bold);
        Blank();
        Text(`${this.getTotalContacts()} 位联系人`)
          .fontSize(13)
          .fontColor('#999999');
      }
      .width('100%')
      .height(56)
      .padding({ left: 16, right: 16 });

      // ================= 分组列表核心结构 =================
      List({ space: 0 }) {
        // 外层 ForEach:遍历分组(数组元素 → ListItemGroup)
        ForEach(this.contactGroups, (group: ContactGroup) => {
          ListItemGroup({
            // 给组绑定头部(吸顶的就是这个 Builder)
            header: this.ContactHeader(group.letter),
          }) {
            // 内层 ForEach:遍历组员(单个 Contact → ListItem)
            ForEach(group.contacts, (contact: Contact, idx: number) => {
              ListItem() {
                Column() {
                  this.ContactItem(contact, () => {
                    promptAction.showToast({ message: `拨打电话给 ${contact.name}`, duration: 1500 });
                  });
                  // 条目之间加分隔线,最后一条不加
                  if (idx < group.contacts.length - 1) {
                    Divider()
                      .strokeWidth(0.5)
                      .color('#EEEEEE')
                      .margin({ left: 72, right: 16 });
                  }
                }
              };
            });
          };
        });
      }
      // ★★★ 核心开关:打开分组头部吸顶 ★★★
      .sticky(StickyStyle.Header)
      // 边缘弹性回弹,手感更好
      .edgeEffect(EdgeEffect.Spring)
      // List 高度占满标题栏之外的剩余区域
      .layoutWeight(1)
      .width('100%')
      .backgroundColor('#F5F5F5');
    }
    .width('100%')
    .height('100%');
  }

  /** 统计联系人总数 */
  private getTotalContacts(): number {
    let total: number = 0;
    for (let i: number = 0; i < this.contactGroups.length; i++) {
      total += this.contactGroups[i].contacts.length;
    }
    return total;
  }
}

3.4 效果说明

当你运行并上下滑动时,将看到以下丝滑行为:

  1. 滑动到 A 组内部 → 蓝色 A 头部固定在列表顶部;
  2. 继续向上滑,B 组头部向上移动,到达顶部时推动 A 头部一起上移,完全推出后 B 头部接管吸顶位置;
  3. 反向滑动同理,前一组头部会"落回"到数据位置。

这个过渡效果是框架原生的,你不需要写任何动画代码。


四、场景二实战:电商「我的订单」按日期分组(卡片样式 + 操作按钮 + 状态色)

本场景比通讯录更贴近业务:分组头部带操作入口、每条订单是一个带圆角的白色卡片、显示订单号/商品/价格/状态/时间、底部有"联系客服 + 主按钮(按状态变化文案)"两组操作按钮。

4.1 数据模型

/** 订单实体 */
class Order {
  orderId: string;
  productName: string;
  price: number;
  status: string;     // 待付款/待发货/已发货/运输中/已签收/已完成/退换中
  time: string;       // 下单时间 HH:mm
  constructor(o: string, p: string, pr: number, s: string, t: string) {
    this.orderId = o; this.productName = p; this.price = pr; this.status = s; this.time = t;
  }
}

/** 订单分组(按日期) */
class OrderGroup {
  date: string;       // 今天 yyyy-MM-dd / 昨天 / 具体日期
  orders: Order[];
  constructor(date: string, orders: Order[]) {
    this.date = date; this.orders = orders;
  }
}

4.2 状态 → 颜色 / 按钮文案映射(Switch 模式)

ArkTS 不支持动态对象属性访问(obj[key])的动态映射写法,推荐用 switch 显式分支,性能与可读性双优:

private getStatusColor(status: string): string {
  switch (status) {
    case '待付款': return '#FF6B00';   // 橙:催促用户行动
    case '待发货': return '#FF9800';
    case '已发货': return '#2196F3';   // 蓝:物流阶段
    case '运输中': return '#3F51B5';
    case '已签收': return '#009688';
    case '已完成': return '#4CAF50';   // 绿:正向终态
    case '退换中': return '#9C27B0';   // 紫:异常流程
    default:       return '#666666';
  }
}

private getMainAction(status: string): string {
  switch (status) {
    case '待付款': return '去支付';
    case '待发货': return '催发货';
    case '已发货': case '运输中': return '查看物流';
    case '已签收': return '确认收货';
    case '已完成': return '再次购买';
    default: return '查看详情';
  }
}

private getMainActionBg(status: string): string {
  switch (status) {
    case '待付款': return '#E53935';
    case '待发货': return '#FF9800';
    case '已发货': case '运输中': case '已签收': return '#2196F3';
    default: return '#007DFF';
  }
}

4.3 组装分组 + 卡片 UI

订单分组头部采用红色主题,组与组之间通过 List({ space: 12 }) 留白 12VP,每条订单用白底圆角卡片,与通讯录的朴素条目形成对比。

import promptAction from '@ohos.promptAction';

@Entry
@Component
struct OrderListPage {
  private orderGroups: OrderGroup[] = [
    new OrderGroup('今天 2026-07-14', [
      new Order('DD202607140001', '华为 Mate 70 Pro 智能手机', 6999, '待付款', '09:30'),
      new Order('DD202607140002', '鸿蒙智选 无线蓝牙耳机', 599, '待发货', '10:15'),
      new Order('DD202607140003', '智能手表 GT5 运动版', 2188, '已发货', '14:20'),
    ]),
    new OrderGroup('昨天 2026-07-13', [
      new Order('DD202607130001', '小米空气净化器 Pro', 1499, '运输中', '11:00'),
      new Order('DD202607130002', '罗技 MX Master 3S 鼠标', 699, '已签收', '16:45'),
      new Order('DD202607130003', 'Kindle Paperwhite 电子书', 1299, '已完成', '20:30'),
    ]),
    new OrderGroup('2026-07-12', [
      new Order('DD202607120001', 'Apple iPad Air 平板电脑', 4599, '已完成', '08:20'),
      new Order('DD202607120002', '索尼 WH-1000XM5 降噪耳机', 2699, '已完成', '13:10'),
      new Order('DD202607120003', '大疆 Pocket 3 云台相机', 4499, '退换中', '19:00'),
    ]),
    // ... 更多历史日期组:2026-07-10 / 2026-07-05 / 2026-06-28 省略
  ];

  @Builder
  OrderHeader(date: string) {
    Row() {
      Text(date).fontSize(14).fontColor('#FFFFFF').margin({ left: 16 });
      Blank();
      Text('查看全部 >').fontSize(12).fontColor('#FFFFFF').opacity(0.85).margin({ right: 16 });
    }
    .width('100%').height(40).backgroundColor('#E53935').alignItems(VerticalAlign.Center);
  }

  @Builder
  ActionBtn(text: string, fontColor: string, bg: string, click: () => void) {
    Text(text)
      .fontSize(13).fontColor(fontColor).fontWeight(FontWeight.Medium)
      .padding({ left: 14, right: 14, top: 6, bottom: 6 })
      .backgroundColor(bg).borderRadius(16)
      .border({ width: fontColor === '#FFFFFF' ? 0 : 0.5, color: '#DDDDDD' })
      .margin({ left: 8 })
      .onClick(click);
  }

  @Builder
  OrderCard(order: Order) {
    Column() {
      // 第一行:商品 + 状态(状态右对齐)
      Row() {
        Text(order.productName)
          .fontSize(15).fontColor('#1A1A1A').maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis }).layoutWeight(1);
        Text(order.status)
          .fontSize(12).fontColor(this.getStatusColor(order.status))
          .fontWeight(FontWeight.Medium).margin({ left: 8 });
      }.width('100%');

      // 第二行:价格
      Text(`¥${order.price.toFixed(2)}`)
        .fontSize(18).fontColor('#E53935').fontWeight(FontWeight.Bold).margin({ top: 8 });

      // 第三行:订单号 + 时间
      Row() {
        Text(`订单号: ${order.orderId}`).fontSize(12).fontColor('#999999').layoutWeight(1);
        Text(order.time).fontSize(12).fontColor('#999999');
      }.width('100%').margin({ top: 8 });

      // 第四行:操作按钮区(右对齐:次按钮 + 主按钮)
      Row() {
        Blank();
        this.ActionBtn('联系客服', '#666666', '#FFFFFF', () => {
          promptAction.showToast({ message: `联系客服 - ${order.orderId}` });
        });
        this.ActionBtn(
          this.getMainAction(order.status), '#FFFFFF',
          this.getMainActionBg(order.status), () => {
            promptAction.showToast({ message: `${this.getMainAction(order.status)} - ${order.orderId}` });
          }
        );
      }.width('100%').margin({ top: 12 });
    }
    .width('100%').padding(16)
    .backgroundColor('#FFFFFF').borderRadius(8);
  }

  build() {
    Column() {
      // 顶部标题栏
      Row() {
        Text('📦 我的订单').fontSize(20).fontWeight(FontWeight.Bold);
        Blank();
        Text(`${this.getTotalOrders()} 笔订单`).fontSize(13).fontColor('#999999');
      }.width('100%').height(56).padding({ left: 16, right: 16 });

      // ================ 订单分组核心 ================
      List({ space: 12 }) {           // 组与组之间 12VP 间隔
        ForEach(this.orderGroups, (g: OrderGroup) => {
          ListItemGroup({
            header: this.OrderHeader(g.date),
            space: 8,                  // 组内两张卡片之间 8VP
          }) {
            ForEach(g.orders, (order: Order) => {
              ListItem() {
                this.OrderCard(order)
                  .padding({ left: 12, right: 12, top: 8, bottom: 8 });
              };
            });
          };
        });
      }
      .sticky(StickyStyle.Header)     // ★ 日期头部吸顶
      .edgeEffect(EdgeEffect.Spring)
      .layoutWeight(1)
      .width('100%')
      .backgroundColor('#F5F5F5')
      .padding({ top: 8, bottom: 8 });
    }
    .width('100%').height('100%');
  }

  private getTotalOrders(): number {
    let n: number = 0;
    for (let i: number = 0; i < this.orderGroups.length; i++) {
      n += this.orderGroups[i].orders.length;
    }
    return n;
  }
}

五、进阶能力:从"能用"到"好用"的 7 大特性

5.1 LazyForEach 懒加载(IDataSource 模式)

当分组数 > 10 或 条目数 > 100 时,ForEach 的一次性创建策略会导致首帧慢、内存占用高。LazyForEach 通过 IDataSource 接口实现按需创建 + 销毁:

/** 分组级数据源(外层 ListItemGroup 级) */
class OrderGroupSource implements IDataSource {
  private data: OrderGroup[] = [];
  private listeners: DataChangeListener[] = [];

  constructor(data: OrderGroup[]) { this.data = data; }

  totalCount(): number { return this.data.length; }
  getData(index: number): OrderGroup { return this.data[index]; }

  registerDataChangeListener(listener: DataChangeListener): void {
    if (this.listeners.indexOf(listener) < 0) this.listeners.push(listener);
  }
  unregisterDataChangeListener(listener: DataChangeListener): void {
    const i = this.listeners.indexOf(listener);
    if (i >= 0) this.listeners.splice(i, 1);
  }
  notifyReload(): void {
    this.listeners.forEach(l => l.onDataReloaded());
  }
  notifyAdd(index: number, count: number): void {
    this.listeners.forEach(l => l.onDataAdd(index, count));
  }
  notifyChange(index: number, count: number): void {
    this.listeners.forEach(l => l.onDataChange(index, count));
  }
  notifyDelete(index: number, count: number): void {
    this.listeners.forEach(l => l.onDataDelete(index, count));
  }
  notifyMove(from: number, to: number, count: number): void {
    this.listeners.forEach(l => l.onDataMove(from, to, count));
  }
}

使用方式:把 List 外层的 ForEach 换成 LazyForEach,内部 ListItemGroup 的第二层也可以用懒加载:

List() {
  LazyForEach(this.groupSource, (g: OrderGroup) => {
    ListItemGroup({ header: this.OrderHeader(g.date) }) {
      // 内层也可实现 OrderItemSource 做嵌套懒加载
      LazyForEach(new OrderItemSource(g.orders), (o: Order) => {
        ListItem() { this.OrderCard(o); };
      }, (o: Order) => o.orderId);
    };
  }, (g: OrderGroup) => g.date);
}
.sticky(StickyStyle.Header)
.cachedCount(4)   // 额外缓存 4 组(可视区上下各 2 组)

5.2 cachedCount 预加载(API 22+ 跨组生效)

  • API 7~21:cachedCount 在每个 ListItemGroup 内部独立计算;
  • API 22+:全局计数,在分组边界也能平滑预加载,消除"滑动到下一组白屏"的偶现抖动。

生产建议值:

  • 简单条目:cachedCount = 屏数 × 2(2~6 即可);
  • 复杂卡片(多图/多状态):cachedCount = 屏数 × 3 ~ 4,避免加载阻塞。

5.3 lanes 多列布局(API 9+ / API 22+ LanesOptions 自适应)

订单/联系人通常是单列,但在"相册按日期分组""课程表按星期分组"等场景需要多列。通过 List.lanes 可以在保持分组吸顶的同时实现多列:

// API 9+:固定 3 列
List({ space: 8 }) {
  ForEach(this.albumGroups, (g: AlbumGroup) => {
    ListItemGroup({ header: this.AlbumHeader(g.date) }) {
      ForEach(g.photos, (p: Photo) => {
        ListItem() { this.PhotoCard(p); };
      });
    };
  });
}
.lanes(3)   // 交叉轴固定 3 列
.sticky(StickyStyle.Header)

// API 22+:根据屏幕宽度自适应列数(minLength = 每项最小宽度)
.lanes({ minLength: 120, maxLength: 160 })
// 手机竖屏 → 3 列;平板横屏 → 8 列;折叠屏展开 → 6 列

5.4 组内独立 divider(覆盖全局)

List 上设置的 divider 会作用到所有直接子组件之间(包括 ListItemGroup 之间),而 ListItemGroup 上设置的 divider 只作用在本组内部 ListItem之间,实现分组级样式定制:

List({ space: 24 }) {
  // 第一组:无分割线
  ListItemGroup({ header: ... }) {
    // 卡片之间不加线
  }.divider(null);

  // 第二组:窄线,浅灰
  ListItemGroup({ header: ... }) {
    // 联系人条目窄分隔
  }.divider({ strokeWidth: 0.5, color: '#EEEEEE', startMargin: 72 });

  // 第三组:粗线,品牌色
  ListItemGroup({ header: ... }) {
    // 视觉强调分组
  }.divider({ strokeWidth: 2, color: '#007DFF' });
}
.divider(null)  // List 全局不画线

5.5 ListItemGroupStyle.Card(API 10+ 零代码卡片风)

如果你不想写 .backgroundColor + .borderRadius + .margin,可以直接使用卡片样式枚举:

ListItemGroup({
  header: this.OrderHeader(g.date),
  style: ListItemGroupStyle.Card,   // ★ 自动加圆角、阴影、边距
}) {
  // 内部 ListItem,框架已经给卡片外观了
}

5.6 Scroller 编程式滚动(跳转到指定组 / 返回顶部)

长列表必须提供"返回顶部"以及"字母索引跳转"。Scroller 是 List 的滚动指令通道:

@Component
struct ContactListPage {
  private scroller: Scroller = new Scroller();

  build() {
    Stack({ alignContent: Alignment.BottomEnd }) {
      List({ scroller: this.scroller }) { /* ... */ }
        .sticky(StickyStyle.Header);

      // 返回顶部悬浮按钮
      Button('↑ Top')
        .width(52).height(52).borderRadius(26)
        .margin({ right: 16, bottom: 24 })
        .backgroundColor('#007DFF')
        .fontColor('#FFFFFF')
        .onClick(() => {
          // 平滑滚动到第 0 项(A 组),动画时长 300ms
          this.scroller.scrollToIndex({
            index: 0,
            smooth: true,
            animation: { duration: 300, curve: Curve.FastOutSlowIn }
          });
        });
    }.width('100%').height('100%');
  }
}

指定分组跳转:按字母跳时,需要先算出目标分组在 List 直接子组件中的索引。由于 ListItemGroup 作为整体计 1 个索引(内部 ListItem 不计),A=0, B=1, C=2 ...,直接 scrollToIndex({index: targetGroupIndex}) 就会把对应组的头部滚到吸顶位置。

5.7 maintainVisibleContentPosition(聊天/订单追加场景)

当在列表顶部加载历史消息、或在底部追加新订单时,希望当前可视内容不"跳"。API 12+ 提供此属性:

List() { ... }
.maintainVisibleContentPosition({
  minIndexForVisible: 0,     // 至少保证第 minIndexForVisible 项可见
  autoScroller: false        // 是否自动滚到最新
})

六、8 条性能优化最佳实践

分组列表是最容易掉帧的组件类型之一(条目多 + 布局复杂 + 滚动时频繁创建/销毁)。以下 8 条来自生产环境的经验:

6.1 数据量 < 30:ForEach 足矣,> 30:LazyForEach 必用

这是硬指标。实测 500 个联系人 + ForEach 在低端机首帧 > 500ms,换 LazyForEach 后 < 120ms。

6.2 给 ListItem 一个稳定、唯一的 key(ForEach 第三参数 / LazyForEach 第三参数)

ForEach(group.contacts, (c: Contact) => { ListItem() {...} }, (c: Contact) => c.phone)
// key 用手机号/订单号这种业务唯一 id,不要用 index

当数组发生增删时,稳定 key 能让框架正确复用节点,避免整组重建。key 用 index 是列表性能的第一杀手。

6.3 Builder 内禁止声明 @State / 做 heavy 计算

@State / @Provide 只能在 struct 字段上声明;Builder 内的临时计算(如格式化时间/价格)提前在数据层 ViewModel 算好,别在渲染时跑 toFixed/Intl.DateTimeFormat

6.4 吸顶头部高度固定且尽量小

sticky 头部在滚动过程中每帧都要重绘。如果头部复杂(含动画/大图/渐变),会导致滚动 60FPS→40FPS 肉眼可见卡顿。建议:

  • 高度 32~48VP;
  • 纯色背景 + 短文本优先;
  • 渐变/模糊效果放静态展示页,不放吸顶区。

6.5 图片用 autoResize + 明确尺寸,禁止 ListItem 高度由图片原始尺寸撑开

Image(src)
  .width(80).height(80)           // 写死尺寸
  .objectFit(ImageFit.Cover)
  .autoResize(true)               // API 20+ 自动缩放到目标像素
  .syncLoad(false)                // 异步加载,不卡主线程

6.6 childrenMainSize(API 12+)预先声明 ListItem 尺寸数组

当所有条目的高度已知且固定(如 72VP),通过此属性直接告知框架,跳过测量阶段:

// 通讯录:每组 3 个联系人,每个条目 72VP
List() { ... }
.childrenMainSize({
  mainSize: [72, 72, 72, 72, ...],   // 或用函数动态返回
  crossSize: null
})

6.7 避免在 List/ListItemGroup 的 onScroll 回调里 setState

onScroll 每滚动 1VP 触发一次,一秒几十次。如果回调里 setState 会触发整棵子树 rebuild,必卡。做法:

  • 只写轻量逻辑(计算百分比、累加器);
  • UI 更新使用 scrollStop 事件一次性批量 setState。

6.8 分组内部的 ListItem 使用 @Reusable(API 18+)复用装饰器

在条目组件 struct 上加 @Reusable 注解,配合 LazyForEach,框架在滚动时会直接复用已创建的节点(类似 Android ViewHolder),图片/大列表滚动场景 FPS 提升 ~25%:

@Reusable
@Component
struct ContactListItem {
  @ObjectLink contact: Contact;
  build() { /* UI */ }
}

七、10 大常见踩坑点与解决方案

坑 1:写了 .sticky(StickyStyle.Header) 就是不吸顶

原因 Top3

  1. List 下直接塞 ListItem,没有用 ListItemGroup 包裹;
  2. ListItemGroup 传的是 children 里放了头部(应该在构造参数 header 里传);
  3. Sticky 枚举写错成 Sticky.Header(正确是 StickyStyle.Header)。

一句话自检List → ListItemGroup({header: 你的构建器}) → ListItem 三层必须齐全,且属性写 StickyStyle

坑 2:吸顶头部和内容之间有 1px 缝隙

API 24 文档里明确提到由于浮点数像素对齐,sticky 滚动时偶现缝隙。解法是给 List 加 .pixelRound(PixelRound.DOWN) 强制向下取整。

坑 3:ListItemGroup 上设置 .height(xxx) 完全不生效

属于 ArkTS 约定行为:垂直 List 中,组的高度是 header + 所有条目高度之和,固定高度会破坏语义所以被忽略。如果你需要组内固定可视区域滚动,应该把内部的子 ListItem 放在 Scroll + Column(注意要关 List 的滚动用 enableScrollInteraction(false) + 启用嵌套滚动)。

坑 4:嵌套 Scroll 手势冲突(外层 List + 内层 Scroll)

默认会抢手势。给子 Scroll 加:

Scroll(this.innerScroller) { ... }
.nestedScroll({
  scrollForward: NestedScrollMode.PARENT_FIRST,   // 手指向下先让父滚
  scrollBackward: NestedScrollMode.SELF_FIRST
})

或者直接用 List 的 nestedScroll 属性设策略。

坑 5:ForEach 改了数组里对象的字段,UI 完全不刷新

因为 ForEach 的 diff 是按 key 做"引用相等"判断。如果直接 arr[0].name = '新',框架认为数组没变。正确姿势:

  • 生成新数组(map 出新对象)后赋值给 @State 数组;
  • 或用 LazyForEach + 调用 dataSource.notifyChange(i, 1)

坑 6:List 的 initialIndex 不生效

检查:initialIndex 是 List 直接子组件的索引(ListItemGroup 算 1 个、ListItem 算 1 个),不是内部条目索引。想滚到 B 组第 2 个条目应该用 Scroller.scrollToIndex + 先算 ListItemGroup 在第 1 位。

坑 7:sticky Header 的点击事件不响应

这是 API 24 之前的已知限制:吸顶阶段的 Header 是框架合成视图(overlay 层),事件会穿透到下面的内容。解决:

  • 升级到 API 24+ 已修复;
  • 或者在 Header 里放 Button(而非整个 Row 绑 onClick),Button 在合成层也能收到事件。

坑 8:LazyForEach 滚动到底部白屏

常见于:totalCount() 计算错(返回 0 或固定小数字),导致框架以为没数据了。把日志打在 totalCountgetData,检查返回值数量是否匹配数组长度。

坑 9:ListItemGroup.style = Card 但内容溢出卡片

Card 模式下,ListItemGroup 会加默认的 margin。如果你的 ListItem 写了 .width('100%'),在 API 20 之前会溢出。解决:升级 API 版本,或在内部再套一层 .clip(true) 的 Column。

坑 10:两个 ListItem 点击效果串台(A 项按下去 B 项高亮)

根因:ForEach/LazyForEach 的 key 重复或不唯一。检查你的第三参数,保证每条数据的 key 在业务层面不可重复(手机号/订单号/uuid,别用数组 index)。


八、组合拳:List + ListItemGroup + AlphabetIndexer 完成仿微信通讯录

真正的生产级通讯录不止分组吸顶,还需要右侧字母索引条(AlphabetIndexer),点击字母直接跳转对应分组。整体架构:

@Entry
@Component
struct WeChatContacts {
  private scroller: Scroller = new Scroller();
  private groups: ContactGroup[] = ...;
  private letters: string[] = [];   // ['#','A','B',...,'Z'](从 groups 中抽)
  @State selectedLetter: number = 0;

  aboutToAppear(): void {
    this.letters = this.groups.map(g => g.letter);
  }

  build() {
    Stack({ alignContent: Alignment.End }) {
      // 主列表
      List({ scroller: this.scroller }) {
        ForEach(this.groups, (g: ContactGroup, i: number) => {
          ListItemGroup({ header: this.ContactHeader(g.letter) }) {
            ForEach(g.contacts, (c: Contact) => { ListItem() { this.ContactItem(c); }; }, c => c.phone);
          };
        }, g => g.letter);
      }
      .sticky(StickyStyle.Header)
      .onScrollIndex((first, last) => {
        // 滚动时联动字母条高亮:first 是当前可视区第一个 ListItemGroup 索引
        if (first < this.letters.length) this.selectedLetter = first;
      });

      // 右侧字母索引条
      AlphabetIndexer({
        arrayValue: this.letters,
        selected: this.selectedLetter
      })
      .width(28)
      .height('60%')
      .margin({ right: 4 })
      .selectedColor('#007DFF')
      .popupColor('#007DFF')
      .onSelect((index: number) => {
        // 点击字母:平滑滚动到对应 ListItemGroup
        this.scroller.scrollToIndex({ index: index, smooth: true, animation: { duration: 250 } });
      })
      .onRequestPopupData((index: number) => [this.letters[index]]);
    }
    .width('100%').height('100%');
  }
}

三段联动(滚动 → 字母高亮 / 点击字母 → 滚动)即完成了与系统通讯录一致的交互体验。核心仍是 ListItemGroup + sticky 提供分组 + 吸顶基础设施,其他组件在此之上增强。


九、总结

本文系统地从概念 → API 24 接口清单 → 两大完整代码实战 → 7 项进阶 → 8 条性能优化 → 10 大踩坑点七个维度拆解了鸿蒙分组列表的核心能力,以下是几个可落地的总结:

  1. 结构是地基:任何分组列表都先把三层结构写对 List → ListItemGroup({header}) → ListItem,再谈样式与交互;
  2. 吸顶是一行.sticky(StickyStyle.Header) 绑定到 List 上,吸顶效果由框架原生处理,不要尝试自己算偏移;
  3. 数据量 > 30 必上 LazyForEach:用 IDataSource + 稳定 key + cachedCount 三件套保性能底线;
  4. 组件复用加 @Reusable:这是 HarmonyOS NEXT 的免费升级,图片多的列表能省 30% 创建耗时;
  5. 状态色 / 按钮文案用 Switch 映射:ArkTS 禁止动态属性访问的场景下,显式分支是性能 + 可读性的双赢;
  6. 分组是业务建模,不是 UI 问题:在拿到需求时先把数据按"维度"(字母/日期/分类)切分成两层数组,UI 只是把它映射成对应组件——这一步错,后面所有分组效果都无从谈起。

回到本文开头的类比:分组列表就像给长列表装"路牌",List 是公路、ListItemGroup 是每一段的路牌+路面、sticky 是让路牌始终挂在你视线内的悬挂系统。三件套各司其职,最终交付的是"在几千条数据中不迷路"的浏览安全感——这是所有优秀 App 的共同底线,而在鸿蒙 ArkTS 中,实现它只需要三行结构加一行属性。

:本文所有代码均通过 HarmonyOS NEXT API 24 编译验证,可直接在 DevEco Studio NEXT(Build Version 5.0.3.800+)中新建空白工程后粘贴运行。两大场景(通讯录 + 订单)在示例工程的 Index.ets 底部通过自绘 TabBar 切换,无需额外路由配置。

Logo

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

更多推荐