HarmonyOS ArkGraphics 2D可变帧率实操——同屏对比30、60和90 FPS动画

如果只把 ArkGraphics 2D 理解成“画圆、画线”,就低估它了。它更像应用里的视觉加工厂:能让光轨按不同节奏运动,把路线实时画成地图,把照片裁成票根和明信片,把数据变成会刷新的仪表盘,还能把画面离屏生成一张冻结的成品。

这篇先从最容易看见差别的一项能力开始:让同一个页面里的不同内容按不同帧率运行。上面的 GIF 来自同一次真机运行的 26 张连续画面;三个小球同时往返,观察 FPS 和累计回调持续更新,中段暂停后一起定格,恢复后继续变化。
ArkGraphics 2D 可以做出哪些效果
把接口名称先放到一边,实际项目里可以把它用在这些地方:
| 动态光轨、粒子路径、游戏提示 | 元素持续运动,主体保持流畅,装饰动画降低刷新节奏 | 动画、自绘制、可变帧率 |
| 夜间地图、通勤路线、实时仪表盘 | 道路、河流、站点和数据在同一张画布上持续更新 | Path、图形、文字、直接上屏Canvas |
| 旅拍明信片、头像框、票根相册 | 照片被裁进自定义轮廓,再移动、缩放和旋转 | 图片绘制、裁剪、矩阵变换、状态保存与恢复 |
| 徽章、海报、主题卡片 | 用路径、填充、描边和文字组成可切换主题的原创视觉 | Brush、Pen、Path、文本绘制 |
| 分享卡、路线快照、成品预览 | 把当前动态画面冻结到PixelMap,再作为图片显示 | 离屏Canvas、PixelMap、drawImage |
| 模糊背景、灰度封面、智能取色 | 同一张图片快速得到不同氛围和配色 | 图像效果处理、颜色能力 |
华为官方给出的能力范围包括文本、矢量图和位图绘制、自绘制、图像效果以及可变帧率;Canvas既可以直接上屏,也可以离屏绘制,并支持裁剪、平移、旋转、缩放和状态保存恢复。对应资料可查看 ArkGraphics 2D产品页、ArkGraphics 2D简介、画布获取与结果显示 和 画布操作及状态处理。
封面负责展示这些能力组合起来后的视觉方向,不作为真机运行证据;本篇真正完成并验证的是下面的“可变帧率竞技场”。同一个页面放三条完全相同的动画轨道,分别请求 30、60 和 90 FPS,路径、周期、曲线和小球尺寸都保持一致,只改变期望帧率。
真机连续运行约 10 秒后,页面得到下面这组结果:

| 低频节奏 | 30 | 29.9 | 33.40 ms | 0.67 ms | 311 |
| 均衡流畅 | 60 | 59.9 | 16.70 ms | 1.33 ms | 619 |
| 高频顺滑 | 90 | 59.9 | 16.70 ms | 6.89 ms | 620 |
30 FPS 轨道基本按 33.3 ms 一次的节奏回调,60 FPS 轨道基本按 16.7 ms 一次回调。90 FPS 虽然成功设置了期望范围,但本轮没有得到 90 次左右的回调,而是停在约 59.9 FPS。
系统屏幕信息显示,这次测试时的活动模式正好是 60 Hz。因此,这个结果不是“90 FPS 设置失败”,而是清楚地看到:应用提交的是期望帧率,最终调度结果还会受到当前屏幕模式和系统状态影响。
本次做了什么
实验页完成了下面这条完整链路:

