HarmonyOS 长列表性能优化实战:LazyForEach、分页加载与图片缓存治理

请添加图片描述

资讯流、商品流、路线列表、消息列表这类页面,刚上线时通常只有几十条数据,看起来很流畅。问题往往出现在版本迭代后:运营要求首屏多露出内容,服务端一次返回更多图片,列表项里又加了点赞、收藏、标签、倒计时。最后用户一滑动就掉帧,内存曲线也开始往上爬。

这篇文章只解决一个问题:HarmonyOS 应用里的长列表,如何用稳定数据源、LazyForEach、分页加载和图片缓存,把滑动卡顿和内存上涨压下去。

本文不是组件 API 速查,而是一个可迁移的工程方案。你可以把示例里的 ArticleFeed 换成商品、路线、帖子、课程,核心思路一样。

一、资料定位:先把官方能力边界找清楚

类型 资料/能力 本文使用点
官方组件能力 LazyForEach 数据懒加载 列表项按需渲染、稳定 key、数据源监听
官方列表组件 List 列表组件 列表容器、滚动监听、列表项组织
官方性能实践 应用性能优化实践 避免主线程重计算、减少不必要刷新
官方调优工具 DevEco Profiler 帧率、内存、主线程耗时复测

本文代码以 HarmonyOS 5.0.0+、Stage 模型、ArkTS 为边界。图片加载部分使用工程化伪实现表达缓存策略,真实项目可以替换成自己的网络层、文件缓存或图片库。

二、问题拆解:长列表慢通常不是一个点慢

请添加图片描述

长列表卡顿一般来自几类叠加问题:

问题 表现 根因
一次性塞入过多数据 首次进入页面慢 首屏前加载了非首屏数据
key 不稳定 刷新后列表抖动 框架无法稳定复用组件
列表项过重 滑动掉帧 每个 item 都在做复杂计算
图片无缓存 反复解码和请求 列表复用时重复加载
状态更新过宽 点赞一个 item,整页刷新 状态放置粒度太粗

所以优化顺序不要反:先让数据稳定,再做 LazyForEach,再做分页和图片缓存。只换一个组件名,解决不了长列表问题。

三、工程结构:把数据、渲染和缓存分开

请添加图片描述

推荐目录:

entry/src/main/ets/
├── pages/feed/
│   └── ArticleFeedPage.ets
├── features/feed/
│   ├── ArticleFeedRepository.ets
│   ├── ArticlePagedDataSource.ets
│   └── ArticleCardModel.ets
├── common/image/
│   └── FeedImageCache.ets
└── common/perf/
    └── FeedPerfLogger.ets
文件 职责
ArticleFeedRepository 请求分页数据,隐藏网络细节
ArticlePagedDataSource 给 LazyForEach 提供稳定数据源
ArticleFeedPage 只负责页面状态和列表渲染
FeedImageCache 管理图片缓存命中和预加载
FeedPerfLogger 记录分页、渲染、图片命中日志

这个拆法的好处是:列表滑动慢时,可以分别看数据源、组件渲染、图片缓存,不会所有逻辑都堆在页面里。

四、列表模型:每条数据必须有稳定 ID

export interface ArticleCardModel {
  id: string;
  title: string;
  summary: string;
  coverUrl: string;
  authorName: string;
  publishTime: string;
  likeCount: number;
  tags: string[];
}

export interface PageResult<T> {
  pageNo: number;
  pageSize: number;
  hasMore: boolean;
  list: T[];
}

代码解释:

  1. id 是列表复用的核心,不能用数组下标代替。
  2. coverUrl 不在渲染阶段处理,图片策略交给缓存层。
  3. PageResult 明确分页边界,避免页面自己猜有没有下一页。
  4. tags 这种数组字段要控制数量,列表项里不要渲染无限标签。

如果后端没有稳定 ID,要在客户端按业务字段合成,例如 sourceId + publishTime。不要用 Date.now()index 当 key。

五、分页仓库:不要一次把全量数据塞给页面

