
平台:HarmonyOS 5.1.1 (API 19) 模拟器 · DevEco Studio 6.1.1.300 · ArkTS Stage 模型
关键词:冷启动、TaskPool、TypedArray、LazyForEach、renderGroup、displaySync、hitrace
一、前言
性能优化最怕两件事:凭感觉猜 ,和只在 PPT 上优化。
网上关于 ArkTS 性能优化的文章不少,但绝大多数停留在"官方建议 12 条"的转述层面------const 比 let 快、避免整数浮点混用、用 TypedArray、用 LazyForEach......这些结论当然是对的,可**到底快多少?在什么场景下才明显?**很少有人给出可复现的实测数据。
这篇文章想做的,是把"优化"这件事从口号变成实验:
- 用一套自建的基准测试工程,把官方建议逐条放到真机(模拟器)上跑;
- 用三种互相印证的观测手段(应用内单调时钟、hilog、hitrace 系统级 trace)采集数据,而不是只看一个数字;
- 把踩过的坑一并记下来------恰恰是这些坑,决定了你能不能不翻车地开始优化。
所有数据都来自本机实测,代码可完整复现。文中会明确区分"数据结论"与"经验推测",不做过度解读。
二、实测环境与工程搭建
2.1 环境清单
| 项目 | 版本/参数 |
|---|---|
| 宿主系统 | Windows 11 (10.0.26200) |
| IDE | DevEco Studio 6.1.1.300 |
| 工程 SDK | HarmonyOS 6.1.1 / API 24 |
| 运行设备 | 模拟器 Huawei_Phone,HarmonyOS 5.1.1 (API 19),5.1.0.212 |
| 设备规格 | x86_64、RAM 8192MB、1260×2720 |
| 被测应用 | PerfLab(Stage 模型,单 entry 模块,API 19) |
说明:工程编译 SDK(API 24)与运行设备 API(19)可以不同 ,
compatibleSdkVersion决定下界即可。模拟器为 x86 架构,绝对性能与真机 ARM 有差异,因此本文所有数据只用于横向对比(A/B),不作为绝对性能基线。
2.2 冷启动的第一个坑:JAVA_HOME 指向 JDK 1.7
第一次 hvigorw assembleHap 时,ArkTS 编译全部通过,却在打包环节失败:
text
Error Code: 00308018 Unknown Error
java.lang.UnsupportedClassVersionError: ohos/CompressEntrance : Unsupported major.minor version 52.0
52.0 对应 Java 8 字节码,而系统 JAVA_HOME 指向的是祖传的 JDK 1.7(C:\Program Files\Java\jdk1.7.0_51),无法运行打包工具。
解法:使用 DevEco Studio 自带的 JBR(JetBrains Runtime,本机为 OpenJDK 21):
powershell
$env:JAVA_HOME="F:\dev\DevEco Studio\jbr" # Java 21,足够运行 Java 8 工具
$env:DEVECO_SDK_HOME="F:\dev\DevEco Studio\sdk"
$env:NODE_HOME="F:\dev\DevEco Studio\tools\node" # 内置 Node v18.20.1
$env:Path="F:\dev\DevEco Studio\jbr\bin;" + $env:Path
cd E:\hongmeng-dev\ArkTSBench
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
text
> hvigor Finished :entry:default@PackageHap... after 956 ms
> hvigor Finished :entry:default@SignHap... after 3 s 200 ms
> hvigor BUILD SUCCESSFUL in 8 s 357 ms
于是拿到了可直接安装的 entry-default-signed.hap(227 KB)。结论:命令行构建鸿蒙工程,务必显式指定 JDK 8+,否则会卡在一个非常误导性的错误上。
2.3 第二个坑:@Concurrent 只能引用"导入符号"或"局部变量"
我把矩阵基准算法抽成模块级函数,再交给 TaskPool 执行,结果编译报错:
text
10705000 Syntax Error
Concurrent function should only use import variable or local variable,
'fmt' is not one of them
依次踩了 fillSeq(模块级函数)、fmt(模块级函数)、DOMAIN(模块级常量)三个雷,最后才明白规则:
@Concurrent标注的函数运行在 TaskPool 工作线程,函数体内只允许引用「import导入的符号」或「函数内部的局部变量」,禁止引用同文件/同模块的普通顶层变量和函数。
这不是"建议",而是语法级硬约束(因为并发函数会被序列化后送到子线程执行)。最终我把所有计算逻辑内联进并发函数体,只从外部传入两个基本类型参数:
typescript
@Concurrent
export function runMatrixBench(n: number, rounds: number): string {
// 所有数据结构构造、辅助逻辑都写在函数内部
const aD: number[] = new Array<number>(n * n).fill(0);
// ... 省略具体实现
return `nested=${tNested.toFixed(2)}ms | flatD=${tFlatD.toFixed(2)}ms | ...`;
};
调用处只需传值:
typescript
const res: string = await taskpool.execute(runMatrixBench, MAT_N, MAT_ROUNDS) as string;
这条规则是 TaskPool 实战的第一道门槛,务必记住。
三、测量方法:三种观测手段互相印证
只看一个数字容易被骗。本工程同时使用三层观测:
手段一:应用内单调时钟(业务视角)
不要用 Date.now() 测耗时------它受系统时间调整影响。用单调时钟 systemDateTime.getUptime:
typescript
import { hilog } from '@kit.PerformanceAnalysisKit';
import { systemDateTime } from '@kit.BasicServicesKit';
// UIAbility.onCreate 阶段记录起点
export const LAUNCH_NS: number = systemDateTime.getUptime(systemDateTime.TimeType.STARTUP, true);
// 首页 aboutToAppear 记录终点,换算为毫秒
aboutToAppear(): void {
const nowNs: number = systemDateTime.getUptime(systemDateTime.TimeType.STARTUP, true);
const ms: number = (nowNs - LAUNCH_NS) / 1000000;
this.startupMs = ms.toFixed(1);
hilog.info(DOMAIN, 'PERFLAB', 'STARTUP firstFrameMs=%{public}s', this.startupMs);
}
手段二:hilog 埋点(过程视角)
关键路径主动打点,形成可回溯的时间线:
text
ABILITY onCreate launchNs=1351249042622
STARTUP firstFrameMs=372.8
FRAME heartbeat=360 frames=359 scrolled=3590
[LIST] mode=LazyForEach+renderGroup items=2000 frames=360 elapsed=6337ms fps=56.8 jank=29 maxInterval=80.0ms
手段三:hitrace 系统级 trace(上帝视角)
应用内计时看不到进程启动、Ability 调度、首帧上屏这些"应用之外"的环节。用 hitrace 抓系统 trace 补全:
powershell
hdc shell hitrace --trace_begin app ace graphic sched
hdc shell aa start -a EntryAbility -b com.examplex.x -m entry
hdc shell hitrace --trace_finish -o /data/local/tmp/t.ftrace
hdc file recv /data/local/tmp/t.ftrace ./t.ftrace
抓到的关键 span(进程名 com.examplex.x):
text
1607.149870 H:AppSpawnExecuteClearEnvHook ← 进程孵化起点
1607.892682 H:PageRouterManager::RunPage ← 开始加载首页
1607.901076 H:CustomNode:BuildItem [Index] ← 首页组件树构建
1607.911967 H:OnVsyncEvent ← 首个 vsync 帧信号
进程孵化 → 首页组件树构建 ≈ 751 ms,→ 首帧信号 ≈ 762 ms。 这是应用内计时无法拆解的粒度。
四、实测一:冷启动首帧耗时
在 8 次冷启动(aa force-stop 后重新拉起)中,采集到的首页首帧耗时:
| 序号 | 首帧耗时 (ms) | 备注 |
|---|---|---|
| 1 | 204.3 | 安装后首次启动 |
| 2 | 545.5 | 重新安装后首次(编译产物全新) |
| 3 | 673.1 | 同上 |
| 4 | 372.8 | 稳定态 |
| 5 | 356.8 | 稳定态 |
| 6 | 335.3 | 稳定态 |
| 7 | 326.6 | 稳定态 |
观察:
- 数值跨度很大(204--673 ms),方差本身就是信息 :全新安装后的前几次启动要付出 JIT 预热、资源首次解码、模块首次加载的代价,明显更慢;稳定后收敛在 326--373 ms。
- 所以拿单次冷启动数字做"优化前后对比"是不可靠 的。正确做法是:每轮至少 5 次取中位数,或直接看 hitrace 里各阶段的占比。
优化启示(对应官方建议):
- 缩短启动耗时的抓手在启动路径本身 :延迟初始化非首屏必需的能力、用动态
import()按需加载、把耗时逻辑丢给 TaskPool; - 用
hiTraceMeter给关键阶段打 trace,落到 Profiler 里看谁是"第一耗时大户",再动手。
五、实测二:计算热点 ------ 数据结构与并发
这是全文最有意思的一段,因为结果和直觉相反。
5.1 基准设计
N=96 的三重循环矩阵乘法(n³ ≈ 88 万次乘加),每种实现重复 10 轮取均值,实现变体:
| 变体 | 说明 |
|---|---|
nested |
嵌套数组 number[][] |
flatD |
一维 number[](行主序线性化) |
f64 |
Float64Array |
f32 |
Float32Array |
closure |
反例:内层循环每次创建箭头函数闭包 |
5.2 实测数据(TaskPool 并发,三次采样)
| 采样 | nested | flatD | Float64 | Float32 | closure |
|---|---|---|---|---|---|
| #1 | 63.20 ms | 79.50 ms | 123.60 ms | 116.20 ms | 197.50 ms |
| #2 | 62.60 ms | 72.60 ms | 110.80 ms | 104.60 ms | 156.00 ms |
| #3 | 62.70 ms | 74.40 ms | 115.10 ms | 109.10 ms | 162.00 ms |
| 中位数 | 62.7 | 74.4 | 115.1 | 109.1 | 162.0 |
5.3 三个反直觉结论
结论一:闭包反例是真的贵。
closure 中位数 162 ms,是 nested 的 2.6 倍 。在内层循环里每轮新建一个箭头函数,V8/ArkVM 虽然会做分配优化,但创建闭包对象 + 逃逸分析失败 的代价在热点路径上被放大了数十万倍。官方"避免在循环内创建闭包"的建议,在本实验中得到了量化证实。
结论二:TypedArray 在本场景反而更慢。
Float64Array(115 ms)比普通 number[](62.7--74.4 ms)慢了约 55%。这与"TypedArray 更快"的直觉相反。可能的原因(推测,需进一步验证):
- 本基准的累加模式
c[i] += a[i] * b[i]会触发 TypedArray 元素的装箱/拆箱或类型检查开销; - ArkVM 对纯 number 数组做了更激进的元素类型特化(元素种类为 SMI/Double),而 TypedArray 的访问路径未必走了同样的快车道;
- x86 模拟器上 JIT 行为与真机 ARM 可能不同。
⚠️ 这里必须克制:单一微基准不足以推翻"TypedArray 更快"这一通用经验 。TypedArray 的价值主要体现在大块二进制数据、与 Native/图形接口交互、内存占用可控 等场景,而不是小规模热点循环。建议在自己的真实业务数据上实测后再决定。
结论三:一维线性化未必优于嵌套数组。
flatD(74.4 ms)反而比 nested(62.7 ms)慢约 19%。在支持 元素种类追踪(elements kind) 的引擎里,number[][] 的内层小数组若无越界访问,其元素访问可以被高度优化,不一定输给手写线性索引。
5.4 TaskPool 并发 vs 主线程同步
同一份算法,分别用 TaskPool 和工作线程/主线程直接调用:
| 实现 | nested | flatD | Float64 | Float32 | closure |
|---|---|---|---|---|---|
| TaskPool 并发 | 67.40 ms | 72.80 ms | 129.30 ms | 120.10 ms | 231.00 ms |
| 主线程同步 | 72.10 ms | 82.40 ms | 138.90 ms | 127.20 ms | 253.00 ms |
两次运行耗时接近(差异 5--22 ms,处于采样噪声量级)。这说明 TaskPool 的意义首先不是"让单次计算更快",而是"把计算从 UI 主线程搬走、避免卡顿"。
验证方法:点"主线程运行"时,界面会明显冻结约 0.7--1 秒(button 无响应、无过渡动画);点"TaskPool 运行"时界面全程流畅。对用户体验而言,这远比那几毫秒的计算耗时重要。
5.5 代码要点
typescript
import { taskpool } from '@kit.ArkTS';
import { runMatrixBench, MAT_N, MAT_ROUNDS } from '../bench/Algo';
// TaskPool:不阻塞 UI
private async runAlgo(): Promise<void> {
const res: string = await taskpool.execute(runMatrixBench, MAT_N, MAT_ROUNDS) as string;
this.algo = res;
}
// 对照:主线程同步,会阻塞 UI
private runAlgoMain(): void {
const res: string = runMatrixBench(MAT_N, MAT_ROUNDS);
this.algoMain = res;
}
并发模型选择建议:
- TaskPool:短时、独立的计算任务,由框架管理线程池,推荐首选;
- Worker:需要常驻、有状态、长期交互的后台线程;
- 并发任务总量建议 不超过 64,且单个任务避免再创建子任务。
六、实测三:长列表渲染 ------ ForEach vs LazyForEach + renderGroup
6.1 基准设计
- 列表 2000 条,每条含标题 + 副标题 + 标签;
- 用
displaySync(@kit.ArkGraphics2D)的 vsync 帧回调驱动滚动,步长 10 vp/帧,总滚动 3600 vp(正好 360 帧); - 统计指标:帧数、耗时、FPS、卡顿帧数(帧间隔 > 25 ms,即超过 1.5 个 60Hz 周期)、最大帧间隔。
6.2 实测数据
| 模式 | 运行 | FPS | 卡顿帧 | 最大帧间隔 |
|---|---|---|---|---|
| LazyForEach + renderGroup | #1 | 56.8 | 29 | 80.0 ms |
| #2 | 59.8 | 11 | 96.0 ms | |
| ForEach 全量构建 | #1 | 51.9 | 52 | 112.0 ms |
| #2 | 57.4 | 22 | 144.0 ms |
汇总对比(两次均值):
| 模式 | 平均 FPS | 平均卡顿帧 | 最大帧间隔峰值 |
|---|---|---|---|
| LazyForEach + renderGroup | 58.3 | 20 | 96 ms |
| ForEach 全量构建 | 54.7 | 37 | 144 ms |
6.3 结论
- LazyForEach + renderGroup 全面占优:FPS 更高、卡顿帧约少一半、最大帧间隔峰值从 144 ms 降到 96 ms;
- 差距的根源是渲染数量 :ForEach 会一次性构建全部 2000 个 ListItem 的组件树,首帧与滚动时的布局/绘制压力巨大;LazyForEach 只构建可视窗口(+
cachedCount缓存),配合renderGroup(true)把每个 Item 内部的多次绘制合成一张位图,显著降低重绘开销; - 需要强调:即使 LazyForEach 仍有约 20 个卡顿帧 (最大间隔 80--96 ms),说明模拟器上仍有优化空间(如降低单 Item 复杂度、避免滚动中触发同步布局)。优化是渐进的,不是"用了就丝滑"。
6.4 代码要点
typescript
List({ space: 6, scroller: this.scroller }) {
if (this.mode === 1) {
LazyForEach(this.ds, (item: BenchItem) => {
ListItem() { this.ItemView(item) }
}, (item: BenchItem) => item.id.toString()) // 唯一键必须稳定
} else {
ForEach(this.items, (item: BenchItem) => {
ListItem() { this.ItemView(item) }
}, (item: BenchItem) => item.id.toString())
}
}
.cachedCount(4) // 缓存可视区外条目
.layoutWeight(1)
// 单条目内部:renderGroup 把多次绘制合成为一次
@Builder
ItemView(item: BenchItem) {
Row({ space: 10 }) {
Column() {
Text(item.title).fontSize(16).fontWeight(FontWeight.Medium).maxLines(1)
Text(item.subtitle).fontSize(12).fontColor('#888888').maxLines(1)
}.layoutWeight(1)
Text(item.tag).fontSize(11).backgroundColor('#34a853')
}
.renderGroup(true) // ← 关键
}
七、踩坑记录:displaySync 的两个隐蔽陷阱
这两个坑直接决定了长列表基准能不能跑,值得单独成节。
陷阱一:帧回调必须显式 on('frame', cb) 注册
我最初只写了 sync.start(),结果测试永远停不下来。加看门狗后拿到真相:
text
WATCHDOG t=12s frames=0 scrolled=0 ← 一帧都没有派发
原因 :displaySync.create() 只创建对象,start() 只启动计时,必须先用 on('frame', callback) 注册回调,帧事件才会被派发。
typescript
private sync: displaySync.DisplaySync = displaySync.create();
aboutToAppear(): void {
this.sync.on('frame', this.onFrame); // ← 必须先注册!
}
aboutToDisappear(): void {
this.sync.off('frame', this.onFrame); // 及时反注册,避免泄漏
this.sync.stop();
}
陷阱二:IntervalInfo.timestamp 的单位是纳秒,不是毫秒
注册回调后测试能跑了,但统计结果荒谬:
text
[LIST] frames=360 fps=59.0 jank=360 maxInterval=112000000.0ms
卡顿帧 = 全部帧,最大间隔 1.12 亿毫秒(约 31 小时) ------一眼假。原因:info.timestamp 是纳秒 时间戳,我直接当作毫秒参与 dt > 25 判定,于是每一帧都被判成"卡顿"。
修正:除以 1e6 转毫秒。
typescript
if (this.lastTs > 0) {
const dt: number = (info.timestamp - this.lastTs) / 1000000.0; // ns → ms
this.frameCount++;
if (dt > this.maxInterval) { this.maxInterval = dt; }
if (dt > 25) { this.jankCount++; } // > 1.5 个 60Hz 周期
}
this.lastTs = info.timestamp;
修正后数据立刻合理:fps=56.8 jank=29 maxInterval=80.0ms。
**教训:拿到一个"好得离谱"或"差得离谱"的性能数字时,先怀疑单位,再怀疑代码,最后才下结论。**若没有加看门狗和
maxInterval这两个交叉校验,很可能就把 112000000ms 这种垃圾数据写进了报告。
八、最佳实践清单(实测支撑版)
| # | 实践 | 本实验证据 |
|---|---|---|
| 1 | 热点循环内禁止创建闭包/对象 | closure 比 nested 慢 2.6× |
| 2 | 长列表用 LazyForEach + 稳定唯一键 + cachedCount | FPS +6%、卡顿帧 −46% |
| 3 | 列表 Item 用 .renderGroup(true) 合并绘制 |
同上 |
| 4 | 耗时计算交 TaskPool,别占 UI 主线程 | 主线程运行界面冻结约 1s |
| 5 | 计时用单调时钟,别用墙钟 | systemDateTime.getUptime |
| 6 | 用 hitrace + hiTraceMeter 拆解启动各阶段 | 孵化→建树 ≈751ms |
| 7 | 性能对比取多次中位数,警惕冷启动方差 | 204--673ms 跨度 |
| 8 | TypedArray 按场景实测,勿盲目套用 | 本基准中反而慢 55% |
| 9 | @Concurrent 函数只用导入符号或局部变量 |
语法硬约束 |
| 10 | 命令行构建显式指定 JDK 8+ | JDK 1.7 → 打包失败 |
九、复现步骤
powershell
# 1) 环境
$env:JAVA_HOME="F:\dev\DevEco Studio\jbr"
$env:DEVECO_SDK_HOME="F:\dev\DevEco Studio\sdk"
$env:NODE_HOME="F:\dev\DevEco Studio\tools\node"
# 2) 构建
cd E:\hongmeng-dev\ArkTSBench
ohpm install --all --registry https://ohpm.openharmony.cn/ohpm/ --strict_ssl true
hvigorw assembleHap -p product=default -p buildMode=debug --no-daemon
# 3) 安装并启动(需先唤醒/解锁模拟器)
hdc install -r entry\build\default\outputs\default\entry-default-signed.hap
hdc shell aa start -a EntryAbility -b com.examplex.x -m entry
# 4) 采集日志
hdc shell hilog -x > logs.txt
hdc shell hitrace --trace_begin app ace graphic sched
hdc shell aa start -a EntryAbility -b com.examplex.x -m entry
hdc shell hitrace --trace_finish -o /data/local/tmp/t.ftrace
hdc file recv /data/local/tmp/t.ftrace ./t.ftrace
十、结语
这次实测最大的收获,其实不是"哪条建议更快",而是建立了一套敢于证伪的工作方法:
- 用三种观测手段互相印证,而不是相信单一数字;
- 用多次采样对抗方差,而不是拿一次结果当结论;
- 用交叉校验(看门狗、maxInterval)识别垃圾数据,而不是让明显异常的数值蒙混过关;
- 对与自己直觉相反的结果(TypedArray 更慢)保持诚实:如实报告,标注推测,绝不为了"符合经验"而篡改或回避。
性能优化没有银弹,只有测量 → 假设 → 验证 → 迭代的循环。希望这套可复现的基准工程与方法论,能帮你把下一次"优化"真正落到实处。