HarmonyOS 列表卡顿优化实战:LazyForEach、复用、图片与掉帧
HarmonyOS 列表卡顿优化实战:LazyForEach、复用、图片与掉帧
长列表卡顿是最容易被用户感知的问题:滑动掉帧、图片闪烁、加载更多时停顿、返回后位置丢失。ArkUI 列表优化不能只靠“少写一点 UI”,而要从数据源、组件复用、图片加载、帧率监控四层入手。本文以商品列表/文章列表场景讲清楚排查路径。

1. 列表卡顿先看数据稳定性
列表项必须有稳定 key。没有稳定 key,滑动时组件复用会混乱,图片和状态也容易错位。

| 问题 | 原因 | 优化方向 |
|---|---|---|
| 滑动掉帧 | item 构建过重 | 拆轻组件 |
| 图片闪烁 | 图片尺寸和加载无序 | 缓存和占位 |
| 状态错位 | key 不稳定 | 使用业务 ID |
| 加载停顿 | 一次性追加过多 | 分页和批量 |
2. ArkUI 列表资料和目录
官方资料可参考 LazyForEach、列表布局 和 性能优化。
entry/src/main/ets/common/list/
ListDataSource.ets
ItemKeyPolicy.ets
ImageLoadPolicy.ets
FrameMonitor.ets
ListPerfReport.ets
3. ListDataSource 管理数据变化
export interface FeedItem {
id: string
title: string
coverUrl: string
updatedAt: number
}
export class ListDataSource {
private items: FeedItem[] = []
append(next: FeedItem[]): void {
const exists = new Set(this.items.map(item => item.id))
this.items = [...this.items, ...next.filter(item => !exists.has(item.id))]
}
list(): FeedItem[] {
return [...this.items]
}
}
数据源先去重,再追加,避免重复 item 造成布局抖动。
4. ItemKeyPolicy 提供稳定 key
export class ItemKeyPolicy {
key(item: FeedItem): string {
if (!item.id) throw new Error('列表项缺少业务 ID')
return item.id
}
}
不要用数组下标做 key。分页、插入、删除都会让下标变化。
5. 图片加载要限尺寸
export interface ImageRequest {
url: string
width: number
height: number
}
export class ImageLoadPolicy {
build(item: FeedItem): ImageRequest {
return { url: item.coverUrl, width: 240, height: 160 }
}
}
列表图片不应该加载原图。先确定展示尺寸,再请求合适尺寸的资源。
6. FrameMonitor 记录掉帧

