HarmonyOS应用<民族图鉴>开发第9篇:列表组件——List/Grid/ForEach高效渲染深度解析

📖 引言
前几篇我们学习了基础组件、状态管理和路由导航,已经能够构建简单的页面了。但真实的应用中,最常见的界面是什么?是列表。
民族列表、收藏列表、历史记录、搜索结果……几乎每个应用都有大量的列表页面。列表也是最容易出现性能问题的地方——几百个条目一次性渲染出来,卡得动不了;滚动的时候掉帧,用户体验极差。
你可能会问:
- ForEach 和 LazyForEach 有什么区别?什么时候用哪个?
- List 组件为什么比 Scroll + Column 性能好?
- 为什么要给每个列表项加 key?不加会怎样?
- Grid 网格布局怎么用?和 List 有什么区别?
- 列表性能优化有哪些技巧?
这些问题非常关键。列表用好了,应用流畅丝滑;用不好,卡成 PPT。「民族图鉴」项目中有 56 个民族的列表,虽然数量不算特别多,但列表的实现方式决定了用户体验的上限。
本文将以「民族图鉴」的民族列表为载体,从基础用法到性能优化,从 ForEach 到 LazyForEach,从 List 到 Grid,带你彻底搞懂鸿蒙的列表组件。
🎯 学习目标
完成本文后,你将能够:
- ✅ 熟练使用 ForEach 渲染列表,理解 key 的作用
- ✅ 掌握 List 组件的使用,理解 List 为什么性能好
- ✅ 深入理解 LazyForEach 的懒加载原理与实现
- ✅ 掌握 Grid 网格布局的使用场景
- ✅ 学会列表性能优化的各种技巧
- ✅ 能够构建高性能的民族列表页面
- ✅ 避开列表开发的常见坑
💡 需求分析
为什么列表这么重要?
列表是移动应用中最常见的界面形态。为什么?
- 信息密度高:有限的屏幕空间展示大量内容
- 交互自然:上下滑动是用户最熟悉的手势
- 扩展性强:加一项少一项,界面都能适应
- 实现简单:数据驱动,有多少数据就有多少项
但列表也是性能重灾区。几百条数据一次性渲染:
- 内存占用高
- 首屏加载慢
- 滚动掉帧
- 低端机直接卡死
所以,列表的性能优化是每个开发者的必修课。
「民族图鉴」的列表场景
「民族图鉴」项目中有多种列表场景:
| 场景 | 数据量 | 特点 | 技术方案 |
|---|---|---|---|
| 民族列表页 | 56 项 | 中等数量,卡片式 | List + ForEach |
| 收藏列表 | 不定 | 可能很少也可能很多 | List + ForEach |
| 浏览历史 | 不定 | 时间倒序 | List + ForEach |
| 首页精选网格 | 8 项 | 少量,两列网格 | Grid + ForEach |
| 测验题目列表 | 10 题 | 少量,每题一屏 | Swiper 或 List |
56 个民族,数量不算特别大,用 ForEach 就够了。但我们还是会讲 LazyForEach,因为当数据量变大的时候(比如几百首民族音乐),必须用懒加载。
🛠️ 核心实现
步骤1:ForEach——最基础的列表渲染
1.1 基本用法
ForEach 是 ArkUI 中最基础的列表渲染组件,用来遍历数组生成多个子组件。
@Entry
@Component
struct SimpleList {
@State names: string[] = ['汉族', '壮族', '满族', '回族', '苗族'];
build() {
Column({ space: 10 }) {
// 遍历 names 数组,为每个元素生成一个 Text
ForEach(this.names, (name: string) => {
Text(name)
.fontSize(16)
.padding(16)
.width('100%')
.backgroundColor('#FFFFFF')
.borderRadius(8)
}, (name: string) => name) // key 生成函数
}
.padding(16)
.width('100%')
}
}
ForEach 的三个参数:
| 参数 | 类型 | 说明 |
|---|---|---|
| 第一个 | 数组 | 要遍历的数据源 |
| 第二个 | 函数 | 每个数组项生成什么 UI |
| 第三个 | 函数 | 生成唯一 key 的函数(可选但推荐) |
1.2 key 的作用
第三个参数(key 生成函数)非常重要,但很多人不理解为什么需要它。
为什么需要 key?
当数组变化的时候(比如增加、删除、排序),框架需要知道哪些项没变、哪些是新增的、哪些是移动了。没有 key 的话,框架只能全部重建,性能差;有了 key,框架可以精准地 diff,只更新变化的部分。
key 的要求:
- 唯一:同一个列表中,每个项的 key 不能重复
- 稳定:同一个项的 key 不应该变(不要用 index 当 key)
- 简单:字符串或数字最好
为什么不要用 index 当 key?
// ❌ 不好:用 index 当 key
ForEach(this.items, (item: Item, index: number) => {
Text(item.name)
}, (item: Item, index: number) => index.toString())
如果数组排序了,index 变了,但其实数据还是那些数据。这时候框架会以为所有项都变了,全部重建,性能很差。
正确做法:用数据的唯一 ID
// ✅ 好:用数据本身的唯一标识
ForEach(this.ethnicList, (ethnic: EthnicGroup) => {
EthnicCard({ ethnic: ethnic })
}, (ethnic: EthnicGroup) => ethnic.id) // 用民族 ID 当 key
💡 记住:只要能用数据唯一 ID 当 key,就不要用 index。这是列表性能优化的基础。
1.3 ForEach 的限制
ForEach 虽然简单,但有一些限制:
- 不能单独使用:必须放在容器组件(Column、Row、List、Grid 等)里面
- 完整渲染:所有项一次性全部渲染,不管看不看得见
- 数据量不宜太大:建议几十条以内,太多了性能差
如果有几百上千条数据,ForEach 就不够用了,这时候需要 List + LazyForEach。
步骤2:List——高性能列表容器
2.1 为什么要用 List?
你可能会想:我用 Scroll + Column + ForEach 也能做出滚动列表啊,为什么要用 List?
答案是:性能差距很大。
| 特性 | Scroll + Column + ForEach | List + LazyForEach |
|---|---|---|
| 渲染方式 | 所有项一次性渲染 | 按需渲染,滑到哪渲染到哪 |
| 内存占用 | 高(所有项都在内存里) | 低(只有可见的 + 缓冲区) |
| 首屏速度 | 慢(要渲染所有项) | 快(只渲染首屏可见的) |
| 复用机制 | 没有,每项都是独立的 | 有,滚动时回收复用 |
| 适用数据量 | 几十条以内 | 几百上千条都行 |
List 组件内置了虚拟化(Virtualization) 和组件复用(Recycling) 机制,这是它性能好的根本原因。
2.2 List 的基本用法
@Entry
@Component
struct EthnicListPage {
@State ethnicList: EthnicGroup[] = ETHNIC_GROUPS;
build() {
List({ space: 12 }) {
ForEach(this.ethnicList, (ethnic: EthnicGroup) => {
ListItem() {
EthnicCard({ ethnic: ethnic })
}
}, (ethnic: EthnicGroup) => ethnic.id)
}
.width('100%')
.height('100%')
.padding({ left: 16, right: 16, top: 16, bottom: 24 })
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
}
}
注意:List 的子组件必须是 ListItem,不能直接放自定义组件。
2.3 List 的常用属性
| 属性 | 类型 | 说明 |
|---|---|---|
| space | number | 列表项之间的间距 |
| initialIndex | number | 初始滚动到第几项 |
| scrollBar | BarState | 滚动条显示状态 |
| edgeEffect | EdgeEffect | 边缘效果(弹簧/淡出/无) |
| listDirection | Axis | 滚动方向(垂直/水平) |
| divider | object | 分割线样式 |
| lanes | number | Length | 列数(多列布局时用) |
EdgeEffect 对比:
| 效果 | 说明 | 适用场景 |
|---|---|---|
| EdgeEffect.Spring | 弹簧效果,拉到底会弹回来 | 列表类(最常用,体验好) |
| EdgeEffect.Fade | 淡出效果,拉到底有渐变阴影 | 页面内容 |
| EdgeEffect.None | 没有效果,拉到底就不动了 | 很少用 |
2.4 ListItemGroup:列表分组与粘性标题
很多列表都需要分组显示,比如联系人按字母分组、民族按地区分组。每组有一个标题,滚动的时候标题会"粘"在顶部,直到下一组的标题把它顶上去。这就是 sticky header(粘性标题)。
ArkUI 提供了 ListItemGroup 组件来实现列表分组。
基本用法:
@Entry
@Component
struct GroupedListPage {
@State ethnicGroups: { category: string; list: EthnicGroup[] }[] = [
{
category: '华北地区',
list: ETHNIC_GROUPS.filter(e => e.region === '华北')
},
{
category: '华东地区',
list: ETHNIC_GROUPS.filter(e => e.region === '华东')
},
{
category: '西南地区',
list: ETHNIC_GROUPS.filter(e => e.region === '西南')
},
];
build() {
List({ space: 8 }) {
ForEach(this.ethnicGroups, (group: { category: string; list: EthnicGroup[] }) => {
ListItemGroup({ header: this.buildHeader(group.category) }) {
ForEach(group.list, (ethnic: EthnicGroup) => {
ListItem() {
EthnicCard({ ethnic: ethnic })
}
}, (ethnic: EthnicGroup) => ethnic.id)
}
}, (group: { category: string; list: EthnicGroup[] }) => group.category)
}
.width('100%')
.height('100%')
.sticky(StickyStyle.Header) // 粘性标题
}
@Builder
buildHeader(title: string) {
Text(title)
.fontSize(16)
.fontWeight(FontWeight.Bold)
.width('100%')
.padding({ left: 16, right: 16, top: 12, bottom: 8 })
.backgroundColor('#F5F5F5')
}
}
关键属性:
| 属性 | 类型 | 说明 |
|---|---|---|
| ListItemGroup | 组件 | 列表分组容器 |
| header | CustomBuilder | 分组标题组件 |
| footer | CustomBuilder | 分组底部组件(可选) |
| sticky | StickyStyle | 粘性效果(Header/Footer/Both/None) |
StickyStyle 枚举:
| 值 | 说明 |
|---|---|
| StickyStyle.None | 不粘性(默认) |
| StickyStyle.Header | 标题粘性,滚动时停在顶部 |
| StickyStyle.Footer | 底部粘性 |
| StickyStyle.Both | 标题和底部都粘性 |
「民族图鉴」的分组场景:
「民族图鉴」可以按多种方式分组:
| 分组方式 | 说明 | 适用场景 |
|---|---|---|
| 按地区分组 | 华北、华东、西南、西北等 | 地理分布浏览 |
| 按拼音首字母 | A、B、C、D… | 字母索引快速查找 |
| 按人口数量 | 百万以上、十万以上等 | 人口规模浏览 |
| 按语系 | 汉藏语系、阿尔泰语系等 | 语言文化研究 |
2.5 List 的高级属性详解
让我们深入了解几个重要但容易被忽略的 List 属性:
1. listDirection:滚动方向
// 垂直滚动(默认)
List({ space: 12 }) { ... }
.listDirection(Axis.Vertical)
// 水平滚动
List({ space: 12 }) { ... }
.listDirection(Axis.Horizontal)
水平列表的适用场景:
- 横向滚动的 Banner
- 横向卡片列表
- 标签栏滚动
2. scrollBar:滚动条
// 不显示滚动条
.scrollBar(BarState.Off)
// 自动显示(滚动时显示,默认)
.scrollBar(BarState.Auto)
// 一直显示
.scrollBar(BarState.On)
3. edgeEffect:边缘效果
// 弹簧效果(推荐,体验好)
.edgeEffect(EdgeEffect.Spring)
// 淡出效果
.edgeEffect(EdgeEffect.Fade)
// 无效果
.edgeEffect(EdgeEffect.None)
4. divider:分割线
.divider({
strokeWidth: 1, // 线宽
color: '#EEEEEE', // 颜色
startMargin: 16, // 左边距
endMargin: 16 // 右边距
})
5. lanes:多列列表
// 两列列表
List({ space: 12 }) { ... }
.lanes(2)
// 自适应列数(根据宽度自动调整)
List({ space: 12 }) { ... }
.lanes('auto')
步骤3:LazyForEach——大数据量的懒加载
3.1 为什么需要懒加载?
假设你有 1000 条数据,如果用 ForEach:
- 1000 个组件一次性创建
- 1000 个组件都在内存里
- 首屏要等 1000 个组件渲染完才能显示
用户看到的是什么?白屏很久,然后突然出来一长串。滚动的时候也会卡,因为有太多组件在内存里。
懒加载(Lazy Loading) 的思路是:
- 只渲染当前可见的项
- 滚动到哪,渲染到哪
- 滚出去的项,回收复用
这样:
- 首屏只渲染十几项,速度飞快
- 内存里只有二三十项(可见 + 缓冲区),占用小
- 滚动时回收复用,不会越滚越卡
3.2 IDataSource 接口深度解析
LazyForEach 需要一个特殊的数据源——实现了 IDataSource 接口的对象。这个接口是懒加载的核心,让我们深入理解它。
IDataSource 接口定义:
interface IDataSource {
// 数据总数量
totalCount(): number;
// 获取指定位置的数据
getData(index: number): any;
// 注册数据变化监听器(框架调用)
registerDataChangeListener(listener: DataChangeListener): void;
// 注销数据变化监听器(框架调用)
unregisterDataChangeListener(listener: DataChangeListener): void;
}
interface DataChangeListener {
// 数据全部重新加载
onDataReloaded(): void;
// 某一项数据改变
onDataChanged(index: number): void;
// 新增一项
onDataAdd(index: number): void;
// 删除一项
onDataDelete(index: number): void;
// 移动一项
onDataMoved(from: number, to: number): void;
}
为什么设计成这样?
IDataSource 接口的设计体现了数据源与 UI 分离的思想:
- UI 不问数据从哪来:LazyForEach 只关心"第 index 项是什么",不关心数据是本地的、网络的还是数据库的
- 数据变化主动通知:数据变了,数据源主动通知 UI 更新,而不是 UI 轮询
- 按需加载:UI 只在需要的时候才调用 getData() 取数据
监听器机制的工作原理:
框架(List/LazyForEach) 数据源(IDataSource)
│ │
│ registerDataChangeListener(listener) │
│─────────────────────────────────────>│
│ │
│ 数据变化了 │
│ │
│ notifyDataChange(index) │
│<─────────────────────────────────────│
│ │
│ 调用 getData(index) 重新获取数据 │
│─────────────────────────────────────>│
│ │
│ 更新 UI │
│ │
完整的 IDataSource 实现模板:
/**
* 通用数据源基类
* 封装 IDataSource 的通用逻辑,业务数据源继承这个类即可
*/
abstract class BaseDataSource<T> implements IDataSource {
protected dataList: T[] = [];
private listeners: DataChangeListener[] = [];
// ========== IDataSource 接口实现 ==========
totalCount(): number {
return this.dataList.length;
}
getData(index: number): T {
return this.dataList[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 > -1) {
this.listeners.splice(index, 1);
}
}
// ========== 通知方法(子类调用) ==========
protected notifyReload(): void {
this.listeners.forEach(listener => listener.onDataReloaded());
}
protected notifyChange(index: number): void {
this.listeners.forEach(listener => listener.onDataChanged(index));
}
protected notifyAdd(index: number): void {
this.listeners.forEach(listener => listener.onDataAdd(index));
}
protected notifyDelete(index: number): void {
this.listeners.forEach(listener => listener.onDataDelete(index));
}
protected notifyMove(from: number, to: number): void {
this.listeners.forEach(listener => listener.onDataMoved(from, to));
}
// ========== 业务方法(子类可扩展) ==========
setData(data: T[]): void {
this.dataList = data;
this.notifyReload();
}
add(index: number, item: T): void {
this.dataList.splice(index, 0, item);
this.notifyAdd(index);
}
update(index: number, item: T): void {
this.dataList[index] = item;
this.notifyChange(index);
}
remove(index: number): void {
this.dataList.splice(index, 1);
this.notifyDelete(index);
}
move(from: number, to: number): void {
const item = this.dataList.splice(from, 1)[0];
this.dataList.splice(to, 0, item);
this.notifyMove(from, to);
}
getItem(index: number): T | undefined {
return this.dataList[index];
}
getAll(): T[] {
return [...this.dataList];
}
clear(): void {
this.dataList = [];
this.notifyReload();
}
}
/**
* 民族数据源(业务类,继承 BaseDataSource)
*/
class EthnicDataSource extends BaseDataSource<EthnicGroup> {
// 可以在这里添加业务特有的方法
// 比如分页加载、搜索过滤等
async loadPage(page: number, pageSize: number): Promise<void> {
// 模拟网络请求
const result = await fetchEthnicList(page, pageSize);
if (page === 1) {
this.setData(result.list);
} else {
const startIndex = this.totalCount();
result.list.forEach((item, i) => {
this.dataList.push(item);
this.notifyAdd(startIndex + i);
});
}
}
}
使用方式:
@Entry
@Component
struct EthnicListPage {
private dataSource: EthnicDataSource = new EthnicDataSource();
aboutToAppear(): void {
this.dataSource.loadPage(1, 20);
}
build() {
List() {
LazyForEach(this.dataSource, (ethnic: EthnicGroup) => {
ListItem() {
EthnicCard({ ethnic: ethnic })
}
}, (ethnic: EthnicGroup) => ethnic.id)
}
.cachedCount(5)
}
}
继承 BaseDataSource 的好处:
- 不用每个数据源都写一遍重复的监听器逻辑
- 业务代码更简洁,专注于业务逻辑
- 统一的接口,方便维护
3.3 ForEach vs LazyForEach:原理性差异
很多人知道 LazyForEach 性能好,但好在哪里?底层原理是什么?让我们深入对比一下。
1. 渲染时机不同
ForEach:一次性全部渲染
数据:[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ... 1000]
│
▼
一次性创建 1000 个组件
│
▼
全部渲染完才显示首屏
LazyForEach:按需渲染
数据:[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, ... 1000]
│
▼
只创建可见的 + 缓存的(比如 15 个)
│
▼
首屏很快显示
│
▼
滚动时,创建新的,回收旧的
2. 内存占用不同
| 维度 | ForEach | LazyForEach |
|---|---|---|
| 组件数量 | = 数据总量 | = 可见数量 + 缓存数量 |
| 内存占用 | 高(线性增长) | 低(基本恒定) |
| 1000 条数据 | 1000 个组件在内存 | 约 20-30 个在内存 |
3. 首屏加载速度不同
- ForEach:首屏时间 ∝ 数据总量 × 单项渲染时间
- LazyForEach:首屏时间 ∝ 可见数量 × 单项渲染时间
比如 1000 条数据,每条渲染 1ms,一屏显示 15 条:
- ForEach:首屏需要 1000ms(1秒)
- LazyForEach:首屏需要 15ms(几乎瞬间)
4. 组件复用机制
LazyForEach 配合 List 使用时,有组件复用(Recycling) 机制:
滚动时的复用机制:
┌─────────────┐ ┌─────────────┐
│ 可见区域 │ │ 可见区域 │
│ ┌─────────┐ │ │ ┌─────────┐ │
│ │ Item 1 │ │ │ │ Item 2 │ │
│ ├─────────┤ │ 向上 │ ├─────────┤ │
│ │ Item 2 │ │ 滚动 │ │ Item 3 │ │
│ ├─────────┤ │ → │ ├─────────┤ │
│ │ Item 3 │ │ │ │ Item 4 │ │
│ └─────────┘ │ │ └─────────┘ │
└─────────────┘ └─────────────┘
Item 1 滚出去了 → 回收这个组件 ↘
→ 重新填充数据 → Item 4
需要显示 Item 4 了 → 拿回收的组件 ↗
复用的好处:
- 不用频繁创建/销毁组件(创建组件开销大)
- 减少 GC(垃圾回收)压力
- 滚动更流畅
5. 适用场景对比
| 特性 | ForEach | LazyForEach |
|---|---|---|
| 数据量 | 小(建议 < 50) | 大(建议 > 50) |
| 首屏速度 | 慢(全渲染) | 快(按需渲染) |
| 内存占用 | 高 | 低 |
| 滚动性能 | 差(多了会卡) | 好(复用机制) |
| 实现复杂度 | 简单 | 稍复杂(需要 IDataSource) |
| 动态数据 | 简单(直接改数组) | 需要调用 notify |
| 「民族图鉴」场景 | 56个民族列表 | 音乐列表、搜索结果 |
6. 决策树:什么时候用哪个?
需要渲染列表吗?
│
├─ 数据量 < 20 条
│ └─ ForEach(简单够用)
│
├─ 数据量 20-100 条
│ ├─ 卡片简单(纯文字) → ForEach 也行
│ └─ 卡片复杂(多图) → 推荐 LazyForEach
│
└─ 数据量 > 100 条
└─ 必须 LazyForEach
💡 经验之谈:不要过早优化,但也不要事后救火。56 个民族用 ForEach 完全没问题,但如果以后要加民族音乐(几百首),就得用 LazyForEach 了。心里有这个意识,写代码的时候留好扩展空间。
@Entry
@Component
struct BigListPage {
private dataSource: EthnicDataSource = new EthnicDataSource();
aboutToAppear(): void {
// 加载数据
this.dataSource.setData(BIG_ETHNIC_LIST);
}
build() {
List({ space: 12 }) {
// 用 LazyForEach,而不是 ForEach
LazyForEach(this.dataSource, (ethnic: EthnicGroup) => {
ListItem() {
EthnicCard({ ethnic: ethnic })
}
}, (ethnic: EthnicGroup) => ethnic.id)
}
.width('100%')
.height('100%')
.cachedCount(5) // 缓存 5 项(上下各缓冲几项)
}
}
cachedCount 属性:
- 设置列表项的缓存数量
- 数值越大,越不容易出现白屏,但内存占用越高
- 一般设 3-10 就够了
- 默认值是 0(但框架内部也有自己的缓冲逻辑)
3.4 「民族图鉴」中的 LazyForEach 场景
「民族图鉴」目前只有 56 个民族,用 ForEach 就够了。但有些场景可以考虑用 LazyForEach:
| 场景 | 数据量 | 用不用 LazyForEach |
|---|---|---|
| 民族列表 | 56 | 不用(ForEach 足够) |
| 音乐列表 | 可能几百首 | 建议用 |
| 搜索结果 | 不确定 | 建议用 |
| 冷知识列表 | 几十条 | 不用 |
什么时候该用 LazyForEach?
- 数据量 > 50 条,考虑一下
- 数据量 > 100 条,推荐用
- 数据量 > 500 条,必须用
- 图片多、卡片复杂的,阈值可以低一点
步骤4:Grid——网格布局
4.1 什么时候用 Grid?
列表是一行一个项,但有时候需要多列布局,比如:
- 首页的民族精选(2列)
- 民族图鉴页(3列瀑布流?)
- 图片相册(多列网格)
这时候就用 Grid 组件——网格布局的列表。
4.2 Grid 的基本用法
@Entry
@Component
struct GridPage {
@State ethnicList: EthnicGroup[] = ETHNIC_GROUPS.slice(0, 8);
build() {
Grid() {
ForEach(this.ethnicList, (ethnic: EthnicGroup) => {
GridItem() {
// 网格项内容
Column({ space: 8 }) {
Image($rawfile(`coverImage/${ethnic.coverImage}`))
.width('100%')
.aspectRatio(1)
.objectFit(ImageFit.Cover)
.borderRadius(8)
Text(ethnic.name)
.fontSize(14)
.fontWeight(FontWeight.Medium)
.maxLines(1)
}
.width('100%')
}
}, (ethnic: EthnicGroup) => ethnic.id)
}
.columnsTemplate('1fr 1fr') // 两列,等宽
.rowsGap(12) // 行距
.columnsGap(12) // 列距
.padding(16)
.width('100%')
.height('100%')
}
}
注意:Grid 的子组件必须是 GridItem。
4.3 关键属性详解
columnsTemplate / rowsTemplate:
定义列和行的模板。
// 两列等宽
.columnsTemplate('1fr 1fr')
// 三列,中间宽两边窄
.columnsTemplate('1fr 2fr 1fr')
// 四列等宽
.columnsTemplate('1fr 1fr 1fr 1fr')
1fr 表示一份(fraction),几份就是几列。
rowsGap / columnsGap:
行距和列距,和 List 的 space 类似,但 Grid 两个方向都有。
layoutDirection:
布局方向,默认是按行排列(一行一行从左到右,从上到下)。
4.4 Grid 的懒加载
Grid 也支持 LazyForEach,数据量大的时候用:
Grid() {
LazyForEach(this.dataSource, (item: Item) => {
GridItem() {
// ...
}
})
}
步骤5:列表性能优化实战
学了这么多组件,让我们系统总结一下列表性能优化的各种技巧。从基础到进阶,一共 10 个优化技巧。
5.1 技巧1:选择合适的渲染方式
这是最基础也是最重要的优化——选对方案。
| 数据量 | 推荐方案 | 原因 |
|---|---|---|
| < 20 条 | Column/Row + ForEach | 简单,够用 |
| 20-100 条 | List + ForEach | 有复用,性能不错 |
| > 100 条 | List + LazyForEach | 懒加载,内存占用低 |
| 多列网格 | Grid + (ForEach/LazyForEach) | 网格布局 |
「民族图鉴」实践:56 个民族用 List + ForEach,简单够用;如果以后加民族音乐(几百首),就换成 LazyForEach。
5.2 技巧2:用唯一稳定的 key
key 是 diff 优化的基础,没有好的 key,其他优化都是白搭。
// ❌ 不要用 index
ForEach(items, (item, index) => { ... }, (item, index) => index)
// ✅ 用数据唯一 ID
ForEach(items, (item) => { ... }, (item) => item.id)
为什么不能用 index?
- 数组排序后,index 变了但数据没变
- 框架会误以为所有项都变了,全部重建
- 性能差,还可能有状态错乱
key 的三个原则:
- 唯一:同一列表中不能重复
- 稳定:同一项的 key 不应该变
- 简单:字符串或数字最好
5.3 技巧3:简化列表项结构
列表项越简单,渲染越快,滚动越流畅。
优化方向:
- 减少嵌套层级:尽量控制在 3-5 层以内
- 抽取小组件:复杂卡片拆成子组件
- 避免不必要的组件:能不用就不用
- 减少条件渲染:频繁切换的条件渲染会影响性能
// ❌ 不好:嵌套太深
Column() {
Row() {
Column() {
Row() {
Text(...)
}
}
}
}
// ✅ 好:结构扁平,用堆叠布局
Stack() {
Image(...)
Column() {
Text(...)
}
}
5.4 技巧4:提前计算,避免 build 时耗时
不要在 build 方法里做耗时计算,每次渲染都算一遍,性能差。
// ❌ 不好:每次 build 都计算
ForEach(this.list, (item: Item) => {
Text(this.calculateSomething(item))
})
// ✅ 好:提前算好,存到数据里
ForEach(this.list, (item: Item) => {
Text(item.calculatedValue)
})
常见的耗时操作:
- 复杂的格式化(日期、金额)
- 字符串拼接/处理
- 数组过滤/排序
- 图片路径处理
最佳实践:在拿到数据的时候就处理好,存到数据模型里,build 的时候直接用。
5.5 技巧5:优化图片加载
列表里的图片,往往是性能瓶颈。
优化要点:
- 用合适的尺寸:不要用 2000px 的大图显示在 100px 的位置
- 用合适的格式:WebP 优先,比 JPG/PNG 小
- objectFit 用 Cover:保持比例,填满容器
- 懒加载:LazyForEach 本身就是懒加载,图片也会按需加载
- 占位图:加载过程中显示占位图,避免布局跳动
Image(item.coverUrl)
.width(100)
.height(100)
.objectFit(ImageFit.Cover)
.borderRadius(8)
5.6 技巧6:减少滚动回调中的耗时操作
onScroll、onScrollIndex 等回调会在滚动时频繁触发(每帧都可能触发),不要在里面做复杂的事情。
// ❌ 不好:每次滚动都做很多事
List() { ... }
.onScroll(() => {
this.doSomethingHeavy(); // 耗时操作,会卡
})
// ✅ 好:防抖节流,或者只在需要的时候做
List() { ... }
.onScroll((scrollOffset) => {
// 简单的逻辑可以,复杂的要防抖
this.updateHeaderOpacity(scrollOffset);
})
常见场景优化:
- 顶部导航栏透明度变化 → 可以直接做,计算简单
- 滚动到底部加载更多 → 用 onReachEnd,不要自己判断
- 滚动时隐藏浮动按钮 → 加防抖,不要每帧都改
5.7 技巧7:合理设置 cachedCount
LazyForEach 的 cachedCount 控制缓存数量,不是越大越好,也不是越小越好。
| cachedCount | 内存占用 | 白屏概率 | 滚动流畅度 |
|---|---|---|---|
| 小(1-2) | 低 | 高(快速滚动可能白屏) | 一般 |
| 中(3-10) | 中 | 低 | 好 |
| 大(>20) | 高 | 很低 | 一般(内存压力大了反而卡) |
推荐值:
- 简单列表项(纯文字):3-5
- 复杂列表项(多图):5-10
- 根据实际测试调整
5.8 技巧8:使用 List 而不是 Scroll + Column
List 内置了虚拟化和组件复用,Scroll + Column + ForEach 没有。
// ❌ 性能差:Scroll + Column + ForEach
Scroll() {
Column() {
ForEach(this.list, (item) => { ... })
}
}
// ✅ 性能好:List + ForEach / LazyForEach
List() {
ForEach(this.list, (item) => {
ListItem() { ... }
})
}
List 的优势:
- 组件复用机制
- 滚动性能优化
- 内置分割线、分组等功能
- 支持 sticky header
5.9 技巧9:避免频繁的数据更新
数据每次更新,框架都要 diff 和重新渲染,太频繁了会卡。
优化方法:
- 批量更新:多次数据修改合并成一次
- 减少不必要的更新:只有真的变了才更新
- 用局部更新:只更新变化的那一项,不要全量刷新
// ❌ 不好:循环里一次次更新
for (let i = 0; i < list.length; i++) {
this.dataSource.updateItem(i, list[i]);
}
// ✅ 好:批量更新
this.dataSource.setData(newList);
5.10 技巧10:合理使用组件复用与缓存
除了框架自带的复用,我们还可以自己做一些缓存优化。
常见做法:
- 列表项组件用 @Observed + @ObjectLink:精细控制刷新
- 图片缓存:用框架的图片缓存机制
- 数据缓存:列表数据缓存,不要每次进入页面都重新加载
- 避免在 onPageShow 里全量刷新:只刷新变化的部分
「民族图鉴」实践:收藏列表用 onPageShow 时,只从 StorageService 取最新的收藏状态,不重新加载整个列表数据。
步骤6:实战——实现「民族图鉴」字母索引列表
理论讲了这么多,让我们动手实现一个实用的功能:字母索引列表。就像手机通讯录那样,右边有 A-Z 的字母索引,点哪个字母就滚到哪个分组。
这是「民族图鉴」百科页的一个实用功能——56 个民族按拼音首字母分组,右边字母索引快速定位。
6.1 需求分析
功能需求:
- 民族按拼音首字母分组(A、B、C…)
- 每组有 sticky header(粘性标题)
- 右侧有字母索引栏(A-Z)
- 点击字母,滚动到对应分组
- 滚动时,高亮当前所在的字母
技术方案:
- List + ListItemGroup 实现分组和 sticky header
- Scroller 控制滚动位置
- 右侧索引栏用自定义组件
- onScrollIndex 监听当前位置,更新高亮
6.2 数据准备
首先,我们需要把民族列表按拼音首字母分组:
// 分组后的数据结构
interface EthnicGroupItem {
letter: string; // 首字母(A、B、C...)
list: EthnicGroup[]; // 该字母下的民族列表
}
// 分组函数
function groupByFirstLetter(list: EthnicGroup[]): EthnicGroupItem[] {
const groupMap = new Map<string, EthnicGroup[]>();
for (const ethnic of list) {
const firstLetter = ethnic.pinyin[0].toUpperCase();
if (!groupMap.has(firstLetter)) {
groupMap.set(firstLetter, []);
}
groupMap.get(firstLetter)!.push(ethnic);
}
// 按字母排序
const result: EthnicGroupItem[] = [];
const letters = Array.from(groupMap.keys()).sort();
for (const letter of letters) {
result.push({
letter: letter,
list: groupMap.get(letter)!
});
}
return result;
}
6.3 完整实现
@Entry
@Component
struct AlphabetIndexList {
private scroller: Scroller = new Scroller();
@State groupedList: EthnicGroupItem[] = [];
@State currentLetter: string = 'A';
private allLetters: string[] = [];
aboutToAppear(): void {
this.groupedList = groupByFirstLetter(ETHNIC_GROUPS);
this.allLetters = this.groupedList.map(g => g.letter);
}
build() {
Stack() {
// 主列表
List({ scroller: this.scroller, space: 4 }) {
ForEach(this.groupedList, (group: EthnicGroupItem, groupIndex: number) => {
ListItemGroup({
header: this.buildGroupHeader(group.letter)
}) {
ForEach(group.list, (ethnic: EthnicGroup) => {
ListItem() {
this.buildEthnicItem(ethnic)
}
}, (ethnic: EthnicGroup) => ethnic.id)
}
}, (group: EthnicGroupItem) => group.letter)
}
.width('100%')
.height('100%')
.sticky(StickyStyle.Header)
.scrollBar(BarState.Off)
.edgeEffect(EdgeEffect.Spring)
.onScrollIndex((first: number) => {
// 滚动时更新当前字母
this.updateCurrentLetter(first);
})
// 右侧字母索引栏
this.buildAlphabetBar()
}
.width('100%')
.height('100%')
}
// 分组标题
@Builder
buildGroupHeader(letter: string) {
Text(letter)
.fontSize(14)
.fontWeight(FontWeight.Bold)
.width('100%')
.padding({ left: 16, right: 16, top: 8, bottom: 6 })
.backgroundColor('#F5F5F5')
.foregroundColor('#666666')
}
// 民族列表项
@Builder
buildEthnicItem(ethnic: EthnicGroup) {
Row({ space: 12 }) {
Image($rawfile(`coverImage/${ethnic.coverImage}`))
.width(48)
.height(48)
.borderRadius(24)
.objectFit(ImageFit.Cover)
Column({ space: 4 }) {
Text(ethnic.name)
.fontSize(16)
.fontWeight(FontWeight.Medium)
Text(ethnic.pinyin)
.fontSize(12)
.fontColor('#999999')
}
.alignItems(HorizontalAlign.Start)
Blank()
Text(ethnic.region)
.fontSize(12)
.fontColor('#999999')
}
.width('100%')
.padding(12)
.backgroundColor('#FFFFFF')
.borderRadius(8)
.margin({ left: 12, right: 12 })
.onClick(() => {
this.navigateToDetail(ethnic.id);
})
}
// 字母索引栏
@Builder
buildAlphabetBar() {
Column({ space: 2 }) {
ForEach(this.allLetters, (letter: string) => {
Text(letter)
.fontSize(12)
.fontWeight(this.currentLetter === letter ? FontWeight.Bold : FontWeight.Normal)
.fontColor(this.currentLetter === letter ? '#409EFF' : '#999999')
.width(24)
.height(20)
.textAlign(TextAlign.Center)
.borderRadius(10)
.backgroundColor(this.currentLetter === letter ? 'rgba(64, 158, 255, 0.1)' : 'transparent')
.onClick(() => {
this.scrollToLetter(letter);
})
})
}
.width(32)
.padding({ top: 8, bottom: 8 })
.backgroundColor('rgba(255, 255, 255, 0.9)')
.borderRadius(16)
.margin({ right: 4 })
.position({ x: '100%', y: '50%' })
.translate({ x: -36, y: '-50%' })
}
// 滚动到指定字母
scrollToLetter(letter: string): void {
// 找到这个字母分组的第一项在整个列表中的索引
let totalIndex = 0;
for (const group of this.groupedList) {
if (group.letter === letter) {
break;
}
totalIndex += group.list.length + 1; // +1 是 header
}
this.scroller.scrollToIndex(totalIndex);
this.currentLetter = letter;
}
// 更新当前字母
updateCurrentLetter(firstVisibleIndex: number): void {
// 计算第一个可见项属于哪个分组
let totalIndex = 0;
for (const group of this.groupedList) {
totalIndex += group.list.length + 1; // +1 是 header
if (firstVisibleIndex < totalIndex) {
if (this.currentLetter !== group.letter) {
this.currentLetter = group.letter;
}
break;
}
}
}
navigateToDetail(ethnicId: string): void {
// 跳转到详情页
}
}
6.4 实现要点解析
1. 分组数据结构:
- 先把数据按字母分好组
- 每组有一个 letter 和对应的 list
- 提前计算好,不要滚动时才分组
2. Sticky Header:
- 用 ListItemGroup 的 header 配合 List.sticky(StickyStyle.Header)
- 滚动时标题自动粘在顶部
3. Scroller 控制滚动:
- 创建 Scroller 对象,传给 List
- 点击字母时调用 scrollToIndex 滚动到指定位置
4. onScrollIndex 监听位置:
- 监听第一个可见项的索引
- 计算当前在哪个字母分组
- 更新右侧索引栏的高亮状态
5. 性能优化点:
- 分组数据提前计算好
- 用 ForEach 就够了(56 项不多)
- 字母索引栏用定位,不影响主列表布局
- 点击字母时直接滚动到索引,体验流畅
「民族图鉴」中的应用:
这个字母索引列表可以用在百科页,用户可以快速定位到某个拼音首字母的民族,比一点点滚动快多了。特别是 56 个民族,翻起来也要一会儿,有了字母索引,用户体验提升明显。
⚠️ 常见问题与解决方案
问题1:列表滚动卡顿,怎么办?
现象:
列表滑动的时候掉帧,不流畅,特别是快速滚动的时候。
排查方向:
| 原因 | 概率 | 解决方法 |
|---|---|---|
| 一次性渲染太多项 | 30% | 改用 LazyForEach |
| 列表项太复杂/嵌套深 | 25% | 简化布局,减少嵌套 |
| 图片太大太多 | 20% | 图片压缩,用合适尺寸 |
| 没有设置 key | 15% | 加唯一 key |
| onScroll 里有耗时操作 | 10% | 防抖,减少操作 |
优化步骤:
第1步:确认用的是 List 还是 Scroll + Column?
→ Scroll + Column → 改用 List
↓ 已经是 List
第2步:数据量多大?
→ >100 条 → 改用 LazyForEach
↓ 数量不多
第3步:列表项复杂吗?
→ 嵌套很深,图片很多 → 简化布局,优化图片
↓ 还好
第4步:key 设置了吗?是唯一稳定的吗?
→ 没有/用了 index → 改成数据唯一 ID
↓ 设置了
第5步:onScroll 里有什么?
→ 复杂逻辑 → 防抖/节流,或移出去
问题2:ForEach 和 LazyForEach 怎么选?
快速决策:
数据量多少?
│
├─ < 20 条 → ForEach(简单,够用)
│
├─ 20-100 条
│ ├─ 卡片简单(文字为主) → ForEach 也行
│ └─ 卡片复杂(多图/多组件) → 推荐 LazyForEach
│
└─ > 100 条 → 必须用 LazyForEach
另外考虑因素:
- 卡片越复杂,阈值越低(越应该用 LazyForEach)
- 低端机多,阈值低一些
- 快速滚动多,阈值低一些
💡 经验法则:拿不准的时候,先用 ForEach,跑起来看看。如果卡顿再换成 LazyForEach。不要过早优化,但也不要等到用户反馈卡了才优化。
问题3:列表项高度不固定怎么办?
现象:
每个列表项的高度不一样(比如有的简介长有的短),LazyForEach 还能用吗?
答案:可以用,没问题。
List + LazyForEach 支持动态高度,框架会自动测量每个项的高度。
List() {
LazyForEach(this.dataSource, (item: Item) => {
ListItem() {
// 内容多高就多高,不用固定高度
Column() {
Text(item.title)
Text(item.content)
.fontSize(14)
.lineHeight(22)
}
.width('100%')
}
})
}
注意:动态高度比固定高度性能稍差一点(因为要测量),但大多数场景感知不到。除非特别卡,否则不用刻意追求固定高度。
问题4:列表怎么跳转到指定位置?
现象:
想让列表滚动到第 20 项,或者滚动到顶部/底部。
解决方法:用 Scroller
@Entry
@Component
struct MyListPage {
private scroller: Scroller = new Scroller();
@State list: Item[] = [];
build() {
Column() {
Button('跳到第 20 项')
.onClick(() => {
// 滚动到指定位置
this.scroller.scrollToIndex(20);
})
Button('滚到顶部')
.onClick(() => {
this.scroller.scrollToIndex(0);
})
List({ scroller: this.scroller }) {
ForEach(this.list, (item: Item) => {
ListItem() {
Text(item.name)
}
})
}
.layoutWeight(1)
}
}
}
Scroller 的常用方法:
| 方法 | 说明 |
|---|---|
| scrollToIndex(index) | 滚动到指定项 |
| scrollTo(value) | 滚动到指定像素位置 |
| scrollPage(up/down) | 滚动一页 |
| currentOffset() | 获取当前滚动偏移 |
| isAtEnd() | 是否到底部 |
问题5:列表怎么下拉刷新/上拉加载?
现象:
列表需要下拉刷新和上拉加载更多的功能。
解决方法:
下拉刷新:用 Refresh 组件包裹
@Entry
@Component
struct RefreshListPage {
@State isRefreshing: boolean = false;
@State list: Item[] = [];
build() {
Refresh({
refreshing: this.isRefreshing,
onRefresh: () => {
// 下拉刷新回调
this.refreshData();
}
}) {
List() {
ForEach(this.list, (item: Item) => {
ListItem() {
Text(item.name)
}
})
}
}
}
refreshData(): void {
this.isRefreshing = true;
// 加载新数据...
setTimeout(() => {
// 加载完了
this.isRefreshing = false;
}, 2000);
}
}
上拉加载更多:监听 onScrollEnd 或者用 onReachEnd
List() {
ForEach(this.list, (item: Item) => {
ListItem() {
Text(item.name)
}
})
// 底部加载提示
if (this.isLoadingMore) {
ListItem() {
Text('加载中...')
.width('100%')
.textAlign(TextAlign.Center)
.padding(20)
}
}
}
.onReachEnd(() => {
// 滚动到底部了,加载更多
this.loadMore();
})
📝 本章小结
核心知识点
本文系统讲解了 ArkUI 的列表组件与性能优化:
1. ForEach
- 最基础的列表渲染,遍历数组生成子组件
- key 的重要性:唯一、稳定、不用 index
- 适合小数据量(几十条以内)
- 一次性渲染所有项,没有懒加载
2. List 组件
- 高性能列表容器,内置虚拟化和复用
- 子组件必须是 ListItem
- 比 Scroll + Column 性能好很多
- 支持滚动条、边缘效果、分割线等
3. LazyForEach 懒加载
- 大数据量必备,按需渲染
- 需要实现 IDataSource 接口
- 首屏快、内存低、滚动流畅
- cachedCount 控制缓存数量
4. Grid 网格布局
- 多列网格布局
- 子组件必须是 GridItem
- columnsTemplate 定义列数和宽度
- rowsGap / columnsGap 设置间距
- 也支持 LazyForEach
5. 性能优化
- 选对方案:数据量决定用 ForEach 还是 LazyForEach
- 唯一 key:用数据 ID,不用 index
- 简化列表项:减少嵌套,优化图片
- 减少滚动回调里的耗时操作
- 合理设置 cachedCount
最佳实践总结
✅ key 一定要用数据唯一 ID
// ✅ 正确
ForEach(this.list, (item: Item) => {
ItemCard({ item: item })
}, (item: Item) => item.id)
✅ 数据量 > 100 条用 LazyForEach
// 实现 IDataSource
// 用 List + LazyForEach
// 合理设置 cachedCount
✅ 列表容器用 List,不用 Scroll + Column
// ✅ List 有复用机制,性能好
List({ space: 12 }) {
ForEach(...)
}
✅ 列表项组件要抽取
// 不要把所有逻辑都写在 ForEach 里
// 抽成单独的组件,结构清晰,也好复用
ForEach(this.list, (ethnic: EthnicGroup) => {
EthnicCard({ ethnic: ethnic })
})
✅ 图片优化要重视
列表里的图片,往往是性能瓶颈
- 用合适的尺寸,不要大图小用
- 用合适的格式(WebP 优先)
- objectFit 用 Cover
✅ 不要过早优化,但也要心里有数
先写对,再优化
56 条数据,ForEach 就够了,不用硬上 LazyForEach
但要知道:数据量变大了,该换什么方案
下一步预告
在下一篇文章中,我们将:
- 🌍 深入学习资源管理的多端适配机制
- 📏 掌握 vp/fp/lpx 等单位的区别与使用场景
- 🎨 理解限定符的匹配规则与优先级
- 🌐 学会国际化(i18n)的实现方式
- 🎯 掌握深色模式的适配方法
- 🚀 为多设备、多语言、多主题适配打下基础
🔗 相关链接
- 项目源码: GitCode 仓库
- ForEach: 官方文档
- List 组件: 官方文档
- LazyForEach: 官方文档
- Grid 组件: 官方文档
- 列表性能优化: 官方指南
- 下拉刷新: 官方文档
💡 提示:列表是应用中最常见的界面,也是性能优化的重点。不要等到用户说卡了才来优化——写代码的时候就多想想:这个列表有多少条数据?卡片复杂吗?会不会快速滚动?心里有数,写出来的代码质量才高。好的列表体验,是流畅应用的基石。
更多推荐

所有评论(0)