掰掰 Lottie

4,378 阅读7分钟

前言

之前做运营活动的时候,写了一个比较有趣的打开盲盒的交互动画,流程如下:一个挣扎的未打开的盲盒,点击出现确认弹窗,确认后集齐的5张卡片会飞入盲盒,盲盒开启弹出礼品。怎么实现的呢?其中挣扎的效果是使用的CSS3做的一个呼吸加抖动的 Animation,飞入的效果是使用JS获取到盲盒的中心点坐标并设置弹窗只展示5张卡片和要改变后样式,盲盒开启的过程则是一个 Lottie 动画。

打开一个盒子

如果想要在页面中实现类似的动效,那大概有以下几个方案可以选用:

  1. CSS3动画:使用CSS3新增的属性和选择器来实现动画,也是最常见的前端动画方案
    • 优点是实现简单,不需要太多的代码也可以实现较为复杂的动画效果
    • 缺点是不可控。比较适用于对于元素做一些形变、位移、透明度变化等,比如:按钮呼吸,文本淡出等
  2. JavaScript动画:通过JavaScript脚本来实现动画效果
    • 优点是非常灵活,可以交互,渲染动态数据等,可以配合CSS3做一些动画流程,比如开红包等
    • 缺点是实现较为复杂,需要编写大量的代码,动画质量受限于开发水平
  3. GIF/APNG图片:通过引入动图来实现动画效果,APNG 相较于 GIF 支持更多的色彩,性能稍好
    • 优点是没什么开发成本
    • 缺点是循环播放动画不可控,存在较大的性能问题。适合做小图标,比如:loading 加载
  4. Lottie:本文的主角
    • 优点是开发成本较低,可以实现复杂的动画效果,动画可控,性能较好
    • 缺点在需要一个很会的UI设计,动画中动态渲染数据较为麻烦

前端做动画还存在许多方案例如:Canvas、 SVG、 SVGA 等,这里不做过多介绍大家可以自行了解

什么是Lottie

Lottie 是 Airbnb 开发的一款开源的动画库,它可以把 Adobe After Effects 制作的动画导出为交互式的矢量动画,可以在 iOS、Android 和 React Native 等移动平台上使用。它通过解析用Bodymovin导出为json的AE动画,在移动端和 Web 上进行了原生渲染。能够帮助开发者快速制作出精美的动画效果,而且还可以节省大量的开发时间。

Lottie如何使用

第一步

由动画设计同学使用AE实现动画效果后通过 Bodymovin 插件导出 json 文件 或者在Lottie动画资源网站如:Lottiefiles 找到现成的动画进行编辑后导出。

对于Bodymovin插件的安装与使用可以参考该文章AE 插件 Bodymovin介绍

在实现动画时要考虑各端对于AE效果的支持,具体可参考官方文档

第二步

开发同学安装 Lottie ,根据设计同学提供的 json 文件在项目中使用

安装 lottie-web 依赖,在项目中进行引用

npm install lottie-web
# 或使用pnpm
pnpm add lottie-web
import bodymovin from 'lottie-web'

通过以下几种方式初始化Lottie并挂载到页面中

使用LottieloadAnimation 方法进行配置,其中挂载节点 container 和 json 文件路径 path 这两个是必须配置的

<template>
  <div style="width:700px;height:390px;" id="bm01" />
</template>
<script>
  const bm01 = document.getElementById('bm01')
  bodymovin.loadAnimation({
      container: bm01, // 挂载DOM
      animType: 'svg', // 渲染类型
      loop: true, // 是否循环播放
      autoplay: true, // 自动播放
      path: 'data1.json' // JSON文件路径
  });
</script>

使用LottieregisterAnimation 方法进行注册,挂载节点以入参形式传入,json 文件路径需在节点中配置data-animation-path属性

<template>
  <div style="width:100%;height:100%;" id="bm02" data-animation-path="data2.json" />
</template>
<script>
  var bm02 = document.getElementById('bm02')
  bodymovin.registerAnimation(bm02)
</script>

使用 searchAnimations 方法进行注册,Lottie自行通过 document.querySelector 选择类名为 “lottie” 的节点进行挂载,json 文件路径需在节点中添加 data-animation-path 属性

<template>
  <div style="width:100%;height:100%;" class="lottie" data-animation-path="data3.json" />
</template>
<script>
  bodymovin.searchAnimations()
</script>

另外Lottie实例也提供了许多的 MethodEvent,可以控制动画播放、暂停、卸载等。更多API请移步官方文档

看懂JSON文件

其实 json 文件就是对于导出动画的具象描述