这里的“观察 FPS”只表示应用收到 onFrame 回调的节奏,不是外部高速摄像机或仪器测得的屏幕物理刷新率。
华为官方的 ArkGraphics 2D 简介 提到,同一个窗口可以让不同动画或自绘制内容使用不同帧率。本次只验证其中的 ArkUI 动画部分,不加入 Canvas 自绘、NativeDisplaySoloist 或整窗帧率控制。
实验准备
本次环境如下:
| 设备 | HUAWEI Mate 60 Pro |
| 系统 | HarmonyOS 7.0 |
| SDK | API 26 |
| 开发方式 | ArkTS / ArkUI |
| 相机权限 | 不需要 |
| AR 能力 | 不需要 |
实验不访问相机、定位、相册或网络,进入页面后可以直接运行。
页面使用到三个主要接口:
- getUIContext().createAnimator():在当前 UI 上下文中创建动画;
- AnimatorResult.setExpectedFrameRateRange():设置动画的期望帧率范围;
- AnimatorResult.onFrame:接收每次动画进度回调。
createAnimator() 和 onFrame 的基本用法可以对照官方 帧动画开发指导。
第一步:准备每条轨道的运行数据
三条动画不仅要移动,还要分别记住自己的回调数量和时间窗口。这里使用一个普通类保存单条轨道的数据:
class FrameLaneRuntime {
readonly targetFps: number;
callbackCount: number = 0;
observedFps: number = 0;
averageIntervalMs: number = 0;
maxDeviationMs: number = 0;
accumulatedActiveMs: number = 0;
activeStartedAt: number = 0;
private windowStartedAt: number = 0;
private windowFrameCount: number = 0;
private lastFrameAt: number = 0;
private windowMaxDeviationMs: number = 0;
constructor(targetFps: number) {
this.targetFps = targetFps;
}
}
页面分别创建 30、60、90 三个运行对象:
const TARGET_30: number = 30;
const TARGET_60: number = 60;
const TARGET_90: number = 90;
private readonly runtime30: FrameLaneRuntime =
new FrameLaneRuntime(TARGET_30);
private readonly runtime60: FrameLaneRuntime =
new FrameLaneRuntime(TARGET_60);
private readonly runtime90: FrameLaneRuntime =
new FrameLaneRuntime(TARGET_90);
三个对象完全分开,这样 30 FPS 轨道的回调不会混进 60 或 90 FPS 的统计。
第二步:创建三组相同参数的 Animator
每条轨道都通过当前页面的 UIContext 创建 Animator。除目标帧率外,动画参数保持一致:
const ANIMATION_DURATION_MS: number = 2400;
private createLaneAnimator(
targetFps: number
): AnimatorResult {
const laneAnimator: AnimatorResult =
this.getUIContext().createAnimator({
duration: ANIMATION_DURATION_MS,
easing: 'linear',
delay: 0,
fill: 'both',
direction: 'alternate',
iterations: -1,
begin: 0,
end: 1
});
laneAnimator.setExpectedFrameRateRange({
min: targetFps,
max: targetFps,
expected: targetFps
});
laneAnimator.onFrame =
(progress: number): void => {
this.onLaneFrame(targetFps, progress);
};
return laneAnimator;
}
然后一次创建三个实例:
private createAnimators(): void {
this.animator30 =
this.createLaneAnimator(TARGET_30);
this.animator60 =
this.createLaneAnimator(TARGET_60);
this.animator90 =
this.createLaneAnimator(TARGET_90);
}
本次期望范围是固定值:
30 FPS:min=30, max=30, expected=30
60 FPS:min=60, max=60, expected=60
90 FPS:min=90, max=90, expected=90
这样能直接比较三种请求节奏,不需要在运行过程中拖动滑块改变条件。
第三步:在 onFrame 中移动小球
progress 会在 0 到 1 之间变化。三条轨道各自保存进度,UI 再把进度乘以轨道的可移动距离:
private onLaneFrame(
targetFps: number,
progress: number
): void {
if (this.isDestroyed || !this.isRunning) {
return;
}
if (targetFps === TARGET_30) {
this.progress30 = progress;
} else if (targetFps === TARGET_60) {
this.progress60 = progress;
} else {
this.progress90 = progress;
}
const now: number = Date.now();
const runtime: FrameLaneRuntime =
this.runtimeFor(targetFps);
const published: boolean =
runtime.recordFrame(now);
this.updateCallbackState(
targetFps,
runtime.callbackCount
);
if (published) {
this.publishRuntime(runtime, now);
}
}
小球的位移代码只有一行:
Circle()
.width(30)
.height(30)
.fill(color)
.shadow({ radius: 15, color: color })
.translate({
x: this.laneProgress(targetFps) * 238
})
三条轨道都使用 238 vp 的移动距离。动画周期也是相同的 2400 ms,所以同一时刻三个小球的逻辑进度一致,区别只在回调与绘制节奏。
第四步:计算观察 FPS 和平均间隔
页面按约 1 秒建立一个观察窗。第一次回调只保存开始时间;之后每次回调计算与上一帧的时间差:
const METRIC_WINDOW_MS: number = 1000;
recordFrame(now: number): boolean {
this.callbackCount += 1;
if (this.windowStartedAt === 0) {
this.windowStartedAt = now;
this.windowFrameCount = 1;
this.lastFrameAt = now;
return false;
}
this.windowFrameCount += 1;
const interval: number =
now – this.lastFrameAt;
const expectedInterval: number =
1000 / this.targetFps;
this.windowMaxDeviationMs = Math.max(
this.windowMaxDeviationMs,
Math.abs(interval – expectedInterval)
);
this.lastFrameAt = now;
const elapsed: number =
now – this.windowStartedAt;
if (elapsed < METRIC_WINDOW_MS ||
this.windowFrameCount < 2) {
return false;
}
const intervalCount: number =
this.windowFrameCount – 1;
this.observedFps =
intervalCount * 1000 / elapsed;
this.averageIntervalMs =
elapsed / intervalCount;
this.maxDeviationMs =
this.windowMaxDeviationMs;
this.windowStartedAt = now;
this.windowFrameCount = 1;
this.windowMaxDeviationMs = 0;
return true;
}
本次页面上的三个数分别表示:
- 观察 FPS:当前观察窗的回调间隔数量 ÷ 首尾时间;
- 平均间隔:当前观察窗总时间 ÷ 间隔数量;
- 最大偏差:单次间隔与目标理想间隔之间的最大差值。
30 FPS 的理想间隔约为 1000 / 30 = 33.33 ms,60 FPS 约为 16.67 ms,90 FPS 约为 11.11 ms。
真机截图中 90 FPS 轨道的平均间隔仍是 16.70 ms,因此它的最大偏差约为 16.70 – 11.11 = 5.59 ms,再叠加当前窗口内的瞬时调度波动,页面记录到 6.89 ms。
第五步:把三组指标放到同一页面
轨道顶部同时显示“请求值”和“观察值”,避免只看到一个 FPS 数字时产生误解:
Row() {
Text(`${targetFps}`)
.fontSize(25)
.fontWeight(FontWeight.Bold)
.fontColor(color)
Text('FPS 请求')
.fontSize(9)
Text(
`观察 ${this.laneMetricText(
targetFps, 'fps')} FPS`
)
.fontSize(13)
.fontWeight(FontWeight.Bold)
}
轨道下方显示平均间隔、最大偏差和累计回调。三个发光球分别使用青色、绿色和紫色,运行时可以直接看到 30 FPS 轨的移动更新更稀疏,60 FPS 与当前条件下的 90 FPS 轨接近。
第六步:同时开始三条动画
“同时开始”会在同一个时间点打开三条有效运行时间段,然后依次播放三个 Animator:
private startAll(): void {
const now: number = Date.now();
this.runtime30.beginActive(now);
this.runtime60.beginActive(now);
this.runtime90.beginActive(now);
this.hasStarted = true;
this.isRunning = true;
this.animator30?.play();
this.animator60?.play();
this.animator90?.play();
}
真机操作只有三步:
本轮约 8 秒时的脱敏日志如下:
VFR_METRIC target=30 observed=29.9
averageIntervalMs=33.43 maxDeviationMs=0.67
callbacks=242 activeMs=8048
VFR_METRIC target=60 observed=59.8
averageIntervalMs=16.72 maxDeviationMs=1.33
callbacks=481 activeMs=8027
VFR_METRIC target=90 observed=59.8
averageIntervalMs=16.72 maxDeviationMs=6.89
callbacks=482 activeMs=8027
30 FPS 的累计回调约为 60 FPS 的一半;60 与 90 两轨只差 1 次回调,与当时 60 Hz 的活动模式一致。
90 FPS 为什么只观察到约 60 FPS
这台设备支持 30、60、90 和 120 Hz 模式,但本轮测试时,RenderService 返回的当前活动模式是 60 Hz:
resolution=1260×2720
supportedRefreshRates=120,90,60,30
activeModeRefreshRate=60
setExpectedFrameRateRange() 提交的是内容期望范围。设备能力、当前活动模式、系统负载和温度都会参与最终调度。因此本次结果写成:
90 FPS 请求已设置;
当前 60 Hz 活动模式下,onFrame 观察值约为 59.9 FPS。
没有把它写成“90 FPS 运行成功”,也没有为了得到理想数字修改日志。
暂停:回调计数保持不变
运行一段时间后点击“暂停对比”,三个 Animator 一起暂停,同时关闭当前有效运行时间段:
private pauseAll(): void {
const now: number = Date.now();
this.animator30?.pause();
this.animator60?.pause();
this.animator90?.pause();
this.runtime30.endActive(now);
this.runtime60.endActive(now);
this.runtime90.endActive(now);
this.isRunning = false;
}
暂停瞬间的累计回调是:
VFR_PAUSE callbacks30=1751
callbacks60=3499 callbacks90=3500
activeMs=58449
等待约 4 秒后,三个数仍是 1751 / 3499 / 3500,期间没有新的 VFR_METRIC:

