源码解读 -- SVGAPlayer-Web-Lite
摘要
SVGAPlayer-Web-Lite(下面简称SVGA)是一个优秀的动画库,他能解析svga格式的文件,并将其渲染到canvas上,性能比gif和mp4要好,体积也小。那么在好奇心的驱使下,我们来阅读下他的源码,看看他是如何实现的。所幸,SVGA是由国人开发的,资料比较全,实现思路也比较符合国人的思维模式,阅读起来,还是比较流畅的(本文基于版本2.0.8-alpha.1
)。
SVGA的使用
- 在官网上可以找到例子
<canvas id="canvas"></canvas>
import { Parser, Player } from 'svga'
const parser = new Parser()
const svga = await parser.load('xx.svga')
const player = new Player(document.getElementById('canvas'))
await player.mount(svga)
player.onStart = () => console.log('onStart')
player.onResume = () => console.log('onResume')
player.onPause = () => console.log('onPause')
player.onStop = () => console.log('onStop')
player.onProcess = () => console.log('onProcess', player.progress)
player.onEnd = () => console.log('onEnd')
// 开始播放动画
player.start()
// 暂停播放动画
// player.pause()
// 继续播放动画
// player.resume()
// 停止播放动画
// player.stop()
// 清空动画
// player.clear()
// 销毁
// parser.destroy()
// player.destroy()
- 我们可以按着这个使用的流程,对源码进行阅读
Paser
- 初始化
const INLINE_WORKER_FLAG = '#PARSER_V2_INLINE_WROKER#'
export class Parser {
public worker: MockWebWorker | Worker
private readonly isDisableImageBitmapShim: boolean = false
constructor (options: ParserConfigOptions = {
isDisableWebWorker: false,
isDisableImageBitmapShim: false
}) {
const { isDisableWebWorker, isDisableImageBitmapShim } = options
if (isDisableImageBitmapShim === true) {
this.isDisableImageBitmapShim = isDisableImageBitmapShim
}
if (isDisableWebWorker === true) {
// eslint-disable-next-line no-eval
eval(INLINE_WORKER_FLAG)
if (window.SVGAParserMockWorker === undefined) throw new Error('SVGAParserMockWorker undefined')
this.worker = window.SVGAParserMockWorker
} else {
this.worker = new Worker(window.URL.createObjectURL(new Blob([INLINE_WORKER_FLAG])))
}
}
...
}
- 初始化流程里,最关键的就是这个
INLINE_WORKER_FLAG
,从定义上看,他明明只是一个看起来没什么意义的字符串,为什么可以被eval
以及Worker
执行呢 - 全局搜一下
#PARSER_V2_INLINE_WROKER#
,我们可以看到,在inject-parser.mjs
文件里,对这串文字进行了替换,粗略看一下这里面的逻辑,发现他是将所有dist/index.*
的文件,都进行了一遍替换,将原文件里的#PARSER_V2_INLINE_WROKER#
,替换成了dist/parser.js
即parser
文件夹下面打包后产物的代码,所以最终实际运行在浏览器上时,eval
执行的其实是dist/parser.js
文件
- 进一步搜索
inject-parser.mjs
,可以发现他被plugins.mjs
引用了,而该文件被rollup
的打包软件引入,进一步说明了#PARSER_V2_INLINE_WROKER#
的替换处理是在打包阶段进行的 - 那为什么要这么处理,而不直接使用import引入
parser
文件呢?这是因为代码本意是希望通过worker
来执行parser
,worker
提供了js异步执行的能力,能在主线程以外创建线程去执行对应的脚本,而如果用import,就只能同步引入,也无法使用worker
了
...
this.worker = new Worker(window.URL.createObjectURL(new Blob([INLINE_WORKER_FLAG])))
...
- 这样处理之后,下载svga文件以及解析svga文件,都是独立于主线程之外执行的,不会阻塞当前页面的渲染
- 接下来看看
parser
文件夹里的内容,主要看index.ts
文件,剩余的svga-proto.ts
是用来定义svga格式的(二代svga通过Protocol Buffers对动画文件进行序列化编码成了二进制文件,前端通过protobufjs工具进行解析),video-entity.ts
则是进一步处理解析后的数据,方便后续使用
async function onmessage (event: { data: ParserPostMessageArgs }): Promise<void> {
try {
const { url, options } = event.data
const buffer = await download(url)
const dataHeader = new Uint8Array(buffer, 0, 4)
if (Utils.getVersion(dataHeader) !== 2) throw new Error('this parser only support version@2 of SVGA.')
const inflateData: Uint8Array = new Zlib.Inflate(new Uint8Array(buffer)).decompress()
const movie = message.decode(inflateData) as unknown as Movie
const images: RawImages = {}
for (const key in movie.images) {
const image = movie.images[key]
if (!options.isDisableImageBitmapShim && self.createImageBitmap !== undefined) {
images[key] = await self.createImageBitmap(new Blob([image]))
} else {
const value = uint8ArrayToString(image)
images[key] = btoa(value)
}
}
worker.postMessage(new VideoEntity(movie, images))
} catch (error) {
let errorMessage: string = (error as unknown as any).toString()
if (error instanceof Error) errorMessage = error.message
worker.postMessage(
new Error(`[SVGA Parser Error] ${errorMessage}`)
)
}
}
- 而
parser/index.ts
文件主要需要关注的是onmessage
方法,其接收一个url和options作为参数,首先会先执行download
,即创建一个XMLHttpRequest
进行请求,获得数据后进行解析,利用VideoEntity
将解析出来的数据做进一步处理,最后执行worker.postMessage
,前面说过,该文件最终会被worker
执行,所以这里是通知主线程已经解析完毕,得到描绘动画文件的json了 - 回到
src/parser.ts
文件,我们可以看到new Parser
的时候,已经创建了一个worker
,随后用户执行parser.load('xx.svga')
指定了svga路径
async load (url: string): Promise<Video> {
if (url === undefined) throw new Error('url undefined')
if (this.worker === undefined) throw new Error('Parser Worker not found')
return await new Promise((resolve, reject) => {
if (url.indexOf('http') !== 0) {
const a = document.createElement('a')
a.href = url
url = a.href
}
const { isDisableImageBitmapShim } = this
const postData = { url, options: { isDisableImageBitmapShim } }
if (this.worker instanceof Worker) {
this.worker.onmessage = ({ data }: { data: Video | Error }) => {
data instanceof Error ? reject(data) : resolve(data)
}
this.worker.postMessage(postData)
} else {
this.worker.onmessageCallback = (data: Video | Error) => {
data instanceof Error ? reject(data) : resolve(data)
}
this.worker.onmessage({ data: postData })
}
})
}
load
方法中,执行worker.postMessage
之前,进行了相对路径的判断,利用a标签将相对路径转成了绝对路径,这是因为worker
是独立于主线程的,相对路径无法正确获取资源,最后定义worker
的回调worker.onmessage
,执行postMessage
告知worker
执行下载和解析逻辑,最终得到了动画描述对象,该对象后续将传递给Player
进行渲染。
Player
- 首先看看
Player
的构造函数
constructor (options: HTMLCanvasElement | PlayerConfigOptions) {
this.animator = new Animator()
this.animator.onEnd = () => {
if (this.onEnd !== undefined) this.onEnd()
}
let container: HTMLCanvasElement | undefined
if (options instanceof HTMLCanvasElement) {
container = options
} else if (options.container !== undefined) {
container = options.container
this.setConfig(options)
}
this.config.container = container ?? this.config.container
this.ofsCanvas = window.OffscreenCanvas !== undefined ? new window.OffscreenCanvas(this.config.container.width, this.config.container.height) : document.createElement('canvas')
}
- 这里主要关注
new Animator
和setConfig
Animator
是用来控制动画播放的,具体可以细看一下对应的源码,其原理主要是利用requestAnimationFrame
,在回调中,不断调用onUpdate
通知外部更新画面- 这里有一个有趣的代码实现
const WORKER = 'onmessage = function () {setTimeout(function() {postMessage(null)}, 1 / 60)}'
public start (): void {
this.isRunning = true
this.startTime = this.currentTimeMillsecond()
this.currentFrication = 0.0
if (this.isOpenNoExecutionDelay && this.worker === null) {
this.worker = new Worker(window.URL.createObjectURL(new Blob([WORKER])))
}
this.onStart()
this.doFrame()
}
isOpenNoExecutionDelay
如果是true的话,将会新建一个worker线程,并在worker线程中进行回调,执行dofame,并且dofame也会触发worker进行下一轮更新,从而形成刷新机制- 这里使用worker的原因是因为promise和settimout都不是完全异步的,是区分宏任务和微任务,实际还是要等待主线程任务执行结束后,才会执行微/宏任务的回调,而worker是专门为了异步设计出来的,没有这个等待时间
- 但在代码里,我们可以看到,其设置的间隔时间是
1/60
ms,这里我个人觉得源码是写错了,本意应该是想要实现1秒60帧的频率进行刷新,应该是1000/60
ms才对,个人见解,有待验证 - 回到
Player
的构造函数中,后续的代码都是在进行配置的初始化,包括各种配置的赋值,判断用于播放的canvas容器等操作
player.mount
- 有了player实例后,下一步就是将parser导出的播放配置导入到player中,同时执行一些初始化的操作,包括下载图片,准备绘制函数,设置画面大小等,这些都通过
await player.mount(svga)
执行
public async mount (videoEntity: Video): Promise<void> {
return await new Promise((resolve, reject) => {
this.currentFrame = 0
// 实际上是index,从0算起,所以减1
this.totalFrames = videoEntity.frames - 1
this.videoEntity = videoEntity
this.clearContainer()
// 根据svga内部定义的尺寸,重新设置canvas容器大小
this.setSize()
// base64 -> imageelement
this.bitmapsCache = {}
if (this.videoEntity === undefined) {
resolve()
return
}
if (Object.keys(this.videoEntity.images).length === 0) {
resolve()
return
}
let totalCount = 0
let loadedCount = 0
// 图片资源塞进缓存数组,base64的转成对应img对象
for (const key in this.videoEntity.images) {
const image = this.videoEntity.images[key]
if (typeof image === 'string') {
totalCount++
const img = document.createElement('img')
img.src = 'data:image/png;base64,' + image
this.bitmapsCache[key] = img
img.onload = () => {
loadedCount++
loadedCount === totalCount && resolve()
}
} else {
this.bitmapsCache[key] = image
totalCount++
loadedCount++
loadedCount === totalCount && resolve()
}
}
})
}
- 代码比较简单,这里说几个值的注意的点,其中
mount
方法中的clearContainer
源码如下
private clearContainer (): void {
const width = this.config.container.width
this.config.container.width = width
}
- 这里利用的是,canvas长宽变化后,会引起画布重绘,把已有的绘制清除,从而实现clear方法
setSize
方法,这里涉及canvas的三种设置宽高的方式以及实际宽高和展示宽高的区别- canvas可以通过三种方式设置宽高:1)通过js设置 2)通过css方式设置 3)通过html标签中的属性设置
- 其中js设置的宽高会覆盖html中的属性设置,而css设置的样式最终会决定canvas在html中展示的大小
- js和html属性设置的宽高是等价的,而js和css设置的区别在于,通过js设置的宽高,会影响后续绘图的大小,后续调用canvas的api进行图案绘制时使用的坐标都是基于canvas.width和canvas.height基础上的,其决定了图案的可视范围,而css则是将最终cnavas整个容器进行等价缩放,最终展示成样式所设置的大小;
- 即假设通过js设置了canvas的宽高是500,然后绘制了一个半径为250的圆,最终设置css的宽高是300,则页面上展示的是一个半径150的完整的圆
- 所以player.mount中首先重置了canvas的宽高,而如果我们在使用这个库进行svga播放时,会发现,如果没有设置css宽高,通常情况下,svga的展示效果是超大的,而如果我们通过设置html属性试图缩小canvas的展示,是不会生效的,最终还是需要设置css来使canvas展示符合预期
- 最后补充一下,svga中的图片资源,经过parser的处理,都变成了
ImageBitmap
的格式,这种格式可以减少canvas绘制时解码png或jpg所需要的时间
player.start
- 资源都准备好了之后,执行
player.start
进行动画渲染,代码比较好理解,就不贴出来了,最终新建了一个Animator
的类,定义了他的回调函数onUpdate
,以一秒60帧的频率执行drawFrame
这个方法
private drawFrame (frame: number): void {
...
const context = this.config.container.getContext('2d')
...
if (this.config.isCacheFrames && this.cacheFrames[frame] !== undefined) {
const ofsFrame = this.cacheFrames[frame]
// ImageData
// context.putImageData(ofsFrame, 0, 0)
context.drawImage(ofsFrame, 0, 0, ofsFrame.width, ofsFrame.height, 0, 0, ofsFrame.width, ofsFrame.height)
return
}
...
let ofsCanvas = this.ofsCanvas
...
render(
ofsCanvas,
this.bitmapsCache,
this.videoEntity.dynamicElements,
this.videoEntity.replaceElements,
this.videoEntity,
this.currentFrame
)
context.drawImage(
ofsCanvas,
0, 0, ofsCanvas.width, ofsCanvas.height,
0, 0, ofsCanvas.width, ofsCanvas.height
)
if (this.config.isCacheFrames) {
// ImageData
// const imageData = (ofsCanvas.getContext('2d') as OffscreenCanvasRenderingContext2D | CanvasRenderingContext2D).getImageData(0, 0, ofsCanvas.width, ofsCanvas.height)
// this.frames[frame] = imageData
if ('toDataURL' in ofsCanvas) {
const ofsImageBase64 = ofsCanvas.toDataURL()
const ofsImage = new Image()
ofsImage.src = ofsImageBase64
this.cacheFrames[frame] = ofsImage
} else {
this.cacheFrames[frame] = ofsCanvas.transferToImageBitmap()
}
}
}
drawFrame
方法主要是利用离屏canvasofsCanvas
先对当前帧的所有元素进行一遍绘制,然后将离屏canvas最终呈现的图案绘制到页面容器中,并且如果设置保留绘制缓存的话,将会将这个图案保存到cacheFrames
中留待下一次需要绘制同一帧时使用render
方法里定义了多种图案的绘制方式,如rectangle的绘制,贝塞尔曲线的绘制,图片的绘制等,都是一些常规的canvas绘制的api调用,主要是针对svga事先定义的格式,根据配置进行图案绘制,具体可以直接看源码render
方法是绘制在离屏canvas上的,最终将离屏canvas上的数据,通过context.drawImage
拷贝一份到当前页面的canvas上,即完成了这一帧的绘制- 到此,就是svga解析到播放的全部流程,可以看到原理还是比较简单的,还有一些没有提及的代码,主要是实现在播放或者解析的各个阶段,插入回调函数,来进一步提供更灵活的流程控制,以及暂停,重播等功能。
写在最后
- 整个svga播放的实现,个人觉得最出彩的是
parser
的部分,包括通过打包插入parser.js
代码,以及利用worker
异步下载图片,解析svga的过程,如果我们想自己实现一个库,这是一个很好的参考方向 - 但也可以看到,这种播放方式,适合图片不多,体积不大,同一时间仅存在少量svga的情况下使用,需要设计师在设计环节就对素材优化,尽可能的增加可复用的图片元素;这是由于不同于视频边播放边下载,svga播放前就需要先将所有图片都下载到本地,并保留引用,以便后续绘制的时候使用,所以如果svga中图片资源多,体积大的话,内存消耗还是比较大的;
- 当页面存在内存泄露时,可以考虑是否初始化了多个svga,而没有将他们的引用去除;
- 在播放环节,一个优化方向是提供主动降帧,即检测到页面卡顿的情况下,主动降低帧率,避免阻塞主应用的运行;
- 渲染环节则是考虑使用webgl进行渲染,利用GPU减少CPU的压力,以及提高计算的效率。
转载自:https://juejin.cn/post/7188078996263796796