HarmonyOS NativeVSync 帧调度实战:回调节拍、任务合并与过载降级

请添加图片描述

自绘图表、游戏 HUD 或 Native XComponent 动画最容易出现一种“平均耗时不高但仍然抖”的问题:业务线程按自己的定时器更新,渲染线程按显示节拍提交,两者偶尔错开,一个逻辑更新刚好落在提交之后,只能再等一帧。继续把定时器从 16 ms 改成 15 ms 不会解决相位错位,反而可能在同一显示周期内做两次无效计算。

OH_NativeVSync 提供显示节拍回调,让自绘任务围绕下一次 VSync 组织。真正稳定的方案还需要三件事:一帧只预约一次、同类更新在帧前合并、超出预算时推迟非关键工作。本文从 C API 接入到线程安全关闭,给出一条完整 Native 帧调度链路。

1. VSync 回调是节拍信号,不是渲染引擎

NativeVSync 通知“下一帧时刻到了”,不会替应用执行布局、绘制或交换缓冲。应用仍需维护自己的任务队列和渲染提交,只是把这些动作安排到统一节拍上。

enum class FrameTaskKind {
    INPUT,
    ANIMATION,
    DATA_REFRESH,
    DECORATION,
};

struct FrameTask {
    FrameTaskKind kind;
    uint64_t version;
    bool critical;
};

输入反馈和主动画通常是关键任务;装饰动画、统计采样或后台数据预排可以在超预算时延后。先定义优先级,降级才不会变成随机丢任务。

2. 接口版本、线程模型与链接配置

本文以 HarmonyOS SDK API 23 的 native_vsync.h 为基线。创建、销毁和单回调请求从 API 9 起提供;GetPeriod 从 API 10 起提供;多回调请求从 API 12 起提供;关联窗口创建和 DVSync 从 API 14 起提供;预期帧率区间从 API 20 起提供。

target_link_libraries(entry PUBLIC
    libace_napi.z.so
    libhilog_ndk.z.so
    libnative_vsync.so
)
#include <native_vsync/native_vsync.h>
#include <atomic>
#include <condition_variable>
#include <cstdint>
#include <cstring>
#include <mutex>
#include <vector>

回调由系统按 VSync 节拍触发,不能假设它运行在 ArkTS UI 线程。跨线程访问队列、窗口和 NativeWindow 时必须遵守对应对象的线程约束。

3. 创建时给连接一个稳定且可读的名称

名称用于标识连接,length 应传实际字节长度,不包括结尾的 \0。创建失败要立即停止初始化,不能带着空指针继续请求帧。

class FrameScheduler {
public:
    bool Init()
    {
        static constexpr char NAME[] = "chart_frame_scheduler";
        vsync_ = OH_NativeVSync_Create(NAME, std::strlen(NAME));
        return vsync_ != nullptr;
    }

private:
    OH_NativeVSync* vsync_ = nullptr;
};

一个渲染通道持有一个调度器即可。页面重建时反复创建、旧实例又未销毁,会增加连接数量并让回调落到已失效的业务对象上。

4. 请求下一帧,而不是启动永久定时器

请添加图片描述

OH_NativeVSync_RequestFrame() 请求下一次 VSync。回调完成后,如果还有动画或脏数据,再请求下一帧;没有工作就停止预约。这样的按需模式比常驻 60 Hz 定时器节省无效唤醒。

static void OnFrame(long long timestamp, void* data)
{
    auto* scheduler = static_cast<FrameScheduler*>(data);
    if (scheduler != nullptr) {
        scheduler->HandleFrame(timestamp);
    }
}

bool RequestNextFrame()
{
    if (vsync_ == nullptr || !running_.load()) return false;
    bool expected = false;
    if (!requestPending_.compare_exchange_strong(expected, true)) return true;

    int result = OH_NativeVSync_RequestFrame(vsync_, OnFrame, this);
    if (result != 0) requestPending_.store(false);
    return result == 0;
}

requestPending_ 保证同一时刻只有一个待回调请求。单回调 API 在一帧内被多次调用时,只会执行最后一次回调,业务不应依赖前几次请求分别完成。

5. 回调时间戳是帧时间,不要换成当前墙钟

回调参数 timestamp 代表 VSync 时间。动画进度应基于连续帧时间戳计算,避免在回调中重新读墙钟后引入额外抖动。首帧没有前值时,只建立基线。