这一步验证了页面暂停的不只是小球位置,统计回调也确实停止。
恢复:先重置观察窗,再继续播放
暂停几秒后直接继续,如果仍保留上一帧时间,第一条新间隔会把整个暂停时间算进去,平均间隔会突然变大。
本次在恢复前调用 beginActive()。它会重新打开有效时间段,并清掉当前观察窗的上一帧时间:
private resumeAll(): void {
const now: number = Date.now();
this.runtime30.beginActive(now);
this.runtime60.beginActive(now);
this.runtime90.beginActive(now);
this.isRunning = true;
this.animator30?.play();
this.animator60?.play();
this.animator90?.play();
}
恢复前后的真机数据:
VFR_RESUME callbacks30=1751
callbacks60=3499 callbacks90=3500
约 1 秒后:
30 FPS:observed=29.9 callbacks=1782
60 FPS:observed=59.8 callbacks=3560
90 FPS:observed=59.8 callbacks=3561

回调重新增长,平均间隔仍保持在约 33.4 ms 和 16.7 ms,没有混入刚才约 4 秒的暂停时间。
清零重测:销毁旧 Animator 后重新创建
“清零重测”不是只把页面数字改成 0。它先释放旧 Animator,再重置运行对象,最后创建三组新实例:
private resetExperiment(): void {
this.releaseAnimators('RESET');
this.runtime30.reset();
this.runtime60.reset();
this.runtime90.reset();
this.progress30 = 0;
this.progress60 = 0;
this.progress90 = 0;
this.observed30 = 0;
this.observed60 = 0;
this.observed90 = 0;
this.callbacks30 = 0;
this.callbacks60 = 0;
this.callbacks90 = 0;
this.createAnimators();
}
清零后的真机页面回到 0.0 s,三条观察值和回调全部归零:

