前言
做小宇宙Studio的过程中,剪辑页面的音轨区域用了很长时间做性能优化,具体的效果可以看视频,基本覆盖音轨区域的所有交互。
这里讲一下踩到的坑,以及各种性能优化的方案。
概念
- 项目:当前剪辑页面打开的就是一个剪辑项目
- 音轨:中间的一条由波形组成的就是音轨。目前只有一条音轨
- 片段:一条音轨由若干个片段组成,每个片段会对应一个音频文件的一个区间。
截图是现在小宇宙Studio剪辑页面的样子,这里划分几个部分,方便后续讲解。
其中蓝色音轨部分主要由这几个组件组成:
- 时间轴:绿色
- 某个片段:红色
- 滚动条:黄色
- 两个悬浮的Toolbar
音轨区域迭代
方案1:不做Virtualized,使用浏览器滚动条
这是最直观,最好理解的方案。在没有任何例子和性能测试之前,没必要直接进行优化。
整个音轨的宽度,就是项目在当前ZoomLevel下的实际宽度。只需要把片段从前到后依次排列,Toolbar的定位只需要相对于项目找到left值即可。可以直接使用浏览器给的滚动条,不需要监听滚动事件。基于此实现了第一版音轨区域。
但很快就发现下列问题:
- 时间轴使用canvas进行绘制,当ZoomLevel越大,元素宽度就越大,就会 超过canvas的最大宽度,也就是32767像素。
- 同样的,波形图也使用canvas绘制,一个片段如果过长,当ZoomLevel变大时,也会超过浏览器限制,导致canvas无法绘制。
- 另外,当Zoomlevel变化时,会导致所有片段重绘,从而导致所有波形图也会重绘。这个过程是很慢的,尤其是使用触摸板或者Slider微调时,卡顿尤为明显。
可以发现这些问题几乎都是canvas导致的,所以就有下一个方案,做Virtualized。
方案2:做Virtualized,使用浏览器滚动条
针对方案1的问题,主要有两点优化:
- 时间轴只绘制可见部分
- 波形图只绘制可见部分,但是片段正常绘制
时间轴区域:使用 Konva 绘制,可以方便的实现鼠标拖拽且仅能沿X轴移动等。实际上会创建时间轴上的所有元素,因为用Konva和canvas,最终只有可见部分被渲染。
片段:一个片段在音轨的位置有五种可能,需要分别处理:
- 完全不可见:仅渲染片段容器
- 完全可见:完整渲染
- 前半部分可见,后半部分不可见:波形图从片段资源起始位置绘制到屏幕
- 前半部分不可见,后半部分可见:波形图从屏幕位置绘制到片段资源结束为止,并且加上
flex-end
- 前半部分和后半部分都不可见,该片段撑满屏幕:绘制可见区域,再用
translate-x
将波形图移到屏幕内
滚动条:
- 拖拽滚动条时,调整时间轴的位置,以及被virtualized的波形图
- 拖拽时间轴时,调整滚动条的offset,以及virtualized的波形图。
- 调整滚动条offset是一个副作用,但其它都不是,处理
onScroll
时需要注意这一点。
这个方案在性能上已经可以接受,也不会触及到任何浏览器限制。小宇宙剪辑工具的第一版就采用该方案。
但该方案存在一个严重影响体验的问题: 缩放时,无法相对于鼠标位置居中缩放,就像Figma等大多数工具那样。基于该方案实现过相对鼠标位置居中缩放,实现方式是:
当缩放时(修改ZoomLevel),基于鼠标当前位置计算新的ZoomLevel下 scrollLeft
的期望值,修改容器的 scrollLeft
,但由于滚动是异步的,当ZoomLevel变化速度(触摸板产生的wheel事件)太快时,修改 scrollLeft
的速度不如ZoomLevel变化的速度,就导致滚动条的位置反复横跳。
最终该方案在修改ZoomLevel时,不修改 scrollLeft。但这个问题会导致用户在调整ZoomLevel时,在时间轴中迷失,很影响体验。
当前方案:做Virtualized,自己实现滚动条
针对方案2的问题,有这几点优化:
- 使用自己实现的滚动条
- 只绘制屏幕可见的片段
- 音轨容器宽度从原本的项目长度变为屏幕宽度
- 时间轴进一步优化性能
该方案自己实现滚动条比较麻烦,其他逻辑相较于方案2反而更加简单。因为不需要监听滚动事件,不需要处理 scrollLeft
这些副作用。所有的信息都可以从store中获取,所有的改动都可以通过修改store完成。
这里说一下实现滚动条需要注意的几点:
- Drag'n Drop时,如果被拖拽元素在音轨外,需要自己处理滚动条的滚动。
- 滚动条包含滑块(Thumb)和轨道(Track)。滑块有最小宽度。
- 已知音轨总长度(trackLength),当前可见区域的宽度(viewWidth),Scrollbar的宽度(scrollbarWidth),以及可见区域相对音轨的位置(viewOffset)。来计算Thumb的宽度和Thumb的位置(thumbOffset)。
- 一开始很容易想到,用
ratio=trackLength/scrollbarWidth
计算出一个比例。然后等比缩小,就得出Thumb的宽度和offset。但如果等比缩小后,Thumb的宽度小于最小宽度时,这样的计算结果就有问题。所以需要这样计算:
const viewRatio = viewWidth / trackLength // 先计算一个比例
const thumbWidth = Math.max(25, viewRatio * scrollbarWidth) // 根据这个比例以及最小值,算出Thumb实际的宽度
const maxOffset = scrollbarWidth - thumbWidth // 根据新的thumbWidth计算滚动条里Thumb的最大offset
// 因为 thumbWidth 可能是25或者是 viewRatio计算出来的,所以需要重新计算offset的ratio
const ratio = maxOffset / (userMaxOffset ?? totalWidth - viewWidth)
const thumbOffset = ratio * offset
当把滚动条实现完成后,发现性能大幅度提高,并且也很容易做到相对鼠标位置居中缩放。
但发现调整ZoomLevel时会掉帧,一开始怀疑是转文字区域文字太多导致的。经过Profile看到慢的地方都和Konva有关,因为整个项目中只有时间轴用了Konva,所以把时间轴暂时隐藏掉,就非常流畅。
后来发现,虽然Konva当元素在屏幕外时并不会绘制,也不会有很多事件监听,但它依然会产生大量的Konva Object。当ZoomLevel改变时,就会重新创建所有的Konva Object,导致卡顿。
到此位置,音轨区域和波形图的性能优化就告一段落。
最后再简单说几个比较细节的优化点:
优化波形图生成和计算速度
生成波形图的方式是:
- 先下载音频文件
- 执行 decodeAudioData ,产生 AudioBuffer
- 从AudioBuffer中
getChannelData
即可获得音频的原始采样数据。
但因为是播客音频,单文件动辄几十分钟,如果采样率是44100hz的话,第3步一个声道就有过亿的采样数。为减少采样数,在解码时创建一个采样率只有8000的 AudioContext
,用它来执行 decodeAudioData
, 就可以减少采样数,计算也会更快。另外也会把生产的波形图数据缓存到IndexedDB里,避免重复生成。
平滑缩放
当使用鼠标滚轮或者触摸板产生Wheel时间时,想要尽可能顺滑的改变ZoomLevel。这里直接使用 d3-zoom的函数
export function wheelDelta(deltaY: number, deltaMode: number): number {
return -deltaY * (deltaMode === 1 ? 0.05 : deltaMode ? 1 : 0.002)
}
function zoomByDelta(delta: number): number {
const next = this.zoomLevel * Math.pow(2, delta)
return this.setZoomLevel(next,)
}