结合Lottie-web源码的深入分析

2,458 阅读11分钟

为什么选择Lottie

最近在做一个车展的活动,H5中涉及很多炫酷的动画,所以就找看看有没有现成的动画库,经过调研发现 lottie 比较合适,也是目前市面上用的比较多的库。

"Lottie is a library for Android, iOS, Web, and Windows that parses Adobe After Effects animations exported as json with Bodymovin and renders them natively on mobile and on the web!"

上面是官方对 Lottie 的一个定义,大概意思是:Lottie 是解析设计师通过 AE(Adobe After Effects) 导出的 JSON 格式的动画数据,并渲染他们。

目前多帧动画一般的实现方式是:

  • 前端实现svg、canvas动画(实现成本较高、维护成本高)
  • 设计师切gif(文件较大、容易有锯齿)
  • png序列帧(文件较大、容易有锯齿)

目前的动画实现方案有着各自的问题,所以我们需要寻找一种更加简单、高效、性能高的动画方案。airbnb 的 Lottie 是一套良好的动画解决方案。

1、怎样实现一个 Lottie 的动画

  • 设计师使用 AE 制作动画。
  • 通过 Lottie 提供的 AE 插件 Bodymovin 把动画导出 JSON 数据文件。
  • 加载 Lottie 库结合 JSON动画 文件和下面几行代码就可以实现一个 Lottie 动画。
    import lottie from 'lottie-web';
    import animationJson from './lottieJson.json';
    
    const anim = lottie.loadAnimation({
        container: document.querySelector('.lottie-container'), // the dom element that will contain the animation
        renderer: 'svg',
        loop: false,
        autoplay: false,
        animationData: animationJson,
        rendererSettings: {
          progressiveLoad: true,
          preserveAspectRatio: 'xMidYMid slice',
          imagePreserveAspectRatio: 'xMidYMid slice',
        },
      });
     anim.play();

2、分析Lottie动画的JSON数据格式

2.1 总体数据结构

