《HarmonyOS技术精讲-UI开发》第5篇:列表与滚动组件

1. 为什么需要专门处理列表和滚动

HarmonyOS NEXT 开发中,列表(List、Grid)是最常用的容器组件。很多新手直接往 List 里塞一堆子组件,然后发现页面卡顿、滚动不流畅、数据更新后界面不刷新。这是 List 的渲染机制没搞明白。

ListGrid 默认是虚拟滚动------也就是只渲染屏幕上可视区域内的子组件,其他组件在不可见时被回收。这个机制性能很好,但同时也带来了一些隐藏约束:如果数据源是动态变化的,必须通知组件树重新构建,否则旧组件还在内存里,新数据不会显示。

本文通过构建两个典型场景来梳理这些组件的正确用法:一个 商品列表(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 实例需要与 Listscroller 属性绑定,否则 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 返回的对象必须有唯一标识。
  • GridrowsTemplate 为空表示行数自适应,高度由内容撑开。

3.4 滚动监听:当用户滚动到顶部或底部时隐藏按钮

有时我们希望按钮只在非顶部/底部时才显示。可以通过 ScrolleronScrollStartonScrollStop 事件监听。

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: () => {
      // 同样更新状态
    }
  }) {
    // ...
  }
}

更稳妥的方式是绑定 onReachStartonReachEnd 事件(针对 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 没有新增项。

原因 :数据源类没有正确调用 notifyDataAddnotifyDataReload。必须继承 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. 最佳实践

  1. 优先使用 LazyForEach 替代 ForEach:当数据量超过 100 条时,ForEach 在 UI 初次构建的耗时明显上升,且内存占用随数据量线性增长。LazyForEach 则保持常量级的内存开销。
  2. 不要在 build() 中创建对象或数组 :每次状态变化触发 build 都会创建新对象,造成额外的 JS 对象分配和回收压力。数据源应在 aboutToAppear 中一次性生成,或使用 @State 管理的数组。
  3. 滚动事件中不要执行耗时操作onScrollStartonScrollStop 被频繁调用,如果在这里做复杂计算或网络请求,会拖慢滚动帧率。建议使用 onScroll 配合节流(throttle)处理。

6. FAQ

Q:为什么真机上 List 滚动比模拟器流畅?

A:模拟器通常使用软件渲染,与真机硬件加速差异显著。性能优化应以真机为准。

Q:Grid 的 columnsTemplate 如果用百分比起什么作用?

A:百分数是针对 Grid 父容器宽度的百分比,例如 '30% 40% 30%'。推荐使用 fr 单位,更弹性。

Q:LazyForEach 数据源能否在运行时替换整个数组?

A:可以,但需要调用 notifyDataReload() 全量刷新,性能较差。推荐增量操作(add/delete)。