import { ArticleCardModel, PageResult } from './ArticleCardModel';

export class ArticleFeedRepository {
  async queryPage(pageNo: number, pageSize: number): Promise<PageResult<ArticleCardModel>> {
    const start = (pageNo - 1) * pageSize;

    const list: ArticleCardModel[] = Array.from({ length: pageSize }).map((_, index) => {
      const id = `article_${start + index + 1}`;
      return {
        id,
        title: `HarmonyOS 工程实践 ${start + index + 1}`,
        summary: '围绕真实项目中的性能、适配和上架问题做工程化拆解。',
        coverUrl: `https://example.com/covers/${id}.png`,
        authorName: 'HarmonyOS Developer',
        publishTime: '2026-07-15',
        likeCount: 1000 + index,
        tags: ['HarmonyOS', 'ArkTS', '性能优化']
      };
    });

    return {
      pageNo,
      pageSize,
      hasMore: pageNo < 20,
      list
    };
  }
}

代码解释:

  1. 仓库层负责分页,不让页面关心接口细节。
  2. pageSize 建议从 10 到 20 开始压测,不要默认 50 或 100。
  3. hasMore 由数据层返回,页面只负责触发下一页。
  4. 示例里用本地数据模拟,真实项目替换成 HTTP 请求即可。

分页不是为了少写代码,而是为了控制首屏和内存峰值。列表内容越复杂,pageSize 越要保守。

六、LazyForEach 数据源:让框架知道哪一条变了

export class ArticlePagedDataSource implements IDataSource {
  private listeners: DataChangeListener[] = [];
  private items: ArticleCardModel[] = [];

  totalCount(): number {
    return this.items.length;
  }

  getData(index: number): ArticleCardModel {
    return this.items[index];
  }

  registerDataChangeListener(listener: DataChangeListener): void {
    if (this.listeners.indexOf(listener) < 0) {
      this.listeners.push(listener);
    }
  }

  unregisterDataChangeListener(listener: DataChangeListener): void {
    const index = this.listeners.indexOf(listener);
    if (index >= 0) {
      this.listeners.splice(index, 1);
    }
  }

  appendPage(list: ArticleCardModel[]): void {
    const startIndex = this.items.length;
    this.items.push(...list);
    this.listeners.forEach(listener => {
      listener.onDataAdd(startIndex);
    });
  }

  updateLikeCount(id: string, likeCount: number): void {
    const index = this.items.findIndex(item => item.id === id);
    if (index < 0) {
      return;
    }

    this.items[index] = {
      ...this.items[index],
      likeCount
    };
    this.listeners.forEach(listener => {
      listener.onDataChange(index);
    });
  }
}

代码解释:

  1. IDataSource 的边界是告诉 LazyForEach 数据有多少、哪一条发生变化。
  2. appendPage() 只通知新增位置,避免整表刷新。
  3. updateLikeCount() 只通知单条变更,点赞不会导致整页重建。
  4. items 不直接暴露给页面,减少误操作。

这里要注意:新增一页有多条数据时,实际项目可根据框架能力选择逐条通知或范围通知。核心原则是别用一个粗暴的全量刷新代替精确变更。

七、页面渲染:列表项要轻,状态要窄

@Entry
@Component
struct ArticleFeedPage {
  private repository: ArticleFeedRepository = new ArticleFeedRepository();
  private dataSource: ArticlePagedDataSource = new ArticlePagedDataSource();
  @State private pageNo: number = 1;
  @State private loading: boolean = false;
  @State private hasMore: boolean = true;

  async aboutToAppear(): Promise<void> {
    await this.loadNextPage();
  }

  build() {
    Column() {
      List() {
        LazyForEach(this.dataSource, (item: ArticleCardModel) => {
          ListItem() {
            ArticleCard({ item })
          }
        }, (item: ArticleCardModel) => item.id)

        if (this.hasMore) {
          ListItem() {
            this.LoadingFooter()
          }
        }
      }
      .onReachEnd(() => {
        this.loadNextPage();
      })
      .width('100%')
      .height('100%')
    }
  }