VFR_RELEASE action=RESET
callbacks30=2454 callbacks60=4905
callbacks90=4906 activeMs=81949
VFR_READY targets=30,60,90
durationMs=2400 easing=linear
direction=alternate
VFR_RESET success=true
再次点击开始约 1 秒后,三条轨道重新得到 31 / 61 / 61 次回调,说明清零后仍可重复运行。
退出页面时解除回调
页面退出时先把四种回调替换为空函数,再调用 cancel(),最后清空三个 Animator 引用:
private detachAnimator(
laneAnimator?: AnimatorResult
): void {
if (!laneAnimator) {
return;
}
laneAnimator.onFrame =
(_progress: number): void => {};
laneAnimator.onRepeat = (): void => {};
laneAnimator.onFinish = (): void => {};
laneAnimator.onCancel = (): void => {};
laneAnimator.cancel();
}
aboutToDisappear(): void {
this.releaseAnimators('DISAPPEAR');
}
重复运行后退出的日志为:
VFR_RELEASE action=EXIT
callbacks30=104 callbacks60=206
callbacks90=207 activeMs=3441
DASHBOARD_RETURNED=true
页面返回实验工作台,没有继续输出新的帧率指标。
遇到的情况:命令行打包找不到 java
最终收口执行 clean 签名构建时,第一次在 PackageHap 阶段报错:
Failed :entry:default@PackageHap
spawn java ENOENT
此时 CompileArkTS 已经完成,问题出在当前 PowerShell 的 PATH 中没有 DevEco Studio 自带 JBR 的 bin 目录。
给本次终端补上 JBR 后重新执行构建:
$env:JAVA_HOME = '<DevEco Studio>/jbr'
$env:Path = '<DevEco Studio>/jbr/bin;' + $env:Path
hvigorw.bat clean assembleHap
最终结果:
CompileArkTS… Finished
PackageHap… Finished
SignHap… Finished
BUILD SUCCESSFUL in 22 s 810 ms
这是命令行环境问题,不是 Animator 或 ArkTS 代码错误。
一次真实调度波动
连续运行期间,30 FPS 轨道有一个约 1 秒的窗口出现下面的数据:
VFR_METRIC target=30 observed=30.8
averageIntervalMs=32.42
maxDeviationMs=25.33
后续窗口又恢复到约 29.9 FPS / 33.4 ms。页面保留了这一真实波动,没有筛掉异常窗口。这个现象也说明,单个观察窗不是永久不变的硬指标,结果要结合连续多秒的数据判断。
最终结果
本次实验得到四个可以复核的结果:
- 30 FPS 请求的观察值稳定在约 29.9 FPS,平均间隔约 33.4 ms;
- 60 FPS 请求的观察值稳定在约 59.9 FPS,平均间隔约 16.7 ms;
- 90 FPS 请求在当前 60 Hz 活动模式下观察到约 59.9 FPS,没有把期望值写成保证值;
- 暂停、恢复、清零重测和退出释放都完成了真机验证。
关键验证日志已经直接放在对应操作步骤里,读到哪一步就能核对哪一步的结果。最终 clean 构建、打包和签名全部通过,签名 HAP 覆盖安装并启动成功。
这次的可变帧率链路已经跑通。下一篇可以继续验证 ArkGraphics 2D 的“过度绘制调试”:先制造可见的重复绘制,再用调试能力定位并减少无效覆盖。






