likes
comments
collection
share

源码解读 -- SVGAPlayer-Web-Lite

作者站长头像
站长
· 阅读数 16

摘要

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.jsparser文件夹下面打包后产物的代码,所以最终实际运行在浏览器上时,eval执行的其实是dist/parser.js文件

源码解读 -- SVGAPlayer-Web-Lite

  • 进一步搜索inject-parser.mjs,可以发现他被plugins.mjs引用了,而该文件被rollup的打包软件引入,进一步说明了#PARSER_V2_INLINE_WROKER#的替换处理是在打包阶段进行的
  • 那为什么要这么处理,而不直接使用import引入parser文件呢?这是因为代码本意是希望通过worker来执行parserworker提供了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 AnimatorsetConfig
  • 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/60ms,这里我个人觉得源码是写错了,本意应该是想要实现1秒60帧的频率进行刷新,应该是1000/60ms才对,个人见解,有待验证
  • 回到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方法主要是利用离屏canvas ofsCanvas先对当前帧的所有元素进行一遍绘制,然后将离屏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
评论
请登录