HarmonyOS 长列表性能优化实战:LazyForEach、分页加载与图片缓存治理
HarmonyOS 长列表性能优化实战:LazyForEach、分页加载与图片缓存治理

资讯流、商品流、路线列表、消息列表这类页面,刚上线时通常只有几十条数据,看起来很流畅。问题往往出现在版本迭代后:运营要求首屏多露出内容,服务端一次返回更多图片,列表项里又加了点赞、收藏、标签、倒计时。最后用户一滑动就掉帧,内存曲线也开始往上爬。
这篇文章只解决一个问题:HarmonyOS 应用里的长列表,如何用稳定数据源、LazyForEach、分页加载和图片缓存,把滑动卡顿和内存上涨压下去。
本文不是组件 API 速查,而是一个可迁移的工程方案。你可以把示例里的 ArticleFeed 换成商品、路线、帖子、课程,核心思路一样。
一、资料定位:先把官方能力边界找清楚
| 类型 | 资料/能力 | 本文使用点 |
|---|---|---|
| 官方组件能力 | LazyForEach 数据懒加载 | 列表项按需渲染、稳定 key、数据源监听 |
| 官方列表组件 | List 列表组件 | 列表容器、滚动监听、列表项组织 |
| 官方性能实践 | 应用性能优化实践 | 避免主线程重计算、减少不必要刷新 |
| 官方调优工具 | DevEco Profiler | 帧率、内存、主线程耗时复测 |
本文代码以 HarmonyOS 5.0.0+、Stage 模型、ArkTS 为边界。图片加载部分使用工程化伪实现表达缓存策略,真实项目可以替换成自己的网络层、文件缓存或图片库。
二、问题拆解:长列表慢通常不是一个点慢

长列表卡顿一般来自几类叠加问题:
| 问题 | 表现 | 根因 |
|---|---|---|
| 一次性塞入过多数据 | 首次进入页面慢 | 首屏前加载了非首屏数据 |
| key 不稳定 | 刷新后列表抖动 | 框架无法稳定复用组件 |
| 列表项过重 | 滑动掉帧 | 每个 item 都在做复杂计算 |
| 图片无缓存 | 反复解码和请求 | 列表复用时重复加载 |
| 状态更新过宽 | 点赞一个 item,整页刷新 | 状态放置粒度太粗 |
所以优化顺序不要反:先让数据稳定,再做 LazyForEach,再做分页和图片缓存。只换一个组件名,解决不了长列表问题。
三、工程结构:把数据、渲染和缓存分开

