华为鸿蒙开发高级篇03-高性能列表深入:懒加载与组件复用
华为鸿蒙开发高级篇03-高性能列表深入:懒加载与组件复用(1 万条消息列表案例)
高级系列第 3 篇 · 案例:1 万条聊天消息列表的流畅滚动与快速定位。 目标:讲透
LazyForEach的工作机制,掌握cachedCount、组件复用(可复用)、滚动定位等生产级优化手段,并给出一份"优化前后"的完整对比代码。

一、案例背景
聊天、日志、排行榜这类列表动辄上万条。如果还用入门篇的 ForEach:
- 一次性创建上万个子组件,首屏卡顿数秒;
- 每条消息的文本、头像、图片都要常驻内存,内存持续增长;
- 滚到底部要加载"看不见的"几千个节点。
生产级方案是 LazyForEach + 组件复用 + 分页加载三件套。本文用消息列表把它们全部落地。
二、核心原理:LazyForEach 到底做了什么
LazyForEach 的本质是按需创建 + 窗口回收:
可视区 ── cachedCount 缓冲区 ── 回收区
│ 只创建可视窗口内的 item │ 滚动离开后销毁子组件(回收内存)│
- 系统维护一个"渲染窗口":可视区 + 前后各
cachedCount个缓冲项。 - 滚出窗口的 item 被销毁,滚入的 item 才创建。
- 数据源(
IDataSource)负责告诉框架:总共多少条、第 N 条是什么、以及增删改发生在哪个位置(差量通知)。
结论:LazyForEach 解决的是"节点创建"问题;组件复用解决的是"节点重建"成本问题——两者配合才丝滑。


