HarmonyOS ArkGraphics 2D场景化可变帧率实操------做一个智能帧率专注计时器

先看完整操作:把时间从5分钟拖到25分钟,再回到1分钟,随后开始、暂停、恢复并复位。紫色交互层只在拖动时运行,绿色核心层只在计时时运行。

这段GIF来自真机连续操作。拖动、倒计时、暂停和复位都在同一实验页中完成,不是用几张结果图拼接。
一个页面里的动画,不一定都值得长期运行在同一帧率。
专注计时器的背景光带只是氛围,30 FPS 已经能持续变化;中央倒计时和进度圆环是正在阅读的核心内容,运行时请求 60 FPS;时间滑块只有手指拖动的几秒需要高响应,因此只在拖动期间临时请求 90 FPS,松手后立即停止。
这次做出的页面不是帧率测试面板,而是一个可以选择写作、阅读或编码任务,可以设置 1~25 分钟,可以开始、暂停、继续和复位的专注计时器。
真机运行约 10 秒后,背景氛围收到 309 次回调,核心倒计时收到 618 次回调,页面直接显示"装饰回调减少 50%":

本次结果
| 页面状态 | 正在运行的内容 | 真机结果 |
|---|---|---|
| 等待设置 | 氛围层 30 FPS | 观察约 29.9~30.0 FPS |
| 拖动时间 | 氛围层 30 FPS + 交互层请求 90 FPS | 交互观察约 59.9 FPS,松手后停止 |
| 专注运行 | 氛围层 30 FPS + 核心层 60 FPS | 309 / 618 次回调,差值 50% |
| 暂停计时 | 氛围层继续,核心层停止 | 4 秒内时间和核心回调不变 |
| 恢复计时 | 氛围层 + 核心层重新运行 | 剩余时间继续减少,核心回调继续增长 |
| 复位 | 只保留氛围层 | 核心和交互统计归零 |
这里没有把"回调减少 50%"写成"省电 50%"。本次只测量了应用收到的动画回调,没有测量整机功耗、温度或续航。
华为官方的 ArkGraphics 2D 简介 提到,同一窗口中的不同动画和自绘制内容可以使用不同帧率。这个专注计时器把能力落在了一个容易复用的产品结构里:非关键内容低频持续、核心内容按需运行、短时交互临时提高请求。