基本信息

{
  "v": "5.7.1", // Bodymovin 插件版本号
  "fr": 25, // 帧率
  "ip": 0, // 开始帧
  "op": 20, // 结束帧
  "w": 700, // 宽
  "h": 600, // 高
  "nm": "测试文件", // 名称
  "ddd": 0, // 是否为3D
  "assets": [], // 静态资源
  "layers": [], // 图层信息
  "markers": [] // 标记
}

其中包括了一些基本信息:插件版本、画布的宽高、帧率、起始关键帧,结束帧等。从中我们不难看出这个测试动画 1000 / 25 * 20 = 800 播放一次时长为 800 毫秒,宽高比为 7 :6

assets静态资源

{
  "assets": [
    {
      "id": "image_0", // 唯一标识
      "w": 169, // 宽
      "h": 172, // 高
      "u": "images/", // 静态资源导出文件夹
      "p": "img_0.png", // 文件路径
      "e": 0 // 是否直接使用p作为路径
    }
  ]
}

这里是制作动画时引用到的静态资源,使用时通过唯一标识“id”进行指定

关于获取静态资源的路径,遵从以下逻辑:

  • e 不为 0 时直接以 p 作为路径
  • e0 时,如果初始化时配置了 assetsPath 则使用其与 p 进行拼接,若没配置则使用 u 进行拼接作为路径
function getAssetsPath(assetData, assetsPath, originalPath) {
  var path = '';
  if (assetData.e) {
    path = assetData.p;
  } else if (assetsPath) {
    path = assetsPath +  assetData.p;
  } else {
    path = originalPath;
    path += assetData.u ? assetData.u : '';
    path += assetData.p;
  }
  return path;
}

layers图层

{
  "layers": [
    {
      "ddd": 0, // 是否使用了3d
      "ind": 1, // 索引
      "ty": 2, // 图片图层
      "nm": "图层2.png", // 名称
      "cl": "png", // 图片后缀
      "refId": "image_0", // 在assets中的id
      "sr": 1,
      "ks": { // 需要做的变化
        "o": {}, // 不透明度
        "r": {}, // 旋转
        "p": {}, // 位置
        "a": {}, // 锚点
        "s": {} // 缩放
      },
      "ao": 0, // 自动
      "ip": 4, // 开始帧
      "op": 18, // 持续帧
      "st": 0, // 开始时间
      "bm": 0 // 混合模式
    }
  ]
}

各个图层相关的信息,其中包括了对应元素的ORPAS变换,即为透明,旋转,位置,锚点、缩放 每一个变化的对象内部又包括了:起止时间,贝塞尔曲线的入参等描述

源码解析

registerAnimation 方法为例,看看Lottie的工作流程吧

