Scroll + List 虚拟列表 --- 长列表性能优化全攻略

一、引言
在记账应用中,账单列表是最核心的数据展示场景,可能包含数百甚至上千条交易记录。如果一次性渲染所有列表项,将导致严重的内存占用和帧率下降。鸿蒙系统提供了 Scroll + List 组合方案,结合 ForEach / LazyForEach 实现虚拟列表渲染,只渲染可视区域内的列表项,大幅提升长列表性能。MoneyTrack 在资产详情页和首页账单流中广泛运用了这套方案。
二、核心知识点
2.1 Scroll 容器
Scroll 是可滚动容器组件,常用属性如下:
- scrollable :控制滚动方向,取值
ScrollDirection.Vertical(垂直)或ScrollDirection.Horizontal(水平),默认垂直 - scrollBar :滚动条显示模式,可选
BarState.Auto(自动显隐)、BarState.Always(常驻)、BarState.Off(隐藏) - edgeEffect :边缘效果,
EdgeEffect.Spring(弹性回弹)、EdgeEffect.Fade(淡出渐变)、EdgeEffect.None(无效果) - enableScrollInteraction :布尔值,控制是否允许用户手势滚动,设为
false可禁用滚动交互
Scroll 可以包裹任意内容,但不具备虚拟化能力,适合内容长度可控(如几十个组件)的场景。当内容数据量较大时,应优先使用 List。
2.2 List + ForEach / LazyForEach
List 是专门为列表场景优化的组件,支持虚拟列表机制。ForEach 与 LazyForEach 的对比如下:
| 对比维度 | ForEach | LazyForEach |
|---|---|---|
| 渲染策略 | 全量参与布局计算,渲染可视项 | 按需创建/销毁组件实例 |
| 内存占用 | 中等(几十条可接受) | 极低(适合几百上千条) |
| 适用场景 | 数据量 ≤ 50 条 | 数据量 50 条以上 |
| key 要求 | 必须提供稳定唯一 key | 必须提供稳定唯一 key |
| 数据变更 | 全量刷新 | 按索引增量更新 |
LazyForEach 需要实现 IDataSource 接口:
typescript
class TransactionDataSource implements IDataSource {
private dataArray: TransactionItem[] = [];
totalCount(): number {
return this.dataArray.length;
}
getData(index: number): TransactionItem {
return this.dataArray[index];
}
registerDataChangeListener(listener: DataChangeListener): void {}
unregisterDataChangeListener(listener: DataChangeListener): void {}
}
// 使用 LazyForEach
List({ space: 8, scroller: this.listScroller }) {
LazyForEach(this.dataSource, (item: TransactionItem) => {
ListItem() {
TransactionCard({ transaction: item })
}
}, (item: TransactionItem) => item.id)
}
2.3 List 辅助属性
除了基础滚动,List 还提供以下实用属性:
- stickyHeader :设置分组粘性标题,取值
StickyStyle.Header(吸顶)或StickyStyle.None(不吸顶)。在按日期分组的账单列表中非常实用,用户滚动时始终能看到当前日期分组 - divider :列表项分割线,配置颜色和左右边距,例如
.divider({ strokeWidth: '1px', color: '#E8E8E8', startMargin: 16, endMargin: 16 }) - cachedCount:设置列表项缓存数量,在可视区域外额外预创建 N 个组件,提升快速滚动时的体验,建议设为 1~3
typescript
List({ space: 8, scroller: this.listScroller }) {
ForEach(this.viewModel.transactions, (tx: TransactionItem) => {
ListItem() {
TransactionCard({ transaction: tx })
}
}, (tx: TransactionItem) => tx.id)
}
.edgeEffect(EdgeEffect.Spring)
.divider({ strokeWidth: '1px', color: '#E8E8E8' })
.stickyHeader(StickyStyle.Header)
.cachedCount(2)
三、虚拟列表渲染原理
#mermaid-svg-yN3SpDEZxAZv9TZ4{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-yN3SpDEZxAZv9TZ4 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-yN3SpDEZxAZv9TZ4 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-yN3SpDEZxAZv9TZ4 .error-icon{fill:#552222;}#mermaid-svg-yN3SpDEZxAZv9TZ4 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-yN3SpDEZxAZv9TZ4 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-yN3SpDEZxAZv9TZ4 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-yN3SpDEZxAZv9TZ4 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-yN3SpDEZxAZv9TZ4 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-yN3SpDEZxAZv9TZ4 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-yN3SpDEZxAZv9TZ4 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-yN3SpDEZxAZv9TZ4 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-yN3SpDEZxAZv9TZ4 .marker.cross{stroke:#333333;}#mermaid-svg-yN3SpDEZxAZv9TZ4 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-yN3SpDEZxAZv9TZ4 p{margin:0;}#mermaid-svg-yN3SpDEZxAZv9TZ4 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-yN3SpDEZxAZv9TZ4 .cluster-label text{fill:#333;}#mermaid-svg-yN3SpDEZxAZv9TZ4 .cluster-label span{color:#333;}#mermaid-svg-yN3SpDEZxAZv9TZ4 .cluster-label span p{background-color:transparent;}#mermaid-svg-yN3SpDEZxAZv9TZ4 .label text,#mermaid-svg-yN3SpDEZxAZv9TZ4 span{fill:#333;color:#333;}#mermaid-svg-yN3SpDEZxAZv9TZ4 .node rect,#mermaid-svg-yN3SpDEZxAZv9TZ4 .node circle,#mermaid-svg-yN3SpDEZxAZv9TZ4 .node ellipse,#mermaid-svg-yN3SpDEZxAZv9TZ4 .node polygon,#mermaid-svg-yN3SpDEZxAZv9TZ4 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-yN3SpDEZxAZv9TZ4 .rough-node .label text,#mermaid-svg-yN3SpDEZxAZv9TZ4 .node .label text,#mermaid-svg-yN3SpDEZxAZv9TZ4 .image-shape .label,#mermaid-svg-yN3SpDEZxAZv9TZ4 .icon-shape .label{text-anchor:middle;}#mermaid-svg-yN3SpDEZxAZv9TZ4 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-yN3SpDEZxAZv9TZ4 .rough-node .label,#mermaid-svg-yN3SpDEZxAZv9TZ4 .node .label,#mermaid-svg-yN3SpDEZxAZv9TZ4 .image-shape .label,#mermaid-svg-yN3SpDEZxAZv9TZ4 .icon-shape .label{text-align:center;}#mermaid-svg-yN3SpDEZxAZv9TZ4 .node.clickable{cursor:pointer;}#mermaid-svg-yN3SpDEZxAZv9TZ4 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-yN3SpDEZxAZv9TZ4 .arrowheadPath{fill:#333333;}#mermaid-svg-yN3SpDEZxAZv9TZ4 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-yN3SpDEZxAZv9TZ4 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-yN3SpDEZxAZv9TZ4 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-yN3SpDEZxAZv9TZ4 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-yN3SpDEZxAZv9TZ4 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-yN3SpDEZxAZv9TZ4 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-yN3SpDEZxAZv9TZ4 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-yN3SpDEZxAZv9TZ4 .cluster text{fill:#333;}#mermaid-svg-yN3SpDEZxAZv9TZ4 .cluster span{color:#333;}#mermaid-svg-yN3SpDEZxAZv9TZ4 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-yN3SpDEZxAZv9TZ4 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-yN3SpDEZxAZv9TZ4 rect.text{fill:none;stroke-width:0;}#mermaid-svg-yN3SpDEZxAZv9TZ4 .icon-shape,#mermaid-svg-yN3SpDEZxAZv9TZ4 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-yN3SpDEZxAZv9TZ4 .icon-shape p,#mermaid-svg-yN3SpDEZxAZv9TZ4 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-yN3SpDEZxAZv9TZ4 .icon-shape .label rect,#mermaid-svg-yN3SpDEZxAZv9TZ4 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-yN3SpDEZxAZv9TZ4 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-yN3SpDEZxAZv9TZ4 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-yN3SpDEZxAZv9TZ4 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
数据源 DataSource
虚拟列表引擎
判断是否在可视区域
创建/复用 ListItem 组件
不创建 / 销毁回收
渲染到屏幕
用户滚动触发新位置
虚拟列表的核心思想是"只渲染用户看得到的部分"。当用户快速滚动时,滑出屏幕的列表项被回收,新进入区域的列表项被创建或复用,从而将 DOM 节点数量控制在可视高度对应的范围内。
四、性能监控:验证虚拟列表是否生效
使用 DevEco Studio 的 Profiler 工具可以监控列表性能:
- 查看组件树节点数:打开 DevTools > Elements 面板,滚动列表时观察节点数是否保持稳定。若节点数随滚动持续增长,说明虚拟化未生效
- 分析内存占用:Profiler > Memory,观察滚动过程中内存是否平稳。若内存持续上涨,可能存在列表项未被正确回收
- 帧率检测 :Profiler > Frame,确保滚动帧率稳定在 60fps。若出现掉帧,可适当增大
cachedCount减少快速滚动时的白屏
五、最佳实践
- ListItem 组件化 :将列表项封装为独立组件(如
TransactionCard),避免列表项内部状态变化触发整个列表刷新 - 稳定 key 值:务必为 ForEach/LazyForEach 提供唯一且稳定的 key 函数,避免使用索引作为 key,否则会导致渲染异常
- 合理配置缓存大小 :
cachedCount并非越大越好,过大会增加内存占用;建议设为 1~3,兼顾快速滚动体验与内存效率 - 选择正确的渲染方式:数据量超过 50 条时优先使用 LazyForEach;对于频繁增删操作的列表,LazyForEach 的增量更新优势更明显
六、总结
Scroll + List + ForEach/LazyForEach 构成了鸿蒙长列表性能优化的核心方案。Scroll 作为通用滚动容器,适合内容可控的场景;List 结合虚拟列表机制,专门针对大量列表数据优化。ForEach 适用于中等数据量,LazyForEach 更适合大数据量场景,其 IDataSource 接口提供了灵活的数据管理能力。在实际开发中,配合 stickyHeader、divider 等辅助属性和 cachedCount 缓存优化,可以构建出流畅、高性能的长列表页面。