实验准备
本次环境如下:
| 项目 | 实际环境 |
|---|---|
| 设备 | HUAWEI Mate 60 Pro |
| 系统 | HarmonyOS 7.0 |
| SDK | API 26 |
| 开发方式 | ArkTS / ArkUI |
| 相机与 AR | 不需要 |
| 其他权限 | 不需要 |
页面使用三个 AnimatorResult。创建方式可以对照官方 帧动画开发指导:
getUIContext().createAnimator()创建动画;setExpectedFrameRateRange()为单个内容设置期望范围;onFrame接收动画进度并更新当前图层。
先把页面内容分成三层
帧率不是按照组件类型分配,而是按照内容在当前状态中的作用分配。
氛围层:30 FPS
背景层包含轨道光点、底部波形和渐变光团。它们负责让页面保持动态,但不承载倒计时信息,也不响应用户手指。
ArkTS/ets
const AMBIENT_FPS: number = 30;
private ambientAnimator?: AnimatorResult;
private onAmbientFrame(progress: number): void {
if (this.isReleased) {
return;
}
this.ambientProgress = progress;
const now: number = Date.now();
if (this.isRunning) {
this.ambientOverlapCallbacks += 1;
}
if (this.ambientRuntime.recordFrame(now)) {
this.publishRuntime(
'ambient',
this.ambientRuntime,
now
);
}
}
氛围层在页面打开后就开始运行,暂停倒计时时也不会消失。
核心层:60 FPS
中央圆环、剩余时间和完成百分比是计时器的核心。它们只在专注开始或恢复后运行:
ArkTS/ets
const TIMER_FPS: number = 60;
private timerAnimator?: AnimatorResult;
private onTimerFrame(progress: number): void {
if (this.isReleased || !this.isRunning) {
return;
}
this.timerPulse = progress;
const now: number = Date.now();
this.remainingMs = Math.max(
0,
this.targetEndAt - now
);
if (this.timerRuntime.recordFrame(now)) {
this.publishRuntime(
'timer',
this.timerRuntime,
now
);
}
}
核心层停止后,不再通过动画回调更新剩余时间。
交互层:拖动时临时请求 90 FPS
交互层不做常驻动画。用户开始拖动 Slider 时才播放 Animator,松开手指就暂停:
ArkTS/ets
const INTERACTION_FPS: number = 90;
private handleDurationChange(
value: number,
mode: SliderChangeMode
): void {
if (this.hasStarted) {
return;
}
const minutes: number = Math.round(value);
this.selectedMinutes = minutes;
this.totalDurationMs = minutes * MINUTE_MS;
this.remainingMs = this.totalDurationMs;
if (mode === SliderChangeMode.Begin ||
mode === SliderChangeMode.Moving) {
this.beginAdjustment();
return;
}
this.endAdjustment();
}
beginAdjustment() 打开交互层:
ArkTS/ets
private beginAdjustment(): void {
if (this.isAdjusting ||
!this.interactionAnimator) {
return;
}
const now: number = Date.now();
this.isAdjusting = true;
this.interactionRuntime.begin(now);
this.interactionAnimator.play();
this.statusText = '正在调节专注时长';
this.strategyText =
'滑块反馈临时请求 90 FPS,松手后停止';
}
endAdjustment() 关闭交互层,并保留刚才的观察值和累计回调:
ArkTS/ets
private endAdjustment(): void {
if (!this.isAdjusting) {
return;
}
const now: number = Date.now();
this.interactionAnimator?.pause();
if (this.interactionRuntime.end(now)) {
this.publishRuntime(
'interaction',
this.interactionRuntime,
now
);
}
this.isAdjusting = false;
this.interactionProgress = 0;
}
创建三个独立 Animator
三个图层都通过同一个辅助方法创建,但每次传入自己的目标 FPS:
ArkTS/ets
private createLayerAnimator(
targetFps: number,
frameHandler: (progress: number) => void
): AnimatorResult {
const animator: AnimatorResult =
this.getUIContext().createAnimator({
duration: 1200,
easing: 'linear',
delay: 0,
fill: 'both',
direction: 'alternate',
iterations: -1,
begin: 0,
end: 1
});
animator.setExpectedFrameRateRange({
min: targetFps,
max: targetFps,
expected: targetFps
});
animator.onFrame = frameHandler;
return animator;
}
页面初始化时分别传入 30、60 和 90:
ArkTS/ets
this.ambientAnimator =
this.createLayerAnimator(
AMBIENT_FPS,
(progress: number): void => {
this.onAmbientFrame(progress);
}
);
this.timerAnimator =
this.createLayerAnimator(
TIMER_FPS,
(progress: number): void => {
this.onTimerFrame(progress);
}
);
this.interactionAnimator =
this.createLayerAnimator(
INTERACTION_FPS,
(progress: number): void => {
this.onInteractionFrame(progress);
}
);
页面出现后只播放 ambientAnimator。另外两个实例已经创建,但还没有开始回调。
倒计时不能按回调次数递减
30、60、90 FPS 的回调次数不同,如果每次 onFrame 都固定减去 16 ms,不同帧率和调度波动会让计时速度发生变化。
本次开始专注时先保存真实结束时间:
ArkTS/ets
private startFocus(): void {
const now: number = Date.now();
this.totalDurationMs =
this.selectedMinutes * MINUTE_MS;
this.remainingMs = this.totalDurationMs;
this.targetEndAt = now + this.remainingMs;
this.isRunning = true;
this.hasStarted = true;
this.ambientRuntime.reset();
this.ambientRuntime.begin(now);
this.timerRuntime.reset();
this.timerRuntime.begin(now);
this.timerAnimator?.play();
}
每次核心回调只做一件事:targetEndAt - Date.now()。回调少一次或短暂延迟,不会让总倒计时跟着少算或多算一帧。
用同一观察窗计算回调节奏
每个图层都有独立的运行对象。对象按约 800 ms 形成一个观察窗:
ArkTS/ets
recordFrame(now: number): boolean {
this.callbackCount += 1;
if (this.windowStartedAt === 0) {
this.windowStartedAt = now;
this.windowFrameCount = 1;
return false;
}
this.windowFrameCount += 1;
const elapsed: number =
now - this.windowStartedAt;
if (elapsed < METRIC_WINDOW_MS ||
this.windowFrameCount < 2) {
return false;
}
const intervals: number =
this.windowFrameCount - 1;
this.observedFps =
intervals * 1000 / elapsed;
this.averageIntervalMs =
elapsed / intervals;
this.windowStartedAt = now;
this.windowFrameCount = 1;
return true;
}
观察值描述的是应用收到的 onFrame 节奏,不是外部仪器测得的面板物理刷新率。
真机操作一:拖动时临时启动交互层
页面初始为 5 分钟。真机先从 5 分钟慢拖到 25 分钟,再从 25 分钟拖回 1 分钟。
拖动过程中,圆环外侧出现紫色高响应光晕,中央明确显示"高响应调节 · 请求 90 FPS":

