ArkUI 长列表掉帧治理:LazyForEach 键值、@Reusable 复用与预加载的五个坑

举报
yd_274993192 发表于 2026/11/08 12:37:01 2026/11/08
【摘要】 长列表滑动掉帧,多数时候不是数据量太大,而是键值生成、组件复用和预加载这三处把框架的优化抵消掉了。本文结合一个 HarmonyOS 单词打卡应用的实测,拆解 LazyForEach 用 index 当 key 导致的全量重建、keyGenerator 里的隐式开销、@Reusable 的 aboutToReuse 状态残留、cachedCount 的取舍,以及 onScrollIndex 预加载的

最近在给一个 HarmonyOS 单词打卡应用做性能收尾:首页是今日词表,往下滑能翻到上千条历史记录。功能早就跑通了,但快速滑动时帧率会明显掉一截,偶尔还出现"这一行的展开状态串到了下一行"。查了一圈,问题既不在数据库也不在网络,全在 List 的写法上。这篇按排查顺序记五个坑,附带可复用的分层思路。

先说结论:掉帧往往是"优化被抵消"

ArkUI 的 List + LazyForEach + cachedCount 本来就是为长列表准备的:按需创建组件、复用出屏节点、预加载临近项。但这三件事各自有前置条件——键值必须稳定、复用时必须重置局部状态、预加载必须去重。任何一条不满足,框架不但帮不上忙,还会额外做一轮无用功。所以排查顺序建议固定为:先数组件创建次数 → 再看键值稳定性 → 再查复用残留 → 最后看预加载请求量。

一、坑 1:用 index 当 key,插入一条数据全表重建

最容易写也最致命的一版是这样的:

// 反例:key 与位置绑定
LazyForEach(this.dataSource, (item: WordItem, index: number) => {
  ListItem() { WordRow({ item: item }) }
}, (item: WordItem, index: number) => index.toString())

LazyForEach 靠 keyGenerator 的返回值判断"这个组件还能不能复用"。用 index 做 key,意味着第 5 个位置永远是 key=5。当用户在列表顶部插入一条新词,原本 key=5 的位置换成了另一条数据,框架发现 key 没变、数据却变了,只能走重建;而删除中间一项时,后面所有位置的 key 全部错位,等于触发一轮级联重建。表现就是:滑动时偶尔一顿,插入/删除后必卡。

正确做法是用与位置无关、生命周期内稳定的业务 ID:

// 正例:稳定业务主键
LazyForEach(this.dataSource, (item: WordItem) => {
  ListItem() { WordRow({ item: item }) }
}, (item: WordItem) => item.id)

这里有个容易混淆的点:key 稳定之后,同一条数据的字段变化不会再触发组件重建(框架认为还是同一个节点)。所以列表项的数据模型要用 @Observed 装饰,子组件用 @ObjectLink 接收,让字段级变化走状态刷新,而不是靠改 key 强行重建。把"身份"和"内容"分开,是后面所有优化的前提。

二、坑 2:keyGenerator 里藏了隐式开销

知道要用稳定 ID 之后,另一个极端是"为了保险多拼点东西":

// 反例:每次都做一次序列化
(item: WordItem) => item.id + '_' + JSON.stringify(item.tagList) + '_' + item.updatedAt

keyGenerator 会在每一轮可见区域计算里被反复调用,滚动一屏可能执行几十上百次。里面放 JSON.stringify、字符串模板拼接、数组排序这类操作,等于把开销压到主线程的关键路径上,掉帧反而更明显。而且把 updatedAt 拼进 key 会重新引入坑 1:字段一变 key 就变,组件又被重建。

原则很简单:keyGenerator 只做一次 O(1) 的字段读取,最好直接返回一个已有的 string 或 number,不拼接、不计算、不访问嵌套对象。需要按条件区分渲染分支时,把分支判断放进组件内部用 @State/@Prop 处理,别塞进 key。

三、坑 3:@Reusable 复用后,局部状态串了行

加上 @Reusable 之后创建次数确实降下来了,但很快出现开头说的怪现象:某一行点开的释义卡片,滑出去再滑回来,展开状态跑到了另一行;图片的加载中标记也偶尔错位。原因是复用组件不会重新走 aboutToAppear,所有非数据源驱动的局部状态都留在实例上。

ArkUI 给的钩子是 aboutToReuse(params),它会在组件被重新绑定到新数据时调用。所有局部状态都必须在这里显式重置或按新数据重算:

@Reusable
@Component
struct WordRow {
  @ObjectLink item: WordItem;
  @State expanded: boolean = false;
  @State imgLoading: boolean = true;

  aboutToReuse(params: Record<string, Object>) {
    this.expanded = false;              // 展开态必须归零
    this.imgLoading = true;            // 加载标记必须归零
    this.item = params.item as WordItem;
  }

  build() {
    Column() {
      Text(this.item.word).fontSize(18)
      if (this.expanded) { Text(this.item.meaning) }
    }.onClick(() => { this.expanded = !this.expanded })
  }
}

按这个思路过一遍:expanded、selected、动画进度、计时器句柄、图片加载标记、输入框草稿,凡是"只属于这一次展示"的状态,都要在 aboutToReuse 里处理。反过来,属于数据本身的状态(是否已掌握、打卡进度)就该留在 @Observed 模型里,不要放进组件局部状态,否则复用时也会被误清掉。

