取件伙伴性能提升——长列表

125 阅读4分钟

取件伙伴性能提升——长列表

在移动应用开发中,List是最常见也是最容易出现性能瓶颈的场景之一。在 取件伙伴 项目中,取件列表页面需要展示可能多达数百条的包裹信息。如果不进行优化,随着数据量的增长,应用会出现滑动掉帧、内存占用过高甚至崩溃的问题,特别是最近我增加了在深色模式下的雪花效果,列表更是卡的不行!

本文将详细介绍我们如何利用 性能优化 "三剑客" —— LazyForEach@ReusablecachedCount,将列表渲染性能提升至极致。


核心问题分析

在早期的开发中,如果直接使用 ForEach 渲染列表:

// ❌ 性能较差的写法
List() {
  ForEach(this.packages, (item) => {
    PackageCard({ packageInfo: item })
  })
}

这种方式存在两个主要缺陷:

  1. 全量加载:无论列表有多长,ForEach 都会一次性创建所有的数据对象和组件节点。如果有 1000 个包裹,就会瞬间创建 1000 个 PackageCard,导致内存激增。
  2. 频繁销毁与创建:当用户滑动列表时,移出屏幕的组件会被销毁,新进入屏幕的组件需要重新创建、布局和渲染。对于包含图片和复杂布局的卡片,这种开销是巨大的,直接导致滑动卡顿。

解决方案:性能优化 "三剑客"

1. LazyForEach:按需加载

LazyForEach 是专门为长列表设计的渲染控制语法。与 ForEach 不同,它只渲染屏幕可见区域的组件,并配合数据源(IDataSource)实现按需加载。

实现步骤:

首先,我们需要实现一个 IDataSource 接口的数据源类:

entry/src/main/ets/utils/BasicDataSource.ets

// 通用数据源基类,实现了 IDataSource 接口
export class BasicDataSource<T> implements IDataSource {
  private listeners: DataChangeListener[] = [];
  private originDataArray: T[] = [];

  // 获取数据的总条数
  public totalCount(): number {
    return this.originDataArray.length;
  }

  // 获取指定索引的数据
  public getData(index: number): T {
    return this.originDataArray[index];
  }

  // 注册/注销监听器(框架调用)
  registerDataChangeListener(listener: DataChangeListener): void {
    if (this.listeners.indexOf(listener) < 0) {
      this.listeners.push(listener);
    }
  }
  unregisterDataChangeListener(listener: DataChangeListener): void {
    const pos = this.listeners.indexOf(listener);
    if (pos >= 0) {
      this.listeners.splice(pos, 1);
    }
  }

  // === 通知 LazyForEach 刷新 ===
  notifyDataReload(): void {
    this.listeners.forEach(listener => listener.onDataReloaded());
  }
  
  public setData(data: T[]) {
    this.originDataArray = data;
    this.notifyDataReload();
  }
}

2. @Reusable:组件复用

这是解决“滑动卡顿”的关键。通过 @Reusable 装饰器,我们可以让组件具备“复用”能力。当一个列表项滑出屏幕时,它的组件实例不会被销毁,而是被放入缓存池;当新数据滑入屏幕时,直接从缓存池取出实例并更新数据,跳过了昂贵的组件创建和布局计算过程

entry/src/main/ets/components/PackageCard.ets

@Component
@Reusable // <--- 1. 标记为可复用组件
export struct PackageCard {
  @State packageInfo: PackageInfo | undefined = undefined;
  
  /**
   * 2. 复用生命周期回调
   * 当组件被复用时触发。在此处更新状态变量,驱动 UI 刷新。
   * 
   * @param params 上层传入的新参数
   */
  aboutToReuse(params: Record<string, Object>) {
    // 快速更新数据
    this.packageInfo = params.packageInfo as PackageInfo;
    
    // 更新其他状态
    if (params.compactModeEnabled !== undefined) {
      this.compactModeEnabled = params.compactModeEnabled as boolean;
    }
    // ...
  }