  @Builder
  LoadingFooter() {
    Row() {
      Text(this.loading ? '加载中...' : '继续加载')
        .fontSize(14)
        .fontColor('#667')
    }
    .justifyContent(FlexAlign.Center)
    .height(56)
    .width('100%')
  }

  private async loadNextPage(): Promise<void> {
    if (this.loading || !this.hasMore) {
      return;
    }

    this.loading = true;
    const result = await this.repository.queryPage(this.pageNo, 15);
    this.dataSource.appendPage(result.list);
    this.pageNo += 1;
    this.hasMore = result.hasMore;
    this.loading = false;
  }
}

代码解释:

  1. LazyForEach 的 key 使用 item.id,保证组件复用稳定。
  2. onReachEnd() 只触发下一页,不直接拼装数据。
  3. loadinghasMore 是页面状态,不放进每个 item。
  4. ArticleCard 单独拆组件,方便后续控制列表项复杂度。

aboutToAppear() 里只加载第一页。很多列表慢,就是因为首次进入页面时把多页数据一起拉了回来。

八、列表项组件:不要在 build 里做重计算

@Component
struct ArticleCard {
  @Prop item: ArticleCardModel;

  build() {
    Row({ space: 12 }) {
      FeedCoverImage({ url: this.item.coverUrl, cacheKey: this.item.id })

      Column({ space: 6 }) {
        Text(this.item.title)
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .maxLines(1)
          .textOverflow({ overflow: TextOverflow.Ellipsis })

        Text(this.item.summary)
          .fontSize(13)
          .fontColor('#667')
          .maxLines(2)
          .textOverflow({ overflow: TextOverflow.Ellipsis })

        Row({ space: 8 }) {
          Text(this.item.authorName).fontSize(12).fontColor('#889')
          Text(`${this.item.likeCount}`).fontSize(12).fontColor('#247A4D')
        }
      }
      .layoutWeight(1)
    }
    .padding(12)
    .backgroundColor(Color.White)
    .borderRadius(14)
    .margin({ left: 12, right: 12, top: 8 })
  }
}

代码解释:

  1. 列表项只负责展示,不在 build() 里过滤、排序、格式化大数据。
  2. 文本使用 maxLinestextOverflow,避免超长内容撑开布局。
  3. 图片交给 FeedCoverImage,方便统一缓存策略。
  4. @Prop item 只接收当前行数据,减少状态影响范围。

如果某个字段需要复杂格式化,应该在数据进入列表前处理好,而不是每次组件构建时处理。

九、图片缓存:列表滑动时最怕重复解码

export class FeedImageCache {
  private static memoryCache: Map<string, string> = new Map();
  private static maxCount: number = 80;

  static get(cacheKey: string): string | undefined {
    return FeedImageCache.memoryCache.get(cacheKey);
  }

  static put(cacheKey: string, localPath: string): void {
    if (FeedImageCache.memoryCache.size >= FeedImageCache.maxCount) {
      const firstKey = FeedImageCache.memoryCache.keys().next().value;
      FeedImageCache.memoryCache.delete(firstKey);
    }

    FeedImageCache.memoryCache.set(cacheKey, localPath);
  }

  static clear(): void {
    FeedImageCache.memoryCache.clear();
  }
}

代码解释:

  1. 这里演示内存缓存边界,真实项目可接文件缓存。
  2. maxCount 必须有限制,不能无限缓存图片。
  3. cacheKey 使用业务 ID,而不是 URL 全量字符串,便于统一管理。
  4. 页面销毁或内存告警时可以调用 clear()

图片优化要谨慎,不是缓存越多越好。缓存过大反而会让内存曲线抬高,最终被系统回收。

十、图片组件:先占位,再加载

@Component
struct FeedCoverImage {
  @Prop url: string;
  @Prop cacheKey: string;
  @State private displaySource: ResourceStr = $r('app.media.feed_placeholder');

