
1. 为什么需要专门处理列表和滚动
HarmonyOS NEXT 开发中,列表(List、Grid)是最常用的容器组件。很多新手直接往 List 里塞一堆子组件,然后发现页面卡顿、滚动不流畅、数据更新后界面不刷新。这是 List 的渲染机制没搞明白。
List 和 Grid 默认是虚拟滚动------也就是只渲染屏幕上可视区域内的子组件,其他组件在不可见时被回收。这个机制性能很好,但同时也带来了一些隐藏约束:如果数据源是动态变化的,必须通知组件树重新构建,否则旧组件还在内存里,新数据不会显示。
本文通过构建两个典型场景来梳理这些组件的正确用法:一个 商品列表(List) 和一个 照片网格(Grid),并加上"回顶部""回底部"按钮演示滚动控制。
2. 环境说明
text
DevEco Studio 版本:DevEco Studio 6.1.0 及以上
HarmonyOS SDK 版本:HarmonyOS 6.1.0(23) 及以上
目标设备:手机
本文示例使用 @Entry 单页面完成,所有逻辑集中在一个文件中,方便初学者直接运行。
3. 核心实现
3.1 数据模型与 Mock 数据
商品列表需要包含图片、名称、价格;照片网格只需要图片地址和描述。先用 class 定义数据模型,并用 @Observed 装饰,使数据变化能触发 UI 刷新。
typescript
// ProductModel.ets
@Observed
class ProductModel {
id: number;
name: string;
price: number;
imageUrl: string;
constructor(id: number, name: string, price: number, imageUrl: string) {
this.id = id;
this.name = name;
this.price = price;
this.imageUrl = imageUrl;
}
}
@Observed
class PhotoModel {
id: number;
description: string;
imageUrl: string;
constructor(id: number, description: string, imageUrl: string) {
this.id = id;
this.description = description;
this.imageUrl = imageUrl;
}
}
3.2 使用 ForEach 构建简单商品列表
List 配合 ListItem 使用。这里先用 ForEach 遍历数组,展示一个基础的列表。注意 ForEach 要求数组 item 有唯一的 key,否则会出现渲染错乱。
typescript
// ProductList.ets
@Entry
@Component
struct ProductListPage {
@State products: ProductModel[] = [];
private scroller: Scroller = new Scroller();
aboutToAppear() {
// 模拟 50 条商品数据
for (let i = 0; i < 50; i++) {
this.products.push(new ProductModel(
i,
`商品${i}`,
Math.floor(Math.random() * 1000) + 1,
`https://picsum.photos/200?random=${i}` // 随机图片
));
}
}
build() {
Column() {
// 列表内容
List({ scroller: this.scroller }) {
ForEach(this.products, (item: ProductModel) => {
ListItem() {
Row() {
Image(item.imageUrl)
.width(60)
.height(60)
.borderRadius(8)
Column() {
Text(item.name).fontSize(16).fontWeight(FontWeight.Bold)
Text(`¥${item.price}`).fontSize(14).fontColor('#FF4444')
}
.layoutWeight(1)
.margin({ left: 12 })
}
.padding(12)
.width('100%')
}
.width('100%')
}, (item: ProductModel) => item.id.toString()) // 必须指定 key 生成器
}
.width('100%')
.layoutWeight(1) // 占据剩余高度
// 滚动控制按钮
Row() {
Button('回顶部')
.onClick(() => {
this.scroller.scrollTo({ xOffset: 0, yOffset: 0, animation: { duration: 300 } });
})
Button('回底部')
.onClick(() => {
this.scroller.scrollEdge(Edge.Bottom);
})
}
.justifyContent(FlexAlign.SpaceAround)
.padding(12)
}
.width('100%')
.height('100%')
}
}
注意事项:
ForEach的第三个参数是keyGenerator,必须返回一个唯一且稳定的字符串。如果省略,ArkUI 会使用默认的 index 作为 key,当数组顺序变化时会导致组件复用错误(经典踩坑点)。Scroller实例需要与List的scroller属性绑定,否则scrollTo无效。
3.3 使用 LazyForEach 实现照片网格(懒加载)
当数据量超过 200 条时,ForEach 把全部数据一次性构建到虚拟 DOM 中,虽然不渲染不可见项,但组件对象仍占用内存。LazyForEach 则能做到更彻底的懒加载------只有即将进入可视区域的项才会创建组件。
需要实现一个 IDataSource 接口的类。官方推荐使用 BasicDataSource 基类。
typescript
// PhotoGrid.ets
import { BasicDataSource } from '@ohos.arkui.collection';
// 数据源类,继承 BasicDataSource
class PhotoDataSource extends BasicDataSource {
private dataArray: PhotoModel[] = [];
// 必须实现的方法
totalCount(): number {
return this.dataArray.length;
}
getData(index: number): any {
return this.dataArray[index];
}
addData(index: number, data: PhotoModel): void {
this.dataArray.splice(index, 0, data);
this.notifyDataAdd(index);
}
pushData(data: PhotoModel): void {
this.dataArray.push(data);
this.notifyDataAdd(this.dataArray.length - 1);
}
deleteData(index: number): void {
this.dataArray.splice(index, 1);
this.notifyDataDelete(index);
}
}
在组件内创建数据源并绑定到 Grid:
typescript
@Entry
@Component
struct PhotoGridPage {
private photoDataSource: PhotoDataSource = new PhotoDataSource();
private gridScroller: Scroller = new Scroller();
aboutToAppear() {
// 模拟 200 张照片
for (let i = 0; i < 200; i++) {
this.photoDataSource.pushData(new PhotoModel(
i,
`照片${i}`,
`https://picsum.photos/300?random=${i + 1000}`
));
}
}
build() {
Column() {
Grid({ scroller: this.gridScroller, columnsTemplate: '1fr 1fr 1fr', rowsTemplate: '' }) {
LazyForEach(this.photoDataSource, (item: PhotoModel, index?: number) => {
GridItem() {
Column() {
Image(item.imageUrl)
.width('100%')
.aspectRatio(1)
.borderRadius(8)
Text(item.description)
.fontSize(12)
.textAlign(TextAlign.Center)
.width('100%')
.margin({ top: 4 })
}
.padding(4)
.width('100%')
}
}, (item: PhotoModel) => item.id.toString())
}
.width('100%')
.layoutWeight(1)
// 滚动控制
Row() {
Button('回顶部')
.onClick(() => {
this.gridScroller.scrollTo({ xOffset: 0, yOffset: 0, animation: { duration: 300 } });
})
Button('回底部')
.onClick(() => {
this.gridScroller.scrollEdge(Edge.Bottom);
})
}
.padding(12)
}
.width('100%')
.height('100%')
}
}
关键点:
columnsTemplate: '1fr 1fr 1fr'表示三列等宽。也可以写'150px 1fr 1fr'混合单位。LazyForEach的第三个参数keyGenerator同样必须提供,且数据源内的getData返回的对象必须有唯一标识。Grid的rowsTemplate为空表示行数自适应,高度由内容撑开。
3.4 滚动监听:当用户滚动到顶部或底部时隐藏按钮
有时我们希望按钮只在非顶部/底部时才显示。可以通过 Scroller 的 onScrollStart 和 onScrollStop 事件监听。
typescript
private isAtTop: boolean = true;
private isAtBottom: boolean = false;
build() {
List({
scroller: this.scroller,
onScrollStart: () => {
// 判断位置
let currentOffset = this.scroller.currentOffset().yOffset;
this.isAtTop = currentOffset <= 0;
// 判断是否到底部需要更复杂:比较 scrollable distance
},
onScrollStop: () => {
// 同样更新状态
}
}) {
// ...
}
}
更稳妥的方式是绑定 onReachStart 和 onReachEnd 事件(针对 List/Grid):
typescript
List() {
// ...
}
.onReachStart(() => {
this.isAtTop = true;
})
.onReachEnd(() => {
this.isAtBottom = true;
})
注意:onReachEnd 默认是在内容末尾还有一定缓冲距离时触发(约 5px),如果需要严格到末端,可以调整 edgeEffect 属性。
4. 常见踩坑记录
坑1:ForEach 未指定 keyGenerator 导致 UI 闪烁
现象 :添加/删除商品后,列表项内容错位或闪一下恢复。
原因 :ForEach 默认使用数组 index 作为 key。当数组变化(如删除中间项),index 改变,ArkUI 认为组件需要重新创建,但旧组件被复用后内容更新不及时。
解决方案 :始终提供 (item) => item.id 这样的稳定 keyGenerator。
坑2:LazyForEach 数据源更新后界面不刷新
现象 :调用 dataSource.pushData 后 Grid 没有新增项。
原因 :数据源类没有正确调用 notifyDataAdd 或 notifyDataReload。必须继承 BasicDataSource 或手动实现通知机制。
解决方案 :使用官方提供的 BasicDataSource,所有增删改操作都同步调用对应的 notify 方法。
坑3:Scroller.scrollTo 在动画期间再次触发导致崩溃
现象 :快速连点"回顶部"按钮,应用闪退。
原因 :scrollTo 返回一个 Promise,如果前一个动画未完成就调用新的,会导致内部状态紊乱。
解决方案:添加标志位控制:
typescript
private isScrolling: boolean = false;
onClick() {
if (this.isScrolling) return;
this.isScrolling = true;
this.scroller.scrollTo({ ... }).then(() => {
this.isScrolling = false;
}).catch(() => {
this.isScrolling = false;
});
}
5. 最佳实践
- 优先使用 LazyForEach 替代 ForEach:当数据量超过 100 条时,ForEach 在 UI 初次构建的耗时明显上升,且内存占用随数据量线性增长。LazyForEach 则保持常量级的内存开销。
- 不要在 build() 中创建对象或数组 :每次状态变化触发 build 都会创建新对象,造成额外的 JS 对象分配和回收压力。数据源应在
aboutToAppear中一次性生成,或使用@State管理的数组。 - 滚动事件中不要执行耗时操作 :
onScrollStart和onScrollStop被频繁调用,如果在这里做复杂计算或网络请求,会拖慢滚动帧率。建议使用onScroll配合节流(throttle)处理。
6. FAQ
Q:为什么真机上 List 滚动比模拟器流畅?
A:模拟器通常使用软件渲染,与真机硬件加速差异显著。性能优化应以真机为准。
Q:Grid 的 columnsTemplate 如果用百分比起什么作用?
A:百分数是针对 Grid 父容器宽度的百分比,例如 '30% 40% 30%'。推荐使用 fr 单位,更弹性。
Q:LazyForEach 数据源能否在运行时替换整个数组?
A:可以,但需要调用 notifyDataReload() 全量刷新,性能较差。推荐增量操作(add/delete)。