推荐目录:
entry/src/main/ets/
├── pages/feed/
│ └── ArticleFeedPage.ets
├── features/feed/
│ ├── ArticleFeedRepository.ets
│ ├── ArticlePagedDataSource.ets
│ └── ArticleCardModel.ets
├── common/image/
│ └── FeedImageCache.ets
└── common/perf/
└── FeedPerfLogger.ets
| 文件 | 职责 |
|---|---|
ArticleFeedRepository |
请求分页数据,隐藏网络细节 |
ArticlePagedDataSource |
给 LazyForEach 提供稳定数据源 |
ArticleFeedPage |
只负责页面状态和列表渲染 |
FeedImageCache |
管理图片缓存命中和预加载 |
FeedPerfLogger |
记录分页、渲染、图片命中日志 |
这个拆法的好处是:列表滑动慢时,可以分别看数据源、组件渲染、图片缓存,不会所有逻辑都堆在页面里。
四、列表模型:每条数据必须有稳定 ID
export interface ArticleCardModel {
id: string;
title: string;
summary: string;
coverUrl: string;
authorName: string;
publishTime: string;
likeCount: number;
tags: string[];
}
export interface PageResult<T> {
pageNo: number;
pageSize: number;
hasMore: boolean;
list: T[];
}
代码解释:
id是列表复用的核心,不能用数组下标代替。coverUrl不在渲染阶段处理,图片策略交给缓存层。PageResult明确分页边界,避免页面自己猜有没有下一页。tags这种数组字段要控制数量,列表项里不要渲染无限标签。
如果后端没有稳定 ID,要在客户端按业务字段合成,例如 sourceId + publishTime。不要用 Date.now() 或 index 当 key。
五、分页仓库:不要一次把全量数据塞给页面
import { ArticleCardModel, PageResult } from './ArticleCardModel';
export class ArticleFeedRepository {
async queryPage(pageNo: number, pageSize: number): Promise<PageResult<ArticleCardModel>> {
const start = (pageNo - 1) * pageSize;
const list: ArticleCardModel[] = Array.from({ length: pageSize }).map((_, index) => {
const id = `article_${start + index + 1}`;
return {
id,
title: `HarmonyOS 工程实践 ${start + index + 1}`,
summary: '围绕真实项目中的性能、适配和上架问题做工程化拆解。',
coverUrl: `https://example.com/covers/${id}.png`,
authorName: 'HarmonyOS Developer',
publishTime: '2026-07-15',
likeCount: 1000 + index,
tags: ['HarmonyOS', 'ArkTS', '性能优化']
};
});
return {
pageNo,
pageSize,
hasMore: pageNo < 20,
list
};
}
}
代码解释:
- 仓库层负责分页,不让页面关心接口细节。
pageSize建议从 10 到 20 开始压测,不要默认 50 或 100。hasMore由数据层返回,页面只负责触发下一页。- 示例里用本地数据模拟,真实项目替换成 HTTP 请求即可。
分页不是为了少写代码,而是为了控制首屏和内存峰值。列表内容越复杂,pageSize 越要保守。
六、LazyForEach 数据源:让框架知道哪一条变了
export class ArticlePagedDataSource implements IDataSource {
private listeners: DataChangeListener[] = [];
private items: ArticleCardModel[] = [];
totalCount(): number {
return this.items.length;
}
getData(index: number): ArticleCardModel {
return this.items[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 >= 0) {
this.listeners.splice(index, 1);
}
}
appendPage(list: ArticleCardModel[]): void {
const startIndex = this.items.length;
this.items.push(...list);
this.listeners.forEach(listener => {
listener.onDataAdd(startIndex);
});
}
updateLikeCount(id: string, likeCount: number): void {
const index = this.items.findIndex(item => item.id === id);
if (index < 0) {
return;
}
this.items[index] = {
...this.items[index],
likeCount
};
this.listeners.forEach(listener => {
listener.onDataChange(index);
});
}
}
代码解释:
IDataSource的边界是告诉 LazyForEach 数据有多少、哪一条发生变化。appendPage()只通知新增位置,避免整表刷新。updateLikeCount()只通知单条变更,点赞不会导致整页重建。items不直接暴露给页面,减少误操作。
这里要注意:新增一页有多条数据时,实际项目可根据框架能力选择逐条通知或范围通知。核心原则是别用一个粗暴的全量刷新代替精确变更。
七、页面渲染:列表项要轻,状态要窄
@Entry
@Component
struct ArticleFeedPage {
private repository: ArticleFeedRepository = new ArticleFeedRepository();
private dataSource: ArticlePagedDataSource = new ArticlePagedDataSource();
@State private pageNo: number = 1;
@State private loading: boolean = false;
@State private hasMore: boolean = true;
async aboutToAppear(): Promise<void> {
await this.loadNextPage();
}
build() {
Column() {
List() {
LazyForEach(this.dataSource, (item: ArticleCardModel) => {
ListItem() {
ArticleCard({ item })
}
}, (item: ArticleCardModel) => item.id)
if (this.hasMore) {
ListItem() {
this.LoadingFooter()
}
}
}
.onReachEnd(() => {
this.loadNextPage();
})
.width('100%')
.height('100%')
}
}
@Builder
LoadingFooter() {
Row() {
Text(this.loading ? '加载中...' : '继续加载')
.fontSize(14)
.fontColor('#667')
}
.justifyContent(FlexAlign.Center)
.height(56)
.width('100%')
}
private async loadNextPage(): Promise<void> {
if (this.loading || !this.hasMore) {
return;
}
this.loading = true;
const result = await this.repository.queryPage(this.pageNo, 15);
this.dataSource.appendPage(result.list);
this.pageNo += 1;
this.hasMore = result.hasMore;
this.loading = false;
}
}
代码解释:
LazyForEach的 key 使用item.id,保证组件复用稳定。onReachEnd()只触发下一页,不直接拼装数据。loading和hasMore是页面状态,不放进每个 item。ArticleCard单独拆组件,方便后续控制列表项复杂度。
aboutToAppear() 里只加载第一页。很多列表慢,就是因为首次进入页面时把多页数据一起拉了回来。
八、列表项组件:不要在 build 里做重计算
@Component
struct ArticleCard {
@Prop item: ArticleCardModel;
build() {
Row({ space: 12 }) {
FeedCoverImage({ url: this.item.coverUrl, cacheKey: this.item.id })
Column({ space: 6 }) {
Text(this.item.title)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(this.item.summary)
.fontSize(13)
.fontColor('#667')
.maxLines(2)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Row({ space: 8 }) {
Text(this.item.authorName).fontSize(12).fontColor('#889')
Text(`${this.item.likeCount} 赞`).fontSize(12).fontColor('#247A4D')
}
}
.layoutWeight(1)
}
.padding(12)
.backgroundColor(Color.White)
.borderRadius(14)
.margin({ left: 12, right: 12, top: 8 })
}
}
代码解释:
- 列表项只负责展示,不在
build()里过滤、排序、格式化大数据。 - 文本使用
maxLines和textOverflow,避免超长内容撑开布局。 - 图片交给
FeedCoverImage,方便统一缓存策略。 @Prop item只接收当前行数据,减少状态影响范围。
如果某个字段需要复杂格式化,应该在数据进入列表前处理好,而不是每次组件构建时处理。
九、图片缓存:列表滑动时最怕重复解码
export class FeedImageCache {
private static memoryCache: Map<string, string> = new Map();
private static maxCount: number = 80;
static get(cacheKey: string): string | undefined {
return FeedImageCache.memoryCache.get(cacheKey);
}
static put(cacheKey: string, localPath: string): void {
if (FeedImageCache.memoryCache.size >= FeedImageCache.maxCount) {
const firstKey = FeedImageCache.memoryCache.keys().next().value;
FeedImageCache.memoryCache.delete(firstKey);
}
FeedImageCache.memoryCache.set(cacheKey, localPath);
}
static clear(): void {
FeedImageCache.memoryCache.clear();
}
}
代码解释:
- 这里演示内存缓存边界,真实项目可接文件缓存。
maxCount必须有限制,不能无限缓存图片。cacheKey使用业务 ID,而不是 URL 全量字符串,便于统一管理。- 页面销毁或内存告警时可以调用
clear()。
图片优化要谨慎,不是缓存越多越好。缓存过大反而会让内存曲线抬高,最终被系统回收。
十、图片组件:先占位,再加载
@Component
struct FeedCoverImage {
@Prop url: string;
@Prop cacheKey: string;
@State private displaySource: ResourceStr = $r('app.media.feed_placeholder');
aboutToAppear(): void {
const cached = FeedImageCache.get(this.cacheKey);
if (cached) {
this.displaySource = cached;
return;
}
this.loadImageLater();
}
build() {
Image(this.displaySource)
.width(96)
.height(72)
.borderRadius(10)
.objectFit(ImageFit.Cover)
}
private loadImageLater(): void {
setTimeout(() => {
const localPath = this.url;
FeedImageCache.put(this.cacheKey, localPath);
this.displaySource = localPath;
}, 60);
}
}
代码解释:
- 首屏先展示占位图,避免图片阻塞列表结构。
- 命中缓存时立即显示,降低重复加载成本。
loadImageLater()模拟延后加载,真实项目替换为图片下载或解码逻辑。- 组件只更新自己的
displaySource,不影响整个列表。
如果图片很多,建议再加可见区域预加载策略:只预加载当前可见区域前后少量图片,不要把后面几十屏图片都拉下来。
十一、验证方法:用数据说话
建议每次优化都记录以下数据:
| 指标 | 优化前 | 优化后 |
|---|---|---|
| 首屏加载耗时 | 记录实际值 | 记录实际值 |
| 快速滑动帧率 | DevEco Profiler 观察 | DevEco Profiler 观察 |
| 内存峰值 | DevEco Profiler 观察 | DevEco Profiler 观察 |
| 图片缓存命中率 | 日志统计 | 日志统计 |
| 下一页加载失败率 | 弱网测试 | 弱网测试 |
测试步骤:
1. 清理应用后台进程,进入列表页。
2. 使用同一账号、同一网络、同一数据量。
3. 录制快速下滑、上滑、触底加载下一页。
4. 观察 Frame、Memory、CPU 变化。
5. 修改分页大小、缓存上限后重复测试。
6. 选择帧率和内存都稳定的参数。
不要只测“慢慢滑”。长列表真正的问题经常在快速滑动、网络慢、图片多、返回再进入时暴露。
十二、常见问题排查
| 现象 | 可能原因 | 检查方法 | 修复建议 |
|---|---|---|---|
| 列表刷新后跳动 | key 使用 index | 搜索 LazyForEach 第三个参数 | 改为业务稳定 ID |
| 点赞导致整页闪动 | 状态放在页面全局 | 查看状态更新范围 | 单条数据源通知变更 |
| 快速滑动掉帧 | item 组件过重 | Profiler 看主线程 | 拆小组件,减少 build 计算 |
| 内存持续上涨 | 图片缓存无限增长 | 观察缓存数量 | 设置缓存上限和释放策略 |
| 弱网触底重复请求 | loading 锁缺失 | 查看请求日志 | 请求中禁止重复触发 |
| 首屏慢 | 首次加载页数过多 | 检查第一页数据量 | 首屏只加载一页 |
十三、上线前验收清单
| 检查项 | 是否完成 |
|---|---|
| LazyForEach 使用稳定业务 ID 作为 key | |
| 首屏只加载第一页数据 | |
| 列表项 build 中没有大计算、排序、过滤 | |
| 图片有占位图和缓存上限 | |
| 触底加载有 loading 锁 | |
| 点赞、收藏等操作只刷新单条数据 | |
| 快速滑动、弱网、返回再进入都测过 | |
| DevEco Profiler 已完成帧率和内存复测 |
这张清单建议在提测前由开发自己先过一遍。长列表问题一旦进入测试阶段,复现成本会比较高,因为它依赖数据量、网络、图片大小和设备性能。开发阶段先把 key、分页、缓存、状态粒度这些基础项确认掉,后面的性能调优才不会变成无头苍蝇式排查。
如果项目中有埋点能力,也可以把第一页加载耗时、下一页加载耗时、图片缓存命中率作为观察指标。上线后如果某个版本列表变慢,先看这些指标,再决定是接口变慢、图片变大,还是组件渲染变重。
十四、总结
HarmonyOS 长列表优化的关键不是“把 ForEach 换成 LazyForEach”这么简单,而是让整条链路都可控:
- 数据要分页。
- key 要稳定。
- 列表项要轻。
- 图片要缓存但不能无限缓存。
- 状态更新要精确。
- 最后用 Profiler 复测。
只要这几件事做好,列表页从几十条扩展到几百、几千条时,也不会轻易变成卡顿源。
更多推荐



所有评论(0)