HarmonyOS Repeat 虚拟滚动实战:模板分流、缓存数量与懒加载
HarmonyOS Repeat 虚拟滚动实战:模板分流、缓存数量与懒加载

长列表卡顿往往不是“数据太多”这一句能解释。一次创建过多子组件、键值不稳定导致整批重建、不同卡片共用错误的复用池、缓存数量按总数据量设置,都会让滑动过程产生额外构建和内存压力。Repeat的虚拟滚动把可见窗口、组件复用和按需补数放在同一条链路中,但前提是开发者给出可靠的数据身份与组件分类。
本文实现一个包含文字、图片和视频卡片的动态信息流。读完后你可以明确回答三个问题:哪一项能复用、复用到哪个组件类型、数据尚未到达时由谁补齐。
1. 先把卡顿拆成构建、复用和补数
列表问题可按发生时机分类。首屏就慢,通常是一次构建太多节点或图片解码过重;滑动一段后才掉帧,常见原因是key变化、复用失败或缓存过大;滑到数据尾部出现空白,则要检查totalCount与懒加载回调是否一致。
首屏创建慢 -> 缩小可见构建范围,检查复杂子树
往返滑动重建 -> 检查稳定key与组件分类
内存持续增长 -> 按卡片成本设置cachedCount
尾部出现空洞 -> 核对totalCount、占位项和补数回调
不要只观察平均帧率。组件创建次数、数据请求次数、图片内存和长帧位置更能说明是哪一层失效。
2. API版本决定可采用的策略
Repeat、each、key、virtualScroll和template从API 12起可用;VirtualScrollOptions.reusable从API 18起提供;按需补数相关的onLazyLoading与onTotalCount从API 19起提供。项目的compatibleSdkVersion低于这些版本时,需要分别准备兼容分支。
| 能力 | 起始版本 | 作用 |
|---|---|---|
| Repeat虚拟滚动 | API 12 | 只构建可见范围及缓存项 |
| reusable开关 | API 18 | 控制被移出窗口的节点是否进入复用 |
| 懒加载回调 | API 19 | 数据未就绪时按索引补数 |
版本表不是形式信息。若团队设备跨度较大,应先用API 12基础链路跑通,再逐步开启高版本能力。
3. 数据身份不能由数组下标代替
删除、插入、置顶会改变数组下标。如果key使用index,原来的组件状态可能错误地落到另一条数据上。稳定key应来自业务主键,并且在同一个列表生命周期中保持唯一。
type FeedKind = 'text' | 'image' | 'video';
interface FeedItem {
id: string;
kind: FeedKind;
title: string;
coverUrl?: string;
durationSec?: number;
}
function feedKey(item: FeedItem): string {
return `${item.kind}:${item.id}`;
}
把kind放进key并不是替代分类,而是避免不同数据域的主键碰撞。组件进入哪个复用池,仍由后面的templateId决定。
4. 虚拟滚动链路的五个环节

数据先被准备为稳定对象,随后根据类型选择渲染分支,只创建可见项和缓存项。离开窗口的节点若允许复用,就进入对应缓存池;接近未加载区域时,再由数据层补齐。任何一环失去边界,都会退化成“边滑边重建”或“提前加载全部”。
5. 先实现单一类型的最小链路
下面的例子只渲染文本卡片,用于确认key和虚拟窗口本身工作正常。totalCount必须表示列表逻辑总数,而不是随意写一个比数据大很多的常量。
@Component
struct TextFeedList {
@State items: FeedItem[] = [];
build() {
List({ space: 12 }) {
Repeat<FeedItem>(this.items)
.each((repeatItem: RepeatItem<FeedItem>) => {
ListItem() {
Text(repeatItem.item.title)
.fontSize(18)
.padding(16)
}
})
.key((item: FeedItem) => feedKey(item))
.virtualScroll({ totalCount: this.items.length })
}
.cachedCount(2)
}
}
先用插入、删除、排序和往返滑动验证组件身份。若状态串行,再增加卡片类型只会让问题更隐蔽。
6. 不同卡片必须进入不同复用池
文字、图片和视频卡片的组件树与资源成本不同。使用templateId按数据类型分流,再为每个template设置缓存数量,可以避免视频节点被当作文本节点复用。
Repeat<FeedItem>(this.items)
.each((repeatItem: RepeatItem<FeedItem>) => {
ListItem() {
Text(repeatItem.item.title)
}
})
.key((item: FeedItem) => feedKey(item))
.virtualScroll({ totalCount: this.items.length, reusable: true })
.templateId((item: FeedItem) => item.kind)
.template('text', (repeatItem: RepeatItem<FeedItem>) => {
ListItem() { TextCard({ item: repeatItem.item }) }
}, { cachedCount: 4 })
.template('image', (repeatItem: RepeatItem<FeedItem>) => {
ListItem() { ImageCard({ item: repeatItem.item }) }
}, { cachedCount: 2 })
.template('video', (repeatItem: RepeatItem<FeedItem>) => {
ListItem() { VideoCoverCard({ item: repeatItem.item }) }
}, { cachedCount: 1 })
默认each分支仍应保留,以处理未知类型或灰度数据。不要为每个业务状态创建一个类型,例如“已读图片”“未读图片”若组件结构相同,应在同一分支内改变样式。
7. 缓存数量应按组件成本计算

