查看: 1499|回复: 3

ArkTS长列表卡顿优化:状态粒度、懒加载与TaskPool实战

[复制链接]
发表于 昨天 08:00 | 显示全部楼层 |阅读模式
在 HarmonyOS 里,稳定 60fps 意味着一帧只有 16.6ms 预算。事件、状态变更、diff、布局测量、绘制、提交都要在这条链路里完成。任何一帧超预算,用户看到的就是掉帧、卡顿,长列表快滑时甚至会出现“黏手”。因此 ArkTS 性能优化最终都落在一个第一性原理上:让 UI 主线程只做它该做的事,把不该在主线程的工作挪走或推迟。具体手段可以拆成缩窄刷新范围、懒加载、并发计算、延迟加载和异步解码。动手前先用 DevEco Profiler 录制真机快滑 Trace,确认最慢几帧到底耗在组件创建、布局测量、图片解码还是 ArkTS 计算上,先量化再优化,不要用调大缓存掩盖根因。

一、状态管理:把刷新范围缩到最小
ArkTS 声明式 UI 靠状态驱动刷新。@State 变化后,框架会做脏节点标记和局部刷新;但如果状态粒度太粗,刷新范围会被放大。实践要点有三条:只把真正会变化且需要触发 UI 更新的数据标成 @State,能 private 就不要 State;嵌套对象用 @Observed 修饰类、@ObjectLink 接收,修改子属性时只刷新对应组件,而不是整行甚至整列表;不要为了改一个字段就用 this.bigObj = { ...this.bigObj, x: 1 } 整体替换对象,优先原地修改被 @Observed 跟踪的子属性。
  1. @Observed
  2. class NewsItem {
  3.   id: string;
  4.   title: string;
  5.   readCount: number;
  6.   constructor(id: string, title: string, readCount: number) {
  7.     this.id = id;
  8.     this.title = title;
  9.     this.readCount = readCount;
  10.   }
  11. }
  12. @Component
  13. struct NewsRow {
  14.   @ObjectLink item: NewsItem;
  15.   build() {
  16.     Row() {
  17.       Text(this.item.title).fontSize(16).layoutWeight(1)
  18.       Text(`阅读: ${this.item.readCount}`).fontSize(14).fontColor('#0369a1')
  19.     }
  20.   }
  21. }
  22. // 在列表点击中原地修改:this.list[0].readCount += 1
复制代码
这段代码的重点不是写法花哨,而是把“阅读量变化”限制在 NewsRow 内部:this.list[0].readCount += 1 是原地改子属性,@ObjectLink 指向同一对象,只触发这一行刷新。一个巨大的 @State 对象一旦被替换,依赖它的整棵子树都会重算,这是长列表高频更新时常见的隐性成本。

二、长列表:LazyForEach + cachedCount + @Reusable
长列表是卡顿重灾区。ForEach 会一次性创建所有列表项,1000 条就建 1000 个节点;LazyForEach 只按需创建可视区域内的组件,滑出屏幕的节点进入复用池。有实践数据显示:从 ForEach 切到 LazyForEach,长距离快滑的组件回收延迟可降低约 45%。但 LazyForEach 只解决“建多少个”,建组件本身的开销要靠 @Reusable:列表项滑出屏幕时不销毁、进复用池,新数据进屏优先复用,通过 aboutToReuse 回调更新数据,省去 build() 重建成本。cachedCount 控制屏外预加载条数,默认 1,建议设为可见项的 1 到 2 倍,减少快滑白屏。
  1. import { LazyForEach } from '@kit.ArkUI';
  2. class BasicDataSource implements IDataSource {
  3.   private listeners: DataChangeListener[] = [];
  4.   private data: NewsItem[] = [];
  5.   totalCount(): number { return this.data.length; }
  6.   getData(index: number): NewsItem { return this.data[index]; }
  7.   registerDataListener(listener: DataChangeListener): void { this.listeners.push(listener); }
  8.   unregisterDataListener(listener: DataChangeListener): void {
  9.     const pos = this.listeners.indexOf(listener);
  10.     if (pos >= 0) { this.listeners.splice(pos, 1); }
  11.   }
  12.   pushItem(item: NewsItem): void {
  13.     this.data.push(item);
  14.     this.listeners.forEach(l => l.onDataAdd(this.data.length - 1));
  15.   }
  16. }
  17. @Reusable
  18. @Component
  19. struct ItemCard {
  20.   @State title: string = '';
  21.   aboutToReuse(params: Record<string, Object>): void {
  22.     this.title = params.title as string;
  23.   }
  24.   build() { Text(this.title).fontSize(16) }
  25. }
  26. @Entry
  27. @Component
  28. struct FeedPage {
  29.   private dataSource: BasicDataSource = new BasicDataSource();
  30.   build() {
  31.     List() {
  32.       LazyForEach(this.dataSource, (item: NewsItem) => {
  33.         ListItem() { ItemCard({ title: item.title }) }
  34.       }, (item: NewsItem) => item.id)
  35.     }
  36.     .cachedCount(8)
  37.     .width('100%')
  38.     .height('100%')
  39.   }
  40. }
