一、两种时钟:DisplaySync vs setInterval

先厘清一个常常被忽视的事实:这个项目里,逐帧动画用了两种时钟,而且这是一个技术演进的轨迹,不是随意选择。

时钟

使用的组件

出现阶段

setInterval(16)

FlowAvatar(E016)、GradientSpin(E014)、SlotText(E015)、Coverflow(部分)

早期

displaySync.create()

GrokBot(E025)、ThinkingOrbs(E022)、BorderBeam(E023)

后期

为什么后来转向 DisplaySync?因为 setInterval 有三个硬伤:

  1. 帧率不可控setInterval(16) 期望 60fps,但实际触发时间由 JS 事件循环决定,会抖动、会和屏幕 vsync 脱节。

  2. 无法表达「期望帧率」:DisplaySync 的 setExpectedFrameRateRange 能明确告诉系统「我要 60fps,但允许你降到 0~120 自适应」。

  3. 无法优雅停表: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;
}

两个细节:

  1. stop()off('frame')——避免摘监听的同时还有一帧在路上。

  2. 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(Date.now()

单实例、相位连续性比同相重要

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 是通用的(允许系统在你静止时不给帧,配合「离屏停表」更省电);expectedmax 根据每帧的实际成本设定——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 是铁律。
销毁就清理,aboutToDisappearoff('frame')
帧时间要 clamp,防后台恢复突跳。
第一帧 dt=0,重新启动不跳变。
reduceMotion 画固定代表帧,不是慢速动画。

记住一句话就够了:一个会「跑」的 Canvas 不稀奇,一个会「停」的 Canvas 才是工程化的分水岭。 如果你的鸿蒙动效组件还没落实这六条,先别急着优化像素——先把时钟治理干净。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