ArkUI 渲染性能优化实战:从列表掉帧到状态拆分、LazyForEach 和 Profiler 回归

ArkUI 渲染性能优化实战:从列表掉帧到状态拆分、LazyForEach 和 Profiler 回归

做 ArkUI 页面时,最容易被低估的性能问题不是“页面打不开”,而是“页面能用,但越滑越卡”。商品列表、路线列表、动态流、消息列表都有类似症状:第一屏还算流畅,滑到第二屏开始掉帧;筛选条件一变,整页闪一下;图片刚出现时手感变沉;删除一条数据后列表项错位;开发机上还行,真机一录 Profiler 就能看到帧耗时抖动。

这篇文章不讲抽象的性能口号,只处理一个真实问题:ArkUI 长列表滚动掉帧,怎么用状态拆分、稳定 key、LazyForEach、图片异步加载和 DevEco Profiler 做一次可验证的优化闭环。

请添加图片描述

读完以后,你应该能带走四件事:

  1. 知道列表卡顿该先看哪里,而不是一上来乱拆组件。
  2. 能把大对象状态拆成更可控的页面状态、列表数据源和局部选中状态。
  3. 能用 LazyForEach 和稳定 key 减少无意义的列表项重建。
  4. 能用 Profiler 录制前后结果,判断优化到底有没有用。

一、这类卡顿在真实项目里长什么样

先把问题说具体。下面这些现象如果只靠肉眼判断,很容易被误判成“手机性能不行”或者“图片太大”:

现象可能原因先看哪里
列表滑动到第二屏开始掉帧可见区域外组件创建太多,或者 item 重建过频繁ForEach / LazyForEach 使用方式
修改筛选条件后整页闪烁页面状态粒度过粗,一个字段变化牵动整棵 UI@State 是否包了大对象
删除一条数据后列表错位key 使用数组索引,数据位置变化后复用关系乱掉key 生成函数
图片出现瞬间卡一下图片请求、解码、占位策略压到渲染路径上图片加载时机和缓存策略
Profiler 里帧耗时有尖峰主线程有密集构建、布局或同步任务Trace 中 ArkUI 与主线程泳道

我建议不要直接改代码。先写一条能稳定复现的路径,例如:

  1. 打开商品列表页。
  2. 等首屏数据加载完成。
  3. 连续向下滑动 3 屏。
  4. 点击筛选条件。
  5. 再滑动 2 屏。
  6. 删除或收藏其中一个 item。

这条路径后面会作为优化前后的对比脚本。没有稳定路径,优化就容易变成“感觉快了”。

二、资料与版本边界:本文解决的是 ArkUI 列表渲染,不是全链路性能

本文示例面向 HarmonyOS NEXT / ArkTS / ArkUI 声明式 UI 工程,重点放在页面层和列表渲染层。网络请求、数据库查询、图片 CDN、后端接口耗时不在本文主线里,只在影响 UI 渲染时给出处理边界。

项目本文边界
UI 框架ArkUI 声明式开发范式
开发语言ArkTS
性能工具DevEco Studio Profiler、ArkUI 相关 Trace
列表组件ListListItemLazyForEach
数据源实现 IDataSource,手动通知列表数据变化
验证目标滚动流畅度、帧耗时尖峰、列表项重建范围

官方文档里有几个点很关键:ForEach 在大量子组件场景下可能带来卡顿,应考虑 LazyForEachLazyForEach 需要开发者实现 IDataSource,并通过 key 识别数据项;DevEco Profiler 可以录制 Trace,帮助定位耗时点。本文的代码就是围绕这些边界组织的。

请添加图片描述

三、先写一个“会卡”的版本,才能知道改哪里

很多列表页一开始会写成这样:页面把筛选条件、选中状态、loading、列表数据全部塞进一个 @State 对象里,然后用 ForEach 直接渲染。

interface ProductListPageState {
  keyword: string;
  sort: 'default' | 'price' | 'sales';
  selectedId: string;
  loading: boolean;
  products: ProductCardModel[];
}

@Entry
@Component
struct ProductListPage {
  @State pageState: ProductListPageState = {
    keyword: '',
    sort: 'default',
    selectedId: '',
    loading: false,
    products: []
  };