void HandleFrame(long long timestamp)
{
    requestPending_.store(false);
    if (!running_.load()) {
        NotifyIdle();
        return;
    }

    long long previous = lastTimestamp_.exchange(timestamp);
    double deltaMs = previous == 0 ? 0.0 : (timestamp - previous) / 1'000'000.0;
    RunFrame(timestamp, deltaMs);

    if (HasMoreWork()) RequestNextFrame();
    else NotifyIdle();
}

时间戳单位应按接口声明处理。动画遇到长暂停时还要限制 deltaMs 最大值,避免应用从后台回来后一帧跨越整个动画。

6. 多次业务更新应合并成一个脏状态

拖动手势、数据订阅和网络回调可能在一帧内多次修改同一个图表。任务队列不应保存三份“重画图表”,而应只保留最新版本。不同任务类型可以分别合并。

void MarkDirty(FrameTaskKind kind, uint64_t version, bool critical)
{
    {
        std::lock_guard<std::mutex> lock(taskMutex_);
        auto it = std::find_if(tasks_.begin(), tasks_.end(),
            [kind](const FrameTask& item) { return item.kind == kind; });
        if (it == tasks_.end()) tasks_.push_back({kind, version, critical});
        else if (version > it->version) *it = {kind, version, critical};
    }
    RequestNextFrame();
}

合并依据应是业务版本或最新状态,而不是简单清空队列。输入和动画属于不同类别,不能因为一次数据刷新就覆盖用户交互反馈。

7. 帧调度边界要把业务更新与提交分开

请添加图片描述

业务线程只标记脏数据;任务队列合并同类更新;NativeVSync 提供帧节拍;渲染层在预算内生成并提交一帧。渲染提交完成后,若仍有工作,才预约下一帧。

std::vector<FrameTask> DrainTasks()
{
    std::lock_guard<std::mutex> lock(taskMutex_);
    std::vector<FrameTask> current;
    current.swap(tasks_);
    return current;
}

void RunFrame(long long timestamp, double deltaMs)
{
    auto current = DrainTasks();
    UpdateAnimation(deltaMs);
    ApplyTasks(current);
    RenderAndSubmit(timestamp);
}

RenderAndSubmit() 内部应遵守 NativeWindow 或图形接口的缓冲申请、绘制和归还规则。VSync 只能校准时机,不能弥补缓冲区长期未归还的问题。

8. GetPeriod 只能在收到首个回调后读取

OH_NativeVSync_GetPeriod() 的周期值在请求并收到 VSync 回调后才刷新。初始化阶段直接读取可能没有有效结果。周期可用于估算预算,但不能写死设备一定是 60 Hz。

long long QueryPeriodNs() const
{
    if (vsync_ == nullptr || lastTimestamp_.load() == 0) return 0;
    long long period = 0;
    int result = OH_NativeVSync_GetPeriod(vsync_, &period);
    return result == 0 ? period : 0;
}

double FrameBudgetMs() const
{
    long long period = QueryPeriodNs();
    return period > 0 ? period / 1'000'000.0 : 16.67;
}

回退值只用于首帧前的保守估算。高刷新率设备的周期会更短,若仍按 16.67 ms 安排工作,很容易连续错过提交窗口。

9. 预算不是整段周期,要给提交保留余量

CPU 更新、绘制命令、GPU 执行和缓冲提交共同占用一帧。业务任务不能把整个周期用完。可以把可用 CPU 预算设为周期的一部分,并在每个任务后核对已用时间。

using Clock = std::chrono::steady_clock;

void ApplyWithBudget(std::vector<FrameTask>& tasks, double periodMs)
{
    const double cpuBudgetMs = periodMs * 0.55;
    const auto started = Clock::now();

    for (const auto& task : tasks) {
        double used = std::chrono::duration<double, std::milli>(Clock::now() - started).count();
        if (!task.critical && used >= cpuBudgetMs) {
            Defer(task);
            continue;
        }
        Execute(task);
    }
}

0.55 是示例策略,需要依据绘制复杂度和设备档位调整。关键任务也不是无限制执行;若关键任务本身持续超预算,应拆分算法或降低渲染复杂度。

10. 过载降级要确定、可恢复、可观测

降级顺序应提前约定,例如先降低装饰粒子数量,再减少非关键标签,最后降低数据采样频率。下一帧预算恢复后,可以逐级恢复,避免画质在两个极端间频繁跳变。

enum class RenderLevel { FULL, REDUCED_DECORATION, CORE_ONLY };

RenderLevel SelectLevel(double costMs, double budgetMs, RenderLevel current)
{
    if (costMs > budgetMs * 1.2) return RenderLevel::CORE_ONLY;
    if (costMs > budgetMs) return RenderLevel::REDUCED_DECORATION;
    if (costMs < budgetMs * 0.65 && current != RenderLevel::FULL) {
        return static_cast<RenderLevel>(static_cast<int>(current) - 1);
    }
    return current;
}