  build() {
    // 构建复杂的卡片布局...
    // 复用时,这里的节点结构保持不变,仅数据发生变化
  }
}

3. cachedCount:预加载

LazyForEach 默认只加载屏幕可见的项。为了让滑动更流畅,我们可以利用 cachedCount 属性,让列表在屏幕上下方预先加载几个项目。

entry/src/main/ets/pages/PackagesPage.ets

List({ space: 12 }) {
  // 使用 LazyForEach + 自定义数据源
  LazyForEach(this.packagesDataSource, (packageInfo: PackageInfo, index: number) => {
    ListItem() {
      // 使用可复用组件
      PackageCard({
        packageInfo: packageInfo,
        // ...
      })
    }
  }, (item: PackageInfo) => `${item.id}_${item.updateTime}`) // 键值生成器
}
.width('100%')
.cachedCount(5) // <--- 设置缓存数量为 5
  • 原理cachedCount(5) 表示在屏幕视口之外,预先渲染并缓存 5 个列表项。
  • 收益:当用户快速滑动时,即将进入屏幕的卡片已经渲染好了,消除了白屏和闪烁,极大提升了跟手性。

优化效果对比

指标优化前 (ForEach)优化后 (LazyForEach + @Reusable)提升原理
首屏加载时间慢(加载所有数据)(仅加载首屏可见项)按需渲染
内存占用高(随数据量线性增长)低且稳定(仅维持可见项+缓存项)对象复用
滑动帧率掉帧明显满帧运行 (60/90/120Hz)避免频繁创建销毁节点
CPU 占用高(频繁 GC 和布局计算)复用现有节点结构

点击查看优化效果

总结

在开发复杂列表界面时,"LazyForEach + @Reusable + cachedCount" 是标准的高性能解决方案。

  1. LazyForEach 替代 ForEach,解决内存和首屏问题。
  2. @Reusable 改造子组件,解决滑动掉帧问题。
  3. cachedCount 调节预加载,进一步提升流畅度。

这套方案在 PickupPartner 项目中经受住了大量数据的考验,为用户提供了丝滑的操作体验。

theme: channing-cyan

取件伙伴性能提升——长列表

在移动应用开发中,List是最常见也是最容易出现性能瓶颈的场景之一。在 取件伙伴 项目中,取件列表页面需要展示可能多达数百条的包裹信息。如果不进行优化,随着数据量的增长,应用会出现滑动掉帧、内存占用过高甚至崩溃的问题,特别是最近我增加了在深色模式下的雪花效果,列表更是卡的不行!

本文将详细介绍我们如何利用 性能优化 "三剑客" —— LazyForEach@ReusablecachedCount,将列表渲染性能提升至极致。


核心问题分析

在早期的开发中,如果直接使用 ForEach 渲染列表:

// ❌ 性能较差的写法
List() {
  ForEach(this.packages, (item) => {
    PackageCard({ packageInfo: item })
  })
}

这种方式存在两个主要缺陷:

  1. 全量加载:无论列表有多长,ForEach 都会一次性创建所有的数据对象和组件节点。如果有 1000 个包裹,就会瞬间创建 1000 个 PackageCard,导致内存激增。
  2. 频繁销毁与创建:当用户滑动列表时,移出屏幕的组件会被销毁,新进入屏幕的组件需要重新创建、布局和渲染。对于包含图片和复杂布局的卡片,这种开销是巨大的,直接导致滑动卡顿。

解决方案:性能优化 "三剑客"

1. LazyForEach:按需加载

LazyForEach 是专门为长列表设计的渲染控制语法。与 ForEach 不同,它只渲染屏幕可见区域的组件,并配合数据源(IDataSource)实现按需加载。

实现步骤:

首先,我们需要实现一个 IDataSource 接口的数据源类:

entry/src/main/ets/utils/BasicDataSource.ets

// 通用数据源基类,实现了 IDataSource 接口
export class BasicDataSource<T> implements IDataSource {
  private listeners: DataChangeListener[] = [];
  private originDataArray: T[] = [];

  // 获取数据的总条数
  public totalCount(): number {
    return this.originDataArray.length;
  }

