鸿蒙原生 ArkTS 布局方式之 List + ListItemGroup 分组布局实战指南(HarmonyOS NEXT / API 24)
摘要:分组列表是移动端开发中最高频的交互形态之一——通讯录按字母索引、订单按日期归类、商品按分类浏览、消息按时间聚合,几乎每个 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 无任何效果。吸顶的对象是ListItemGroup的header参数传入的构建器;吸底的对象是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 效果说明
当你运行并上下滑动时,将看到以下丝滑行为:
- 滑动到 A 组内部 → 蓝色 A 头部固定在列表顶部;
- 继续向上滑,B 组头部向上移动,到达顶部时推动 A 头部一起上移,完全推出后 B 头部接管吸顶位置;
- 反向滑动同理,前一组头部会"落回"到数据位置。
这个过渡效果是框架原生的,你不需要写任何动画代码。
四、场景二实战:电商「我的订单」按日期分组(卡片样式 + 操作按钮 + 状态色)
本场景比通讯录更贴近业务:分组头部带操作入口、每条订单是一个带圆角的白色卡片、显示订单号/商品/价格/状态/时间、底部有"联系客服 + 主按钮(按状态变化文案)"两组操作按钮。
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:
- List 下直接塞 ListItem,没有用 ListItemGroup 包裹;
- ListItemGroup 传的是
children里放了头部(应该在构造参数header里传); - 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 或固定小数字),导致框架以为没数据了。把日志打在 totalCount 和 getData,检查返回值数量是否匹配数组长度。
坑 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 大踩坑点七个维度拆解了鸿蒙分组列表的核心能力,以下是几个可落地的总结:
- 结构是地基:任何分组列表都先把三层结构写对
List → ListItemGroup({header}) → ListItem,再谈样式与交互; - 吸顶是一行:
.sticky(StickyStyle.Header)绑定到 List 上,吸顶效果由框架原生处理,不要尝试自己算偏移; - 数据量 > 30 必上 LazyForEach:用 IDataSource + 稳定 key + cachedCount 三件套保性能底线;
- 组件复用加 @Reusable:这是 HarmonyOS NEXT 的免费升级,图片多的列表能省 30% 创建耗时;
- 状态色 / 按钮文案用 Switch 映射:ArkTS 禁止动态属性访问的场景下,显式分支是性能 + 可读性的双赢;
- 分组是业务建模,不是 UI 问题:在拿到需求时先把数据按"维度"(字母/日期/分类)切分成两层数组,UI 只是把它映射成对应组件——这一步错,后面所有分组效果都无从谈起。
回到本文开头的类比:分组列表就像给长列表装"路牌",List 是公路、ListItemGroup 是每一段的路牌+路面、sticky 是让路牌始终挂在你视线内的悬挂系统。三件套各司其职,最终交付的是"在几千条数据中不迷路"的浏览安全感——这是所有优秀 App 的共同底线,而在鸿蒙 ArkTS 中,实现它只需要三行结构加一行属性。
附:本文所有代码均通过 HarmonyOS NEXT API 24 编译验证,可直接在 DevEco Studio NEXT(Build Version 5.0.3.800+)中新建空白工程后粘贴运行。两大场景(通讯录 + 订单)在示例工程的
Index.ets底部通过自绘 TabBar 切换,无需额外路由配置。
更多推荐

所有评论(0)