
长列表的卡顿,到底卡在哪?
在HarmonyOS NEXT的应用开发中,列表是最常见的交互形式。当你需要展示数百甚至数千条数据时,List组件配合ForEach是最直接的写法。但很多人在第一次尝试加载5000条新闻列表时,会发现在模拟器上滑动就已经开始掉帧,真机上更是卡得没法用。
具体表现是:手指滑动列表时,有明显的迟滞感,帧率显示在20fps左右徘徊,甚至更低。这就是典型的列表渲染瓶颈。
问题的根源在于,ForEach 会一次性创建并渲染所有子组件。当数据源达到5000条时,意味着ArkUI需要同时管理5000个ListItem节点的创建、布局和绘制。这对内存和CPU都是巨大压力,掉帧是必然的。
那这个问题怎么解决?官方推荐的做法是采用"懒加载"机制,只渲染用户当前屏幕上能看到的以及附近少量的项。这在HarmonyOS里就是 LazyForEach。
方案对比:ForEach vs LazyForEach
| 特性 | ForEach | LazyForEach |
|---|---|---|
| 渲染策略 | 一次性全量渲染 | 按需创建,只渲染可视区域及缓存区 |
| 数据源类型 | 普通数组 | 需要实现 IDataSource 接口的类 |
| 内存占用 | 高(所有组件实例常驻) | 低(只缓存少量ListItem实例) |
| 适用场景 | 少量、固定、静态的列表(< 50项) | 长列表、动态更新的列表(> 50项) |
| 维护成本 | 低,直接写数组即可 | 中等,需要管理数据源对象的生命周期和通知 |
对于5000项的新闻列表,LazyForEach 是唯一合理的选择。
环境说明
text
DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 5.0.1(12) 及以上
目标设备:手机
从卡顿到流畅:一个新闻列表的改造过程
我们来实现一个包含5000条新闻的列表。先看用ForEach写的"反面教材",再看用LazyForEach优化后的方案。
第一阶段:卡顿的起点 ------ ForEach
这是一个典型的、会让初学者掉进坑里的写法。
typescript
// NewsItem.ets
@Component
struct NewsItem {
private news: NewsData;
build() {
Row() {
Image(this.news.thumbnailUrl)
.width(48)
.height(48)
.borderRadius(8)
Column() {
Text(this.news.title)
.fontSize(16)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.news.summary)
.fontSize(14)
.fontColor('#666666')
.maxLines(1)
}
.margin({ left: 8 })
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(12)
.alignItems(VerticalAlign.Top)
}
}
// NewsData.ets
export class NewsData {
id: number;
title: string;
summary: string;
thumbnailUrl: string;
constructor(id: number) {
this.id = id;
this.title = `新闻标题${id}: 这是一个示例新闻标题,用于测试长列表性能。`;
this.summary = `这是新闻编号为${id}的摘要内容,你可以忽略具体文字,只看布局效果。`;
// 使用一个公共占位图,避免网络请求的影响
this.thumbnailUrl = $r('app.media.app_icon');
}
}
// Index.ets
import { NewsData } from './NewsData'
@Entry
@Component
struct Index {
@State newsList: NewsData[] = [];
aboutToAppear() {
// 生成5000条新闻数据
let arr: NewsData[] = [];
for (let i = 0; i < 5000; i++) {
arr.push(new NewsData(i));
}
this.newsList = arr;
}
build() {
Column() {
Text('ForEach 实现 (5000项)')
.fontSize(18)
.margin(12)
List() {
ForEach(this.newsList, (item: NewsData) => {
ListItem() {
NewsItem({ news: item })
}
}, (item: NewsData) => item.id.toString())
}
.width('100%')
.height('100%')
}
.width('100%')
.height('100%')
}
}
这段代码的问题在于:
ForEach会遍历newsList数组,为每一项都创建ListItem和NewsItem实例。5000条数据,ArkUI就要创建5000个组件对象。你可以把设备连接到DevEco Studio,用HiDebug或Profiler看一下内存占用,会发现瞬间飙升。滑动的帧率基本别想超过20fps。
第二阶段:质变的关键 ------ LazyForEach
现在用LazyForEach来改造。需要先实现一个IDataSource接口的数据源。
typescript
// NewsListDataSource.ets
import { NewsData } from './NewsData'
class NewsListDataSource implements IDataSource {
private dataArr: NewsData[] = [];
constructor(arr: NewsData[]) {
this.dataArr = arr;
}
// 返回数据总数,LazyForEach会根据这个值判断滚动范围
totalCount(): number {
return this.dataArr.length;
}
// 根据索引返回数据,LazyForEach会在这里取数据
getData(index: number): NewsData {
return this.dataArr[index];
}
// 注册监听器,当数据变化时通知LazyForEach刷新
registerDataChangeListener(listener: DataChangeListener): void {
// 在实际项目中,这里持有listener,并在数据增删改时调用对应方法
// 本示例数据为静态,暂不实现
}
// 注销监听器
unregisterDataChangeListener(listener: DataChangeListener): void {
}
}
typescript
// Index.ets
import { NewsData } from './NewsData'
import { NewsListDataSource } from './NewsListDataSource'
@Entry
@Component
struct Index {
@State dataSource: NewsListDataSource = new NewsListDataSource([]);
aboutToAppear() {
let arr: NewsData[] = [];
for (let i = 0; i < 5000; i++) {
arr.push(new NewsData(i));
}
this.dataSource = new NewsListDataSource(arr);
}
build() {
Column() {
Text('LazyForEach 实现 (5000项)')
.fontSize(18)
.margin(12)
List() {
LazyForEach(this.dataSource, (item: NewsData) => {
ListItem() {
NewsItem({ news: item })
}
}, (item: NewsData) => item.id.toString())
}
.width('100%')
.height('100%')
}
.width('100%')
.height('100%')
}
}