cachedCount不是数据预加载条数,也不应等于服务端分页大小。它描述复用池愿意保留的组件数量。轻量文本卡片可多留几项,包含大图或媒体资源的卡片要更克制。
interface CacheBudget {
text: number;
image: number;
video: number;
}
const cacheBudget: CacheBudget = {
text: 4,
image: 2,
video: 1
};
调参时从小值开始,记录往返滑动的创建次数和内存峰值。如果增加缓存只减少极少构建,却显著提高图片或解码器驻留,就应回退。
8. 数据更新要保留未变化对象的身份
收到分页数据后,不要先深拷贝整个数组再全部替换。只追加新对象,更新已有项时也只替换确实变化的数据。这样Repeat能根据key识别未变化项。
function mergePage(current: FeedItem[], incoming: FeedItem[]): FeedItem[] {
const byId: Map<string, FeedItem> = new Map(
current.map((item: FeedItem) => [feedKey(item), item])
);
incoming.forEach((item: FeedItem) => {
byId.set(feedKey(item), item);
});
return Array.from(byId.values());
}
若服务端返回顺序有意义,应额外按游标顺序合并,不能依赖Map掩盖排序规则。关键是稳定身份,而不是固定数据顺序。
9. 懒加载要区分逻辑总数与已到达数据
当API 19能力可用时,totalCount可以表示服务端已知总数,缺失索引由onLazyLoading触发补数。请求层必须合并相邻索引并防止同一页并发请求。
class FeedPageLoader {
private loadingPages: Set<number> = new Set();
async loadIndex(index: number, pageSize: number): Promise<void> {
const page = Math.floor(index / pageSize);
if (this.loadingPages.has(page)) {
return;
}
this.loadingPages.add(page);
try {
await this.fetchPage(page, pageSize);
} finally {
this.loadingPages.delete(page);
}
}
private async fetchPage(page: number, pageSize: number): Promise<void> {
// 调用仓储层并把结果写回数据源
}
}
补数失败时保留可重试的占位状态,不要反复增减totalCount制造列表跳动。若服务端不知道总数,可以采用“当前数据量加一个加载占位项”的协议。
10. 复用组件内部不能藏一次性状态
组件被复用后会接收新的repeatItem.item。播放器、订阅、定时器或异步图片请求若只在首次创建时绑定,就可能继续服务旧数据。重资源卡片应显式实现绑定与解绑。
@Component
struct VideoCoverCard {
@Prop item: FeedItem;
private boundId: string = '';
aboutToAppear(): void {
this.bind(this.item.id);
}
aboutToDisappear(): void {
this.unbind(this.boundId);
}
private bind(id: string): void {
this.boundId = id;
}
private unbind(id: string): void {
// 取消与该id相关的订阅或加载任务
}
build() {
Text(this.item.title)
}
}
实际项目还要关注属性变化时的重新绑定,不能仅依赖出现与消失回调。最安全的做法是让卡片只保存与当前业务ID可核对的资源句柄。
11. 三种错误会让虚拟列表退化
- key随位置变化:插入一条数据后,大量卡片被视为新节点。
- 分类粒度过细:每个状态一个分支,复用池被切得过碎。
- 缓存盲目增大:屏外重组件长期驻留,内存与解码压力上升。
还要避免在卡片build路径中排序大数组、同步读取文件或创建网络请求。构建函数应消费准备好的视图数据,不承担数据加工。
12. 建立可重复的滑动验收场景
准备固定的1000条混合数据,包含稳定比例的三种卡片。冷启动后从顶部匀速滑到底,再立即回到顶部,重复三轮。记录首屏时间、组件创建次数、长帧、内存峰值和分页请求次数。
[ ] 插入、删除和置顶后卡片状态未串行
[ ] 相同数据往返滑动时创建次数明显下降
[ ] 三种卡片进入各自的复用分支
[ ] 缓存增大不会导致内存持续上升
[ ] 同一分页不会并发请求两次
[ ] 补数失败可重试且列表位置不跳动
13. Repeat虚拟滚动资料索引
- 控制列表渲染范围最佳实践
Repeat、RepeatItem、VirtualScrollOptions与TemplateOptions:以本机HarmonyOS SDK API 23声明为准。
Repeat优化的核心不是少写几个组件,而是让数据身份、组件类型、可见窗口和补数协议保持一致。key决定“是不是同一条数据”,templateId决定“能不能进入同一复用池”,cachedCount决定“愿意为复用付出多少内存”。把这三层分开调,列表性能才可解释、可复现。
更多推荐



所有评论(0)