  build() {
    Column() {
      ProductFilterBar({
        keyword: this.pageState.keyword,
        sort: this.pageState.sort
      })

      List() {
        ForEach(this.pageState.products, (item: ProductCardModel, index: number) => {
          ListItem() {
            ProductCard({
              model: item,
              selected: this.pageState.selectedId === item.id
            })
          }
        })
      }
    }
  }
}

这段代码的问题不是语法,而是职责混在一起:

  1. keyword 改变时,列表数据和选中状态也被包在同一个大对象里。
  2. ForEach 直接面对完整数组,数据量上来后首屏外的构建压力更明显。
  3. index 出现在渲染回调里,后续很容易顺手拿索引做 key 或业务标识。
  4. 图片、收藏、选中等局部变化没有被隔离,容易扩大 UI 更新范围。

这就是“看起来代码少,实际维护成本高”的典型写法。优化的第一步不是拆组件,而是拆变化来源。

四、把大对象状态拆开:让每次变化只影响该影响的地方

列表页通常至少有三类状态:

状态类型例子更新频率推荐位置
页面筛选状态关键词、排序、分类中等页面 @State
列表数据商品、路线、消息数组较低但数据量大独立数据源
局部交互状态选中、收藏、曝光高频item 或轻量映射

改造后的页面不要把所有字段塞进一个对象:

type ProductSort = 'default' | 'price' | 'sales';

@Entry
@Component
struct ProductListPage {
  @State keyword: string = '';
  @State sort: ProductSort = 'default';
  @State selectedId: string = '';
  @State loading: boolean = false;

  private dataSource: ProductListDataSource = new ProductListDataSource();
  private repository: ProductRepository = new ProductRepository();

  aboutToAppear(): void {
    this.reloadProducts();
  }

  private async reloadProducts(): Promise<void> {
    this.loading = true;
    const result = await this.repository.query({
      keyword: this.keyword,
      sort: this.sort
    });
    this.dataSource.replaceAll(result);
    this.loading = false;
  }
}

这段代码的边界很清楚:

  1. 页面 @State 只保存会直接影响页面控制区的轻量字段。
  2. 列表数据交给 ProductListDataSource,后续由它通知列表刷新。
  3. reloadProducts() 的输入只有筛选条件,不把 UI 组件对象传入仓储层。
  4. loading 只控制加载提示,不参与列表 item 的 key 和复用关系。

拆状态的收益不是“代码更优雅”,而是后续排查时能判断:如果只是选中态变化,不应该导致整批列表数据重新装载;如果只是筛选条件变化,item 复用应该由 key 决定,而不是被数组索引牵着走。

五、模型先稳定:不要让 UI 直接依赖接口原始字段

列表 item 的字段应该先整理成 UI 模型。接口返回什么字段是一回事,页面渲染需要什么字段是另一回事。

export interface ProductCardModel {
  id: string;
  title: string;
  coverUrl: string;
  priceText: string;
  tags: string[];
  updatedAt: number;
  favorite: boolean;
}

export function toProductCardModel(raw: ProductResponseItem): ProductCardModel {
  return {
    id: raw.productId,
    title: raw.name ?? '未命名商品',
    coverUrl: raw.cover,
    priceText: `¥${raw.price}`,
    tags: raw.labels ?? [],
    updatedAt: raw.updateTime,
    favorite: raw.favorite === true
  };
}

这里的重点是“稳定”:

  1. id 必须来自业务唯一标识,不能用数组位置代替。
  2. titletags 这类字段在进入 UI 前就处理默认值,避免 item 内到处写空判断。
  3. updatedAt 可以参与 key 生成,用来表达“同一个 id 的内容确实变化了”。
  4. favorite 是局部交互字段,后续可以单独更新某一项。

如果页面直接使用接口原始字段,后续接口改名、空值、排序变化都会扩散到 UI 层。对性能优化来说,这种扩散会让你很难判断到底是渲染问题还是数据问题。

六、稳定 key:列表项能不能复用,先看这里

LazyForEach 的 key 不要用数组索引。索引的问题在删除、插入、排序后最明显:第二个 item 删除后,后面的索引全部变化,框架很难按业务身份判断谁是谁。

export function productKey(item: ProductCardModel): string {
  return `${item.id}_${item.updatedAt}`;
}

如果你的业务里 updatedAt 不可靠,可以只用 id

export function stableProductKey(item: ProductCardModel): string {
  return item.id;
}