  aboutToAppear(): void {
    const cached = FeedImageCache.get(this.cacheKey);
    if (cached) {
      this.displaySource = cached;
      return;
    }

    this.loadImageLater();
  }

  build() {
    Image(this.displaySource)
      .width(96)
      .height(72)
      .borderRadius(10)
      .objectFit(ImageFit.Cover)
  }

  private loadImageLater(): void {
    setTimeout(() => {
      const localPath = this.url;
      FeedImageCache.put(this.cacheKey, localPath);
      this.displaySource = localPath;
    }, 60);
  }
}

代码解释:

  1. 首屏先展示占位图,避免图片阻塞列表结构。
  2. 命中缓存时立即显示,降低重复加载成本。
  3. loadImageLater() 模拟延后加载,真实项目替换为图片下载或解码逻辑。
  4. 组件只更新自己的 displaySource,不影响整个列表。

如果图片很多,建议再加可见区域预加载策略:只预加载当前可见区域前后少量图片,不要把后面几十屏图片都拉下来。

十一、验证方法:用数据说话

建议每次优化都记录以下数据:

指标 优化前 优化后
首屏加载耗时 记录实际值 记录实际值
快速滑动帧率 DevEco Profiler 观察 DevEco Profiler 观察
内存峰值 DevEco Profiler 观察 DevEco Profiler 观察
图片缓存命中率 日志统计 日志统计
下一页加载失败率 弱网测试 弱网测试

测试步骤:

1. 清理应用后台进程,进入列表页。
2. 使用同一账号、同一网络、同一数据量。
3. 录制快速下滑、上滑、触底加载下一页。
4. 观察 Frame、Memory、CPU 变化。
5. 修改分页大小、缓存上限后重复测试。
6. 选择帧率和内存都稳定的参数。

不要只测“慢慢滑”。长列表真正的问题经常在快速滑动、网络慢、图片多、返回再进入时暴露。

十二、常见问题排查

现象 可能原因 检查方法 修复建议
列表刷新后跳动 key 使用 index 搜索 LazyForEach 第三个参数 改为业务稳定 ID
点赞导致整页闪动 状态放在页面全局 查看状态更新范围 单条数据源通知变更
快速滑动掉帧 item 组件过重 Profiler 看主线程 拆小组件,减少 build 计算
内存持续上涨 图片缓存无限增长 观察缓存数量 设置缓存上限和释放策略
弱网触底重复请求 loading 锁缺失 查看请求日志 请求中禁止重复触发
首屏慢 首次加载页数过多 检查第一页数据量 首屏只加载一页

十三、上线前验收清单

检查项 是否完成
LazyForEach 使用稳定业务 ID 作为 key
首屏只加载第一页数据
列表项 build 中没有大计算、排序、过滤
图片有占位图和缓存上限
触底加载有 loading 锁
点赞、收藏等操作只刷新单条数据
快速滑动、弱网、返回再进入都测过
DevEco Profiler 已完成帧率和内存复测

这张清单建议在提测前由开发自己先过一遍。长列表问题一旦进入测试阶段,复现成本会比较高,因为它依赖数据量、网络、图片大小和设备性能。开发阶段先把 key、分页、缓存、状态粒度这些基础项确认掉,后面的性能调优才不会变成无头苍蝇式排查。

如果项目中有埋点能力,也可以把第一页加载耗时、下一页加载耗时、图片缓存命中率作为观察指标。上线后如果某个版本列表变慢,先看这些指标,再决定是接口变慢、图片变大,还是组件渲染变重。

十四、总结

HarmonyOS 长列表优化的关键不是“把 ForEach 换成 LazyForEach”这么简单,而是让整条链路都可控:

  1. 数据要分页。
  2. key 要稳定。
  3. 列表项要轻。
  4. 图片要缓存但不能无限缓存。
  5. 状态更新要精确。
  6. 最后用 Profiler 复测。

只要这几件事做好,列表页从几十条扩展到几百、几千条时,也不会轻易变成卡顿源。

Logo

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

更多推荐