{
    "v": "5.1.13",       // bodymovin 版本
    "fr": 25,            // 帧率
    "ip": 0,             // 起始关键帧
    "op": 46,            // 结束关键帧
    "w": 750,            // 视图宽
    "h": 1625,            // 视图高
    "nm": "818汽车夜-红包",  // 名称
    "ddd": 0,             // 是否为3d
    "assets": [],        // 资源集合 
    "layers": [],        // 图层集合(包含动画中的每一个图层和动作信息)
    "masker": [],        // 蒙层集合
    "comps":  []         // 合成图层

这里 fripoplayersassets是实际应用中可能需要关注的,特别是前 3 个尤为重要。

2.2 图层数据结构

      "ddd": 0,                   // 是否为3d
      "ind": 15,                  // layer的ID,唯一
      "ty": 2,                    // 图层类型 
      "nm": "light /新的.psd",    // 图层名称
      "cl": "psd",
      "parent": 32,
      "refId": "image_0",
      "sr": 1,
      "ks": {                             //变换。对应AE中的变换设置

        "o": { "a": 0, "k": 100, "ix": 11 },     // 透明度
        "r": {...},                              // 旋转
        "p": {... },                             // 位置
        "a": {...},                              // 锚点
        "s": {                                   // scale 缩放
          "a": 1,
          "k": [
            {
              "i": { "x": [0.48, 0.48, 0.48], "y": [1, 1, 1] },
              "o": { "x": [0.26, 0.26, 0.26], "y": [1.01, 1.01, 0] },
              "t": 7,              // 代表关键帧数 (0-7帧 ,前后的变化)
              "s": [0, 0, 100],    // 代表变化前(图层为二维)。
              "e": [99, 99, 100]   // 代表变化后(图层为二维)。
            },
             {
              "i": { "x": [0.833, 0.833, 0.833], "y": [1, 1, 1] },
              "o": { "x": [0.167, 0.167, 0.167], "y": [0, 0, 0] },
              "t": 18,
              "s": [99, 99, 100],
              "e": [99, 99, 100]
            },
           ]
          },                             
      },
      shapes:[...],   //图层的宽高信息
      "ao": 0,
      "ip": 0,
      "op": 150,
      "st": 0,
      "bm": 0

3、结合源码和 JSON 解读

上面我们了解了 JSON 里的数据属性的含义,接下来结合 Lottie源码 和 Demo来分析和梳理下思路,了解Lottie 的执行过程和原理。

3.1 初始化渲染器

接下来我们通过源码,来了解Lottie 通过 loadAnimation 方法来初始化动画。渲染器初始化流程如下:

image.png

   // AnimationManager.js 源码部分内容
   var len =0
   function registerAnimation(element, animationData) {
    if (!element) {
      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);
    return animItem;
  }
  function setupAnimation(animItem, element) {
    // 监听事件
    animItem.addEventListener('destroy', removeElement);
    animItem.addEventListener('_active', addPlayingCount);
    animItem.addEventListener('_idle', subtractPlayingCount);
   // 注册动画
    registeredAnimations.push({ elem: element, animation: animItem });
    len += 1;
  }

  function loadAnimation(params) {
    var animItem = new AnimationItem(); //生成当前动画实例
    setupAnimation(animItem, null); // 注册动画
    animItem.setParams(params); // 初始化动画实例参数
    return animItem;
  }

    // 实际应用
     var anim = lottie.loadAnimation({
        container: lottieRef.current, // the dom element that will contain the animation
        renderer: 'svg',
        loop: false,
        autoplay: false,
        animationData: animJson,
        rendererSettings: {
          progressiveLoad: true,
          preserveAspectRatio: 'xMidYMid slice',
          imagePreserveAspectRatio: 'xMidYMid slice',
        },
      });

  • loadAnimation方法看,它继成了AnimationItem 基类,通过这个基类生成实例并返回,具体可参考 配置参数和方法 来使用。

  • 生成 animItem 实例后,调用 setupAnimation 方法。这个方法首先监听了 destroy_active_idle 三个事件等待被触发。由于可以多个动画并行,因此定义了全局的变量 lenregisteredAnimations 等,用于判断和缓存已注册的动画实例。

  • 接下来调用 animItem 实例的 setParams 方法初始化动画参数,除了初始化 loopautoplay 等参数外,最重要的是选择渲染器。

AnimationItem.prototype.setParams = function (params) {
  if (params.wrapper || params.container) {
    this.wrapper = params.wrapper || params.container;
  }
  var animType = 'svg';
  if (params.animType) {
    animType = params.animType;
  } else if (params.renderer) {
    animType = params.renderer;
  }
  // 渲染器类型
  switch (animType) {
    case 'canvas':
      this.renderer = new CanvasRenderer(this, params.rendererSettings);
      break;
    case 'svg':
      this.renderer = new SVGRenderer(this, params.rendererSettings);
      break;
    default:
      this.renderer = new HybridRenderer(this, params.rendererSettings);
      break;
  }
  // ...
  if (params.animationData) {
    this.configAnimation(params.animationData);// 渲染器初始化参数
  }
  // ...
};

Lottie 提供了 SVG、Canvas 和 HTML 三种渲染模式,一般使用第一种或第二种。

  • SVG 渲染器支持的特性最多,也是使用最多的渲染方式。并且 SVG 是可伸缩的,任何分辨率下不会失真。
  • Canvas 渲染器就是根据动画的数据将每一帧的对象不断重绘出来。
  • HTML 渲染器受限于其功能,支持的特性最少,只能做一些很简单的图形或者文字,也不支持滤镜效果。

以上渲染器都有各自的实现,其复杂的各有不同,动画越复杂,对性能消耗就越大。具体的想了解渲染器的同学,可以自行查看源码下 player/js/renderers目录里,这里我只针对 SVG做具体分析。

3.2 初始化动画和静态资源的加载

setParams方法中调用了configAnimation方法来初始化参数,我们看看它的具体实现:

AnimationItem.prototype.configAnimation = function (animData) {
if (!this.renderer) {
  return;
}
try {
  this.animationData = animData;

  if (this.initialSegment) {
    this.totalFrames = Math.floor(this.initialSegment[1] - this.initialSegment[0]); // 总帧数
    this.firstFrame = Math.round(this.initialSegment[0]);// 第一帧数
  } else {
    this.totalFrames = Math.floor(this.animationData.op - this.animationData.ip);
    this.firstFrame = Math.round(this.animationData.ip);
  }
  this.renderer.configAnimation(animData); // // 渲染器初始化参数
  if (!animData.assets) {
    animData.assets = [];
  }

  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(); // 更新帧
  this.waitForFontsLoaded(); // 等资源加载完成
  if (this.isPaused) {
    this.audioController.pause();
  }
} catch (error) {
  this.triggerConfigError(error);
}
};

上面方法中初始化了较大多的属性,然后加载静态资源,如图片、字体等。

从源码中可以看出,通过传入的配置选择相应的渲染类型,这里我们看下 SVGRenderer渲染流程如下:

image.png 下面是 waitForFontsLoadedcheckLoaded源码:

AnimationItem.prototype.waitForFontsLoaded = function () {
  if (!this.renderer) {
    return;
  }
  if (this.renderer.globalData.fontManager.isLoaded) {
    this.checkLoaded(); // 检查是否 Loaded 完成
  } else {
    setTimeout(this.waitForFontsLoaded.bind(this), 20);
  }
};

AnimationItem.prototype.checkLoaded = function () {
  if (!this.isLoaded
        && this.renderer.globalData.fontManager.isLoaded
        && (this.imagePreloader.loadedImages() || this.renderer.rendererType !== 'canvas')
        && (this.imagePreloader.loadedFootages())
  ) {
    this.isLoaded = true;
    dataManager.completeData(this.animationData, this.renderer.globalData.fontManager);
    if (expressionsPlugin) {
      expressionsPlugin.initExpressions(this);
    }
    // 初始化所有元素
    this.renderer.initItems();
    setTimeout(function () {
      this.trigger('DOMLoaded');
    }.bind(this), 0);
    // 渲染第一帧
    this.gotoFrame();
    // 自动播放
    if (this.autoplay) {
      this.play();
    }
  }
};

根据流程图和源码,可以看出checkLoaded方法中通过initItems初始化了所有图层,然后渲染第一帧,接着通过autoplay属性是否配置为真来调用动画播放操作。那么initItems又是如何实现不同类型的图层的转换的呢?

3.3 如何绘制动画中不同类型初始图层

SVGRenderer.js源码中得知SVGRenderer继承了BaseRenderer基类,initItems也是其基类方法。代码如下:

BaseRenderer.prototype.initItems = function () {
  if (!this.globalData.progressiveLoad) {
    this.buildAllItems();// 将子图层转换为相应的元素
  }
};

该方法中调用了buildAllItems方法,该方法里又条用了SVGRenderer自身的buildItem方法,然后buildItem方法里又执行了基类的createItem方法,代码如下:

BaseRenderer.prototype.buildAllItems = function () {
  var i;
  var len = this.layers.length;
  for (i = 0; i < len; i += 1) {
    this.buildItem(i); // 执行了SVGRenderer自身的buildItem方法
  }
  this.checkPendingElements();
};

SVGRenderer.prototype.buildItem = function (pos) {
  var elements = this.elements;
  if (elements[pos] || this.layers[pos].ty === 99) {
    return;
  }
  elements[pos] = true;
  var element = this.createItem(this.layers[pos]); // 创建元素
  elements[pos] = element; //将元素添加到svg中
  // ...
};


BaseRenderer.prototype.createItem = function (layer) {
// 根据图层类型,创建相应的svg元素类的实例
  switch (layer.ty) {
    case 2:
      return this.createImage(layer);// 图片
    case 0:
      return this.createComp(layer); // 合成图层
    case 1:
      return this.createSolid(layer);
    case 3:
      return this.createNull(layer); // 空元素
    case 4:
      return this.createShape(layer); // 形状图层
    case 5:
      return this.createText(layer); // 文本
    case 6:
      return this.createAudio(layer); // 音频
    case 13:
      return this.createCamera(layer); // 摄像机
    case 15:
      return this.createFootage(layer); // 素材
    default:
      return this.createNull(layer);
  }
};

图层类型源码里就这些类型,我们开发者只需理清思路即可,图层类型的渲染逻辑,如 ImageText等,每一种元素的渲染逻辑都实现在源码 player/js/elements/ 文件夹下,感兴趣的同学自行查看。流程图如下:

image.png

下面以SVGCompElement类为例,展示如何创建相应的实例的。

function SVGCompElement(data,globalData,comp){ // 合成图层
    this.layers = data.layers; // 包含的图层

    this.elements = this.layers ? createSizedArray(this.layers.length) : []; // 后面会根据layers,创建子元素
    this.initElement(data,globalData,comp);
    this.tm = data.tm ? PropertyFactory.getProp(this,data.tm,0,globalData.frameRate,this) : {_placeholder:true};
}

 
ICompElement.prototype.initElement = function(data,globalData,comp) {
    this.initFrame();
    this.initBaseData(data, globalData, comp); // 设置该图层参数
    this.initTransform(data, globalData, comp); // 获取transform相关的数据
    this.initRenderable();
    this.initHierarchy();
    this.initRendererElement();
    this.createContainerElements(); // 创建一个会包含子元素的g元素,并根据前面初始化的参与,设置g元素的属性(transform、filter、蒙层、id等)
    this.addMasks();
    if(this.data.xt || !globalData.progressiveLoad){
        this.buildAllItems(); // 将子图层转换为相应的元素
    }
    this.hide();
};

ks变换

ks对应AE中图层的变换属性,可以通过设置锚点、位置、旋转、缩放、透明度等来控制图层,并设置这些属性的变换曲线,来实现动画。下面是一个ks属性值:

"ks": { // 变换。对应AE中的变换设置
    "o": { // 透明度
        "a": 0,
        "k": 100,
        "ix": 11
    },
    "r": { // 旋转
        "a": 0,
        "k": 0,
        "ix": 10
    },
    "p": { // 位置
        "a": 0,
        "k": [-167, 358.125, 0],
        "ix": 2
    },
    "a": { // 锚点
        "a": 0,
        "k": [667, 375, 0],
        "ix": 1
    },
    "s": { // 缩放
        "a": 0,
        "k": [100, 100, 100],
        "ix": 6
    }
}

lottie-web会把ks处理成transform的属性,用于对元素进行变换操作。transform包含了translate(平移)、scale(缩放)、rotate(旋转)、skew(倾斜)等几种。lottie-web中处理ks(变换)的相关代码为:

  function TransformProperty(elem, data, container) {
    this.elem = elem;
    this.frameId = -1;
    this.propType = 'transform';
    this.data = data;
    this.v = new Matrix();
    // Precalculated matrix with non animated properties
    this.pre = new Matrix();
    this.appliedTransformations = 0;
    this.initDynamicPropertyContainer(container || elem);
    // 获取translate相关的参数
    if (data.p && data.p.s) {
      this.px = PropertyFactory.getProp(elem, data.p.x, 0, 0, this);
      this.py = PropertyFactory.getProp(elem, data.p.y, 0, 0, this);
      if (data.p.z) {
        this.pz = PropertyFactory.getProp(elem, data.p.z, 0, 0, this);
      }
    } else {
      this.p = PropertyFactory.getProp(elem, data.p || { k: [0, 0, 0] }, 1, 0, this);
    }
    // 获取rotate相关的参数
    if (data.rx) {
      this.rx = PropertyFactory.getProp(elem, data.rx, 0, degToRads, this);
      this.ry = PropertyFactory.getProp(elem, data.ry, 0, degToRads, this);
      this.rz = PropertyFactory.getProp(elem, data.rz, 0, degToRads, this);
      if (data.or.k[0].ti) {
        var i;
        var len = data.or.k.length;
        for (i = 0; i < len; i += 1) {
          data.or.k[i].to = null;
          data.or.k[i].ti = null;
        }
      }
      this.or = PropertyFactory.getProp(elem, data.or, 1, degToRads, this);
      // sh Indicates it needs to be capped between -180 and 180
      this.or.sh = true;
    } else {
      this.r = PropertyFactory.getProp(elem, data.r || { k: 0 }, 0, degToRads, this);
    }
    // 获取skew相关的参数
    if (data.sk) {
      this.sk = PropertyFactory.getProp(elem, data.sk, 0, degToRads, this);
      this.sa = PropertyFactory.getProp(elem, data.sa, 0, degToRads, this);
    }
      // 获取translate相关的参数
    this.a = PropertyFactory.getProp(elem, data.a || { k: [0, 0, 0] }, 1, 0, this);
    // 获取scale相关的参数
    this.s = PropertyFactory.getProp(elem, data.s || { k: [100, 100, 100] }, 1, 0.01, this);
    // Opacity is not part of the transform properties, that's why it won't use this.dynamicProperties. That way transforms won't get updated if opacity changes.
    // 透明度
    if (data.o) {
      this.o = PropertyFactory.getProp(elem, data.o, 0, 0.01, elem);
    } else {
      this.o = { _mdf: false, v: 1 };
    }
    this._isDirty = true;
    if (!this.dynamicProperties.length) {
      this.getValue(true);
    }
  }
  function getTransformProperty(elem,data,container){ // data为ks数据
    return new TransformProperty(elem,data,container);
  }
 
  function applyToMatrix(mat) {
    var _mdf = this._mdf;
    this.iterateDynamicProperties();
    this._mdf = this._mdf || _mdf;
    if (this.a) {
      mat.translate(-this.a.v[0], -this.a.v[1], this.a.v[2]);
    }
    if (this.s) {
      mat.scale(this.s.v[0], this.s.v[1], this.s.v[2]);
    }
  
    if (this.sk) {
      mat.skewFromAxis(-this.sk.v, this.sa.v);
    }
    if (this.r) {
      mat.rotate(-this.r.v);
    } else {
      mat.rotateZ(-this.rz.v).rotateY(this.ry.v).rotateX(this.rx.v).rotateZ(-this.or.v[2])
        .rotateY(this.or.v[1])
        .rotateX(this.or.v[0]);
    }
    if (this.data.p.s) {
      if (this.data.p.z) {
        mat.translate(this.px.v, this.py.v, -this.pz.v);
      } else {
        mat.translate(this.px.v, this.py.v, 0);
      }
    } else {
      mat.translate(this.p.v[0], this.p.v[1], -this.p.v[2]);
    }
  }

shape

shape参数的值,对应AE中图层的内容中的形状设置的内容,其主要用于绘制图形。下面一个shape的json为例:

"shapes": [{
  "ty": "gr", // 类型。混合图层
  "it": [{ // 各图层json
      "ind": 0,
      "ty": "sh", // 类型,sh表示图形路径
      "ix": 1,
      "ks": {
          "a": 0,
          "k": {
              "i": [ // 内切线点集合
                  [0, 0],
                  [0, 0]
              ],
              "o": [ // 外切线点集合
                  [0, 0],
                  [0, 0]
              ],
              "v": [ // 顶点坐标集合
                  [182, -321.75],
                  [206.25, -321.75]
              ], 
              "c": false // 贝塞尔路径闭合
          },
          "ix": 2
      },
      "nm": "路径 1",
      "mn": "ADBE Vector Shape - Group",
      "hd": false
  },{
    "ty": "st", // 类型。图形描边
    "c": { // 线的颜色
        "a": 0,
        "k": [0, 0, 0, 1],
        "ix": 3
    },
    "o": { // 线的不透明度
        "a": 0,
        "k": 100,
        "ix": 4
    },
    "w": { // 线的宽度
        "a": 0,
        "k": 3,
        "ix": 5
    },
    "lc": 2, // 线段的头尾样式
    "lj": 1, // 线段的连接样式
    "ml": 4, // 尖角限制
    "nm": "描边 1",
    "mn": "ADBE Vector Graphic - Stroke",
    "hd": false
  }]
}]

从上面shape形状的json示例,可以看出不同的shape类型,参数也不同。shape对应的是AE中的图层的内容的设置。shape中的ty字段表示shape的类型,ty有以下几种:

  • gr: 图形合并
  • st: 图形描边
  • fl: 图形填充
  • tr: 图形变换
  • sh: 图形路径
  • el: 椭圆路径
  • rc: 矩形路径
  • tm: 剪裁路径

4、 总结

虽然我们大致了解了 Lottie 的实现原理,但是在实际应用中也有一些优势和不足,我们要根据项目的实际情况去取舍。

4.1 Lottie 的优势

Lottie 方法方案是由设计师出动画,导出为 json,给前端解析。所以,使用 Lottie 方案的好处在于:

  • 动画由设计使用专业的动画制作工具Adobe After Effects来实现,使动画实现更加方便,动画效果也更好;
  • 前端可以方便的调用动画,并对动画进行控制,减少前端动画工作量;
  • 设计制作动画,前端展现动画,专业人做专业事,分工合理;
  • 还原程度百分之百,SVG 是可伸缩的,任何分辨率下不会失真;
  • 使用 lottie 方案,json 文件大小会比 gif 文件小很多,性能也会更好,json 文件可以多端复用(Web、Android、iOS、React Native)。

4.2 Lottie 的不足

  • lottie-web 文件比较大,lottie.js 大小为 532k,轻量版压缩后也有 150k,经过 gzip 后,大小为 43k。
  • 如果设计师建了很多的图层,可能仍然有 json 文件较大的问题,需要设计师遵循一定的设计规范,比如设计师偷懒使用了插件实现动画,可能造成每帧都是一张图,如下图所示:

image.png 这样会造成JSON 文件非常大,需要提前和设计师进行沟通。

  • 实际应用中也有一些兼容性问题,不同厂商手机和型号的不同对动画的渲染效果有一定差异。

5、 参考资料