怎么选?

key 写法适合场景风险
item.id数据内容变化会通过数据源通知更新内容更新后如果通知不准确,可能看不到变化
item.id + updatedAt内容版本明确,替换关系清晰updatedAt 高频变化会降低复用收益
index.toString()基本不建议插入、删除、排序后容易错位

我的习惯是:业务稳定列表优先用 id;需要明确替换 item 内容时再加入版本字段。不要为了“看起来唯一”把时间戳乱塞进去,否则每次加载都像全量新数据,复用价值会被抵消。

七、LazyForEach 数据源:把“刷新全表”和“更新一项”分开

下面是一个可以直接迁移的 IDataSource 写法。它区分了全量替换和单项更新,后续排查时也更容易判断是哪种更新触发了 UI 变化。

export class ProductListDataSource implements IDataSource {
  private listeners: DataChangeListener[] = [];
  private data: ProductCardModel[] = [];

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

  getData(index: number): ProductCardModel {
    return this.data[index];
  }

  registerDataChangeListener(listener: DataChangeListener): void {
    if (!this.listeners.includes(listener)) {
      this.listeners.push(listener);
    }
  }

  unregisterDataChangeListener(listener: DataChangeListener): void {
    this.listeners = this.listeners.filter((item: DataChangeListener) => item !== listener);
  }

  replaceAll(next: ProductCardModel[]): void {
    this.data = next;
    this.listeners.forEach((listener: DataChangeListener) => listener.onDataReloaded());
  }

  updateOne(index: number, item: ProductCardModel): void {
    if (index < 0 || index >= this.data.length) {
      return;
    }
    this.data[index] = item;
    this.listeners.forEach((listener: DataChangeListener) => listener.onDataChange(index));
  }

  findIndexById(id: string): number {
    return this.data.findIndex((item: ProductCardModel) => item.id === id);
  }
}

这段代码解决三个问题:

  1. replaceAll() 用于筛选条件变化、下拉刷新、重新查询。
  2. updateOne() 用于收藏、选中、局部字段变化,不需要全表重载。
  3. findIndexById() 让业务层通过 id 找 item,不把数组索引暴露给页面交互。

如果你发现收藏一个 item 后整页抖一下,优先检查是否把局部更新写成了 replaceAll()

八、把 LazyForEach 接到页面:item 只拿自己需要的数据

页面里使用 LazyForEach 时,item 组件不要读整个页面状态,只传它真正需要的字段。

build() {
  Column() {
    ProductFilterBar({
      keyword: this.keyword,
      sort: this.sort,
      onSearch: (keyword: string, sort: ProductSort) => {
        this.keyword = keyword;
        this.sort = sort;
        this.reloadProducts();
      }
    })

    if (this.loading) {
      LoadingProgress()
        .width(32)
        .height(32)
    }

    List({ space: 10 }) {
      LazyForEach(this.dataSource, (item: ProductCardModel) => {
        ListItem() {
          ProductCard({
            model: item,
            selected: this.selectedId === item.id,
            onSelect: (id: string) => {
              this.selectedId = id;
            },
            onFavoriteChange: (id: string, favorite: boolean) => {
              this.updateFavorite(id, favorite);
            }
          })
        }
      }, (item: ProductCardModel) => productKey(item))
    }
    .cachedCount(4)
    .edgeEffect(EdgeEffect.Spring)
  }
}

private updateFavorite(id: string, favorite: boolean): void {
  const index = this.dataSource.findIndexById(id);
  const current = this.dataSource.getData(index);
  this.dataSource.updateOne(index, {
    ...current,
    favorite
  });
}

代码里有两个细节容易忽略:

  1. ProductCard 拿到的是单个 model,不是整页 products
  2. 收藏变化走 updateOne(),避免把一个局部动作变成全量刷新。

cachedCount(4) 不是越大越好。它表示列表可缓存的 item 数量,合适的值要结合 item 复杂度、图片数量和设备性能调。列表项很重时,缓存过多反而会增加内存压力。

九、图片加载别堵住渲染路径:先占位,再替换

列表卡顿经常和图片有关,但不要简单理解成“图片越小越好”。更常见的问题是图片加载策略不清楚:item 构建时同步准备图片、重复请求同一张图、失败后不断重试。

先给图片请求一个明确模型:

