HarmonyOS Repeat 虚拟滚动实战:模板分流、缓存数量与懒加载

请添加图片描述

长列表卡顿往往不是“数据太多”这一句能解释。一次创建过多子组件、键值不稳定导致整批重建、不同卡片共用错误的复用池、缓存数量按总数据量设置,都会让滑动过程产生额外构建和内存压力。Repeat的虚拟滚动把可见窗口、组件复用和按需补数放在同一条链路中,但前提是开发者给出可靠的数据身份与组件分类。

本文实现一个包含文字、图片和视频卡片的动态信息流。读完后你可以明确回答三个问题:哪一项能复用、复用到哪个组件类型、数据尚未到达时由谁补齐。

1. 先把卡顿拆成构建、复用和补数

列表问题可按发生时机分类。首屏就慢,通常是一次构建太多节点或图片解码过重;滑动一段后才掉帧,常见原因是key变化、复用失败或缓存过大;滑到数据尾部出现空白,则要检查totalCount与懒加载回调是否一致。

首屏创建慢    -> 缩小可见构建范围,检查复杂子树
往返滑动重建  -> 检查稳定key与组件分类
内存持续增长  -> 按卡片成本设置cachedCount
尾部出现空洞  -> 核对totalCount、占位项和补数回调

不要只观察平均帧率。组件创建次数、数据请求次数、图片内存和长帧位置更能说明是哪一层失效。

2. API版本决定可采用的策略

RepeateachkeyvirtualScrolltemplate从API 12起可用;VirtualScrollOptions.reusable从API 18起提供;按需补数相关的onLazyLoadingonTotalCount从API 19起提供。项目的compatibleSdkVersion低于这些版本时,需要分别准备兼容分支。

能力起始版本作用
Repeat虚拟滚动API 12只构建可见范围及缓存项
reusable开关API 18控制被移出窗口的节点是否进入复用
懒加载回调API 19数据未就绪时按索引补数

版本表不是形式信息。若团队设备跨度较大,应先用API 12基础链路跑通,再逐步开启高版本能力。

3. 数据身份不能由数组下标代替

删除、插入、置顶会改变数组下标。如果key使用index,原来的组件状态可能错误地落到另一条数据上。稳定key应来自业务主键,并且在同一个列表生命周期中保持唯一。

type FeedKind = 'text' | 'image' | 'video';

interface FeedItem {
  id: string;
  kind: FeedKind;
  title: string;
  coverUrl?: string;
  durationSec?: number;
}

function feedKey(item: FeedItem): string {
  return `${item.kind}:${item.id}`;
}

kind放进key并不是替代分类,而是避免不同数据域的主键碰撞。组件进入哪个复用池,仍由后面的templateId决定。

4. 虚拟滚动链路的五个环节

请添加图片描述

数据先被准备为稳定对象,随后根据类型选择渲染分支,只创建可见项和缓存项。离开窗口的节点若允许复用,就进入对应缓存池;接近未加载区域时,再由数据层补齐。任何一环失去边界,都会退化成“边滑边重建”或“提前加载全部”。

5. 先实现单一类型的最小链路

下面的例子只渲染文本卡片,用于确认key和虚拟窗口本身工作正常。totalCount必须表示列表逻辑总数,而不是随意写一个比数据大很多的常量。

@Component
struct TextFeedList {
  @State items: FeedItem[] = [];

  build() {
    List({ space: 12 }) {
      Repeat<FeedItem>(this.items)
        .each((repeatItem: RepeatItem<FeedItem>) => {
          ListItem() {
            Text(repeatItem.item.title)
              .fontSize(18)
              .padding(16)
          }
        })
        .key((item: FeedItem) => feedKey(item))
        .virtualScroll({ totalCount: this.items.length })
    }
    .cachedCount(2)
  }
}

先用插入、删除、排序和往返滑动验证组件身份。若状态串行,再增加卡片类型只会让问题更隐蔽。

6. 不同卡片必须进入不同复用池

文字、图片和视频卡片的组件树与资源成本不同。使用templateId按数据类型分流,再为每个template设置缓存数量,可以避免视频节点被当作文本节点复用。

Repeat<FeedItem>(this.items)
  .each((repeatItem: RepeatItem<FeedItem>) => {
    ListItem() {
      Text(repeatItem.item.title)
    }
  })
  .key((item: FeedItem) => feedKey(item))
  .virtualScroll({ totalCount: this.items.length, reusable: true })
  .templateId((item: FeedItem) => item.kind)
  .template('text', (repeatItem: RepeatItem<FeedItem>) => {
    ListItem() { TextCard({ item: repeatItem.item }) }
  }, { cachedCount: 4 })
  .template('image', (repeatItem: RepeatItem<FeedItem>) => {
    ListItem() { ImageCard({ item: repeatItem.item }) }
  }, { cachedCount: 2 })
  .template('video', (repeatItem: RepeatItem<FeedItem>) => {
    ListItem() { VideoCoverCard({ item: repeatItem.item }) }
  }, { cachedCount: 1 })

