在 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 跟踪的子属性。- @Observed
- class NewsItem {
- id: string;
- title: string;
- readCount: number;
- constructor(id: string, title: string, readCount: number) {
- this.id = id;
- this.title = title;
- this.readCount = readCount;
- }
- }
- @Component
- struct NewsRow {
- @ObjectLink item: NewsItem;
- build() {
- Row() {
- Text(this.item.title).fontSize(16).layoutWeight(1)
- Text(`阅读: ${this.item.readCount}`).fontSize(14).fontColor('#0369a1')
- }
- }
- }
- // 在列表点击中原地修改: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 倍,减少快滑白屏。- import { LazyForEach } from '@kit.ArkUI';
- class BasicDataSource implements IDataSource {
- private listeners: DataChangeListener[] = [];
- private data: NewsItem[] = [];
- totalCount(): number { return this.data.length; }
- getData(index: number): NewsItem { return this.data[index]; }
- registerDataListener(listener: DataChangeListener): void { this.listeners.push(listener); }
- unregisterDataListener(listener: DataChangeListener): void {
- const pos = this.listeners.indexOf(listener);
- if (pos >= 0) { this.listeners.splice(pos, 1); }
- }
- pushItem(item: NewsItem): void {
- this.data.push(item);
- this.listeners.forEach(l => l.onDataAdd(this.data.length - 1));
- }
- }
- @Reusable
- @Component
- struct ItemCard {
- @State title: string = '';
- aboutToReuse(params: Record<string, Object>): void {
- this.title = params.title as string;
- }
- build() { Text(this.title).fontSize(16) }
- }
- @Entry
- @Component
- struct FeedPage {
- private dataSource: BasicDataSource = new BasicDataSource();
- build() {
- List() {
- LazyForEach(this.dataSource, (item: NewsItem) => {
- ListItem() { ItemCard({ title: item.title }) }
- }, (item: NewsItem) => item.id)
- }
- .cachedCount(8)
- .width('100%')
- .height('100%')
- }
- }
复制代码 这里有两个容易踩的坑:LazyForEach 失效常见于 List 没有确定高度,或把 List 放进同方向 Scroll 里;分页追加时发 notifyDataReload() 做全量重载,会表现为“平时丝滑、翻页必卡”。分页更新应尽量走增量通知,让已渲染节点不作废。
三、布局:扁平化,少一层嵌套就少一次测量
布局阶段的 measure + layout 是帧耗时常客。能用 RelativeContainer 或 GridRow 表达的关系,不要用三五层 Column/Row 嵌套。每多一层容器,子节点就多一次递归测量;单个列表项内部也要尽量扁平,避免每个 cell 都成为一次重布局的源头。另一个硬条件是 List 必须给确定高度,例如 height('100%') 或固定值,否则框架无法判断可视区,前面说的 LazyForEach 会直接失效。- // 扁平化:一层 Row + layoutWeight 占比
- Row() {
- Text('标题').layoutWeight(1)
- Text('副标题').layoutWeight(1)
- }
- .width('100%')
复制代码 这段对比说明的是“减少一次测量递归”的价值:左右布局用一层 Row 加 layoutWeight 占比即可,不必为了结构而结构。
四、启动:动态 import 按需加载
冷启动耗时直接影响首屏可交互体验。原则是入口 Ability 只加载首屏必需代码,重模块延迟到真正使用时再加载。ArkTS 支持标准动态 import()。- Button('打开报表')
- .onClick(async () => {
- const heavy = await import('../pages/HeavyReport');
- heavy.openReport();
- });
复制代码 同时,EntryAbility 的 onCreate 和 onWindowStageCreate 里非首屏必须的初始化,例如三方 SDK、预取、日志上报,应延迟到首帧渲染之后或放到后台线程,避免阻塞首屏。
五、并发:TaskPool + @Concurrent 把重活移出主线程
大数组遍历、复杂数学计算、加解密、JSON 大对象解析等 CPU 密集任务,不要在主线程同步执行,否则会直接吃掉多帧预算。用 TaskPool 加 @Concurrent 把任务丢到后台线程,执行完再通过 Promise 把结果送回主线程更新 UI。- import { taskpool } from '@kit.ArkTS';
- @Concurrent
- function computeHeavy(data: number[]): number {
- let sum = 0;
- for (let i = 0; i < data.length; i++) {
- sum += Math.sqrt(data[i]) * Math.sin(data[i]);
- }
- return sum;
- }
- async function runOnBackground(): Promise<void> {
- const bigArray: number[] = new Array(200000).fill(0).map((_, i) => i);
- const task: taskpool.Task = new taskpool.Task(computeHeavy, bigArray);
- const result: number = await taskpool.execute(task) as number;
- console.info(`后台计算完成: ${result}`);
- }
复制代码 边界要记清: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。 |