export interface CoverRequest {
  productId: string;
  url: string;
  widthVp: number;
  heightVp: number;
}

export function coverCacheKey(request: CoverRequest): string {
  return `${request.productId}_${request.widthVp}x${request.heightVp}`;
}

然后在卡片组件里使用占位状态:

@Component
struct ProductCover {
  @Prop request: CoverRequest;
  @State loaded: boolean = false;

  build() {
    Stack() {
      if (!this.loaded) {
        Column()
          .width(this.request.widthVp)
          .height(this.request.heightVp)
          .backgroundColor('#EEF3F6')
          .borderRadius(12)
      }

      Image(this.request.url)
        .width(this.request.widthVp)
        .height(this.request.heightVp)
        .objectFit(ImageFit.Cover)
        .borderRadius(12)
        .onComplete(() => {
          this.loaded = true;
        })
    }
  }
}

这段代码的目的不是实现完整图片缓存,而是先把渲染路径稳定下来:

  1. item 创建时先有固定尺寸占位,避免图片回来后反复挤压布局。
  2. loaded 是卡片内部状态,不影响列表页整体状态。
  3. coverCacheKey() 给后续接入缓存层留边界,避免缓存规则散落在组件里。

如果你的项目有统一图片库,可以保留这个请求模型,把真实下载和缓存交给基础库处理。

十、卡片组件可以拆小,但不要拆碎

有人遇到卡顿后会把一个 item 拆成十几个小组件,结果代码变复杂,性能不一定变好。拆组件的标准不是“越细越好”,而是看变化频率。
请添加图片描述

一个比较稳的拆法如下:

@Component
struct ProductCard {
  @Prop model: ProductCardModel;
  @Prop selected: boolean;
  onSelect: (id: string) => void = () => {};
  onFavoriteChange: (id: string, favorite: boolean) => void = () => {};

  build() {
    Row({ space: 12 }) {
      ProductCover({
        request: {
          productId: this.model.id,
          url: this.model.coverUrl,
          widthVp: 96,
          heightVp: 96
        }
      })

      Column({ space: 6 }) {
        Text(this.model.title)
          .fontSize(16)
          .fontWeight(FontWeight.Medium)
          .maxLines(2)

        Text(this.model.priceText)
          .fontSize(15)
          .fontColor('#E65A2E')

        ProductTagRow({ tags: this.model.tags })

        Row() {
          Text(this.selected ? '已选中' : '查看详情')
            .fontSize(12)
            .fontColor(this.selected ? '#19A66A' : '#64748B')

          Blank()

          Button(this.model.favorite ? '已收藏' : '收藏')
            .fontSize(12)
            .onClick(() => {
              this.onFavoriteChange(this.model.id, !this.model.favorite);
            })
        }
      }
      .layoutWeight(1)
    }
    .padding(12)
    .borderRadius(16)
    .backgroundColor(this.selected ? '#F0FFF7' : '#FFFFFF')
    .onClick(() => {
      this.onSelect(this.model.id);
    })
  }
}

这里我只拆了两个子组件:

  1. ProductCover:图片加载有自己的状态,适合单独拆。
  2. ProductTagRow:标签展示逻辑独立,后续可做折叠或限行。

标题、价格、按钮仍留在 ProductCard 里,因为它们共同构成一个列表项的主要布局。拆太碎会让数据传递和排查路径变长,读代码的人反而更难判断哪个状态触发了更新。

十一、用 Profiler 做前后对比:别只说“我感觉快了”

优化前后都要录制同一条路径。建议记录下面这些信息:

export interface RenderCheckRecord {
  scene: string;
  device: string;
  beforeFps: number;
  afterFps: number;
  maxFrameMs: number;
  traceFile: string;
  note: string;
}

export function renderOptimizationAccepted(record: RenderCheckRecord): boolean {
  return record.afterFps >= 55 && record.maxFrameMs <= 24;
}

记录示例:

const productListCheck: RenderCheckRecord = {
  scene: '商品列表连续滑动 5 屏并收藏 1 个商品',
  device: 'HarmonyOS NEXT 真机',
  beforeFps: 43,
  afterFps: 58,
  maxFrameMs: 21,
  traceFile: 'product_list_scroll_after.trace',
  note: '替换 ForEach 为 LazyForEach,拆分页面状态,图片增加固定占位'
};