还有个配套细节:如果列表项内部有大量 if/else 分支或按类型走不同的 @Builder,复用池会按结构分开缓存,命中率反而下降。差异大的行建议拆成不同的 ListItem 类型,用 LazyForEach 的 onDataReloaded 之外的方式做类型区分,或者干脆在数据源里预置 viewType 并让组件内部结构尽量一致。

四、坑 4:cachedCount 不是越大越好

cachedCount 决定视口外预创建多少项。设太小(比如 1),快速滑动时来不及创建,会出现短暂白块;设太大(比如 30),首屏就要多建三十个组件,内存和首帧耗时一起上去,而且这些常驻节点会拉长每一轮 diff 的耗时。

我的经验值是按一屏可见条数取 1~1.5 倍,再从实测调:先固定一个值,用打点统计"每帧新建组件数"和"滑动时白块出现次数",哪个先到瓶颈就往哪个方向微调。列表项结构重(含图片、多层嵌套)就取偏小值,结构轻(纯文本两行)可以取偏大值。

List({ space: 8 }) {
  LazyForEach(this.dataSource, (item: WordItem) => {
    ListItem() { WordRow({ item: item }) }
  }, (item: WordItem) => item.id)
}
.cachedCount(8)
.edgeEffect(EdgeEffect.None)

另外 List 的 space、divider、edgeEffect 也会影响布局成本,逐个关掉测一轮,比凭感觉调参快得多。

五、坑 5:onScrollIndex 预加载打出请求风暴

为让图片和释义"滑到即显示",常见写法是在 onScrollIndex 里判断接近底部就拉下一页。问题在于快速滑动时这个回调触发极密,一次甩动能打出十几个分页请求,回来后主线程被 JSON 解析和数组 splice 堵住,正好在最需要流畅的时候卡住。

三件事要一起做:

  • 去重:用 loading 标志 + 已请求的 page 集合,保证同一页只发一次,请求没回来前不再触发。
  • 节流与提前量:只在 endIndex >= total - threshold 时触发,且加一个最小间隔(例如 300ms),避免回调级别的抖动。
  • 批量更新数据源:数据到达后一次性 push 并通知 LazyForEach reload,不要逐条 insert——逐条会触发多轮 key diff。
private loading: boolean = false;
private lastTrigger: number = 0;
private requestedPages: Set<number> = new Set();

onReachEnd(endIndex: number, total: number) {
  const now = Date.now();
  if (this.loading || now - this.lastTrigger < 300) return;
  if (endIndex < total - 10) return;
  const page = Math.floor(total / PAGE_SIZE) + 1;
  if (this.requestedPages.has(page)) return;
  this.requestedPages.add(page);
  this.loading = true;
  this.lastTrigger = now;
  fetchWords(page).then(list => {
    this.dataSource.append(list);   // 一次批量 append
    this.loading = false;
  }).catch(() => {
    this.requestedPages.delete(page); // 失败要允许重试
    this.loading = false;
  });
}

补两个容易漏的边界:请求失败时要把 page 从集合里摘掉,否则这一页永远加载不出来;列表被筛选或重置时,requestedPages 必须清空,不然新列表的分页会全被误判成"已请求过"。另外,取消请求时别忘了把 loading 归位,界面会一直卡在"加载中"。

六、怎么确认真的改好了

不要靠"感觉顺了"收工,用可量化的三个指标:

  1. 组件创建次数:在 aboutToAppear 与 aboutToReuse 各打一条 hilog 计数,滑动 3 屏后统计比值。理想状态是创建次数接近一屏条数、复用次数远大于创建次数;如果创建次数随滑动线性增长,说明 key 仍在变。
  2. 帧率与丢帧:用 DevEco Studio 的 Profiler 录一段快速滑动,看 Frame 轨道的超时帧分布,配合 hdc shell 抓取图形相关统计定位是布局还是绘制耗时。
  3. 网络请求数:同样一次甩动,统计分页接口实际发出的次数。改完去重后应该稳定在 1~2 次,而不是十几次。

这三个数改前各记一次、改后各记一次,比任何主观描述都更能说明问题,也方便后面回归。

七、收拢成四层,下次不用重查

把这次的结论固化成分层,后面接别的项目只换第一层:

  1. 数据层:每条记录带稳定业务 ID,模型用 @Observed,字段变化走状态刷新而非重建。
  2. 键值层:keyGenerator 只返回 O(1) 的稳定 ID,不拼接、不序列化、不含时间戳。
  3. 复用层:@Reusable + aboutToReuse 显式重置所有局部状态,组件内部结构尽量统一。
  4. 调度层:cachedCount 按屏容量取值,预加载做去重、节流、批量 append 与失败可重试。

分完之后,"滑起来串数据"只需查第三层,"越滑越卡"查第二层,"滑到底卡一下"查第四层,定位路径是固定的。

小结

这五个坑没有一个是"数据量太大",全都是把框架已有的优化条件破坏掉了:key 不稳定让按需创建退化成反复重建,keyGenerator 里的额外计算把开销搬到了滚动关键路径,复用时不重置状态让节点带着上一条数据的痕迹,预加载不去重则在最需要帧率的时候抢占主线程。

所以长列表性能治理的顺序建议固定为:先用打点确认组件创建次数 → 修 key → 修复用 → 再调 cachedCount 与预加载。按这个顺序走,通常一轮就能把帧率拉回来;反过来从"是不是该换个更快的数据库"开始猜,往往一天就没了,而列表其实根本没在等数据。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。