三、实际开发代码:1 万条消息列表
3.1 数据源:IDataSource 完整实现
// data/MessageDataSource.ets
import { IDataSource, DataChangeListener } from '@kit.ArkUI'
export interface Message {
id: number
sender: string
text: string
time: string
}
export class MessageDataSource implements IDataSource {
private messages: Message[] = []
private listeners: DataChangeListener[] = []
totalCount(): number {
return this.messages.length
}
getData(index: number): Message {
return this.messages[index]
}
registerDataChangeListener(listener: DataChangeListener): void {
if (!this.listeners.includes(listener)) {
this.listeners.push(listener)
}
}
unregisterDataChangeListener(listener: DataChangeListener): void {
const i = this.listeners.indexOf(listener)
if (i >= 0) this.listeners.splice(i, 1)
}
// 模拟批量加载
pushMessages(batch: Message[]) {
const start = this.messages.length
this.messages.push(...batch)
// 差量通知:告诉框架"从 start 开始新增了 batch.length 条"
this.listeners.forEach((l) => l.onDataAdd(start, batch.length))
}
// 单条插入
insert(index: number, msg: Message) {
this.messages.splice(index, 0, msg)
this.listeners.forEach((l) => l.onDataAdd(index, 1))
}
// 删除
remove(index: number) {
this.messages.splice(index, 1)
this.listeners.forEach((l) => l.onDataDelete(index))
}
}
3.2 列表项组件:开启复用(可复用)
@Reusable 让 item 在离开窗口后不销毁、进入窗口时复用,避免重复创建组件对象:
// components/MessageItem.ets
@Component
@Reusable
export struct MessageItem {
@ObjectLink? 不适合——可复用组件建议用普通参数 + 手动刷新
// 可复用组件推荐:内部用状态接收数据,aboutToReuse 里更新
@State sender: string = ''
@State text: string = ''
@State time: string = ''
// 框架回调:复用该组件实例展示新数据时调用
aboutToReuse(params: Record<string, Object>) {
this.sender = params.sender as string
this.text = params.text as string
this.time = params.time as string
}
build() {
Column({ space: 6 }) {
Row({ space: 8 }) {
Text(this.sender).fontSize(14).fontWeight(FontWeight.Medium)
Text(this.time).fontSize(12).fontColor('#86909C')
}
.width('100%')
Text(this.text)
.fontSize(16)
.width('100%')
.textAlign(TextAlign.Start)
}
.alignItems(HorizontalAlign.Start)
.padding(12)
.width('100%')
.backgroundColor(Color.White)
.borderRadius(10)
}
}
说明:
@Reusable与aboutToReuse的写法以所装 SDK 为准;核心机制是"实例复用 + 参数回填"。
3.3 页面:LazyForEach + cachedCount + 定位
// pages/MessageListPage.ets
import { MessageDataSource, Message } from '../data/MessageDataSource'
import { MessageItem } from '../components/MessageItem'
@Entry
@Component
struct MessageListPage {
private data: MessageDataSource = new MessageDataSource()
private scroller: Scroller = new Scroller()
@State totalText: string = ''
aboutToAppear() {
// 模拟 1 万条数据
const batch: Message[] = []
for (let i = 0; i < 10000; i++) {
batch.push({
id: i,
sender: i % 3 === 0 ? '产品经理' : '开发同事',
text: `这是第 ${i + 1} 条消息,内容用于演示长列表性能优化。`,
time: `${String(9 + (i % 8)).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}`
})
}
this.data.pushMessages(batch)
this.totalText = `共 ${this.data.totalCount()} 条`
}
build() {
Column() {
Text(this.totalText).fontSize(14).fontColor('#86909C').padding(8)
List({ scroller: this.scroller }) {
LazyForEach(this.data, (msg: Message, index: number) => {
ListItem() {
MessageItem({ sender: msg.sender, text: msg.text, time: msg.time })
}
// 键必须唯一且稳定:用消息 id
}, (msg: Message) => `${msg.id}`)
}
// cachedCount:可视区前后各预建 15 个缓冲项,滚动手感更平滑
.cachedCount(15)
.width('100%')
.layoutWeight(1)
Row({ space: 16 }) {
Button('滚到底部').onClick(() => this.scroller.scrollEdge(Edge.Bottom))
Button('回到顶部').onClick(() => this.scroller.scrollEdge(Edge.Top))
Button('定位到 5000 条').onClick(() => {
this.scroller.scrollToIndex(4999, true) // 平滑定位
})
}
.padding(12)
}
.width('100%').height('100%')
}
}
3.4 优化前后对比(验证思路)
| 指标 | ForEach(优化前) | LazyForEach + 复用(优化后) |
|---|---|---|
| 首屏创建节点 | 10000 个 | 可视区约 10~30 个 |
| 滑动掉帧 | 明显(频繁 GC) | 平稳(窗口回收 + 实例复用) |
| 内存占用 | 随数据量线性增长 | 稳定在窗口大小附近 |
| 定位到 5000 条 | 滚动到才创建,中间全建 | scrollToIndex 直达,只建沿途 |
验证方式:DevEco Profiler 抓帧对比滑动 FPS;用 HiLog 打点统计 build() 调用次数(LazyForEach 下远小于总条数)。
四、进阶:分页加载 + 双向加载(聊天场景)
真实聊天列表是"先看最新的,向上翻历史":
// 滚到顶部附近时加载更早的消息(聊天室场景)
onScrollIndex(first: number) {
if (first <= 3) {
this.loadEarlierMessages() // 插入到数组头部,注意 onDataAdd 用头部索引
}
}
private loadEarlierMessages() {
// 模拟加载更早 50 条
const earlier: Message[] = []
for (let i = 0; i < 50; i++) {
earlier.push({ id: this.data.totalCount() + i, sender: '历史消息', text: '更早的消息内容', time: '昨日' })
}
// 头部插入:IDataSource 提供 insert(0, ...) 差量通知
earlier.forEach((m) => this.data.insert(0, m))
}
注意:头部插入会改变所有已有 index,务必用差量通知(onDataAdd(0, n))并保证键不变,否则列表会错位。
五、优化与踩坑
| 问题 | 处理 |
|---|---|
| 键重复导致错位/崩溃 | 键生成函数返回唯一稳定值(用业务 id,别用 index 或可变字段) |
| 滚到顶部加载历史后跳动 | 头部插入前记录当前首项 id,插入后用 scrollToIndex 恢复 |
| 复用组件状态串台 | 所有展示数据必须在 aboutToReuse 里回填,勿残留旧值 |
| 图片列表仍卡 | 图片走懒加载 + 缩略图;必要时 item 内 Image 设置合理尺寸与占位 |
cachedCount 太大反而卡 | 平衡内存与预建:经验值 5~20,按 item 复杂度调整 |
六、小结与延伸
- 三件套:
LazyForEach(按需创建)+cachedCount(预建缓冲)+@Reusable(实例复用),外加唯一稳定键这一条铁律。 - 案例沉淀:
MessageDataSource(IDataSource 完整实现)+MessageItem(可复用组件)+ 定位/分页代码,可直接改造为通用长列表模板。 - 延伸:多类型 item(不同消息类型用不同组件 + 复用池)、
onScrollIndex做滚动触发统计埋点、接入图片懒加载库。
下一篇预告:多线程与并发——TaskPool/Worker 实战(图片批量压缩案例)。
1
浏览44 发布于2026-09-10 05:10上海
全部评论
最多点赞
最新发布
最早发布
写回答
新增插入模板功能
一键使用模板,快速填写内容,轻松发帖~
知道了
- 为了保障您的信息安全,请勿上传您的敏感个人信息(如您的密码等信息)和您的敏感资产信息(如关键源代码、签名私钥、调试安装包、业务日志等信息),且您需自行承担由此产生的信息泄露等安全风险。
- 如您发布的内容为转载内容,请注明内容来源。
我要发帖子
了解社区公约,与您携手共创和谐专业的开发者社区。





































苏公安网备 32011402010933号