HarmonyOS应用《民族图鉴》开发第64篇:列表性能优化——LazyForEach深度解析与最佳实践

📖 引言
打开「民族图鉴」,你最先看到的是什么?是列表。
点进「民族百科」,看到的是 56 个民族的网格列表。
点进「收藏」,看到的是收藏的民族列表。
点进「音乐」,看到的是民族音乐列表。
点进「历史」,看到的是浏览历史列表。
列表,是移动应用中最常见的界面形态,也是最容易出性能问题的地方。
为什么列表这么容易卡?因为:
- 数据量大:几十上百条,甚至上千条
- 滚动频繁:用户一直在划
- 每个 item 都可能复杂:图片、文字、按钮、标签……
- 内存压力大:每个 item 都占内存,滚多了内存暴涨
你可能会问:
- ForEach 和 LazyForEach 到底有什么区别?性能差多少?
- LazyForEach 的工作原理是什么?它是怎么做到"懒加载"的?
- 列表项怎么优化才能更流畅?
- key 为什么这么重要?为什么不能用 index 当 key?
- 列表的复用机制是怎么回事?组件池又是什么?
- 长列表怎么优化?虚拟列表是什么?
- 列表里的图片怎么优化才不会卡?
- 为什么滚动的时候内存会一直涨?怎么解决?
这些问题非常关键。列表性能好不好,直接决定了应用的"流畅度"——用户觉得一个 App 卡不卡,很大程度上取决于列表卡不卡。
本文将带你深入理解列表性能优化的方方面面。我们会从"为什么列表是性能重灾区"讲起,深入对比 ForEach 和 LazyForEach,解析 LazyForEach 的工作原理,然后逐一讲解列表优化的各种技巧,最后结合「民族图鉴」的 56 个民族列表进行实战优化。
🎯 学习目标
完成本文后,你将能够:
- ✅ 理解为什么列表是性能重灾区
- ✅ 掌握 ForEach 与 LazyForEach 的原理区别与性能差异
- ✅ 深入理解 LazyForEach 的工作原理:视口回收、组件复用、懒加载
- ✅ 掌握列表项优化的各种技巧:轻量化、减少层级、固定高度
- ✅ 理解复用机制、组件池与 key 的重要性
- ✅ 学会列表性能优化的高级手段:预加载、缓存、虚拟化
- ✅ 掌握长列表优化策略:数据分页、增量加载、虚拟列表
- ✅ 掌握列表中的图片优化策略
- ✅ 能够独立优化「民族图鉴」的列表性能
- ✅ 解决滚动卡顿、白屏闪烁、item 错位、内存增长等常见问题
💡 需求分析
为什么列表是性能重灾区?
在讲优化之前,我们先搞清楚一个问题:为什么列表这么容易出性能问题?
原因主要有四个:数据量大、滚动频繁、item 复杂、内存压力大。
1. 数据量大
列表的数据量通常很大:
- 「民族图鉴」的民族列表:56 项
- 音乐列表:可能几百首
- 搜索结果:可能上千条
- 资讯/社交类应用:可能无限加载
如果一次性把所有数据都渲染出来:
- 首屏要等所有 item 渲染完才能显示 → 首屏慢
- 内存里有所有 item 的组件 → 内存占用高
- 每次数据变化都要 diff 所有 item → 更新慢
数据量越大,性能问题越严重。
2. 滚动频繁
用户用列表的时候,一直在滚动:
- 往上划、往下划
- 快速划、慢慢划
- 划到底部加载更多
滚动的时候,新的 item 不断进入可视区域,旧的 item 不断滚出去。如果每个新出现的 item 都要从头创建和渲染,那滚动的时候肯定卡。
而且滚动是 60fps 的——每 16ms 就要渲染一帧。如果每帧要做的事情太多,超过 16ms,就掉帧了。
3. 每个 item 都可能很复杂
一个列表项可能包含:
- 图片(封面图、头像、图标……)
- 文字(标题、副标题、描述……)
- 按钮(收藏、分享、点赞……)
- 标签(分类、状态……)
- 嵌套的布局结构
item 越复杂,渲染越慢,内存占用越高。如果每个 item 都有好几张图、好几层嵌套,那滚动起来肯定卡。
4. 内存压力大
每个列表项都要占用内存:
- 组件实例的内存
- 图片的内存
- 布局数据的内存
如果一次性渲染几百上千项,内存占用会非常高。低端机可能直接 OOM(内存溢出)崩溃。
而且滚动的时候,如果滚出去的 item 不回收,内存会越滚越大,最后应用被杀掉或者崩溃。
列表性能问题的表现
列表性能不好,用户会有什么感受?
| 问题 | 用户感受 |
|---|---|
| 首屏加载慢 | 进去后白屏半天,列表才出来 |
| 滚动卡顿 | 划的时候一顿一顿的,不跟手 |
| 白屏闪烁 | 快速滚动的时候,有些地方白一下才出来 |
| 内存暴涨 | 划着划着应用就卡了,或者被杀了 |
| item 错位 | 滚动回来发现 item 的位置/内容不对了 |
这些问题,我们都会在本文中找到解决方案。
「民族图鉴」的列表场景
让我们看看「民族图鉴」有哪些列表场景:
| 场景 | 数据量 | item 复杂度 | 技术方案 |
|---|---|---|---|
| 民族列表(网格) | 56 项 | 中等(图标+文字) | Grid + LazyForEach |
| 民族列表(列表) | 56 项 | 简单(图标+两行文字) | List + LazyForEach |
| 收藏列表 | 不定 | 中等 | List + ForEach/LazyForEach |
| 浏览历史 | 不定 | 简单 | List + ForEach/LazyForEach |
| 音乐列表 | 不定(可能几百) | 中等 | List + LazyForEach |
| 首页精选网格 | 8 项 | 简单 | Grid + ForEach |
56 个民族,数量不算特别大,但用 LazyForEach 还是有好处的——特别是网格视图,一屏显示 20 个左右,用 LazyForEach 可以只渲染可见的 + 缓冲区,内存更省。
接下来,让我们深入学习列表优化的知识,然后把「民族图鉴」的列表优化到极致。
🛠️ 核心实现
步骤1:ForEach vs LazyForEach
提到列表性能优化,第一个想到的就是 LazyForEach。那它和 ForEach 到底有什么区别?
1.1 ForEach:全量渲染
ForEach 是最基础的列表渲染方式。它的工作方式很简单:一次性渲染所有项。
List() {
ForEach(this.items, (item: Item) => {
ListItem() {
// ... item 内容
}
}, (item: Item) => item.id)
}
ForEach 的特点:
| 特点 | 说明 |
|---|---|
| 全量渲染 | 所有数据一次性全部渲染成组件 |
| 内存占用高 | 所有 item 都在内存里 |
| 首屏慢 | 要等所有 item 渲染完 |
| 实现简单 | 用法简单,不需要 IDataSource |
| 适合小数据量 | 几十条以内没问题 |
ForEach 的适用场景:
- 数据量小(< 20~30 条)
- item 很简单
- 不会频繁滚动
- 所有数据都需要同时显示(比如短的设置列表)
1.2 LazyForEach:懒加载渲染
LazyForEach 是高性能列表渲染方式。它的工作方式是:按需渲染,只渲染可见区域的内容。
List() {
LazyForEach(this.dataSource, (item: Item) => {
ListItem() {
// ... item 内容
}
}, (item: Item) => item.id)
}
.cachedCount(5) // 缓存数量
LazyForEach 的特点:
| 特点 | 说明 |
|---|---|
| 懒加载 | 只渲染可见区域 + 缓冲区的 item |
| 内存占用低 | 内存里只有二三十项(可见+缓冲) |
| 首屏快 | 只渲染首屏可见的几项 |
| 滚动流畅 | 滚出去的回收,新进来的复用 |
| 需要 IDataSource | 用法稍复杂,要实现数据源接口 |
| 适合大数据量 | 几百上千条都没问题 |
LazyForEach 的适用场景:
- 数据量大(> 50~100 条)
- item 比较复杂
- 频繁滚动
- 长列表、无限列表
1.3 性能对比
ForEach 和 LazyForEach 的性能差距有多大?
我们用一个简单的测试来说明(假设数据量 500 条,item 中等复杂度):
| 指标 | ForEach | LazyForEach | 提升倍数 |
|---|---|---|---|
| 首屏渲染时间 | 1500ms | 100ms | 15x |
| 内存占用 | 200MB | 30MB | ~7x |
| 滚动 FPS | ~20 | ~55 | ~2.7x |
| 数据更新速度 | 慢(diff 500 项) | 快(只 diff 可见的) | 很多 |
可以看到,大数据量下,LazyForEach 的性能是碾压级的。
但如果数据量很小(比如只有 10 条),两者差别不大——甚至 ForEach 可能还快一点,因为 LazyForEach 有一些额外的管理开销。
1.4 怎么选?
选择决策树:
数据量多少?
│
├─ < 20 条
│ └─ ForEach(简单够用)
│
├─ 20~100 条
│ ├─ item 很简单(纯文字,1~2 层) → ForEach 也可以
│ └─ item 较复杂(多图,多层级) → 推荐 LazyForEach
│
└─ > 100 条
└─ 必须用 LazyForEach
「民族图鉴」的选择:
- 56 个民族,item 中等复杂度
- 网格视图一屏能显示 20 个左右
- 推荐用 LazyForEach(实际上项目里已经在用了)
虽然 56 条不算特别多,但用 LazyForEach 有几个好处:
- 内存占用更低
- 首屏渲染更快
- 以后数据量增长了也不怕
- 养成好习惯
步骤2:LazyForEach 的工作原理
LazyForEach 为什么这么快?它的"懒加载"是怎么实现的?
核心原理是三个机制:视口回收、组件复用、懒加载。
2.1 视口(Viewport)与缓冲区
什么是视口?
视口就是用户当前能看到的区域。屏幕就这么大,只能显示有限的列表项。
比如:
- 列表项高度 60px
- 屏幕高度 600px
- 一屏能显示 10 个 item
那视口内就只有 10 个 item。其他的 item,用户根本看不到,为什么要渲染呢?
什么是缓冲区?
如果只渲染视口内的 item,那用户滚动的时候,新出现的 item 要临时创建,可能来不及,导致白屏。
所以 LazyForEach 会在视口的上下各加一个缓冲区——提前渲染几个即将进入视口的 item。
┌─────────────┐
│ 缓冲区上 │ ← 提前渲染,马上要滚进来的
├─────────────┤
│ │
│ 视口 │ ← 用户当前看到的
│ │
├─────────────┤
│ 缓冲区下 │ ← 提前渲染,马上要滚进来的
└─────────────┘
缓冲区大小:
- 由
cachedCount属性控制(大致) - 数值越大,越不容易白屏,但内存占用越高
- 一般设 3~10 就够了
- 默认值由框架内部决定(一般上下各缓冲几屏)
为什么这样做?
- 只渲染可见的 + 缓冲区 → 内存占用低
- 有缓冲区 → 滚动的时候不会白屏
- 滚出去的 item 可以回收 → 内存不会一直涨
2.2 组件复用(Recycling)
光有视口回收还不够。如果每个新出现的 item 都要从头创建组件,那滚动的时候还是会卡——创建组件是有开销的。
所以 LazyForEach 还有一个重要机制:组件复用。
什么是组件复用?
滚出去的 item,组件不要销毁,而是放到一个"组件池"里。新进来的 item,从组件池里拿一个现成的组件,更新一下数据就用。
滚出去的 item → 放入组件池 → 新进来的 item 从池里拿 → 更新数据 → 显示
为什么复用性能好?
- 创建组件是有成本的(实例化、初始化……)
- 复用的话,只需要更新数据,不需要重新创建
- 滚动的时候,组件数量保持稳定(可见 + 缓冲 + 池)
- 内存也不会一直涨
类比一下:
就像餐厅的盘子。客人走了,盘子不要扔,洗一下,下一个客人接着用。这样不需要准备几百个盘子,几十个就够了,而且上菜也快。
2.3 懒加载(Lazy Loading)
懒加载有两层含义:
第一层:组件懒加载
- 不是一次性创建所有组件
- 滚到哪,创建(或复用)到哪
第二层:数据懒加载
- 数据也不一定一次性全加载
- 可以分页加载——滚到底部了再加载下一页
第二层是业务层面的,需要我们自己实现。LazyForEach 本身提供的是第一层——组件的懒加载。
2.4 整体工作流程
把三个机制合起来,LazyForEach 的整体工作流程是这样的:
初始状态:
1. 计算首屏可见的 item 数量
2. 创建这些 item 的组件
3. 加上缓冲区,多创建几个
4. 显示首屏
用户向下滚动:
1. 上面的 item 滚出视口
2. 这些 item 的组件回收到组件池
3. 下面缓冲区的 item 进入视口
4. 缓冲区下面再创建(复用)新的 item
5. 保持缓冲区大小
用户向上滚动:
类似,方向反过来
结果就是:
- 内存里的组件数量保持稳定(可见 + 缓冲 × 2 + 池)
- 不管数据量是 100 还是 10000,内存占用都差不多
- 滚动的时候,组件复用,性能流畅
💡 理解了这些原理,你就明白为什么 LazyForEach 性能好了。它的核心思想就是:不做无用功——用户看不到的东西,就不渲染;用过的东西,尽量重复用。
步骤3:列表项优化
LazyForEach 解决了"数量"的问题,但每个 item 本身的渲染速度也很重要。
如果每个 item 渲染要 5ms,一屏 10 个就是 50ms,远超 16ms 的预算,肯定卡。
所以我们还要优化列表项本身。
3.1 列表项轻量化
原则:列表项越简单越好。
列表项只是"预览",不是"详情"。只放最核心的信息,详细内容点进去再看。
怎么轻量化?
-
减少内容
- 只放标题、副标题、缩略图
- 详细描述、大量标签,都放到详情页
- 列表项的作用是"让用户快速浏览,找到感兴趣的"
-
减少图片
- 一个列表项最好不要超过 1~2 张图
- 小图标尽量用 iconfont 或者 SVG
- 大图列表(比如相册)要特别优化
-
减少组件数量
- 能合并的组件就合并
- 能用 Span 的不要用多个 Text
- 能不用容器的就不用
3.2 减少层级
列表项的布局层级越深,渲染越慢。因为布局是递归计算的,层级越深,计算量越大。
目标:列表项的层级控制在 3~4 层以内。
怎么减少层级?
这个我们上一篇讲过,这里再结合列表项复习一下:
-
避免不必要的嵌套
// ❌ 不好:没必要的嵌套 ListItem() { Column() { Row() { Column() { Text('标题') Text('副标题') } .margin({ left: 10 }) } .padding(10) } } // ✅ 好:更扁平的结构 ListItem() { Row() { Image(...) Column({ space: 4 }) { Text('标题') Text('副标题') } .layoutWeight(1) .margin({ left: 10 }) } .padding(10) } -
用 Span 减少 Text 组件
同一行的多样式文字,用 Span 代替多个 Text + Row。 -
属性直接加在组件上
不要为了加个背景色、padding 就套一层容器。
3.3 固定高度
如果列表项的高度是固定的,LazyForEach 的性能会更好。
为什么?
- 高度固定的话,框架不用测量每个 item 的高度
- 布局计算更快
- 滚动位置计算更准确
- 复用也更高效
怎么固定高度?
直接给 ListItem 设置高度:
ListItem() {
// ... 内容
}
.height(60) // 固定高度
或者用 .aspectRatio() 固定宽高比。
什么时候可以固定高度?
- item 内容是固定的,不会变化
- item 内容的行数是固定的(比如标题最多 1 行,副标题最多 2 行)
什么时候不能固定高度?
- item 内容高度不固定(有的长有的短)
- 动态内容,可能变化
💡 建议:能固定高度就固定,性能更好。实在不能固定也没关系,LazyForEach 也支持动态高度,只是稍微慢一点。
3.4 避免动态计算
列表项的 build 函数里,不要做复杂的计算。
为什么?
build 函数会被频繁调用(每次状态变化、每次复用更新数据),如果里面有复杂计算,会拖慢渲染。
不好的做法:
// ❌ 不好:每次 build 都计算
ForEach(this.list, (item: Item) => {
ListItem() {
Text(this.formatDate(item.timestamp)) // 每次都格式化
Text(this.calculateSomething(item)) // 每次都计算
}
})
好的做法:
// ✅ 好:提前算好,存到数据里
// 拿到数据的时候就格式化好
const processedList = rawList.map(item => ({
...item,
formattedDate: formatDate(item.timestamp),
calculatedValue: calculateSomething(item)
}));
// 渲染的时候直接用
ForEach(processedList, (item: Item) => {
ListItem() {
Text(item.formattedDate) // 直接用,不用计算
Text(item.calculatedValue) // 直接用,不用计算
}
})
原则:build 函数里只做渲染,不做计算。计算逻辑提前做好,存到数据里。
步骤4:复用机制与 key 的重要性
列表的复用机制很重要,但复用的正确性,依赖于key。
4.1 key 是用来干什么的?
当列表数据变化的时候(比如增加、删除、排序),框架需要知道:
- 哪些项是新增的?
- 哪些项是删除的?
- 哪些项只是移动了位置?
- 哪些项的数据更新了?
有了 key,框架就能精准地 diff,只更新变化的部分,复用没变化的组件。
没有 key,或者 key 不正确,框架就只能"全部重建"——性能就差了。
4.2 好的 key 长什么样?
好的 key 有三个特点:
- 唯一:同一个列表里,每个项的 key 不能重复
- 稳定:同一个项的 key 不会变(数据更新了,key 还是同一个)
- 简单:字符串或数字就行,不要太复杂
最好的 key:数据的唯一 ID
// ✅ 好:用数据本身的唯一 ID
ForEach(this.ethnicList, (ethnic: EthnicGroup) => {
ListItem() { ... }
}, (ethnic: EthnicGroup) => ethnic.id) // 用民族 ID 当 key
每个民族都有一个唯一的、不变的 ID,用它当 key 最合适。
4.3 为什么不能用 index 当 key?
很多人图省事,用数组的 index 当 key:
// ❌ 不好:用 index 当 key
ForEach(this.list, (item: Item, index: number) => {
ListItem() { ... }
}, (item: Item, index: number) => index.toString())
为什么不好?
因为 index 是不稳定的。如果列表有增删排序,index 就变了,但其实数据还是那些数据。
举个例子:
- 初始列表:[A, B, C],index 分别是 0, 1, 2
- 删除了 A,列表变成:[B, C],index 变成 0, 1
- 这时候,B 的 index 从 1 变成了 0,C 的 index 从 2 变成了 1
- 框架一看:key 全变了!以为所有项都是新的,全部重建
结果就是:明明只删除了第一项,但所有项都重建了一遍,性能很差。
用 ID 的情况:
- 初始列表:[A(id=1), B(id=2), C(id=3)]
- 删除了 A,列表变成:[B(id=2), C(id=3)]
- 框架一看:key=1 没了(删除),key=2 和 key=3 还在(复用)
- 结果就是:只删除第一项,其他两项复用
性能天差地别。
🎯 记住:只要能用数据唯一 ID 当 key,就不要用 index。这是列表性能优化的基础。
4.4 组件池与复用的关系
有了正确的 key,组件复用才能正常工作。
复用的流程:
- 一个新的 item 要显示了
- 框架根据 key 去组件池里找:有没有相同 key 的组件?
- 如果有:拿出来,更新数据,继续用
- 如果没有:创建一个新的
如果 key 不正确(比如用 index),框架就找不到可以复用的组件,只能每次都创建新的——复用机制就失效了。
所以说:key 是复用机制的基础。key 不正确,复用就无从谈起。
步骤5:列表性能优化手段
讲完了基础,我们来看看列表性能优化的一些高级手段。
5.1 预加载(Preloading)
什么是预加载?
提前加载用户"可能会看"的内容,等用户真正看到的时候,已经加载好了。
列表中的预加载:
- 数据预加载:滚到接近底部的时候,提前加载下一页数据
- 图片预加载:缓冲区里的 item,提前加载图片
- 页面预加载:预判用户会点哪个 item,提前加载详情页数据
数据预加载(上拉加载更多):
List() {
LazyForEach(this.dataSource, (item: Item) => {
ListItem() { ... }
})
// 底部加载提示
if (this.isLoadingMore) {
ListItem() {
Text('加载中...')
.width('100%')
.textAlign(TextAlign.Center)
.padding(20)
}
}
}
.onReachEnd(() => {
// 滚到底部了,加载更多
if (!this.isLoadingMore && this.hasMore) {
this.loadMore();
}
})
优化技巧:
- 不要等滚到底了再加载,提前一点(比如还差 10 项的时候就开始加载)
- 这样用户感觉不到加载过程,以为是无限的
- 但也不要太早,避免浪费流量和性能
5.2 缓存(Caching)
列表中的缓存:
- 图片缓存:加载过的图片缓存起来,不用重新下载/解码
- 数据缓存:加载过的数据缓存起来,不用重新请求
- 组件缓存:LazyForEach 的组件池也是一种缓存
图片缓存尤其重要——图片是列表性能的大头。我们会在后面专门讲。
5.3 虚拟化(Virtualization)
虚拟化是 LazyForEach 背后的核心技术。简单说就是:
- 只有可见的元素是"真实"的
- 不可见的元素只是"数据",不占组件,不占内存
LazyForEach 本身就是虚拟化列表。大多数情况下,用 LazyForEach 就够了,不需要自己实现虚拟化。
5.4 瀑布流优化
瀑布流(多列不等高)是比较特殊的列表,性能优化也更有挑战。
瀑布流的性能问题:
- 每个 item 高度不一样,布局计算更复杂
- 高度不固定,复用效率稍低
- 快速滚动的时候,可能出现位置计算错误
优化建议:
-
尽量给 item 一个预估高度
即使不能完全固定,给个预估高度也能帮助框架更快计算。 -
减少 item 内部的布局变化
瀑布流本身布局计算就重,如果 item 内部还频繁变化,会更卡。 -
图片要固定宽高比
图片加载完成后不要改变 item 的高度,不然会导致"重排瀑布流"——所有项的位置都要重新算,非常慢。
步骤6:长列表优化
如果列表特别长(几百上千条,甚至无限加载),还需要一些额外的优化手段。
6.1 数据分页
不要一次性加载所有数据,一页一页加载。
为什么?
- 初始加载快(只加载第一页)
- 节省流量(用户可能看不了那么多)
- 服务端压力也小
分页策略:
- 每页 20~50 条(根据 item 复杂度调整)
- 滚到底部加载下一页
- 可以提前几屏开始加载(更流畅)
6.2 增量加载
数据更新的时候,不要全部替换,只更新变化的部分。
不好的做法:
// ❌ 不好:直接替换整个数组,全部重绘
this.list = newList;
好的做法:
// ✅ 好:增量更新,只加新的
this.list.push(...newItems); // 追加,而不是替换
对于 LazyForEach + IDataSource,可以用增量更新的方法:
// 追加数据
appendData(data: Item[]): void {
const startIndex = this.dataList.length;
this.dataList = this.dataList.concat(data);
// 通知框架:新增了这些项
for (let i = 0; i < data.length; i++) {
this.notifyDataAdd(startIndex + i);
}
}
增量更新比全量更新性能好很多——框架知道哪些是新增的,只处理新增的部分。
6.3 虚拟列表的极限优化
如果列表特别特别长(几万条),普通的 LazyForEach 可能也不够用了。这时候需要更极端的优化:
-
简化 item 到极致
每个 item 越简单越好,尽量只有文字,少用图片。 -
固定高度
必须固定高度,这样布局计算最快。 -
更大的复用池
适当增加 cachedCount,减少创建新组件的概率。 -
回收机制优化
确保滚出去的组件及时回收,内存不泄漏。
但对于大多数应用来说,LazyForEach 已经足够了。真的有几万条数据的场景不多。
步骤7:图片优化
列表性能问题,图片占了一半以上的功劳。
图片为什么这么影响性能?
- 图片解码耗时(特别是大图)
- 图片内存占用大
- 图片绘制也耗时
- 网络加载图片更慢
所以列表优化,图片优化是重中之重。
7.1 固定宽高
列表里的图片,一定要固定宽高。
为什么?
- 不固定的话,图片加载完会把 item 撑开
- 导致列表布局重新计算(重排)
- 甚至可能导致整个列表的位置都变了
- 用户会看到"跳一下"的感觉,体验很差
怎么固定?
Image(src)
.width(80)
.height(80)
.objectFit(ImageFit.Cover)
或者用宽高比:
Image(src)
.width('100%')
.aspectRatio(1) // 1:1 正方形
.objectFit(ImageFit.Cover)
🎯 记住:列表里的图片必须有确定的尺寸。这是铁则。
7.2 占位图
图片加载需要时间,加载过程中显示什么?
不好的做法:
- 空白(白屏闪烁)
- 直接显示原图(慢慢从上到下出现)
好的做法:显示占位图
占位图的几种形式:
-
纯色占位
最简单,用个纯色方块占位置。 -
模糊小图
先用一张很小的缩略图(几 KB),模糊显示,等大图加载完再替换。 -
骨架屏
用灰色的占位块,模拟内容的大致形状。
占位图的好处:
- 避免白屏闪烁
- 布局稳定(因为尺寸固定了)
- 用户知道"这里有内容,正在加载"
7.3 滑动暂停加载
快速滚动的时候,新的 item 不断出现,如果每个都立刻加载图片,滚动会很卡——因为图片解码和绘制都要时间,会抢占主线程。
所以有一个优化技巧:滚动的时候不加载图片,滚动停下来再加载。
为什么这样好?
- 滚动的时候,用户快速划过,图片加载了用户也看不清
- 不如把资源留给滚动,保证滚动流畅
- 停下来了,用户要看了,再加载图片
怎么实现?
监听列表的滚动状态:
- 滚动开始 → 暂停加载新图片
- 滚动结束 → 开始加载可见区域的图片
这个技巧在快速滚动的时候效果特别明显——滚动会流畅很多。
7.4 图片尺寸优化
用合适尺寸的图片,不要大图小用。
比如:
- 列表里的缩略图,只需要 100×100
- 就不要用 1000×1000 的大图,然后缩小显示
为什么不好?
- 大图文件大,加载慢
- 大图解码慢,耗 CPU
- 大图占内存多(1000×1000 的图占 4MB,100×100 只占 40KB,差 100 倍!)
怎么做?
- 服务端提供多尺寸的图片(缩略图、中图、大图)
- 列表用缩略图,详情页用大图
- 本地图片也要准备合适尺寸的
图片内存的计算:
图片内存 = 宽度 × 高度 × 4 字节(RGBA)
比如:
- 100×100:100 × 100 × 4 = 40,000 字节 ≈ 40KB
- 500×500:500 × 500 × 4 = 1,000,000 字节 ≈ 1MB
- 1000×1000:1000 × 1000 × 4 = 4,000,000 字节 ≈ 4MB
差距非常大。所以图片尺寸优化,收益非常高。
7.5 图片格式优化
不同的图片格式,大小和质量也不一样:
| 格式 | 透明度 | 动画 | 压缩率 | 适用场景 |
|---|---|---|---|---|
| JPG | ❌ | ❌ | 高 | 照片、复杂图片 |
| PNG | ✅ | ❌ | 中 | 图标、简单图形、透明图 |
| WebP | ✅ | ✅ | 很高 | 大部分场景都适用 |
| SVG | ✅ | ❌ | 高(矢量) | 图标、简单图形 |
WebP 是个好选择——同样质量,比 JPG 小 25%~35%,比 PNG 小更多。而且支持透明度和动画。
图标优先用 SVG 或 iconfont——矢量图,缩放不失真,文件也小。
7.6 图片缓存
加载过的图片,缓存起来,下次用的时候直接从缓存里拿,不用重新下载/解码。
缓存的层级:
- 内存缓存:最快,但容量有限,应用被杀就没了
- 磁盘缓存:较慢,但容量大,应用重启还在
- 网络:最慢,最"贵"(耗流量)
缓存策略:
- 最近常用的图片放内存缓存
- 更多的图片放磁盘缓存
- 内存缓存要有大小限制,满了就淘汰最久没用的(LRU)
「民族图鉴」现在用的是本地 rawfile 图片,没有网络加载的问题。但内存缓存还是需要的——图片解码也需要时间,缓存解码后的图片,复用的时候更快。
步骤8:实战——「民族图鉴」56个民族列表性能优化对比
讲了这么多理论,让我们来实战一下——优化「民族图鉴」的民族列表。
8.1 优化前的状态
先看看优化前的情况:
列表实现:
- 网格模式:Grid + LazyForEach
- 列表模式:List + LazyForEach
- 数据源:EthnicListDataSource(继承 BasicDataSource)
- 已经用了 LazyForEach,基础不错
item 结构(网格模式):
GridItem
└─ Column
├─ Text(首字图标)
├─ Text(名称)
└─ Text(人口)
item 结构(列表模式):
ListItem
└─ Row
├─ Text(首字图标)
├─ Column
│ ├─ Text(名称)
│ └─ Text(地区·人口)
└─ Text(箭头)
目前的问题:
- 网格模式用了 Scroll + Grid + LazyForEach,Scroll 可能和 Grid 的滚动有冲突
- item 里有 pressedIndex 状态,点击缩放动画每次都触发重绘
- 用的是文字首字图标,这个挺好,没有图片加载问题
- 层级还可以,不算特别深
8.2 可以优化的点
让我们分析一下可以优化的地方:
| 优化项 | 优化手段 | 预期收益 | 优先级 |
|---|---|---|---|
| Grid 滚动优化 | 直接用 Grid 滚动,不要外层 Scroll | 中 | P0 |
| 点击状态优化 | 用 transform 做缩放,减少重绘 | 小 | P1 |
| 列表项层级优化 | 再看看能不能减少层级 | 小 | P1 |
| key 优化 | 现在的 key 是 ${id}_${index},index 多余,直接用 id |
小 | P2 |
| 固定 item 高度 | 给 GridItem/ListItem 固定高度 | 中 | P1 |
| renderGroup | 给 item 开 renderGroup | 待验证 | P2 |
8.3 实施优化
优化一:Grid 滚动优化
现在的实现是:
Scroll() {
Grid() {
LazyForEach(...)
}
}
外面套了一层 Scroll。其实 Grid 本身就支持滚动,不需要外层 Scroll。外层 Scroll 反而可能和 Grid 的滚动冲突,影响性能。
优化后:
Grid() {
LazyForEach(...)
}
.scrollBar(BarState.Off)
.layoutWeight(1)
收益:
- 去掉一层 Scroll,减少层级
- 滚动更原生,性能更好
- 避免滚动冲突
优化二:点击缩放用 transform
现在的点击缩放是通过 .scale() 实现的,这个其实已经是 transform 了,没问题。
但 pressedIndex 是一个整体状态,每次点击都会改变,导致所有 item 都要检查要不要重绘。虽然影响不大,但可以优化。
优化思路:把按下状态下放到每个 item 内部,而不是用一个全局的 pressedIndex。
但这个改造成本有点高,收益不大,可以先不做。
优化三:key 简化
现在的 key 是 ${ethnic.id}_${index}:
LazyForEach(this.lazyDataSource, (ethnic: EthnicGroup, index: number) => {
// ...
}, (ethnic: EthnicGroup, index: number) => `${ethnic.id}_${index}`)
其实用 ethnic.id 就够了,index 是多余的。因为 id 已经是唯一的了。
优化后:
LazyForEach(this.lazyDataSource, (ethnic: EthnicGroup) => {
// ...
}, (ethnic: EthnicGroup) => ethnic.id)
收益:
- key 更简洁
- 避免 index 变化导致 key 变化(比如排序后)
- 复用更准确
优化四:固定 item 高度
网格模式的 item 高度可以固定一下:
GridItem() {
this.buildGridCard(ethnic, index)
}
.height(100) // 固定高度
列表模式的 item 已经有高度了(.height(56)),这个不用改。
收益:
- Grid 布局计算更快
- 减少测量时间
- 复用更高效
8.4 优化效果对比
优化完了,对比一下效果:
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 首屏渲染时间 | ~200ms | ~150ms | 25% |
| 滚动 FPS | ~50 | ~55 | 10% |
| 内存占用 | ~50MB | ~45MB | 10% |
| 快速滚动白屏 | 偶尔 | 几乎没有 | 明显改善 |
(注:以上数据是估算的,实际数据需要在设备上测量。)
可以看到,虽然已经用了 LazyForEach,但通过细节优化,还能再提升一截。
步骤9:常见问题与解决方案
最后,我们来聊聊列表优化中常见的问题和解决方案。
9.1 问题一:滚动卡顿
现象:
列表滑动的时候掉帧,不流畅,特别是快速滚动的时候。
原因及解决方案:
| 原因 | 概率 | 解决方案 |
|---|---|---|
| 用了 ForEach 而不是 LazyForEach | 30% | 改用 LazyForEach |
| 列表项太复杂/层级深 | 25% | 简化 item,减少层级 |
| 图片太大太多 | 20% | 图片压缩,合适尺寸,懒加载 |
| key 不正确/没有 key | 15% | 用数据唯一 ID 当 key |
| 滚动回调里有耗时操作 | 10% | 防抖,减少操作 |
排查步骤:
第1步:确认用的是 List + LazyForEach 吗?
→ 不是 → 改用 List + LazyForEach
↓ 是
第2步:key 是数据唯一 ID 吗?
→ 不是/用 index → 改成唯一 ID
↓ 是
第3步:item 复杂吗?层级深吗?
→ 是 → 简化布局,减少层级
↓ 还好
第4步:图片多吗?大吗?
→ 是 → 优化图片(尺寸、格式、懒加载)
↓ 还好
第5步:onScroll/onTouch 里有什么?
→ 复杂逻辑 → 防抖/节流,或移出去
9.2 问题二:白屏闪烁
现象:
快速滚动的时候,新出现的 item 白一下才显示出来。
原因:
- 缓冲区太小,来不及渲染
- 图片加载慢,没加载完就是白的
- item 渲染太慢
解决方案:
-
增大 cachedCount
List() { ... } .cachedCount(10) // 适当增大缓存但也不要太大,内存会增加。
-
图片用占位图
图片加载过程中显示占位图,不是白的。 -
滑动暂停加载图片
滚动的时候不加载,滚完再加载。但这个要配合占位图,不然还是白的。 -
简化 item
item 越简单,渲染越快,越不容易白屏。
9.3 问题三:item 错位
现象:
滚动回来之后,发现 item 的内容/位置不对了——比如显示的是别的 item 的数据。
原因:
- 组件复用的时候,数据没更新对
- key 不正确,导致复用到了错误的组件
- 组件内部有状态,复用的时候状态没重置
解决方案:
-
检查 key
确保 key 是唯一且稳定的。不要用 index。 -
确保组件是"纯"的
列表项组件最好是纯展示组件——给什么数据就显示什么,不要有自己的内部状态(或者内部状态要正确重置)。 -
用 @State 的时候小心
如果列表项内部有 @State,复用时要确保状态正确更新。最好把状态提到数据层面,不要让组件自己存状态。
9.4 问题四:内存增长
现象:
列表滚动的时候,内存一直涨,滚得越久,内存越高。
原因:
- 图片内存:滚过的图片都在内存里
- 组件泄漏:滚出去的组件没有被回收
- 没有复用:每次都创建新组件,旧的没释放
解决方案:
-
确认用了 LazyForEach
ForEach 的话,滚多少就创建多少,内存一直涨。LazyForEach 会回收。 -
图片内存优化
- 图片缓存有大小限制,超过了就释放旧的
- 用合适尺寸的图片(不要大图小用)
- 离屏的图片可以考虑释放(但要平衡加载速度)
-
检查内存泄漏
- 列表项里有没有注册监听、定时器,没移除?
- 有没有闭包引用,导致组件回收不了?
- 用 Profiler 看一下内存快照,找泄漏点
-
优化复用
- 确保 key 正确
- 适当增加 cachedCount,提高复用率
9.5 问题五:数据更新后列表不刷新/刷新慢
现象:
- 数据更新了,但列表没变化
- 或者更新很慢,要等很久才刷新
原因:
- 没有正确通知数据变化
- 用了全量更新,每次都全部重绘
- key 不正确,diff 效率低
解决方案:
-
正确使用 IDataSource 的 notify 方法
- 全量变了用 notifyDataReload
- 新增用 notifyDataAdd
- 修改用 notifyDataChange
- 删除用 notifyDataDelete
- 不要每次都用 notifyDataReload(性能差)
-
尽量用增量更新
能追加就追加,能改几条就改几条,不要每次都全量刷新。 -
确保 key 正确
key 正确,diff 才快。
⚠️ 常见问题与解答
Q1:多少条数据需要用 LazyForEach?
A:没有绝对的标准,要看 item 的复杂度。
粗略参考:
| 数据量 | item 简单(纯文字) | item 中等(有图) | item 复杂(多图多组件) |
|---|---|---|---|
| < 20 | ForEach | ForEach | 考虑 LazyForEach |
| 20~50 | ForEach | 考虑 LazyForEach | 推荐 LazyForEach |
| 50~100 | 考虑 LazyForEach | 推荐 LazyForEach | 必须 LazyForEach |
| > 100 | 推荐 LazyForEach | 必须 LazyForEach | 必须 LazyForEach |
简单判断:
- 一屏能显示完的列表 → ForEach
- 超过 2~3 屏的 → 考虑 LazyForEach
- 超过 5 屏的 → 推荐 LazyForEach
💡 建议:拿不准的时候,先用 ForEach,跑起来看看。如果卡顿再换成 LazyForEach。不要过早优化,但也要心里有数。
Q2:cachedCount 设多少合适?
A:一般 3~10 就够了。
cachedCount 的影响:
| cachedCount | 白屏概率 | 内存占用 | 推荐场景 |
|---|---|---|---|
| 0~2 | 高(快速滚动可能白屏) | 最低 | 低端机/内存紧张 |
| 3~5 | 中(正常滚动没问题) | 中等 | 大多数情况 |
| 5~10 | 低(快速滚动也很少白屏) | 较高 | 高性能设备/追求流畅 |
| > 10 | 很低 | 高 | 不推荐,性价比低 |
建议:
- 先设 5,试试看
- 如果快速滚动经常白屏,就加一点
- 如果内存紧张,就减一点
不是越大越好——太大的话内存占用高,反而可能因为内存不足导致卡顿。
Q3:列表里的图片,用本地的好还是网络的好?
A:这个问题的答案是——看情况。但从性能角度说:
本地图片的优点:
- 加载快,没有网络延迟
- 不耗流量
- 离线也能用
本地图片的缺点:
- 增加包体积
- 图片数量有限(不能太多)
- 不能动态更新
网络图片的优点:
- 不占包体积
- 可以动态更新
- 数量不受限制
网络图片的缺点:
- 加载慢(受网络影响)
- 耗流量
- 离线用不了
「民族图鉴」的情况:
- 56 个民族的封面图,数量不多
- 都是固定内容,不需要动态更新
- 用本地 rawfile 图片就挺好
如果以后图片多了(比如几百首音乐的封面),可以考虑换成网络图片 + 缓存。
Q4:列表项的点击效果会影响性能吗?
A:如果做得不好,会有一点影响,但一般不大。
不好的做法:
- 点击的时候改变背景色 → 触发重绘
- 点击的时候改变大小(用 width/height) → 触发重排
好的做法:
- 点击缩放用 transform.scale → 只触发合成,性能好
- 透明度变化用 opacity → 只触发合成,性能好
「民族图鉴」的列表点击用的是 .scale() + .opacity() + .animation(),这个是对的,性能不错。
但要注意:
如果用一个全局的 pressedIndex 来控制所有 item 的状态,每次点击所有 item 都要检查一遍。虽然影响不大,但如果 item 特别多,可以考虑把状态下放到每个 item 内部。
Q5:瀑布流比普通列表性能差很多吗?
A:是的,瀑布流性能更差一些,但也不是不能用。
瀑布流为什么慢?
- 高度不固定,每个都要测量
- 布局计算更复杂(要算放在哪一列)
- 复用效率稍低(因为高度不一样)
瀑布流优化建议:
- item 尽量简单
- 图片一定要固定宽高比(不要加载完改变高度)
- 数据量不要太大(几百项以内比较好)
- 适当增加缓冲,减少白屏
- 尽量用固定宽度,高度按比例算(这样布局计算快一些)
📝 本章小结
核心知识点
本文系统讲解了列表性能优化:
1. 为什么列表是性能重灾区
- 数据量大
- 滚动频繁
- item 可能复杂
- 内存压力大
2. ForEach vs LazyForEach
- ForEach:全量渲染,适合小数据量
- LazyForEach:懒加载,适合大数据量
- 大数据量下性能差距很大(几倍到几十倍)
3. LazyForEach 工作原理
- 视口回收:只渲染可见的 + 缓冲区
- 组件复用:滚出去的放组件池,新进来的复用
- 懒加载:滚到哪渲染到哪
4. 列表项优化
- 轻量化:减少内容,减少图片,减少组件
- 减少层级:3~4 层以内最好
- 固定高度:能固定就固定,性能更好
- 避免动态计算:build 里只渲染,不计算
5. 复用与 key
- key 是复用的基础
- 好的 key:唯一、稳定、简单
- 不要用 index 当 key(不稳定)
- 推荐用数据的唯一 ID
6. 列表优化手段
- 预加载:提前加载数据/图片
- 缓存:图片缓存、数据缓存
- 虚拟化:LazyForEach 就是虚拟化列表
- 瀑布流优化:固定宽高比,简化 item
7. 长列表优化
- 数据分页:一页一页加载
- 增量加载:只更新变化的部分
- 极限优化:简化 item、固定高度、增大复用池
8. 图片优化
- 固定宽高:必须!防止布局抖动
- 占位图:避免白屏闪烁
- 滑动暂停加载:滚动更流畅
- 尺寸优化:用合适尺寸的图,不要大图小用
- 格式优化:WebP > JPG > PNG
- 图片缓存:内存缓存 + 磁盘缓存
最佳实践总结
✅ 数据量大一定要用 LazyForEach
// ✅ 好:大数据量用 LazyForEach
List() {
LazyForEach(this.dataSource, (item: Item) => {
ListItem() { ... }
}, (item: Item) => item.id)
}
✅ key 一定要用数据唯一 ID
// ✅ 好:用 ID,唯一稳定
(item: Item) => item.id
// ❌ 不好:用 index,不稳定
(item: Item, index: number) => index.toString()
✅ 列表项图片必须固定宽高
// ✅ 好:固定尺寸,防止布局抖动
Image(src)
.width(80)
.height(80)
.objectFit(ImageFit.Cover)
✅ 列表项尽量简单,层级尽量少
目标:3~4 层以内
能合并的合并,能不用的容器就不用
能用 Span 的不要用多个 Text
✅ build 函数里只做渲染,不做计算
// ✅ 好:提前算好,渲染直接用
Text(item.formattedDate) // 不用每次都格式化
// ❌ 不好:每次 build 都计算
Text(this.formatDate(item.date))
✅ 图片优化要重视
合适的尺寸 → 节省内存,加载更快
合适的格式 → 减小体积
占位图 → 避免白屏
缓存 → 更快加载
下一步预告
在下一篇文章(第65篇)中,我们将:
- 🧠 理解为什么要关注内存:OOM 崩溃、卡顿、后台被杀
- 💾 学习鸿蒙应用的内存模型:JS 堆、Native 堆、图形内存
- 🕳️ 了解内存泄漏的常见原因:未移除的监听、定时器、闭包、缓存过大
- 🔍 掌握内存泄漏的检测方法:Profiler、手动打点、代码审查
- 🖼️ 学习图片内存优化:压缩、采样、复用、及时释放
- 📦 理解缓存策略:LRU 缓存、大小限制、自动清理
- 📋 掌握列表的内存优化:滚动回收、大图处理、分页加载
- ♻️ 理解对象池与复用模式
- 🎯 实战排查「民族图鉴」的内存泄漏
- 💡 解决列表滚动内存暴涨、页面返回内存不下降、OOM 崩溃等常见问题
内存是移动端的稀缺资源。做好内存优化,让你的应用更稳定、更流畅、更"长寿"。
🔗 相关链接
💡 提示:列表是应用中最常见的界面,也是性能优化的重点。做好列表优化,应用的流畅度会提升一个档次。记住几个关键点:用 LazyForEach、正确的 key、简单的 item、优化好图片。把这几点做好,大多数列表的性能就都没问题了。
更多推荐



所有评论(0)