如果你经常开发 HarmonyOS 应用,肯定遇到过长列表渲染的问题 —— 数据太多时页面卡得动不了,滑动时一顿一顿的,用户体验贼差。别担心,ArkTS 的 Repeat 组件就是来解决这个问题的!它就像个 "智能管家",能按需加载组件、自动回收复用,让长列表滑动如丝般顺滑。今天咱们就用大白话聊聊 Repeat 怎么用,从基础用法到高级技巧,保证看完你就能上手~
咱们先拿生活中的例子打比方:如果把列表渲染比作 "摆地摊",传统方式是不管顾客要不要,先把所有商品都摆出来,占地儿又费力;而 Repeat 就像 "流动摊位",顾客走到哪就把对应商品摆出来,卖完就收起来,效率高多了。
重点总结:
Repeat 是基于数组的循环渲染组件,专为长列表优化,核心优势是 "按需加载" 和 "节点复用",能大幅提升滚动性能。它比老款的 LazyForEach 更智能 —— 不用手动实现数据源接口,还支持多模板渲染,用起来更简单。
特性 | Repeat | LazyForEach |
---|---|---|
数据监听 | 自动监听状态变化 | 需手动实现 IDataSource 接口 |
节点复用 | 自动复用,性能更好 | 复用能力弱 |
多模板 | 支持不同类型子组件 | 不支持 |
用法复杂度 | 简单,直接绑定数组 | 复杂,需写额外接口逻辑 |
简单说,Repeat 就是 LazyForEach 的 "升级版",能少写很多代码,还更高效~
Repeat 必须和滚动容器(List、Grid、Swiper 等)搭配使用,最常用的就是 List。咱们先从最简单的例子开始,用 Repeat 渲染一个 50 条数据的列表。
重点总结:
基础用法只需 3 步:1. 准备数据源数组;2. 在 List 里嵌套 Repeat;3. 用.each () 定义子组件样式。记得加.virtualScroll () 开启懒加载,不然就失去性能优势了。
@Entry
@ComponentV2
struct RepeatBasicDemo {
// 准备数据源:50条字符串数据
@Local dataList: string[] = [];
// 页面加载前初始化数据
aboutToAppear() {
for (let i = 0; i < 50; i++) {
this.dataList.push(`第${i}条数据`);
}
}
build() {
Column() {
Text("基础Repeat列表")
.fontSize(20)
.margin(10)
// 滚动容器用List
List() {
// 核心:用Repeat循环渲染
Repeat(this.dataList)
// 定义每个子组件的样式
.each((item: RepeatItem) => {
ListItem() {
Text(item.item) // 显示数组中的内容
.fontSize(16)
.padding(15)
.width("100%")
.backgroundColor("#f5f5f5")
.borderRadius(5)
}
})
// 开启懒加载,指定总数据量
.virtualScroll({ totalCount: this.dataList.length })
}
.padding(10)
.width("100%")
.height("100%")
}
}
这段代码里,Repeat 会自动监听dataList
的变化,只加载屏幕可视区域 + 预加载区域的内容。滚动时,它会把离开视野的组件回收,新进入的组件复用之前的 "壳子",性能比一次性渲染所有组件好太多!
有时候列表里需要显示不同样式的内容,比如前 5 条是红色标题,中间 10 条是蓝色正文,剩下的是灰色备注。这时候 Repeat 的模板功能就派上用场了,就像给不同数据 "穿不同的衣服"。
重点总结:
用.templateId () 给数据分类型,再用.template () 定义每种类型的样式,默认样式用.each ()。同一类型的组件会被优先复用,进一步提升性能。
@Entry
@ComponentV2
struct RepeatTemplateDemo {
@Local dataList: string[] = [];
aboutToAppear() {
for (let i = 0; i < 30; i++) {
this.dataList.push(`内容${i}`);
}
}
build() {
Column() {
Text("多模板Repeat示例")
.fontSize(20)
.margin(10)
List() {
Repeat(this.dataList)
// 1. 给数据分类(返回模板类型)
.templateId((item: string, index: number) => {
if (index < 5) return "title"; // 前5条用title模板
if (index < 15) return "content"; // 中间10条用content模板
return ""; // 剩下的用默认模板
})
// 2. 定义title模板(红色大字体)
.template("title", (item: RepeatItem) => {
ListItem() {
Text(`【标题】${item.item}`)
.fontSize(18)
.fontColor("#ff4444")
.padding(15)
.width("100%")
.backgroundColor("#fff0f0")
}
}, { cachedCount: 3 }) // 缓存3个title组件备用
// 3. 定义content模板(蓝色中字体)
.template("content", (item: RepeatItem) => {
ListItem() {
Text(`【正文】${item.item}`)
.fontSize(16)
.fontColor("#4444ff")
.padding(15)
.width("100%")
.backgroundColor("#f0f0ff")
}
}, { cachedCount: 5 }) // 缓存5个content组件备用
// 4. 默认模板(灰色小字体)
.each((item: RepeatItem) => {
ListItem() {
Text(`【备注】${item.item}`)
.fontSize(14)
.fontColor("#888888")
.padding(15)
.width("100%")
.backgroundColor("#f5f5f5")
}
})
.virtualScroll({ totalCount: this.dataList.length })
}
}
.padding(10)
.width("100%")
.height("100%")
}
}
运行后你会发现:前 5 条是红色标题,中间 10 条是蓝色正文,剩下的是灰色备注。滚动时,同类组件会被复用,避免重复创建,这就是为什么它比普通循环高效~
很多人好奇:"同样是循环渲染,为啥 Repeat 滑动更流畅?" 关键就在 "节点复用" 和 "懒加载" 这两个黑科技。咱们拆开来讲:
想象一下:你滑动列表时,顶部的组件划出屏幕,Repeat 不会把它销毁,而是放进 "缓存池";当底部需要新组件时,直接从缓存池里拿一个改改内容再用,省去了创建新组件的耗时。这就像奶茶店的杯子 —— 用过的洗干净再装新奶茶,比每次都用新杯子快多了。
重点总结:
Repeat 默认开启节点复用,相同模板类型的组件会被优先复用。从 API 18 开始,可通过reusable
字段关闭,但建议一直开着,性能提升很明显。
// 控制复用功能的示例(API 18+)
Repeat(this.dataList)
.virtualScroll({
totalCount: this.dataList.length,
reusable: true // 开启复用(默认值),设为false关闭
})
// ...其他配置
如果列表有 1000 条数据,一次性加载完会卡死。Repeat 的懒加载就像 "点外卖"—— 先加载当前能看到的,滑动到哪再加载哪,内存占用少,启动速度快。
重点总结:
用.virtualScroll () 开启懒加载,通过totalCount
告诉 Repeat 总共有多少数据。数据量特别大时,还能通过onLazyLoading
动态加载数据,实现 "无限滚动"。
@Entry
@ComponentV2
struct RepeatInfiniteScroll {
@Local dataList: string[] = [];
// 初始加载15条数据(首屏够用)
aboutToAppear() {
for (let i = 0; i < 15; i++) {
this.dataList.push(`初始数据${i}`);
}
}
build() {
Column() {
Text("无限滚动示例")
.fontSize(20)
.margin(10)
List() {
Repeat(this.dataList)
.virtualScroll({
// 总数据量设为当前长度+1,实现"无限"
onTotalCount: () => this.dataList.length + 1,
// 当需要加载新数据时触发
onLazyLoading: (index: number) => {
// 模拟网络请求延迟
setTimeout(() => {
this.dataList[index] = `新加载数据${index}`;
}, 500);
}
})
.each((item: RepeatItem) => {
ListItem() {
Text(item.item)
.fontSize(16)
.padding(15)
.width("100%")
.backgroundColor("#f5f5f5")
}
})
.key((item: string) => item) // 用数据内容当唯一标识
}
}
.padding(10)
.width("100%")
.height("100%")
}
}
这段代码会在你滑动到列表底部时,自动加载新数据,实现类似朋友圈 "下拉加载更多" 的效果,而且全程流畅不卡顿~
如果数据总量很大(比如 10000 条),或者加载每条数据都很耗时(比如从网络拉取),可以用精准懒加载 —— 只在需要显示的时候才加载对应位置的数据,初始化时不用加载全部。
重点总结:
通过onLazyLoading
回调,在组件首次渲染、滑动到对应位置时才加载数据,还能设置占位符优化体验。
@Entry
@ComponentV2
struct RepeatPreciseLazyLoad {
@Local dataList: string[] = []; // 初始为空数组
build() {
Column() {
Text("精准懒加载示例(1000条数据)")
.fontSize(20)
.margin(10)
List({ initialIndex: 100 }) { // 初始定位到第100条
Repeat(this.dataList)
.virtualScroll({
onTotalCount: () => 1000, // 总共有1000条数据
onLazyLoading: (index: number) => {
// 先显示占位符
this.dataList[index] = "加载中...";
// 模拟网络请求,1秒后加载真实数据
setTimeout(() => {
this.dataList[index] = `第${index}条数据(懒加载)`;
}, 1000);
}
})
.each((item: RepeatItem) => {
ListItem() {
Text(item.item)
.fontSize(16)
.padding(15)
.width("100%")
.backgroundColor(item.item === "加载中..." ? "#ffebee" : "#f5f5f5")
}
})
.key((item: string, index: number) => index.toString())
}
.height("80%")
.border({ width: 1 })
}
.padding(10)
.width("100%")
.height("100%")
}
}
运行后你会发现:虽然要显示 1000 条数据,但初始化时几乎瞬间完成,滑动到哪条才加载哪条,还会显示 "加载中..." 的占位符,用户体验拉满~
Repeat 还有些超实用的高级功能,比如让列表项支持拖拽排序,或者在列表前面插入数据时保持当前视图不变,这些在实际开发中特别常用。
想实现类似 "待办清单" 那样,长按列表项就能拖动调整顺序的功能?Repeat 在 List 里配合onMove
事件就能轻松搞定。
重点总结:
在 List 中使用 Repeat 时,设置onMove
事件,在回调中修改数据源的顺序即可实现拖拽排序,记得保持 key 值不变以保证动画正常。
@Entry
@ComponentV2
struct RepeatDragSort {
@Local todoList: string[] = [];
aboutToAppear() {
// 初始化10条待办事项
for (let i = 0; i < 10; i++) {
this.todoList.push(`待办事项${i}`);
}
}
build() {
Column() {
Text("拖拽排序示例(长按拖动)")
.fontSize(20)
.margin(10)
List() {
Repeat(this.todoList)
// 启用拖拽排序
.onMove((from: number, to: number) => {
// 从from位置删除,插入到to位置
const [item] = this.todoList.splice(from, 1);
this.todoList.splice(to, 0, item);
})
.each((item: RepeatItem) => {
ListItem() {
Text(item.item)
.fontSize(18)
.padding(20)
.width("100%")
.backgroundColor("#ffffff")
.borderRadius(8)
.shadow({ radius: 2 })
}
.margin(5)
})
.key((item: string) => item) // 用内容当唯一key
.virtualScroll({ totalCount: this.todoList.length })
}
.backgroundColor("#f5f5f5")
.width("100%")
.height("100%")
}
}
}
这段代码实现的列表项,长按后可以拖动到任意位置,松手后自动更新顺序,整个过程丝滑流畅,比自己写拖拽逻辑简单多了~
你有没有遇到过这种情况:在列表顶部插入新数据后,整个列表突然向上跳,原来看到的内容不见了。Repeat 的 "前插保持" 功能就能解决这个问题 —— 在前面插入数据后,当前显示的内容位置不变。
重点总结:
父容器为 List 时,设置maintainVisibleContentPosition: true
,在列表前面插入 / 删除数据时,当前显示区域的内容位置保持不变。
@Entry
@ComponentV2
struct RepeatPreInsertKeep {
@Local messageList: string[] = [];
private count: number = 100; // 用于生成新数据
aboutToAppear() {
// 初始化30条数据
for (let i = 0; i < 30; i++) {
this.messageList.push(`消息${i}`);
}
}
build() {
Column() {
// 操作按钮
Row({ space: 10 }) {
Button("在顶部插入新消息")
.onClick(() => {
this.messageList.unshift(`新消息${this.count++}`);
})
Button("删除第一条消息")
.onClick(() => {
this.messageList.shift();
})
}
.margin(10)
// 列表(启用前插保持)
List({ initialIndex: 5 }) {
Repeat(this.messageList)
.each((item: RepeatItem) => {
ListItem() {
Text(`序号${item.index}:${item.item}`)
.fontSize(16)
.padding(15)
.width("100%")
.backgroundColor("#ffffff")
}
.margin(5)
})
.key((item: string) => item)
.virtualScroll({ totalCount: this.messageList.length })
}
.maintainVisibleContentPosition(true) // 启用前插保持
.backgroundColor("#f5f5f5")
.width("100%")
.height("80%")
}
}
}
点击 "在顶部插入新消息" 按钮后,你会发现:虽然列表顶部多了新数据,但当前看到的内容位置没变,不会突然跳转,用户体验瞬间提升~
Repeat 不是孤军奋战,它和 List、Grid、Swiper 等容器组件是黄金搭档,不同组合能实现不同效果,咱们看几个实战案例:
很多应用需要 "左滑删除" 功能,比如短信列表。用 List+Repeat 很容易实现:
// 定义数据模型
class Message {
id: string;
content: string;
constructor(id: string, content: string) {
this.id = id;
this.content = content;
}
}
@Entry
@ComponentV2
struct RepeatWithList {
@Local messages: Message[] = [];
aboutToAppear() {
// 初始化10条消息
for (let i = 0; i < 10; i++) {
this.messages.push(new Message(`msg${i}`, `这是第${i}条消息`));
}
}
// 删除消息的方法
deleteMessage(index: number) {
this.messages.splice(index, 1);
}
build() {
Column() {
Text("带滑动删除的消息列表")
.fontSize(20)
.margin(10)
List() {
Repeat(this.messages)
.each((item: RepeatItem) => {
ListItem() {
Text(item.item.content)
.fontSize(16)
.padding(15)
.width("100%")
}
// 右滑显示删除按钮
.swipeAction({
end: {
builder: () => {
Button("删除")
.backgroundColor("#ff4444")
.onClick(() => this.deleteMessage(item.index))
}
}
})
})
.key((item: Message) => item.id) // 用id当唯一标识
.virtualScroll({ totalCount: this.messages.length })
}
.width("100%")
.height("100%")
}
}
}
右滑列表项会出现删除按钮,点击后对应项被删除,列表自动刷新,整个交互流畅自然~
电商 App 常用的瀑布流布局,用 Grid+Repeat 也能轻松实现,还能支持下拉刷新:
// 定义商品数据模型
class Product {
id: string;
name: string;
imgUrl: string;
constructor(id: string, name: string) {
this.id = id;
this.name = name;
this.imgUrl = `https://picsum.photos/200/300?random=${id}`; // 随机图片
}
}
@Entry
@ComponentV2
struct RepeatWithGrid {
@Local products: Product[] = [];
@Local isRefreshing: boolean = false;
aboutToAppear() {
// 初始化20个商品
for (let i = 0; i < 20; i++) {
this.products.push(new Product(`p${i}`, `商品${i}`));
}
}
build() {
Column() {
Text("商品瀑布流(下拉刷新)")
.fontSize(20)
.margin(10)
// 下拉刷新容器
Refresh({ refreshing: $$this.isRefreshing }) {
Grid() {
Repeat(this.products)
.each((item: RepeatItem) => {
GridItem() {
Column() {
Image(item.item.imgUrl)
.width("100%")
.height(150)
.objectFit(ImageFit.Cover)
Text(item.item.name)
.fontSize(14)
.padding(5)
}
.backgroundColor("#ffffff")
.borderRadius(8)
.shadow({ radius: 2 })
}
.padding(5)
})
.key((item: Product) => item.id)
.virtualScroll({ totalCount: this.products.length })
}
.columnsTemplate("repeat(auto-fit, 150)") // 自适应列数
.rowsGap(10)
.columnsGap(10)
.padding(10)
.backgroundColor("#f5f5f5")
}
.onRefreshing(() => {
// 模拟刷新数据
setTimeout(() => {
this.products.unshift(new Product(`new${Date.now()}`, "最新商品"));
this.isRefreshing = false;
}, 1000);
})
}
.width("100%")
.height("100%")
}
}
这个瀑布流会根据屏幕宽度自动调整列数,下拉时会刷新并添加新商品,图片加载和滑动都很流畅~
首页轮播图是 App 的常见组件,用 Swiper+Repeat 实现,还能支持懒加载图片:
// 轮播图数据
const bannerUrls = [
"https://picsum.photos/800/400?random=1",
"https://picsum.photos/800/400?random=2",
"https://picsum.photos/800/400?random=3",
"https://picsum.photos/800/400?random=4",
"https://picsum.photos/800/400?random=5",
];
@Entry
@ComponentV2
struct RepeatWithSwiper {
@Local banners: { id: string; url: string }[] = [];
aboutToAppear() {
// 初始化轮播数据(先不设置图片地址,等滑动时加载)
bannerUrls.forEach((url, i) => {
this.banners.push({ id: `b${i}`, url: "" });
});
}
build() {
Column() {
Text("图片轮播(懒加载)")
.fontSize(20)
.margin(10)
Swiper() {
Repeat(this.banners)
.each((item: RepeatItem<{ id: string; url: string }>) => {
Image(item.item.url || "https://picsum.photos/800/400?blur=2") // 加载前显示模糊占位图
.width("100%")
.height(200)
.objectFit(ImageFit.Cover)
})
.key((item) => item.id)
.virtualScroll({ totalCount: this.banners.length })
}
.indicator(true) // 显示指示器
.loop(true) // 循环播放
.onChange((index) => {
// 滑动到对应页时才加载图片(模拟网络延迟)
setTimeout(() => {
this.banners[index].url = bannerUrls[index];
}, 500);
})
}
.width("100%")
.height("100%")
}
}
这个轮播图会在滑动到对应页面时才加载图片,节省流量,还会显示占位图,用户体验更友好~
用 Repeat 时,很多人会踩坑导致效果异常,咱们总结几个常见问题和解决方案:
很多人在自定义组件里用 @Builder 时,只传item.item
或item.index
,结果导致 UI 不更新。正确做法是把整个RepeatItem
对象传过去。
错误示例:
@Builder
buildItem(itemContent: string) { // 只传了内容,无法监听变化
Text(itemContent).fontSize(16);
}
// 使用时
.template("default", (ri) => {
this.buildItem(ri.item); // 错误!
})
正确示例:
@Builder
buildItem(ri: RepeatItem) { // 传整个RepeatItem
Text(ri.item).fontSize(16);
}
// 使用时
.template("default", (ri) => {
this.buildItem(ri); // 正确!
})
如果totalCount
比实际数据长太多,滑动到后面会出现空白;如果比实际短,会显示不全。正确做法是:数据加载完前,totalCount
设为预期总长度;加载完后,设为实际长度。
// 正确设置totalCount的示例
Repeat(this.dataList)
.virtualScroll({
totalCount: this.isLoading ? 100 : this.dataList.length // 加载中设为100,完成后用实际长度
})
很多人图省事,用index
当 key 值,结果数据顺序变化时,Repeat 以为数据变了,导致组件重新渲染,性能下降。正确做法是用数据中唯一不变的字段(如 id)当 key。
推荐写法:
.key((item: Product) => item.id) // 用唯一id当key
当你不需要懒加载(即不写.virtualScroll()
),Repeat 会一次性加载所有组件,适合数据量少(少于 30 条)的场景。长列表用这个会卡顿!
// 短列表可以关闭懒加载(不写virtualScroll)
Repeat(shortList)
.each((item) => { ... })
.key((item) => item.id)
看到这里,你应该对 Repeat 有全面了解了。咱们再总结下它的核心优势:
下次开发列表类功能时,别再用 ForEach 硬扛了,试试 Repeat 组件,你会发现 HarmonyOS 应用开发原来可以这么轻松~ 赶紧动手试试吧!