两次操作的日志:
text
FOCUS_ADJUST_BEGIN target=90
selectedMinutes=5
FOCUS_ADJUST_END selectedMinutes=25
callbacks=342 observed=59.9 activeMs=5712
FOCUS_ADJUST_BEGIN target=90
selectedMinutes=25
FOCUS_ADJUST_END selectedMinutes=1
callbacks=735 observed=59.9 activeMs=12261
本机当前条件下,90 FPS 请求观察到约 59.9 FPS。松手后交互层停在 735 次,之后启动、暂停和恢复倒计时时都没有继续增长。
真机操作二:运行时按内容优先级分配帧率
时长设为 1 分钟,任务选择"编码",点击"开始专注"。
text
FOCUS_START task=编码 minutes=1
totalMs=60000 ambientTarget=30 timerTarget=60
约 10 秒时,页面剩余 0:50,完成度 17%:
text
氛围层:请求 30,观察 30.0,309 次回调
核心层:请求 60,观察 59.9,618 次回调
交互层:请求 90,观察 59.9,735 次回调
这 10 秒里,氛围层和核心层处于同一有效运行时间段,309 正好约为 618 的一半,所以页面显示"装饰回调减少 50%"。
真机操作三:暂停只停止核心计时
点击"暂停"后,页面剩余 0:49,核心回调为 695,交互回调为 735:
text
FOCUS_PAUSE remainingMs=48406
timerCallbacks=695
ambientCallbacks=348
ambientOverlapCallbacks=348
等待约 4 秒后再次检查:
| 项目 | 暂停瞬间 | 4 秒后 |
|---|---|---|
| 剩余时间 | 0:49 | 0:49 |
| 核心回调 | 695 | 695 |
| 交互回调 | 735 | 735 |
| 氛围总回调 | 411 | 585 |
| 同时段回调差 | 50% | 50% |

暂停没有冻结整个页面。核心时间和进度停止,背景波形仍以 30 FPS 请求保持动态。
遇到的问题:暂停后回调差被算错
首版直接用氛围总回调除以核心总回调:
ArkTS/ets
const reduction: number =
(1 - ambientCallbacks / timerCallbacks) * 100;
运行中两层同时开始,这个公式显示 50%。暂停后核心停止,氛围继续,旧公式却从 50% 变成了 18%。
问题不是帧率发生变化,而是分子继续累计、分母已经停止,比较的时间段不一致。
修订后增加 ambientOverlapCallbacks,只有核心正在运行时才累计氛围回调:
ArkTS/ets
if (this.isRunning) {
this.ambientOverlapCallbacks += 1;
}
const reduction: number = Math.max(
0,
Math.min(
100,
(1 - this.ambientOverlapCallbacks /
this.timerCallbacks) * 100
)
);
最终复测中,暂停 4 秒前后都保持 50%,而氛围总回调仍从 411 增长到 585。页面既能表现背景仍在运行,也不会用不一致的时间段计算结果。
恢复:重新建立结束时间
暂停时已经保存了真实 remainingMs。恢复时不沿用旧结束时间,而是从当前时刻重新计算:
ArkTS/ets
private resumeFocus(): void {
const now: number = Date.now();
this.targetEndAt = now + this.remainingMs;
this.timerRuntime.begin(now);
this.isRunning = true;
this.timerAnimator?.play();
}
恢复后页面从 0:49 继续减少到 0:43,核心回调从 695 增长到 1075:

text
FOCUS_RESUME remainingMs=48406
timerCallbacks=695
恢复后:
remaining=0:43
timerObserved=59.9
timerCallbacks=1075
callbackReduction=50%
复位:核心与交互清零,氛围继续
复位后,计时器回到 1:00 和 0%,核心、交互统计归零,背景仍继续运行:

text
FOCUS_RESET selectedMinutes=1
ambientContinues=true
remaining=1:00
ambientObserved=29.9 callbacks=62
timerObserved=0.0 callbacks=0
interactionObserved=0.0 callbacks=0
这里没有为了复位整个功能而销毁氛围 Animator。页面仍然处于可见状态,背景层继续承担自己的视觉角色。
退出时释放三个 Animator
页面退出时解除所有回调并取消动画:
ArkTS/ets
private detachAnimator(
animator?: AnimatorResult
): void {
if (!animator) {
return;
}
animator.onFrame =
(_progress: number): void => {};
animator.onRepeat = (): void => {};
animator.onFinish = (): void => {};
animator.onCancel = (): void => {};
animator.cancel();
}
最终退出日志:
text
FOCUS_RELEASE action=EXIT
ambientCallbacks=97
timerCallbacks=0
interactionCallbacks=0
remainingMs=60000
RETURNED_HOME=true
构建结果
场景页的静态回归覆盖三组帧率范围、真实结束时间、Slider 拖动状态、同时间段回调差、暂停、恢复、复位和释放。
最终 API 26 clean 签名构建结果:
text
CompileArkTS... Finished
PackageHap... Finished
SignHap... Finished
BUILD SUCCESSFUL in 19 s 953 ms
签名 HAP 覆盖安装成功,Ability 启动成功。
最终效果
这个案例把可变帧率落在了四个实际状态里:
- 等待设置时,只让背景氛围以 30 FPS 请求运行;
- 拖动刻度时,临时启动 90 FPS 交互反馈;
- 专注运行时,背景 30 FPS 与核心 60 FPS 同时工作;
- 暂停时,核心停止回调,背景继续保持动态。
读者最终得到的不只是三个 FPS 数字,而是一种可以直接放进播放器、运动记录、下载进度、仪表盘和长时间运行页面的分层方法:先判断内容在当前状态中的作用,再决定它是否需要持续高频更新。