HarmonyOS Repeat 列表复用怎么用:virtualScroll、稳定 key 和行状态为什么要一起看
ArkUI 长列表最怕两类问题:一个是列表数据一多就开始卡,另一个是筛选、排序、插入数据之后,某一行的选中态、展开态、加载态跑到另一行。很多人会先怀疑接口慢、图片慢、状态管理没刷新,但在列表渲染这里,Repeat、virtualScroll、key 和组件复用如果没一起设计,页面就会出现很难查的错位问题。
我这次不写一个按钮加一行文字的例子,而是用一个消息列表来验证。列表里有普通行、紧凑行、重点行;用户可以筛选、切换首行状态,也可以把末尾数据挪到顶部。这个场景不复杂到看不懂,但足够暴露长列表复用里的关键问题。
先说结论
Repeat 适合做可复用的循环渲染,配合滚动容器时可以用 virtualScroll 减少长列表一次性渲染压力。但它不是“自动帮你把所有状态都管好”。列表项能不能稳定,关键还要看三件事:
| virtualScroll | 让长列表按显示范围和缓存范围加载 | 数据多时首屏压力大,滚动容易抖 |
| 稳定 key | 让框架知道哪条数据是哪条数据 | 筛选、排序后行状态错位 |
| @ReusableV2 | 让自定义行组件参与复用 | 频繁创建销毁组件,复杂行更容易卡 |
我的判断规则很简单:性能交给 Repeat + virtualScroll,身份交给稳定业务 id,行组件内部不要偷偷保存会和数据身份冲突的状态。
问题怎么发生
先看一个常见错误。列表一开始只有展示时,用 index 做 key 好像没问题:
Repeat<FeedCard>(this.visibleCards)
.each((obj: RepeatItem<FeedCard>) => {
ListItem() {
FeedRow({ item: obj.item })
}
})
.key((item: FeedCard, index: number) => index.toString())
这个写法的问题是,0、1、2 只表示当前位置,不表示这条数据本身。只要用户做了筛选、排序、插入、删除,原来第 1 行可能已经换成另一条数据,但框架看到的 key 还是 1。如果行组件里还有选中态、展开态、图片加载状态,这些状态就可能跟着位置走,而不是跟着数据走。
所以长列表不是只问“能不能渲染出来”,而是要问“数据顺序变了以后,行状态还认不认识原来的对象”。
案例一:80 条列表用 Repeat + virtualScroll
先准备一个稍微真实一点的数据模型。这里每条消息都有稳定 id、展示标题、行类型和未读数。selected 是可变化状态,用来模拟已读、勾选、展开这类行内状态。
@ObservedV2
export class FeedCard {
@Trace id: string = '';
@Trace title: string = '';
@Trace kind: string = 'normal';
@Trace selected: boolean = false;
@Trace unread: number = 0;
constructor(id: string, title: string, kind: string, unread: number) {
this.id = id;
this.title = title;
this.kind = kind;
this.unread = unread;
}
}
export function buildFeedCards(): FeedCard[] {
const list: FeedCard[] = [];
for (let i = 0; i < 80; i++) {
const kind = i % 10 === 0 ? 'banner' : i % 3 === 0 ? 'compact' : 'normal';
list.push(new FeedCard(`feed-${i}`, `ArkUI 列表消息 ${i}`, kind, i % 7));
}
return list;
}
页面里不要直接把所有状态散在组件树里。我会先把列表数据和筛选状态放在页面层,再用计算属性得到当前可见列表:
@ComponentV2
export struct CsdnRepeatDemo {
@Local cards: FeedCard[] = buildFeedCards();
@Local compactOnly: boolean = false;
@Computed
get visibleCards(): FeedCard[] {
return this.compactOnly
? this.cards.filter((item: FeedCard) => item.kind === 'compact')
: this.cards;
}
}
这个拆法的好处是,原始数据只有一份,筛选结果只是派生出来的视图。后面点击、移动、筛选,都不会把状态拆成两套。
正确写法:key 跟业务 id 走
下面这段是我本地编译通过的核心写法:
List({ space: 8 }) {
Repeat<FeedCard>(this.visibleCards)
.each((obj: RepeatItem<FeedCard>) => {
ListItem() {
FeedRow({
item: obj.item,
dense: obj.item.kind === 'compact',
onToggle: () => {
obj.item.selected = !obj.item.selected;
}
})
}
})
.key((item: FeedCard) => item.id)
.virtualScroll({ totalCount: this.visibleCards.length })
}
这里有两个关键点。
第一,key 用 item.id,不用 index。筛选以后,feed-12 还是 feed-12,不会因为它现在排在第 0 行就变成另一个身份。
第二,virtualScroll 的 totalCount 跟当前可见列表长度一致。这样列表在“全部”和“只看 compact”之间切换时,虚拟滚动知道当前数据规模变了,不会继续拿旧范围来判断。
案例二:混合行组件参与复用
长列表里经常不是每一行都长一样。有的行是普通消息,有的行是紧凑消息,有的行是重点提示。这个时候如果每次都创建销毁复杂组件,滚动时就容易有压力。
行组件可以用 @ReusableV2 标出来,让它参与 V2 组件复用:
@ReusableV2
@ComponentV2
struct FeedRow {
@Param item: FeedCard = new FeedCard('', '', 'normal', 0);
@Param dense: boolean = false;
@Event onToggle: () => void = () => {};
build() {
Row({ space: 12 }) {
Text(this.item.selected ? '已读' : '未读')
.fontSize(12)
.fontColor(this.item.selected ? '#198754' : '#C2410C')
Column({ space: 4 }) {
Text(this.item.title)
.fontSize(this.dense ? 14 : 16)
.fontWeight(FontWeight.Medium)
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
Text(`id=${this.item.id} / type=${this.item.kind} / unread=${this.item.unread}`)
.fontSize(12)
.fontColor('#6B7280')
}
.layoutWeight(1)
Button('切换')
.fontSize(12)
.height(30)
.onClick(this.onToggle)
}
.width('100%')
.padding(12)
.backgroundColor(this.item.kind === 'banner' ? '#FFF7ED' : '#FFFFFF')
.borderRadius(10)
}
}
这里我故意让 FeedRow 不自己保存 selected。它只接收 item,点击后把修改交回外层数据对象。原因很直接:组件会被复用,数据才是稳定来源。行组件如果自己再存一份选中态,复用时就容易出现“组件被拿去显示另一条数据,但旧状态还没清干净”的问题。
怎么复现问题
可以按这个顺序测:
如果 key 用 index,第三步和第五步最容易暴露问题:状态可能跟着位置走。你以为改的是第一条数据,实际 UI 复用后显示到别的行上。
如果 key 用 item.id,状态跟着 FeedCard 走。哪怕筛选、恢复、移动顺序,feed-79 还是 feed-79,不会因为它到了顶部就拿到别人的状态。
几种写法怎么选
| ForEach | 数据少、结构简单、没有明显性能压力 | 长列表、复杂行、频繁筛选排序 |
| LazyForEach | 老项目已有数据源和懒加载实现 | 新 V2 组件复用场景,迁移成本要评估 |
| Repeat | 数组数据、V2 组件复用、滚动容器里的长列表 | 没有稳定 key、数据身份混乱的列表 |
| Repeat + virtualScroll | 长列表、可滚动页面、需要按需渲染 | 只有几条静态数据,没必要增加复杂度 |
我现在更倾向的写法是:普通短列表不用硬上 Repeat;只要列表开始变长,或者行组件比较重,就优先考虑 Repeat + virtualScroll。但前提是数据模型里必须有稳定 id。
封装成项目规范
这类问题最好不要每个页面临时想。可以封装一个很小的列表规则:
export interface StableListItem {
id: string;
}
export function stableKey(item: StableListItem): string {
return item.id;
}
export function assertStableIds(items: StableListItem[]): boolean {
const ids = new Set<string>();
for (const item of items) {
if (!item.id || ids.has(item.id)) {
return false;
}
ids.add(item.id);
}
return true;
}
页面使用 Repeat 前先确认两个条件:每条数据有 id,并且 id 不重复。这个检查看起来小,但能提前拦住很多列表错位问题。
本地验证结果
这组示例已经接入本地 HarmonyOS 工程完成编译验证:
:entry:default@CompileArkTS 成功
:entry:assembleHap 成功
BUILD SUCCESSFUL in 3 min 52 s 857 ms
验证时只出现了一个签名配置警告,和 ArkUI 示例代码无关。
以后怎么避免
我的检查顺序会固定成这样:
Repeat 不是单独的性能开关。它真正稳的时候,是和稳定数据模型、清楚的状态归属、可复用组件一起使用。只换一个 API,不检查 key 和状态来源,列表问题还是会回来。
网硕互联帮助中心





评论前必须登录!
注册