XCMusic 开发者日志 01 :歌词动画
本文最后更新于 28 天前,其中的信息可能已经有所发展或是发生改变。

网易云音乐的歌词 api 返回的歌词类型有两种:逐行歌词(lrc)和逐字歌词(yrc)。

目标

需要实现的歌词动画包括:

  1. 滚动动画:平滑滚动到当前行
  2. 缩放动画:当前行的缩放从 1.0 缓变为 1.3,上一行反向变化
  3. 逐字歌词动画:从左到右的剪切效果

逐行歌词可视为逐字歌词的特例:即动画长度为 0 的逐字歌词。

初代方案:基于 CSS 动画的实现

初版应用于 XCMusic 0.2.4 及之前版本。

主要原理是:

  1. requestAnimationFrame 计算滚动动画位置
  2. CSS 动画处理缩放效果;
  3. clip-path 实现逐字动画,动画时长对应歌词时间
<!-- 逐字歌词动画 -->
<span
  class="item-white font-color-main"
  :style="{
    transition: `clip-path ${(word.duration ?? 0) + (word.startTime ?? line.startTime) > currentTime ? (word.duration ?? 0) / 1000 : 0}s linear, color 0.5s ease`,
    clipPath:
      word.startTime <= currentTime ? 'inset(0 0% 0 0)' : 'inset(0 100% 0 0)',
    color:
      index === currentLine
        ? 'var(--font-color-main)'
        : 'var(--font-color-standard)',
  }"
>
  {{ word.text }}
</span>

二代方案:使用 Canvas 绘制歌词

应用于 XCMusic 0.2.50.3.0 版本。

在第一版歌词中,我发现其存在诸多不便利性:

  1. CSS 动画无法暂停
  2. 过多的 dom 元素导致的内存占用问题
  3. CSS 动画的自由性太低

恰好 Canvas 能够完美解决上述问题。

使用 Canvas 完成歌词的逻辑:

  1. 获取当前系统缩放和应用缩放并适配,防止 Canvas 模糊。
  2. 根据解析后的歌词,计算每一行歌词的位置。
  3. 使用 requestAnimationFrame 在每一帧完成:
  • 获取当前音频的播放进度
  • 计算当前滚动高度,实现滚动动画
  • 根据滚动高度与当前行高度的差值,计算缩放动画和字体颜色
  • 计算当前逐字动画位置,并计算 cutX 的位置。
    与第一版歌词类似,在 scrollY 处画两份歌词:一份为白色,仅显示 cutX 左侧的部分;另一份为灰色,仅显示 cutX 右侧的部分。
  1. 监听用户鼠标滚轮,并执行对应的滚动动画。

纯 JavaScript 实现带来的自由度是极高的–这一版动画的效果也是最好的。

最终版本:基于 Web Animations API 和 CSS 的歌词动画

最终版歌词动画,在 XCMusic 0.3.1 及之后的版本中使用。

在版本迭代中,我发现使用 Canvas 绘制歌词存在以下问题:

  1. 在使用歌词时,electron 的 GPU 进程占用的内存会逐步上涨,大约在一整天内从 10MB 以内上涨到 100MB 左右。而 electron 没有提供 api 来清除 GPU 进程的内存占用
  2. 放弃使用 DOM 的同时也放弃了现有的滚动容器和排版系统。前者导致歌词的滚动交互体验变差,而后者意味着长行歌词的排版成为一个大问题
  3. 由于需要在每一帧计算动画,性能优化格外重要。这大大限制了代码的可读性,增大了维护难度
  4. 画布大小不灵活,无法动态适应布局改变

Web Animations Api 可以在 JavaScript 中操控 CSS 动画,解决了初代方案中动画灵活性的问题。

至于内存占用问题,在加载 100 句歌词 ^1后,渲染进程内存占用仅增加了 5MB。并且这部分内存是能够稳定得到释放的。

使用 Web Animations Api 完成歌词动画的基本过程为:

  1. 在获取逐字歌词后,调用 computeLyricsElements() 函数:这个函数会完成:
  • 生成歌词对应的 dom 元素,并保存 dom 元素的引用
  • 为行元素添加行动画(缩放动画)
  • 为词元素添加从左到右的剪切动画
  • 暂停所有动画,并保存动画的引用
  1. 同时,从逐字歌词生成时间线
  2. 使用 requestAnimationFrame() 函数,在每一帧监听播放进度,并通过时间线计算是否有需要播放的动画
  3. 需要播放动画时,调用 animate.play() 来播放动画。

下一篇