恢复阈值比降级阈值更保守,形成滞回区间,避免一次轻微波动就反复切换。记录降级级别和持续帧数,比只记录平均 FPS 更容易解释体验问题。

11. 单回调与多回调 API 不能混用语义

OH_NativeVSync_RequestFrame() 在同一帧多次请求时只执行最后回调,适合一个调度器统一合并工作。OH_NativeVSync_RequestFrameWithMultiCallback() 会保留同一 VSync 周期中的所有回调,适合确实需要多个独立订阅者的场景。

统一渲染队列:使用 RequestFrame,一帧只触发一次调度
多个独立 Native 模块:谨慎评估 MultiCallback
同一页面多个组件:优先汇聚到一个 FrameScheduler

多回调不是提高并行度的快捷方式。所有回调仍共享有限帧预算,订阅者越多,越需要统一优先级和关闭顺序。

12. 预期帧率区间从 API 20 起可设置

应用可以为 VSync 设置最小、最大和期望帧率范围。参数必须合理,且最终节拍还受设备能力和系统策略影响。不要把请求值当成实际刷新率。

bool SetExpectedRate(int32_t minFps, int32_t maxFps, int32_t expectedFps)
{
    if (vsync_ == nullptr || minFps <= 0 || minFps > expectedFps || expectedFps > maxFps) {
        return false;
    }
    OH_NativeVSync_ExpectedRateRange range { minFps, maxFps, expectedFps };
    return OH_NativeVSync_SetExpectedFrameRateRange(vsync_, &range) == 0;
}

静态页面可以降低期望值,连续动画再提高;切换策略要避免每帧调用。实际周期仍通过回调与 GetPeriod() 观察。

13. 销毁前必须等最后一个待回调结束

回调的 data 指向调度器实例。如果对象已经析构而系统稍后回调,就会访问悬空指针。停止流程应先禁止新请求,再等待 requestPending_ 清零,最后销毁 VSync。

void Stop()
{
    running_.store(false);
    std::unique_lock<std::mutex> lock(idleMutex_);
    idleCv_.wait_for(lock, std::chrono::milliseconds(100), [this] {
        return !requestPending_.load();
    });
    if (vsync_ != nullptr) {
        OH_NativeVSync_Destroy(vsync_);
        vsync_ = nullptr;
    }
}

void NotifyIdle()
{
    std::lock_guard<std::mutex> lock(idleMutex_);
    idleCv_.notify_all();
}

项目应根据线程和页面生命周期决定等待策略。若超时仍有待回调,不能无条件销毁后继续运行;应保留对象直到回调收口,或由更长生命周期的 Native 所有者统一管理。

14. 定位抖动要看帧分布而非平均值

现象 关键证据 优先修正
平均 60 fps 仍偶发顿挫 单帧耗时高分位、连续超预算帧 拆分峰值任务
同一帧重复计算 RequestFrame 调用次数、任务版本 增加 pending 与合并
高刷设备更容易掉帧 实际 period 与写死预算 动态读取周期
页面退出偶发崩溃 最后请求与对象销毁时间 等待回调收口
动画后台回来跳跃 delta 突然变大 限制时间步长并重建基线
降级画面频繁闪动 级别切换次数 增加恢复滞回区间

建议按帧记录有限字段:时间戳、周期、CPU 耗时、提交耗时、任务数量和降级级别。不要在每帧写大量文本日志,日志 I/O 也会侵占预算。

15. 真机验收清单与资料来源

[ ] VSync 实例创建失败时立即停止初始化
[ ] 同一调度器最多保留一个待回调 RequestFrame
[ ] 动画使用回调时间戳,不用墙钟替代帧时间
[ ] 同类型业务更新按版本合并
[ ] 首帧回调后再读取 VSync period
[ ] CPU 任务只使用帧周期的一部分预算
[ ] 超预算时优先推迟非关键工作,并有滞回恢复
[ ] 高刷、前后台切换和窗口销毁均在真机覆盖
[ ] 停止后不再请求新帧,待回调收口后才 Destroy

资料来源:

NativeVSync 的价值不是“换一个更准的定时器”,而是让更新、绘制和提交围绕同一帧节拍协作。一帧只预约一次、同类工作只保留最新状态、预算不足时有确定降级、关闭时不留下悬空回调,这四条共同成立后,自绘动画才真正具备可预测的流畅性。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