这段记录不要写在生产代码里,可以放在性能测试记录、团队文档或提交说明里。它的价值是让优化能被复盘:下一次有人改列表时,可以沿用同一条路径再录一次。

Profiler 里重点看三类信息:

观察点正常趋势如果异常
FPS 曲线连续滑动时波动减少回到 item 构建和图片加载排查
Frame 耗时尖峰减少,长帧变少看主线程是否仍有同步重任务
ArkUI Trace构建、布局、绘制耗时下降检查状态变化是否仍然过大
请添加图片描述

十二、常见问题排查:按症状倒推,不要凭感觉改

症状优先怀疑检查方法修复方向
收藏一个 item,列表整体闪动局部更新写成全量刷新搜索是否调用 replaceAll()改成 updateOne() 并保持 key 稳定
删除 item 后展示错位key 使用了数组索引查看 key 生成函数改用业务 id 或 id + 内容版本
滑动时图片区域跳动图片没有固定占位尺寸断网或慢网环境复现给图片容器固定宽高和占位背景
首屏加载慢但滑动不卡数据请求或首屏计算重分开录首屏和滑动 Trace优先查接口、解析和首屏初始化
Profiler 录制结果每次差很多测试路径不稳定固定数据量、滑动距离和操作顺序先写复现脚本,再比较结果
LazyForEach 后 item 不刷新数据源通知不正确或 key 没变化检查 onDataChange / onDataReloaded局部变更通知索引,内容替换时调整 key 策略

排查时不要一次改五处。一次只改一个变量,录一次结果。列表性能问题最怕“全都改了,但不知道是哪一刀生效”。

十三、发布前验收清单:让优化能被别人接手

把列表优化合入主分支前,我会按下面这张清单过一遍:

检查项是否必须通过标准
有稳定复现路径同一设备上可重复触发优化前问题
列表 key 不使用索引插入、删除、排序后 item 不错位
大对象状态已拆分筛选、选中、loading、数据源边界清楚
LazyForEach 数据源职责清晰全量刷新和局部更新分开
图片有固定占位建议慢网下布局不跳动
Profiler 有前后记录能看到 FPS 或长帧指标变化
失败回退可解释出问题时知道先查数据源、key 还是图片

如果团队里多人维护同一个列表页,最好把这张清单放到代码评审说明里。性能优化不是一次性的“调参”,而是后续每次需求都要守住的边界。

十四、读者可以直接照做的改造顺序

如果你正在改自己的项目,可以按这个顺序来,别一开始就重构整个页面:

  1. 固定一条复现路径,用 DevEco Profiler 录一次优化前 Trace。
  2. 检查列表是否用数组索引作为 key,如果是,先改成业务 id。
  3. 把页面大对象 @State 拆成筛选、选中、loading 和独立数据源。
  4. ForEach 替换为 LazyForEach,实现 IDataSource
  5. 区分 replaceAll()updateOne(),不要把局部变化写成全量刷新。
  6. 给图片增加固定尺寸占位,避免加载回来后挤压布局。
  7. 再录一次同样路径的 Trace,比较 FPS、长帧和 ArkUI 耗时。

这个顺序的好处是每一步都有明确收益,也方便回滚。真正的工程优化不是把代码写得“看起来高级”,而是能让下一个接手的人知道:这个列表为什么这么写,哪些地方不能随便改。

参考资料

  1. 华为开发者文档:UI 高性能开发
    https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/ui-performance-overview
  2. 华为开发者文档:LazyForEach 数据懒加载
    https://developer.huawei.com/consumer/cn/doc/harmonyos-guides/arkts-rendering-control-lazyforeach
  3. 华为开发者文档:懒加载优化性能
    https://developer.huawei.com/consumer/cn/doc/best-practices/bpta-lazyforeach-optimization
  4. 华为开发者文档:主线程耗时操作优化
    https://developer.huawei.com/consumer/cn/doc/best-practices/bpta-time-optimization-of-the-main-thread

总结

ArkUI 列表卡顿不要只从“换组件”入手。更稳的路径是:先复现,再录制;先稳定模型和 key,再拆状态;先把全量刷新和局部更新分开,再用 LazyForEach 控制可见区域渲染;最后用 Profiler 回归验证。这样写出来的优化不是一次临时修补,而是一套能被团队继续维护的工程边界。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值