默认each分支仍应保留,以处理未知类型或灰度数据。不要为每个业务状态创建一个类型,例如“已读图片”“未读图片”若组件结构相同,应在同一分支内改变样式。

7. 缓存数量应按组件成本计算

请添加图片描述

cachedCount不是数据预加载条数,也不应等于服务端分页大小。它描述复用池愿意保留的组件数量。轻量文本卡片可多留几项,包含大图或媒体资源的卡片要更克制。

interface CacheBudget {
  text: number;
  image: number;
  video: number;
}

const cacheBudget: CacheBudget = {
  text: 4,
  image: 2,
  video: 1
};

调参时从小值开始,记录往返滑动的创建次数和内存峰值。如果增加缓存只减少极少构建,却显著提高图片或解码器驻留,就应回退。

8. 数据更新要保留未变化对象的身份

收到分页数据后,不要先深拷贝整个数组再全部替换。只追加新对象,更新已有项时也只替换确实变化的数据。这样Repeat能根据key识别未变化项。

function mergePage(current: FeedItem[], incoming: FeedItem[]): FeedItem[] {
  const byId: Map<string, FeedItem> = new Map(
    current.map((item: FeedItem) => [feedKey(item), item])
  );
  incoming.forEach((item: FeedItem) => {
    byId.set(feedKey(item), item);
  });
  return Array.from(byId.values());
}

若服务端返回顺序有意义,应额外按游标顺序合并,不能依赖Map掩盖排序规则。关键是稳定身份,而不是固定数据顺序。

9. 懒加载要区分逻辑总数与已到达数据

当API 19能力可用时,totalCount可以表示服务端已知总数,缺失索引由onLazyLoading触发补数。请求层必须合并相邻索引并防止同一页并发请求。

class FeedPageLoader {
  private loadingPages: Set<number> = new Set();

  async loadIndex(index: number, pageSize: number): Promise<void> {
    const page = Math.floor(index / pageSize);
    if (this.loadingPages.has(page)) {
      return;
    }
    this.loadingPages.add(page);
    try {
      await this.fetchPage(page, pageSize);
    } finally {
      this.loadingPages.delete(page);
    }
  }

  private async fetchPage(page: number, pageSize: number): Promise<void> {
    // 调用仓储层并把结果写回数据源
  }
}

补数失败时保留可重试的占位状态,不要反复增减totalCount制造列表跳动。若服务端不知道总数,可以采用“当前数据量加一个加载占位项”的协议。

10. 复用组件内部不能藏一次性状态

组件被复用后会接收新的repeatItem.item。播放器、订阅、定时器或异步图片请求若只在首次创建时绑定,就可能继续服务旧数据。重资源卡片应显式实现绑定与解绑。

@Component
struct VideoCoverCard {
  @Prop item: FeedItem;
  private boundId: string = '';

  aboutToAppear(): void {
    this.bind(this.item.id);
  }

  aboutToDisappear(): void {
    this.unbind(this.boundId);
  }

  private bind(id: string): void {
    this.boundId = id;
  }

  private unbind(id: string): void {
    // 取消与该id相关的订阅或加载任务
  }

  build() {
    Text(this.item.title)
  }
}

实际项目还要关注属性变化时的重新绑定,不能仅依赖出现与消失回调。最安全的做法是让卡片只保存与当前业务ID可核对的资源句柄。

11. 三种错误会让虚拟列表退化

  1. key随位置变化:插入一条数据后,大量卡片被视为新节点。
  2. 分类粒度过细:每个状态一个分支,复用池被切得过碎。
  3. 缓存盲目增大:屏外重组件长期驻留,内存与解码压力上升。

还要避免在卡片build路径中排序大数组、同步读取文件或创建网络请求。构建函数应消费准备好的视图数据,不承担数据加工。

12. 建立可重复的滑动验收场景

准备固定的1000条混合数据,包含稳定比例的三种卡片。冷启动后从顶部匀速滑到底,再立即回到顶部,重复三轮。记录首屏时间、组件创建次数、长帧、内存峰值和分页请求次数。

[ ] 插入、删除和置顶后卡片状态未串行
[ ] 相同数据往返滑动时创建次数明显下降
[ ] 三种卡片进入各自的复用分支
[ ] 缓存增大不会导致内存持续上升
[ ] 同一分页不会并发请求两次
[ ] 补数失败可重试且列表位置不跳动

13. Repeat虚拟滚动资料索引

Repeat优化的核心不是少写几个组件,而是让数据身份、组件类型、可见窗口和补数协议保持一致。key决定“是不是同一条数据”,templateId决定“能不能进入同一复用池”,cachedCount决定“愿意为复用付出多少内存”。把这三层分开调,列表性能才可解释、可复现。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