复制代码
这里有两个容易踩的坑:LazyForEach 失效常见于 List 没有确定高度,或把 List 放进同方向 Scroll 里;分页追加时发 notifyDataReload() 做全量重载,会表现为“平时丝滑、翻页必卡”。分页更新应尽量走增量通知,让已渲染节点不作废。

三、布局:扁平化,少一层嵌套就少一次测量
布局阶段的 measure + layout 是帧耗时常客。能用 RelativeContainer 或 GridRow 表达的关系,不要用三五层 Column/Row 嵌套。每多一层容器,子节点就多一次递归测量;单个列表项内部也要尽量扁平,避免每个 cell 都成为一次重布局的源头。另一个硬条件是 List 必须给确定高度,例如 height('100%') 或固定值,否则框架无法判断可视区,前面说的 LazyForEach 会直接失效。
  1. // 扁平化:一层 Row + layoutWeight 占比
  2. Row() {
  3.   Text('标题').layoutWeight(1)
  4.   Text('副标题').layoutWeight(1)
  5. }
  6. .width('100%')
复制代码
这段对比说明的是“减少一次测量递归”的价值:左右布局用一层 Row 加 layoutWeight 占比即可,不必为了结构而结构。

四、启动:动态 import 按需加载
冷启动耗时直接影响首屏可交互体验。原则是入口 Ability 只加载首屏必需代码,重模块延迟到真正使用时再加载。ArkTS 支持标准动态 import()。
  1. Button('打开报表')
  2. .onClick(async () => {
  3.   const heavy = await import('../pages/HeavyReport');
  4.   heavy.openReport();
  5. });
复制代码
同时,EntryAbility 的 onCreate 和 onWindowStageCreate 里非首屏必须的初始化,例如三方 SDK、预取、日志上报,应延迟到首帧渲染之后或放到后台线程,避免阻塞首屏。

五、并发:TaskPool + @Concurrent 把重活移出主线程
大数组遍历、复杂数学计算、加解密、JSON 大对象解析等 CPU 密集任务,不要在主线程同步执行,否则会直接吃掉多帧预算。用 TaskPool 加 @Concurrent 把任务丢到后台线程,执行完再通过 Promise 把结果送回主线程更新 UI。
  1. import { taskpool } from '@kit.ArkTS';
  2. @Concurrent
  3. function computeHeavy(data: number[]): number {
  4.   let sum = 0;
  5.   for (let i = 0; i < data.length; i++) {
  6.     sum += Math.sqrt(data[i]) * Math.sin(data[i]);
  7.   }
  8.   return sum;
  9. }
  10. async function runOnBackground(): Promise<void> {
  11.   const bigArray: number[] = new Array(200000).fill(0).map((_, i) => i);
  12.   const task: taskpool.Task = new taskpool.Task(computeHeavy, bigArray);
  13.   const result: number = await taskpool.execute(task) as number;
  14.   console.info(`后台计算完成: ${result}`);
  15. }
复制代码
边界要记清:TaskPool 工作线程不能访问 UI 组件,不能用 AppStorage;普通任务 CPU 同步计算不超过 3 分钟,IO 等待不计入;跨线程传实例用 @Sendable(API 11+);大数据可用 SharedArrayBuffer 零拷贝。需要指定固定线程时再用 Worker,一般优先 TaskPool。

六、图片与资源:解码是隐形杀手
列表里同步解码大图是快滑卡顿的常见元凶。不要在 build、@Builder 或滚动回调里同步解码、发网络请求或读磁盘。服务端尽量返回接近显示尺寸的缩略图;本地大图用 ImageSource 的 DecodingOptions 设置 sampleSize 下采样,降低解码耗时与内存;需要缩小的图片用 autoResize(true),让框架自动缩放,减少内存压力。主线程同步解码一张大图可能耗时几十毫秒,直接吃掉多帧预算,在快滑时表现为间歇性掉帧,在 Profiler 里往往就是“图片解码”占主导的帧。

