欢迎光临
我们一直在努力

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

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 秒后,页面得到下面这组结果:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

动画轨道请求 FPS观察 FPS平均回调间隔最大偏差截图时累计回调
低频节奏 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 设置失败”,而是清楚地看到:应用提交的是期望帧率,最终调度结果还会受到当前屏幕模式和系统状态影响。

本次做了什么

实验页完成了下面这条完整链路:

  • 创建三个互相独立的 AnimatorResult;
  • 给三个动画分别设置 30、60、90 FPS 的期望范围;
  • 在各自的 onFrame 中移动小球;
  • 用真实回调时间计算观察 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();
    }

    真机操作只有三步:

  • 从实验工作台进入“实验 15:可变帧率竞技场”;
  • 点击“同时开始”;
  • 保持页面前台运行至少 8 秒,等待三条轨道都出现稳定统计。
  • 本轮约 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 的“过度绘制调试”:先制造可见的重复绘制,再用调试能力定位并减少无效覆盖。

    赞(0)
    未经允许不得转载:171主机测评 » HarmonyOS ArkGraphics 2D可变帧率实操——同屏对比30、60和90 FPS动画
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址