HarmonyOS NativeVSync 帧调度实战:回调节拍、任务合并与过载降级
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
资料来源:
- 华为开发者参考:native_vsync.h
- 华为开发者文档中心:图形绘制与显示 C API
- 本机
D:/harmonyos/SDK/23/native/sysroot/usr/include/native_vsync/native_vsync.h,用于核对 API 23 的函数签名、起始版本和回调语义。
NativeVSync 的价值不是“换一个更准的定时器”,而是让更新、绘制和提交围绕同一帧节拍协作。一帧只预约一次、同类工作只保留最新状态、预算不足时有确定降级、关闭时不留下悬空回调,这四条共同成立后,自绘动画才真正具备可预测的流畅性。
更多推荐



所有评论(0)