  // 获取指定索引的数据
  public getData(index: number): T {
    return this.originDataArray[index];
  }

  // 注册/注销监听器(框架调用)
  registerDataChangeListener(listener: DataChangeListener): void {
    if (this.listeners.indexOf(listener) < 0) {
      this.listeners.push(listener);
    }
  }
  unregisterDataChangeListener(listener: DataChangeListener): void {
    const pos = this.listeners.indexOf(listener);
    if (pos >= 0) {
      this.listeners.splice(pos, 1);
    }
  }

  // === 通知 LazyForEach 刷新 ===
  notifyDataReload(): void {
    this.listeners.forEach(listener => listener.onDataReloaded());
  }
  
  public setData(data: T[]) {
    this.originDataArray = data;
    this.notifyDataReload();
  }
}

2. @Reusable:组件复用

这是解决“滑动卡顿”的关键。通过 @Reusable 装饰器,我们可以让组件具备“复用”能力。当一个列表项滑出屏幕时,它的组件实例不会被销毁,而是被放入缓存池;当新数据滑入屏幕时,直接从缓存池取出实例并更新数据,跳过了昂贵的组件创建和布局计算过程

entry/src/main/ets/components/PackageCard.ets

@Component
@Reusable // <--- 1. 标记为可复用组件
export struct PackageCard {
  @State packageInfo: PackageInfo | undefined = undefined;
  
  /**
   * 2. 复用生命周期回调
   * 当组件被复用时触发。在此处更新状态变量,驱动 UI 刷新。
   * 
   * @param params 上层传入的新参数
   */
  aboutToReuse(params: Record<string, Object>) {
    // 快速更新数据
    this.packageInfo = params.packageInfo as PackageInfo;
    
    // 更新其他状态
    if (params.compactModeEnabled !== undefined) {
      this.compactModeEnabled = params.compactModeEnabled as boolean;
    }
    // ...
  }

  build() {
    // 构建复杂的卡片布局...
    // 复用时,这里的节点结构保持不变,仅数据发生变化
  }
}

3. cachedCount:预加载

LazyForEach 默认只加载屏幕可见的项。为了让滑动更流畅,我们可以利用 cachedCount 属性,让列表在屏幕上下方预先加载几个项目。

entry/src/main/ets/pages/PackagesPage.ets

List({ space: 12 }) {
  // 使用 LazyForEach + 自定义数据源
  LazyForEach(this.packagesDataSource, (packageInfo: PackageInfo, index: number) => {
    ListItem() {
      // 使用可复用组件
      PackageCard({
        packageInfo: packageInfo,
        // ...
      })
    }
  }, (item: PackageInfo) => `${item.id}_${item.updateTime}`) // 键值生成器
}
.width('100%')
.cachedCount(5) // <--- 设置缓存数量为 5
  • 原理cachedCount(5) 表示在屏幕视口之外,预先渲染并缓存 5 个列表项。
  • 收益:当用户快速滑动时,即将进入屏幕的卡片已经渲染好了,消除了白屏和闪烁,极大提升了跟手性。

优化效果对比

指标优化前 (ForEach)优化后 (LazyForEach + @Reusable)提升原理
首屏加载时间慢(加载所有数据)(仅加载首屏可见项)按需渲染
内存占用高(随数据量线性增长)低且稳定(仅维持可见项+缓存项)对象复用
滑动帧率掉帧明显满帧运行 (60/90/120Hz)避免频繁创建销毁节点
CPU 占用高(频繁 GC 和布局计算)复用现有节点结构

点击查看优化效果

总结

在开发复杂列表界面时,"LazyForEach + @Reusable + cachedCount" 是标准的高性能解决方案。

  1. LazyForEach 替代 ForEach,解决内存和首屏问题。
  2. @Reusable 改造子组件,解决滑动掉帧问题。
  3. cachedCount 调节预加载,进一步提升流畅度。

这套方案在 PickupPartner 项目中经受住了大量数据的考验,为用户提供了丝滑的操作体验。