export interface FrameSample {
at: number
fps: number
scene: 'first_load' | 'scroll' | 'load_more'
}
export class FrameMonitor {
private samples: FrameSample[] = []
record(sample: FrameSample): void {
this.samples.push(sample)
}
dropped(): FrameSample[] {
return this.samples.filter(item => item.fps < 50)
}
}
掉帧要和场景绑定。首次加载卡和滑动卡,优化方向不同。
7. 列表项组件要轻
export interface FeedItemViewState {
title: string
cover: ImageRequest
timeText: string
}
export function buildFeedItemView(item: FeedItem): FeedItemViewState {
return {
title: item.title,
cover: new ImageLoadPolicy().build(item),
timeText: new Date(item.updatedAt).toLocaleDateString()
}
}
复杂格式化不要放在组件反复构建路径里,可以提前在 view state 中处理。
8. 列表验收动作
| 场景 | 操作 | 预期结果 |
|---|---|---|
| 首次加载 | 加载 20 条 | 首屏无明显停顿 |
| 快速滑动 | 连续上下滑 | FPS 基本稳定 |
| 加载更多 | 追加下一页 | 不重复、不跳动 |
| 图片弱网 | 限速网络 | 有占位,不闪烁 |
| 删除插入 | 中间插入数据 | 状态不串行 |
export function assertFeedItem(item: FeedItem): void {
if (!item.id || !item.title) throw new Error('列表项缺少关键字段')
if (!item.coverUrl.startsWith('https://')) throw new Error('封面图必须使用 HTTPS')
}
9. 列表卡顿排查表
| 现象 | 优先查看 | 处理建议 |
|---|---|---|
| 快速滑动掉帧 | item 复杂度 | 拆轻组件 |
| 图片闪烁 | 图片缓存和尺寸 | 使用占位和缩略图 |
| 状态错位 | key | 使用业务 ID |
| 加载更多卡 | 追加批次 | 分页追加 |
| 回到顶部 | 数据源重建 | 保持数据源实例 |
列表问题要把“数据变化”和“滑动帧率”放在一起看。只看 FPS 不知道是哪一批数据触发卡顿,只看数据日志又看不到用户体感。
export interface ListPerfRecord {
scene: 'first_load' | 'scroll' | 'append_page'
itemCount: number
droppedFrameCount: number
imageRequestCount: number
at: number
}
export class ListPerfReport {
private readonly records: ListPerfRecord[] = []
append(record: ListPerfRecord): void {
this.records.push(record)
}
risky(): ListPerfRecord[] {
return this.records.filter(item => item.droppedFrameCount > 3 || item.imageRequestCount > item.itemCount)
}
}
如果 imageRequestCount 明显大于可见 item 数,通常说明图片请求没有跟随可见区域收敛;如果 droppedFrameCount 集中在追加分页,优先检查一次性追加数量和 item 构建复杂度。
列表优化复测建议至少覆盖三种场景:
| 场景 | 操作 | 观察指标 |
|---|---|---|
| 首屏 | 首次进入列表 | 首屏构建耗时、图片请求数 |
| 快滑 | 连续上下滑动 | FPS、掉帧次数 |
| 追加 | 加载下一页 | 是否跳动、是否重复 |
| 返回 | 详情页返回列表 | 滚动位置和数据源是否保留 |
列表优化还要避免两个反例。第一,把所有 item 都拆成很小的组件,但每个组件都做复杂计算,结果构建次数更多;第二,给每张图都加高清圆角、阴影和原图加载,视觉更精致但滑动更重。优化时要把视觉成本、图片成本、数据更新成本放在同一个列表场景里测。
| 反例 | 影响 | 更稳做法 |
|---|---|---|
| item 内同步格式化时间 | 每次构建都计算 | 进入 view state 前处理 |
| 原图直接进列表 | 解码和内存压力大 | 使用缩略图 |
| index 作为 key | 插入删除后状态错位 | 使用业务 ID |
| 一次追加 100 条 | 主线程短时压力高 | 分页小批量追加 |
如果列表里存在倒计时、播放状态、选中态这类动态字段,建议把动态状态和静态内容拆开。静态内容走列表数据源,动态状态用单独状态表维护,避免每秒刷新整条列表。
专项验收时可以准备 500 条本地假数据和 3 档图片尺寸:小图、普通图、超大图。先用本地数据排除网络影响,再逐步打开图片加载、分页追加和状态刷新。这样能判断卡顿来自 UI 构建、图片解码还是数据更新,而不是把所有问题混在一次测试里。
| 专项项 | 开关 | 目的 |
|---|---|---|
| 纯文本列表 | 关闭图片 | 观察 item 构建成本 |
| 缩略图列表 | 开启小图 | 观察正常图片成本 |
| 原图列表 | 开启大图 | 验证图片边界保护 |
| 动态状态 | 开启倒计时 | 验证刷新范围 |
如果专项测试中纯文本也掉帧,优先看 item 层级和同步计算;如果只有开图后掉帧,优先查图片尺寸和并发;如果只有追加时掉帧,优先查分页批次和数据去重。
最终交付时,建议把优化前后的录屏、FPS 采样和数据量一起保存。只有代码改动没有复测证据,后续别人很难判断这次优化解决的是哪类卡顿。
列表掉帧复现场景:给读者一组可执行核验
列表优化要验证快速滑动、图片加载、复用和空状态。只看静态页面,无法发现滑动时对象创建和图片解码问题。
| 核验维度 | 读者需要准备的证据 |
|---|---|
| 输入 | 页面入口、用户动作、关键参数 |
| 过程 | 日志、状态变化、异常分支 |
| 输出 | UI 表现、回调结果、持久化结果 |
| 回归 | 同场景重复执行后的结果 |
interface ListReplayCase {
listName: any
itemCount: any
droppedFrames: any
imageCacheHit: any
}
const replay89: ListReplayCase = {
listName: 'sample',
itemCount: 'sample',
droppedFrames: 'sample',
imageCacheHit: 'sample',
}
function assertReplay89(item: ListReplayCase): void {
if (item.itemCount > 100 && item.droppedFrames > 5 && !item.imageCacheHit) throw new Error('长列表掉帧且图片缓存未命中')
}
这组核验把长列表、掉帧和图片缓存命中关联起来,适合用于滑动性能回归。
列表滑动回放表:把文章方法变成可复现动作
列表性能要用真实密度验证。建议准备 20 条、200 条、2000 条数据,分别包含纯文本、图片、混合卡片,观察复用和图片缓存是否生效。
| 回放动作 | 核验方式 |
|---|---|
| 短列表 | 准备输入、执行操作、记录结果、给出结论 |
| 中列表 | 准备输入、执行操作、记录结果、给出结论 |
| 长列表 | 准备输入、执行操作、记录结果、给出结论 |
| 图片混合列表 | 准备输入、执行操作、记录结果、给出结论 |
列表卡顿要用数据密度压测。读者可以准备纯文本列表、图片列表、混合卡片列表,并分别使用几十条、几百条、几千条数据滑动。观察掉帧时要同时记录图片缓存命中、组件复用、分页加载和状态刷新次数。只有这些证据齐全,才能判断卡顿来自渲染、图片还是数据流。
列表性能的落地边界:不要把边界留给读者猜
列表卡顿通常来自多个因素叠加:组件创建过多、图片解码过重、状态刷新过频、分页策略不合理。读者落地时不要只改一个参数,而要分别记录渲染、图片、数据和交互四条线的证据。
| 落地项 | 处理要求 |
|---|---|
| 渲染看组件复用 | 需要有明确输入、处理边界和失败兜底 |
| 图片看缓存命中 | 需要有明确输入、处理边界和失败兜底 |
| 数据看分页大小 | 需要有明确输入、处理边界和失败兜底 |
| 交互看状态刷新 | 需要有明确输入、处理边界和失败兜底 |
这类边界写清楚后,读者不需要猜哪些逻辑属于页面、哪些属于服务、哪些属于发布前验收。文章的价值也会从“讲了一个功能”变成“给了一套可迁移的工程判断”。
列表联调步骤:按真实路径走一遍
列表联调建议准备三套数据。第一套 20 条纯文本,用来验证基础布局;第二套 200 条图文混合,用来验证图片缓存和复用;第三套 2000 条分页数据,用来验证加载、滚动和状态刷新。三套数据的掉帧、内存和图片命中结果都要记录,才能定位卡顿来源。
这一步的意义是让读者拿到文章后可以直接复现,而不是只理解概念。技术文章如果能把“输入、动作、日志、结果、失败兜底”写完整,读者照着做时出错概率会低很多。
列表验收补充:补上容易漏掉的边界
补充一个真实列表场景:用户快速滑动到第 300 条时,图片仍在加载,随后立即点击收藏。验收时要确认收藏状态只刷新当前 item,图片加载完成后不覆盖收藏状态。这个场景能同时验证复用、异步图片和局部状态。
列表联调还要观察状态刷新。很多卡顿不是 item 太复杂,而是筛选、点赞、收藏、分页回调触发了整页刷新。读者可以在滑动中连续点赞和取消收藏,记录刷新范围是否只影响当前 item。如果整页重建,就需要回到状态拆分和局部刷新设计。
这类补充不是为了增加篇幅,而是为了让读者在真实项目里少踩坑:正常路径一般最容易跑通,异常路径、退出路径和恢复路径才是质量差距所在。
列表收尾核验:补齐最后一个真实场景
如果列表里有曝光埋点,还要确认复用不会造成重复曝光。滑动到同一张卡片、返回再进入、切换筛选后再次出现,三种情况下的曝光规则应不同。性能优化不能牺牲数据准确性,否则列表不卡了,后续运营分析却会失真。
这个核验点建议和性能日志一起保存。
这个场景建议打开列表刷新日志,记录筛选条件、分页游标和当前可见 item 范围。切换筛选时如果旧分页继续回调,应丢弃旧游标结果;否则新筛选列表会被旧数据污染。
最后再补一个交互场景:用户滑动过程中切换筛选条件。验收时要确认旧列表请求被丢弃,新列表状态重新计算,滚动位置和空状态不会互相污染。这个场景能发现分页状态和筛选状态没有隔离的问题。
10. 小结:列表性能看复用和图片
列表优化要先保证数据稳定,再控制 item 构建复杂度和图片加载成本。LazyForEach 不是万能药,稳定 key、分页数据、缩略图和帧率采样一起做,列表才能真正流畅。
更多推荐



所有评论(0)