function registerAnimation(element, animationData) {
  if (!element) { // 没传入挂载DOM直接结束
    return null;
  }
  var i = 0;
  // 缓存注册的动画
  while (i < len) {
    if (registeredAnimations[i].elem === element && registeredAnimations[i].elem !== null) {
      return registeredAnimations[i].animation;
    }
    i += 1;
  }
  var animItem = new AnimationItem();
  setupAnimation(animItem, element); // 绑定自定义事件
  animItem.setData(element, animationData); // 与 loadAnimation 的区别
  return animItem;
}
  1. 首先对注册的动画进行缓存,由于一个页面可能存在多个Lottie动画,因此定义了全局的变量 len 用于记录。然后实例化 AnimationItem
  2. setupAnimation 方法绑定 destroy、 _active、 _idle 事件,当你调用Lottie实例上的 play、 pause、 destroy 等方法时会触发
  3. 调用 setData 方法从 DOM 的属性中获取配置参数再调用 setParams,如果使用 loadAnimation 注册的话则直接调用 setParams 方法
  4. setParams 方法中会确定渲染方式并创建对应的渲染器,将配置参数挂载到实例上。支持的渲染器有 CanvasRenderer、 HybridRenderer、 SVGRenderer,官方推荐选用 svg
  AnimationItem.prototype.setParams = function (params) {
    // 确定渲染方式并创建对应的渲染器
    var animType = params.animType || 'svg';
    var RendererClass = getRenderer(animType); // SVGRenderer
    this.renderer = new RendererClass(this, params.rendererSettings);
    this.imagePreloader.setCacheType(animType, this.renderer.globalData.defs);
    this.renderer.setProjectInterface(this.projectInterface);
    // 配置信息挂载到实例上
    this.animType = animType;
    this.autoplay = 'autoplay' in params ? params.autoplay : true;
    this.name = params.name ? params.name : '';
    // ...
    dataManager.loadAnimation(params.path, this.configAnimation, this.onSetupError);
  };
  1. 通过 createWorker 方法生成一个worker。后续执行 assetLoader 方法通过Ajax获取 json 文件,请求完成后对 json 文件中进行检查和处理,由于 json 文件往往比较大这个过程放在worker中进行。完成后开始执行 configAnimation 方法
  function loadAnimation(path, onComplete, onError) {
    setupWorker(); // 生成一个worker
    var processId = createProcess(onComplete, onError);
    workerInstance.postMessage({
      type: 'loadAnimation',
      path: path,
      fullPath: window.location.origin + window.location.pathname,
      id: processId
    });
  }
  // 如果当前浏览器环境支持Worker,则创建Worker
  function createWorker(fn) {
  if (window.Worker && window.Blob && getWebWorker()) {
    var blob = new Blob(['var _workerSelf = self; self.onmessage = ', fn.toString()], {
      type: 'text/javascript'
    }); 
    var url = URL.createObjectURL(blob);
    return new Worker(url);
  }
}
  1. configAnimation 方法中主要是初始化渲染器参数并等待所需资源加载完毕后,调用renderer上的 initItems 方法初始化所有元素再调用 renderFrame 方法进行绘制,若配置了 autoplay 则会自动调用 play 方法播放动画
  AnimationItem.prototype.configAnimation = function (animData) {
    this.totalFrames = Math.floor(this.animationData.op - this.animationData.ip);
    this.firstFrame = Math.round(this.animationData.ip);
    // 初始化渲染器参数
    this.renderer.configAnimation(animData);
    this.assets = this.animationData.assets;
    this.frameRate = this.animationData.fr;
    this.frameMult = this.animationData.fr / 1000;
    this.renderer.searchExtraCompositions(animData.assets);
    this.markers = markerParser(animData.markers || []);
    this.trigger('config_ready');
    // 加载静态资源
    this.preloadImages();
    this.loadSegments();
    this.updaFrameModifier();
    // 每20ms检查一次等待所有资源加载完毕
    this.waitForFontsLoaded();
  };

其他

Matrix矩阵

<g clip-path="url(#__lottie_element_737)" style="display: none;" transform="matrix(0.44115760922431946,-0.679323136806488,0.679323136806488,0.44115760922431946,52.165771484375,997.0018920898438)" opacity="1"><rect width="1920" height="1080" fill="#ffffff"></rect></g>

当使用 SVGRender 进行渲染时,观察 DOM 结构发现对于元素的变化都是使用CSS函数matrix进行描述的

总的来说使用matrix函数可以代替 transform 的以下属性:斜拉(skew)、缩放(scale)、旋转(rotate)、位移(translate),这些方法都是基于matrix函数的再封装,为了易于理解

更为详细的了解可以参考这篇博客

requestAnimationFrame

function resume(nowTime) {
  var elapsedTime = nowTime - initTime; // 时间戳差值
  var i;

  for (i = 0; i < len; i += 1) {
    registeredAnimations[i].animation.advanceTime(elapsedTime);
  }

  initTime = nowTime;

  if (playingAnimationsNum && !_isFrozen) {
    window.requestAnimationFrame(resume);
  } else {
    _stopped = true;
  }
}

lottie-web 中动画的播放是通过不断的调用requestAnimationFrame进行每一次的绘制的,我们知道requestAnimationFrame的回调函数执行次数通常是每秒60次,可以理解为最终呈现的动画是60帧的。

那么如果设计师导出的动画不是60帧的怎么办,Lottie要怎么渲染呢?

通过上边源码我们可以发现,每次渲染都会去执行 advanceTime 方法,该方法会根据每次更新的时间差值进行计算,最终得到要渲染哪一帧;

举个例子:假如设计师导出的动画为 30 帧,一秒内让一个元素位移 1000px。那么当第一次调用requestAnimationFrame后当前动画 elapsedTime 值为16.67ms,就应该渲染动画对应 16.67 / ( 1000 / 30 ) = 0.5 帧时的状态,也就是位移 1000 / 30 * 0.5 = 16.67px,以此类推。

总结

Lottie虽好,但是想要在项目落地使用并达到预期效果,还是要和设计同学有比较充分的沟通。尽量避免三种情况:动画并不复杂(使用CSS3即可达到效果)、帧动画(考虑一下SVGA)、assets中图片素材太多(json文件太大),大家有什么问题也可以多多交流,比如:json文件的压缩处理、在 Lottie 动画中插入动态数据等

参考资料