总结
ArkTS 性能优化有清晰的战场地图:状态管理缩窄刷新范围,长列表用 LazyForEach、cachedCount、@Reusable 三件套,布局做扁平化并给 List 确定高度,启动用动态 import 延迟加载,并发用 TaskPool 加 @Concurrent 移出主线程,图片做异步解码和下采样。贯穿始终的还是那句话:让 UI 主线程只做它该做的事,把不该在主线程的工作挪走或推迟。配合 DevEco Profiler 先量化瓶颈,才能把凭感觉调优升级为数据驱动的精准优化,让应用在任意设备上稳稳跑满 60fps。
回复

使用道具 举报

发表于 昨天 19:30 | 显示全部楼层

Re: ArkTS长列表卡顿优化:状态粒度、懒加载与TaskPool实战

楼主这篇思路很清晰,尤其是“先录 Trace 再动手”这点,很多团队一上来就调 cachedCount,结果根因是图片解码和 ArkTS 侧的计算,白忙一场。 顺着帖子补几个自己踩过的点: 一是 @Observed 和 @ObjectLink 这套,容易忽略的是 @ObjectLink 不能自己赋值,只能在父组件里把被 @Observed 修饰的实例传进来;如果列表数据源里存的是普通对象字面量,ObjectLink 是收不到变更的,得保证整个链路都是类实例。另外原地修改子属性时,如果这个属性本身是个数组或对象,还得再套一层 @Observed,不然还是刷新不到。 二是 LazyForEach 的第三个参数 keyGenerator 一定要稳定且唯一,别用 index 拼。key 不稳定时复用池会频繁失效,表现就是快滑时元素闪烁、滚动位置跳,跟没开懒加载差不多。 三是 @Reusable 的 aboutToReuse 里别只更新文本,图片、动画进度、选中态这些都要显式重置,否则复用出来会串上一条的数据,这个 bug 很隐蔽。 四是 cachedCount 设成可见项 1 到 2 倍这个建议我认同,但要注意它是以“条”为单位,如果单条高度差异很大,或者里面带大图,预加载太多反而会拉高首帧和内存峰值,最好结合 Profiler 的内存曲线一起看。 帖子最后 notifyDataReloa
回复 支持 反对

使用道具 举报

发表于 昨天 19:40 | 显示全部楼层

Re: ArkTS长列表卡顿优化:状态粒度、懒加载与TaskPool实战

这篇写得很实在,尤其是“先用 DevEco Profiler 录真机 Trace,先量化再优化”这点很认同,长列表卡顿确实不能靠调大缓存掩盖根因。状态粒度部分也说到关键了,@Observed 加 @ObjectLink 把阅读量变化限制在行内,比整体替换大对象稳很多;LazyForEach 配合 cachedCount 和 @Reusable,再用 aboutToReuse 更新必要字段,这套组合在快滑场景下收益应该很明显。你提到的 List 没有确定高度、放进同方向 Scroll 导致 LazyForEach 失效,以及分页追加用 notifyDataReload 做全量重载,都是很容易踩的坑。标题里的 TaskPool 实战还没展开,挺期待后续讲讲哪些计算适合挪到 TaskPool、结果回主线程后怎么和状态更新配合。
回复 支持 反对

使用道具 举报

发表于 昨天 19:40 | 显示全部楼层

Re: ArkTS长列表卡顿优化:状态粒度、懒加载与TaskPool实战

楼主这篇总结得很实在,尤其是先把 DevEco Profiler 真机快滑 Trace 录下来,确认瓶颈到底在组件创建、布局测量、图片解码还是 ArkTS 计算这一段,比一上来就调缓存参数靠谱多了。状态管理部分用 @Observed 和 @ObjectLink 把阅读量变化限制在单行内、原地改子属性只刷新对应组件的例子很清楚,之前确实容易整体替换大对象,结果把刷新范围放大。长列表里 LazyForEach 按需创建、@Reusable 复用池、cachedCount 控制屏外预加载,这三者配合的思路也讲明白了,List 要有确定高度、别放进同方向 Scroll 的坑很实用。后面分页追加如果继续展开的话,感觉增量通知那块会很关键,期待把 notifyDataReload 全量重载的问题讲完。
回复 支持 反对

使用道具 举报

您需要登录后才可以回帖 登录 | 注册

本版积分规则

指导单位

江苏省公安厅

江苏省通信管理局

浙江省台州刑侦支队

DEFCON GROUP 86025

Hacking Group 021A

旗下站点

态势感知中心

应急响应中心

红盟安全

联系我们

官方QQ群:112851260

官方邮箱:security#ihonker.org(#改成@)

官方核心成员

关注微信公众号

Archiver|手机版|小黑屋| ( 沪ICP备2021026908号 )

GMT+8, 2026-10-24 17:28 , Processed in 0.027415 second(s), 29 queries , Gzip On.

Powered by ihonker.com

Copyright © 2015-现在.

  • 返回顶部