HarmonyOS 鸿蒙 DisplaySync 高刷绘制最佳实践 —— 按需启停、离屏停表与帧时间治理
一、两种时钟:DisplaySync vs setInterval
先厘清一个常常被忽视的事实:这个项目里,逐帧动画用了两种时钟,而且这是一个技术演进的轨迹,不是随意选择。
|
时钟 |
使用的组件 |
出现阶段 |
|---|---|---|
|
|
FlowAvatar(E016)、GradientSpin(E014)、SlotText(E015)、Coverflow(部分) |
早期 |
|
|
GrokBot(E025)、ThinkingOrbs(E022)、BorderBeam(E023) |
后期 |
为什么后来转向 DisplaySync?因为 setInterval 有三个硬伤:
-
帧率不可控:
setInterval(16)期望 60fps,但实际触发时间由 JS 事件循环决定,会抖动、会和屏幕 vsync 脱节。 -
无法表达「期望帧率」:DisplaySync 的
setExpectedFrameRateRange能明确告诉系统「我要 60fps,但允许你降到 0~120 自适应」。 -
无法优雅停表:DisplaySync 有显式的
start()/stop(),配合「静止就停」的语义更清晰。
E016 的实验文档里明确写了这句话,就是最好的注脚:
后续可选:
displaySync替代 interval。
所以这篇以 DisplaySync 为主,但「按需启停、离屏停表、清理」这些规则,对 setInterval 同样成立(只是 DisplaySync 让它们更明确)。
二、第一铁律:静止就停表(按需启停)
2.1 反模式:一直跑
最差的写法是「组件一挂载就 start(),一直跑到销毁」。这样 Idle 静止态也在 60fps 空转,白耗电。
2.2 正解:每帧末尾判断「还要不要继续跑」
GrokBot 的 stopIfIdle 是范本:
private stopIfIdle(): void {
if (this.spring.active() || this.blinkSeconds >= 0 || this.spinSeconds >= 0) return;
if (this.sync !== null && this.running) this.sync.stop(); // 没有动画了 → 停表
this.running = false;
this.lastFrameMs = 0;
}
private onFrame(): void {
// ... 更新弹簧 / 眨眼 / spin
this.paintNow();
this.stopIfIdle(); // 每帧末尾都检查
}
核心思想:「是否有动画在跑」是一个显式可判断的条件(弹簧是否 active、眨眼/旋转是否进行中)。只要都不满足,立刻 stop()。这样:
-
表情静止时 → 0 帧,不耗电。
-
一旦触发眨眼/表情切换/旋转 →
start(),跑起来。 -
动画结束 → 又
stop()。
2.3 一个 running 标志位防重入
start() / stop() 都要用 running 标志位防重入:
private startAnimation(): void {
if (this.running) return; // 已在跑,别重复 start
// ...
this.sync.start();
this.running = true;
}
private stopAnimation(): void {
if (this.sync !== null && this.running) this.sync.stop();
this.running = false;
}
三、第二铁律:离屏停表(onVisibleAreaChange)
组件滚出可视区后,就不应该再逐帧画。这是六个组件都遵守的铁律,BorderBeam 的实现最完整:
.onVisibleAreaChange([0.0, 1.0], (isVisible, _ratio) => {
this.visible = isVisible;
if (isVisible) {
this.paintNow();
this.reconcileAnimation(); // 重新可见 → 若该跑则 restart
} else {
this.stopAnimation(); // 离屏 → 立刻停表
}
})
配套的 reconcileAnimation 统一了「该不该跑」的判断:
private reconcileAnimation(): void {
if (this.shouldAnimate() && this.canvasReady && this.visible) {
this.startAnimation();
} else {
this.stopAnimation();
}
}
要点:shouldAnimate() 里要包含所有「不该动」的条件(paused / reduceMotion / inactive),visible 是其中之一。这样「离屏」和「暂停」走同一条停表路径,逻辑不重复。
四、第三铁律:aboutToDisappear 必须清理
页面返回、组件销毁时,必须把 DisplaySync 的 frame 监听摘掉,否则会内存泄漏、甚至回调打到一个已销毁的组件上。
aboutToDisappear(): void {
this.teardownSync();
}
private teardownSync(): void {
this.stopAnimation();
if (this.sync !== null) {
if (this.frameCallback !== null) {
this.sync.off('frame', this.frameCallback); // 摘掉指定回调
} else {
this.sync.off('frame'); // 兜底:全摘
}
this.sync = null;
}
this.frameCallback = null;
}
两个细节:
-
先
stop()再off('frame')——避免摘监听的同时还有一帧在路上。 -
off带具体的frameCallback引用(而不是无参off('frame')),确保摘的是「这一个组件」的监听,不影响其他实例共享的 DisplaySync(如果有多实例共用的话)。
五、第四铁律:帧时间 delta 要 clamp
这是最容易被忽视、却最能体现「真机经验」的一条。后台恢复、瞬时卡顿,都会让 dt 变成几十秒甚至几分钟。如果不 clamp,动画会「瞬移」一大圈。
GrokBot 里对弹簧做了 clamp:
// GrokBotSpring.step 里
let remaining = Math.min(Math.max(rawDt, 0), 0.1); // dt 封顶 0.1 秒
FlowAvatar 里对相位累加做了 clamp:
const MAX_FRAME_DELTA_SECONDS = 0.05;
const dt = Math.min((nowMs - this.lastFrameMs) / 1000, MAX_FRAME_DELTA_SECONDS);
this.phaseRadians += dt * this.angularSpeedRadPerSec();
MetalFx 的 C++ 侧也做了同样的处理,而且注释点明了动机:
const double delta = (timestamp - lastFrameTimestamp_) / 1'000'000'000.0;
accumulatedTime_ += std::min(delta, 0.1); // 防后台恢复突跳
规则:任何基于「两帧时间差」推进动画的地方,都要 clamp(dt, 0, 上限),上限取 0.05~0.1 秒即可。
六、第五铁律:第一帧 dt = 0
配合第五点,还有一个细节:重新启动动画后,第一帧的 dt 应该是 0,只画不推。
GrokBot 的做法:
private startAnimation(): void {
// ...
this.lastFrameMs = Date.now(); // 先赋当前时间
this.sync.start();
this.running = true;
}
private onFrame(): void {
const now = Date.now();
const dt = this.lastFrameMs > 0 ? (now - this.lastFrameMs) / 1000 : 0; // 第一帧 dt=0
this.lastFrameMs = now;
// ...
}
startAnimation 里先 lastFrameMs = Date.now(),所以第一帧算出的 dt 是 0,不会因为「从停表到重新 start 之间隔了很久」而产生一次大跳变。
七、reduceMotion:固定代表帧,而不是「慢速动画」
「减少动态效果」的正确做法是返回一个固定的代表帧,而不是「把速度调慢」(慢速仍是动效,且语义不对)。
ThinkingOrbs 和 FlowAvatar 都用 t = 0.6 这个「上游对齐的固定帧」:
// ThinkingOrb
const REDUCED_MOTION_T: number = 0.6;
private timeSeconds(info?): number {
if (this.respectReducedMotion && this.reduceMotion) return REDUCED_MOTION_T;
// ...
}
// FlowAvatar(同款)
private timeSeconds(): number {
if (this.respectReducedMotion && this.reduceMotion) return T = 0.6;
// ...
}
配套:shouldAnimate() 里,reduceMotion 为 true 时直接不启动时钟:
private shouldAnimate(): boolean {
if (this.paused) return false;
if (this.respectReducedMotion && this.reduceMotion) return false; // 不跑,画固定帧
return true;
}
这样 reduceMotion 时:时钟停表 + 只画一帧 t=0.6 的静止图。既不耗电,又给了用户一个「稳定的、可理解的」呈现。
八、多实例同相 vs per-instance 相位
一个被反复遇到的岔路:多个动画实例,是用「共享绝对时间」还是「各自累加相位」?
项目里两种都出现过,但选择是有依据的:
|
场景 |
选法 |
例子 |
|---|---|---|
|
多实例要「同相」(一起呼吸、一起转) |
共享 wall-clock |
ThinkingOrbs( |
|
单实例、相位连续性比同相重要 |
per-instance 连续累加 |
FlowAvatar |
ThinkingOrbs 的注释点明了选择理由:
// Shared wall-clock time keeps mounted orbs in phase.
// DisplaySync timestamp units vary; Date.now keeps orbs in phase with each other.
private timeSeconds(): number {
let ms = Date.now(); // 用墙钟,保证多实例同相
return (ms / 1000) * this.effectiveSpeed();
}
关键陷阱:DisplaySync 的 IntervalInfo.timestamp 单位在不同设备/系统版本间有差异(可能是纳秒,也可能是别的),直接用会导致「多实例不同相」。所以 ThinkingOrbs 明确用 Date.now() 而非 DisplaySync timestamp——这是「为了对齐上游 performance.now() 契约」和「保证多实例同相」的双重理由。
九、DisplaySync 的期望帧率:按成本设定
不是所有动画都需要 60fps。BorderBeam 因为 Canvas soft fill 太重,主动压到 30fps:
const range: ExpectedFrameRateRange = { expected: 30, min: 0, max: 30 };
sync.setExpectedFrameRateRange(range); // 封顶 30fps,因为 fill 太重
而 GrokBot(几何轻)用的是 60:
sync.setExpectedFrameRateRange({ expected: 60, min: 0, max: 120 });
规则:min: 0 是通用的(允许系统在你静止时不给帧,配合「离屏停表」更省电);expected 和 max 根据每帧的实际成本设定——fill 多就 30,fill 少就 60。
十、完整的生命周期基线(可直接照抄)
把六条铁律拼起来,就是一个组件逐帧动画的完整骨架:
@Component
export struct AnimatedCanvas {
private sync: displaySync.DisplaySync | null = null;
private running: boolean = false;
private visible: boolean = true;
private canvasReady: boolean = false;
aboutToAppear(): void { this.ensureSync(); }
aboutToDisappear(): void { this.teardownSync(); } // 铁律三:清理
private ensureSync(): void {
if (this.sync !== null) return;
const sync = displaySync.create();
sync.setExpectedFrameRateRange({ expected: 60, min: 0, max: 120 }); // 铁律九:按成本设帧率
this.frameCallback = (_info) => this.onFrame();
sync.on('frame', this.frameCallback);
this.sync = sync;
}
private shouldAnimate(): boolean {
return this.canvasReady && this.visible && !this.paused && !this.reduceMotion;
}
private start(): void {
if (this.running || !this.shouldAnimate()) return; // 铁律二/七:条件
this.sync?.start(); this.running = true;
this.lastFrameMs = Date.now(); // 铁律六:第一帧 dt=0
}
private stop(): void {
if (this.sync !== null && this.running) this.sync.stop();
this.running = false;
}
private onFrame(): void {
const now = Date.now();
const dt = this.lastFrameMs > 0 ? Math.min((now - this.lastFrameMs)/1000, 0.1) : 0; // 铁律五:clamp
this.lastFrameMs = now;
// ... 推进动画
this.paint();
if (/* 动画结束了 */) this.stop(); // 铁律一:静止停表
}
build() {
Canvas(this.ctx)
.onReady(() => { this.canvasReady = true; this.start(); })
.onVisibleAreaChange([0.0, 1.0], (v) => { // 铁律二:离屏停表
this.visible = v;
v ? this.start() : this.stop();
})
}
}
十一、总结
逐帧动画的「活」和「死」,一半在算法,一半在生命周期。这六条铁律,是从六个组件、几十次真机调试里反复验证出来的:
静止就停表,动画结束立刻
stop()。
离屏就停表,onVisibleAreaChange是铁律。
销毁就清理,aboutToDisappear里off('frame')。
帧时间要 clamp,防后台恢复突跳。
第一帧 dt=0,重新启动不跳变。
reduceMotion 画固定代表帧,不是慢速动画。
记住一句话就够了:一个会「跑」的 Canvas 不稀奇,一个会「停」的 Canvas 才是工程化的分水岭。 如果你的鸿蒙动效组件还没落实这六条,先别急着优化像素——先把时钟治理干净。
更多推荐



所有评论(0)