关键变化:
NewsListDataSource实现了IDataSource接口。LazyForEach会通过调用getData(index)方法来按需获取数据。- 组件创建次数取决于可视区域加上缓存区的数量,通常只有几十个
ListItem实例在复用,而不是5000个。 - 滑动帧率会直接飙到60fps,内存占用也稳定在一个很低的水平。
但到这里还没完,实际项目里还有一个很常见的坑:图片加载问题。
第三阶段:优化进阶 ------ 图片预解码与缓存
即使用了 LazyForEach,如果你的 ListItem 里有网络图片,在快速滑动时,图片加载请求的并发量会很大,加上图片解码消耗CPU,仍然会出现短暂的白块或掉帧。
解决方法:开启图片预解码和内存缓存。
官方提供了 Image 组件的 decoding 属性和 ImageSource 的 getImageInfo 方法。但在列表里更高效的方式,是结合懒加载和组件复用池。
我们可以利用 LazyForEach 配合 cachedCount 属性,在列表滑动时,提前为即将出现的项创建 ListItem。但这个"提前创建"通常不包括图片的提前解码。
更推荐的做法是:
- 开启图片内存缓存 :在
entry/src/main/resources/rawfile下或在代码中通过Image组件的syncLoad属性控制,但耗时任务不能阻塞主线程,所以更建议用ImageSource进行预处理,但这在复杂列表里很难管理。 - 让ArkUI自己管理缓存 :最好的办法,是利用ArkUI框架自带的图片加载机制,它默认会进行一级磁盘缓存和二级内存缓存。我们要做的,是确保图片的Key是唯一的 ,并且避免重复创建相同的
Image对象。
在 NewsItem 组件里,Image 组件是@Component构造的,每次LazyForEach复用ListItem时,内部的Image组件也是复用状态。如果图片URL不变,ArkUI会直接使用缓存的图片,不会重复请求。
但在快滑场景下,解码依然是瓶颈。 这里有一个技巧:使用 Image 组件的 objectFit(ImageFit.Cover) 和 decoding 属性结合,设置 decoding(ImageDecoding.Async) 为异步解码。
typescript
// NewsItem.ets (优化版)
@Component
struct NewsItem {
private news: NewsData;
build() {
Row() {
Image(this.news.thumbnailUrl)
.width(48)
.height(48)
.borderRadius(8)
.decoding(ImageDecoding.Async) // 关键:异步解码,避免阻塞UI线程
.syncLoad(false) // 确保loadImage是异步的
Column() {
Text(this.news.title)
.fontSize(16)
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.news.summary)
.fontSize(14)
.fontColor('#666666')
.maxLines(1)
}
.margin({ left: 8 })
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(12)
.alignItems(VerticalAlign.Top)
}
}
decoding(ImageDecoding.Async) 让图片解码在子线程进行,不会阻塞ArkUI的渲染线程,这对于列表滑动流畅性的提升是非常显著的。
常见问题与踩坑实录
问题1:LazyForEach 的数据源更新后,UI不刷新
现象 :使用 LazyForEach 时,如果你直接修改了 getData 返回的对象(比如数组里的某个元素),UI不会自动更新。
原因 :LazyForEach 依赖 IDataSource 接口的 registerDataChangeListener 来感知数据变化。如果只是修改对象属性,没有调用任何通知方法,框架就不知道数据变了。
解决方案 :正确实现 IDataSource 接口的 notifyDataAdd、notifyDataChange 等方法,并在修改数据后主动调用。例如:
typescript
// 在NewsListDataSource中添加方法
notifyDataChange(index: number, listener: DataChangeListener) {
listener.onDataReloaded(); // 简单做法是整表刷新,更优做法是指定索引
}
更推荐的做法,是把列表项的属性定义为 @Observed 和 @ObjectLink,这样属性级别的变化可以被List内部监听到。但需要权衡性能。
问题2:ListItem 复用池导致的状态混乱
现象 :一个 ListItem 里有一个 @State 变量,比如一个"已读/未读"标记,当这个 ListItem 被回收并复用于另一个新闻项时,这个状态没有被重置,导致显示错误。
原因 :LazyForEach 会维护一个 ListItem 的复用池。当某条数据滑出屏幕后,它的 ListItem 组件实例并未销毁,而是被放入池中。当新数据滑入时,直接复用这个实例,但 @State 变量并不会被自动重置为初始值。
解决方案 :不要在 ListItem 的 @Component 里使用 @State 来存储跟业务数据无关的临时UI状态。 如果需要标记,应将状态作为 @Prop 或 @ObjectLink 从数据源传入,根据数据源的值来展示。
typescript
// 将isRead作为数据的一部分
export class NewsData {
id: number;
title: string;
summary: string;
isRead: boolean = false;
// ...
}
然后 NewsItem 组件根据 this.news.isRead 来显示不同样式即可。
最佳实践
-
设置
List的estimatedSize属性 :这能帮助LazyForEach更精准地计算滚动条的滚动范围,避免在快速滑动时出现"跳跃"感。设置为你的ListItem大致高度。typescriptList() { // ... } .estimatedSize(150) // 假设每个ListItem高度约为150vp -
为
LazyForEach配置合理的cachedCount:cachedCount定义了在屏幕可见区域之外,额外缓存多少个ListItem。数值太小会导致快滑时出现白块,太大则增加内存。对于图片较多的列表,设置20-30会比较平衡。typescriptList() { LazyForEach(this.dataSource, (item: NewsData) => { // ... }, (item: NewsData) => item.id.toString()) } .cachedCount(30) -
避免在
getData方法里做耗时操作 :LazyForEach在滚动时会频繁调用getData。如果在这个方法里做资源读取或复杂计算,会直接拖慢 UI 线程。getData应该是一个单纯的数据返回操作。
验证效果:使用Profiler
写完代码,不要靠"感觉",要用工具说话。
- 打开DevEco Studio,连接真机,运行应用。
- 切换到
Profiler工具,选择Frame标签。 - 分别用
ForEach和LazyForEach的页面,快速滑动列表。 - 观察
Frame Time和Fps曲线。
你会看到:
ForEach页面:滑动时,Frame Time普遍超过 16ms,FPS 在 20-30 之间跳动,出现大量红色掉帧标记。LazyForEach页面:Frame Time稳定在 16ms 左右,FPS 保持在 55-60 的满帧状态,极少掉帧。
这就是你代码优化效果最直接的证明。
FAQ
Q:我的数据源是网络请求返回的,应该在哪里初始化?
A:在 aboutToAppear 中发起网络请求,然后将数据赋值给 @State 修饰的 dataSource。注意,网络请求是异步的,你需要在回调里创建 NewsListDataSource 对象并赋值。
Q:使用 LazyForEach 后,想要实现下拉刷新或上拉加载更多,该怎么做?
A:需要实现 IDataSource 接口的 notifyDataAdd、notifyDataInsert 等方法,在数据添加后通知监听器。并且在 List 外包裹 Swiper 或使用 onReachEnd 事件。
Q:为什么我设置了 cachedCount,快速滑动时还会出现短暂的空白?
A:cachedCount 只是缓存了 ListItem 组件的实例,但 ListItem 内部的图片可能还没加载完。特别是网络图,受限于网络和图片解码速度。decoding(ImageDecoding.Async) 可以缓解解码问题,但网络耗时是硬伤。可以考虑使用占位图或渐进式加载。
示例代码地址:GitHub 项目地址