现代 CSS

Lottie Web动效基本原理

特别声明:如果您喜欢小站的内容,可以点击申请会员进行全站阅读。如果您对付费阅读有任何建议或想法,欢迎发送邮件至: airenliao@gmail.com!或添加QQ:874472854(^_^)

前段时间在《Lottie Web动效在React中的构建》一文中和大家聊了如何通过lottie-web将AE导出来的JSON文件自动生成动效。在该文中,聊的主要是设计软件Figma、Sketch和AE软件之间如何通过相关的插件完成设计资源的互通,并从AE导出动效相关的JSON文件。这些都是最基本的操作链路,但在使用lottie-web还有相应的API可用来控制Lottie动效。今天这篇文章我们主要来聊聊Lottie Web动效的基本原理。

回忆Lottie Web动效的实现

其实在《Lottie Web动效在React中的构建》中详细介绍了如何使用Lottie Web实现AE导出来的动效。这里简单地回顾一下。

import bodymovin from "https://cdn.skypack.dev/lottie-web";

var animation = bodymovin.loadAnimation({
    // 动效加载到的DOM元素
    container: document.getElementById("lottie"), // 必须项
    // 包含动效的JSON文件的相对路径,也可以是绝对路径
    path:
        "https://www.w3cplus.com/sites/default/files/blogs/2021/2102/lottie-web.json",
    // 必须项,描述动效的JSON文件,一般由AE软件中导出的JSON文件
    // 动效渲染出来的格式,可以是svg、canvas和html
    renderer: "svg",
    // 必须项,除了svg选择之外还可以是canvas和html
    // 用于指定动效是否循环播放,true是循环播放,false不是循环播放
    loop: true, // 可选项
    // 指定动效是不是加载后就立即播放,true是立即播放,false不是立即播放
    autoplay: true, // 可选项
    // 指定动效的名称
    name: "mic animation" // 可选项
    // animationData与path互斥,是一个包含导出的动画数据的对象 
    // animationData: { // ... }
});

效果如下:

特别声明,示例中的动效的JSON文件来自于lottiefiles.com网站上 @Arjun Matail 的作品。这里的示例除了提供的动效相关的JSON文件之外还提供了AE的原始文件

Lottie Web相关API

尝试着使用console.log()输出loadAnimation()对应的API。基于上面的示例,像下面来输出:

console.log(animation);

在Chrome浏览器开发者工具中,可以看到输出的相关参数:

其中.loadAnimation()有几个重要的参数:

  • container :用来放置Lottie动效的容器,即渲染容器,一般可以使用一个带有iddiv元素,也要以是其他的有效HTML容器元素
  • renderer :渲染器,即渲染的动画是什么格式,目前主要支持svgcanvashtml三种格式,其中svg的兼容性最佳。上面的示例就是以svg的格式渲染的
  • name :动画名称,主要用于reference,名称建议尽要能的命名成具有语义化的
  • loop :用来定义渲染出来的Lottie Web播放次数,是一个布尔值,如果取值为true,表示无限次的播放,反之则只播放一次
  • autoplay :定义动效是不是自动播放,也是一个布尔值,如果取值为true,则表示加载之后就立即播放,反之则不是立即播放
  • path :用来指定Lottie Web动效的JSON文件路径
  • animationData :可以用来替代path引入的JSON数据,简单地说,可以将path引入的JSON数据放置到animationData中,但不建议这样做,更建议使用path来引入动效对应的JSON数据,因为animaionData会将数据打包进来,使得JS的Bundle过大

另外还提供了一些控制动效的API。比如:

  • stop :用来停止动画
  • play :用来播放动画
  • pause :停止动画播放
  • setSpeed(speed) : 数据类型是Number,设置动效播放速度,1表示1倍速度,0.5表示0.5倍速度
  • setDirection(direction) : 数据类型是Number,设置播放方向,1表示正向播放,-1表示反向播放
  • goToAndStop(value, isFrame) :跳到某一帧或某一秒停止,第二个参数iFrame为是否基于帧模式还是时间,默认为false
  • goToAndPlay(value, isFrame) :跳到某一帧或某一秒开始,第二个参数iFrame为是否基于帧模式还是时间,默认为false
  • playSegments(segments, forceFlag) : 播放片段,参数1为数组,两个元素为开始帧和结束帧;参数2指的是否立即播放片段,还是等之前的动画播放完成
  • destroy : 动效销毁
  • setSubframe(useSubFrames) :如果useSubFrames值为false,它将使用AE原始帧数;如果为true,它将在每个requestAnimationFrame上用中间值更新。默认为true
  • getDuration(inFrames)inFramestrue,则以帧为单位返回持续时间;如果为false,则以秒为单位返回持续时间

另外有几个全局的lottie方法会影响所有的动画:

  • lottie.play() :使用可选的name参数,用于指定一个特定的动画播放
  • lottie.stop() :使用可选的name参数,用于指定一个特定的动画停止
  • lottie.goToAndStop(value, isFrame, name) :将指定名称播放的动画移动到定义的时间。如果name被省略,将移动所有的动画实例
  • lottie.setSpeed() : 第一个参数speed1表示正常事度),另外有一个name为可选参数,用来指定动画名称
  • lottie.setDirection() : 第一个参数direction1表示正常方向,即正向),另外有一个name为可选参数,用来指定动画名称
  • lottie.searchAnimations() : 查找带有lottiebodymovin类的元素
  • lottie.loadAnimation() : 单独返回一个控件的动画实例
  • lottie.destroy(name) : 销毁指定名称(name)的动画。如果省略name,则销毁所有动画实例。DOM元素将被清空
  • lottie.registerAnimation() : 可以直接使用registerAnimation注册一个元素,它必须有data-animation-path属性指向的JSON数据(JSON对应的url
  • lottie.getRegisteredAnimations() : 返回所有动画实例
  • lottie.setQuality() :用来设置动画的质量,默认为high,要以传highmediumlow>1来提高性能
  • lottie.setLocationHref() :设置带有id<svg>元素的相对位置
  • lottie.freeze() : 冻结所有正在播放的动画或将要加载的动画
  • lottie.unfreeze() : 解冻所有动画
  • lottie.inBrowser() : 如果库正在浏览器中运行,则为true
  • lottie.resize() : 整所有动画实例的大小

Lottie Web动画也有一些事件方法,比如onCompleteonLoopCompleteonEnterFrameonSegmentStart,除此之外还可以通过addEventListener来监听以下事件:

  • complete
  • loopComplete
  • enterFrame
  • segmentStart
  • config_ready(初始配置完成时)
  • data_ready(当动画的所有部分都已加载)
  • data_failed(当部分动画无法加载时)
  • loaded_images(当所有的图像加载都成功或错误时)
  • DOMLoaded(元素已添加到DOM)
  • destroy

有关于这部分更详细的介绍,可以参阅Lottie Web的官方文档

在上面的示例基础上,我们添加三个按钮,在click事件中分别给animation动画加上.play()(播放动画)、.pause()(中止动画播放,暂停)和.stop()(停止播放动画):

PlayHander.addEventListener("click", () => {
    animation.play();
});

PauseHander.addEventListener("click", () => {
    animation.pause();
});

StopHander.addEventListener("click", () => {
    animation.stop();
});

Lottie Web的JSON文件

在前面的示例中,.loadAnimation(obj)obj中有一个重要的参数,那就是pathanimationData,用来指定Lottie Web动画所需的数据,即JSON。那么JSON文件如何获得呢?

如果你只是想尝试着使用Lottie Web动效,需要一个测试性的JSON文件,那么很简单,只需要在LottieFiles市场上获取AE动效的JSON文件。如果你是在项目是使用Lottie Web制作动效,则需要AE导出的JSON文件。因为你或你的设计师很有可能是使用AE软件来制作动效。然后使用AE的插件导出JSON文件:

可以点击这里下载AE导出来的JSON文件。打开该JSON文件,可以看到JSON数据:

看到上图这样的JSON数据,一脸懵逼吧!

简单地来说一下JSON数据中的重要参数。在AE软件中,我们可以在创建合层或已有的合层上进行设置,如下图所示:

在弹出的合成配置面板中,可以看到一些配置参数的设置,比如“宽度”、“高度”、“帧率”等:

这些基本配置参数和JSON数据中的一些Key是有对应关系的:

  • nm :合层的名称
  • w :宽度
  • h :高度
  • fr :帧速率
  • ip : 开始帧
  • op : 结束帧
  • ddd: 是事为3d
  • assets : 静态资源信息
  • layers :图层信息
  • v :Bodymovin插件版本号

其中fripop 是Lottie动画中最为重要的参数,只不过Lottie动画中是有帧率来计算动画时间的,比如JSON文件中的fr29.6409606933594op48.9999350215785

另外,Lottie动画还有assetslayers两个关键属性,这两个关键属性对应AE设计软件中的:

其中assets(静态资源)主要包括:

  • image
  • chars
  • precomp

这三种静态资源中,chars是不常用的。对于image静态资源,其对应的类型压缩:

{
    "id": "1",
    "w": 130,
    "h": 487,
    "u": "",
    "p": "",
    "e": 1
}

这里的关键属性是p,指的是图像资源的路径,它可以是相对路径,也可以是绝对路径,还可以是Base64。个人更建议使用Base64,更利于JSON文件的携带,不过会增加JSON文件大小;如果采用图片的路径(相对或绝对),不会额外增加JSON文件大小,但会增加额外的网络请求。

layers在AE设计软件中主要有三个区域:

  • 内容区域:包括形状图层的大小、位置、圆度等信息
  • 变换区域:包含五个变换属性(锚点、位置、缩放、旋转和不透明度)

可以在对应的JSON数据中的layers

Lottie如何将JSON数据动起来

使用Lottie Web制作动效最大的优势是能将AE导出的JSON数据(描述动效的JSON数据)转换成Web动效。那么是怎么将JSON数据动起来呢?我们分成几个阶段来介绍。

初始化渲染器

从前面的示例中我们可以得知Lottie Web通过loadAnimation方法来初始化动画。渲染器初始化流程经过下图这样的过程:

可以查看lottie-webplayer/js/animation中的AnimationManager.js

// 注册动画
function setupAnimation(animItem, element) {
    // 事件监听
    animItem.addEventListener('destroy', removeElement);
    animItem.addEventListener('_active', addPlayingCount);
    animItem.addEventListener('_idle', subtractPlayingCount);

    // 注册动画
    registeredAnimations.push({ elem: element, animation: animItem });
    len += 1;
}

// 加载动画(加载AE导出的JSON数据)
function loadAnimation(params) {
    // 生成当前动画实例
    var animItem = new AnimationItem();

    // 注册动画
    setupAnimation(animItem, null);

    // 初始化动画实例参数
    animItem.setParams(params);
    return animItem;
}

Lottie Web渲染器的代码(查看lottie-webplayer/js/animation中的AnimationItem.js):

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:
            // HTML类型
            this.renderer = new HybridRenderer(this, params.rendererSettings);
            break;
    }

    this.imagePreloader.setCacheType(animType, this.renderer.globalData.defs);
    this.renderer.setProjectInterface(this.projectInterface);
    this.animType = animType;

    if (params.loop === ''
            || params.loop === null
            || params.loop === undefined
            || params.loop === true) {
        this.loop = true;
    } else if (params.loop === false) {
        this.loop = false;
    } else {
        this.loop = parseInt(params.loop, 10);
    }

    this.autoplay = 'autoplay' in params ? params.autoplay : true;
    this.name = params.name ? params.name : '';
    this.autoloadSegments = Object.prototype.hasOwnProperty.call(params, 'autoloadSegments') ? params.autoloadSegments : true;
    this.assetsPath = params.assetsPath;
    this.initialSegment = params.initialSegment;

    if (params.audioFactory) {
        this.audioController.setAudioFactory(params.audioFactory);
    }

    // 渲染器初始化参数
    if (params.animationData) {
        this.configAnimation(params.animationData);
    } else if (params.path) {
        if (params.path.lastIndexOf('\\') !== -1) {
            this.path = params.path.substr(0, params.path.lastIndexOf('\\') + 1);
        } else {
            this.path = params.path.substr(0, params.path.lastIndexOf('/') + 1);
        }

        this.fileName = params.path.substr(params.path.lastIndexOf('/') + 1);
        this.fileName = this.fileName.substr(0, this.fileName.lastIndexOf('.json'));

        assetLoader.load(params.path, this.configAnimation.bind(this), function () {
            this.trigger('data_failed');
        }.bind(this));
    }
};

来看流程图中几个重要的部分:

  • AnimationItem : 它是Lottie Web动画的基类,loadAnimation 方法会生成一个 AnimationItem实例并返回,我们在使用loadAnimation方法配置Lottie Web动画相关的参数都是来于这个类
  • animItem :使用new AnimationItem()生成animItem实例后,调用setupAnimation方法
  • setupAnimation : 该方法首先监听了destroy_active_idle三个事件等待被触发。由于可以多个动画并行,因此定义了全局的变量lenregisteredAnimations等,用于判断和缓存已注册的动画实例
  • setParams : 接下来调用animItem实例的setParams方法来初始化动画参数,其中最重要的是选择动画的渲染器

AnimationItem.prototype.setParams设置了三种渲染模式,因此Lottie Web提供了 SVGCanvasHTML 三种渲染模式,其中默认是HTML模式。不过,一般使用SVG或Canvas渲染模式。

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;
}

Lottie Web中每个渲染器都有各自的实现方式,而且复杂度也各有不同,但是动画越复杂,其对性有的消耗也就越高。我们可以在lottie-webplayer/js/renderers找到对应渲染器的源码:

初始化动画属性,加载静态资源

这里我们只看SVG渲染器。lottie-web调用SVGRenderer渲染器之后,会调用configAnimation方法来初始化SVG渲染器。我们可以在lottie-webplayer/js/animation文件夹的AnimationItem.js文件中找到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.trigger('config_ready');

        // 加载静态资源
        this.preloadImages();
        this.loadSegments();
        this.updaFrameModifier();

        // 等待静态资源加载完毕
        this.waitForFontsLoaded();

        if (this.isPaused) {
            this.audioController.pause();
        }
    } catch (error) {
        this.triggerConfigError(error);
    }
};

// 等待资源加载
AnimationItem.prototype.waitForFontsLoaded = function () {
    if (!this.renderer) {
        return;
    }
    if (this.renderer.globalData.fontManager.isLoaded) {
        // 检查加载完毕
        this.checkLoaded();
    } else {
        setTimeout(this.waitForFontsLoaded.bind(this), 20);
    }
};


AnimationItem.prototype.checkLoaded = function () {
    if (!this.isLoaded
            && this.renderer.globalData.fontManager.isLoaded
            && (this.imagePreloader.loaded() || this.renderer.rendererType !== 'canvas')
    ) {
        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();
        }
    }
};

在这个方法中将会初始化更多动画对象的属性,比如总帧数totalFrames、帧率frameMult等。然后加载一些其他资源,比如图像、字体等。

同时在waitForFontsLoaded方法中等待静态资源加载完毕,加载完毕后便会调用SVG渲染器的initItems方法(初始化所有图层),接着绘制动画。

checkLoaded方法中可以看到,通过initItems初始化所有元素后,便通过gotoFrame渲染第一帧,如果开发者在loadAnimation配置了autoplaytrue,则会直接调用play方法播放动画。

整个流程如下图所示:

绘制动画初始图层

初始化图层方法initItems主要调用buildAllItems创建所有图层。buildItem方法又会调用createItem确定具体图层类型。在lottie-webplayer/js/renderers文件夹的BaseRenderer.js文件,找到buildAllItemscreateItem对应的源码:

BaseRenderer.prototype.initItems = function () {
    if (!this.globalData.progressiveLoad) {

        // 初始化图层
        this.buildAllItems();
    }
};

BaseRenderer.prototype.buildAllItems = function () {
    var i;
    var len = this.layers.length;

    for (i = 0; i < len; i += 1) {
        this.buildItem(i);
    }

    this.checkPendingElements();
};


BaseRenderer.prototype.createItem = function (layer) {
    // 根据图层类型,创建相应的SVG元素类的实例
    switch (layer.ty) {
        case 0:
            // 合层
            return this.createComp(layer);
        case 1:
            // 固态
            return this.createSolid(layer);
        case 2:
            // 图片
            return this.createImage(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);
        default:
            return this.createNull(layer);
    }
};

在制作动画时,设计师操作的图层元素有很多种,比如图像、形状、文字、音频等。所以layers中每个图层会有一个ty字段。比如示例的JSON数据layer中的01ty都是4,对应就是“形状”:

如果用流程图来描述的话,大致如下图所示:

对于每种图层类型的渲染,可以在lottie-webplayer/js/elements中找到:

比如图像类型:

// lottie-web/player/js/elements/ImageElement.js
function IImageElement(data, globalData, comp) {
    this.assetData = globalData.getAssetData(data.refId);
    this.initElement(data, globalData, comp);

    this.sourceRect = {
        top: 0, 
        left: 0, 
        width: this.assetData.w, 
        height: this.assetData.h,
    };
}

extendPrototype([
    BaseElement, 
    TransformElement, 
    SVGBaseElement, 
    HierarchyElement, 
    FrameElement, 
    RenderableDOMElement
], IImageElement);

IImageElement.prototype.createContent = function () {
    var assetPath = this.globalData.getAssetsPath(this.assetData);

    this.innerElem = createNS('image');
    this.innerElem.setAttribute('width', this.assetData.w + 'px');
    this.innerElem.setAttribute('height', this.assetData.h + 'px');
    this.innerElem.setAttribute('preserveAspectRatio', this.assetData.pr || this.globalData.renderConfig.imagePreserveAspectRatio);
    this.innerElem.setAttributeNS('http://www.w3.org/1999/xlink', 'href', assetPath);

    this.layerElement.appendChild(this.innerElem);
};

IImageElement.prototype.sourceRectAtTime = function () {
    return this.sourceRect;
};

Lottie Web动画播放

Lottie Web动画播放主要是使用AnimationItem实例的play方法。如果开发者在loadAnimation配置了autoplaytrue时会在所有初始化工作准备完毕后直接调用play方法。否则由开发者主动调用play方法播放。

// lottie-web/player/js/animation/AnimationItem.js
AnimationItem.prototype.play = function (name) {
    if (name && this.name !== name) {
        return;
    }

    if (this.isPaused === true) {
        this.isPaused = false;
        this.audioController.resume();

        if (this._idle) {
            this._idle = false;
            this.trigger('_active');
        }
    }
};

play方法主要触发了_active事件,这个_active事件在动画初始化时注册的:

// lottie-web/blob/master/player/js/animation/AnimationManager.js):

// 注册动画
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 addPlayingCount() {
    playingAnimationsNum += 1;

    activate();
}

function activate() {
    if (!_isFrozen && playingAnimationsNum) {
        if (_stopped) {
            // 触发第一帧渲染
            window.requestAnimationFrame(first);
            _stopped = false;
        }
    }
}

触发后通过调用requestAnimationFrame方法,不断的调用resume方法来控制动画。

// lottie-web/player/js/animation/AnimationManager.js 
function first(nowTime) {
    initTime = nowTime;
    // requestAnimationFrame 每次都进行计算修改 DOM
    window.requestAnimationFrame(resume);
}

resume方法的主要逻辑如下:

// lottie-web/player/js/animation/AnimationManager.js 
function resume(nowTime) {
    // 两次 requestAnimationFrame 间隔时间
    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/player/js/animation/AnimationItem.js 
AnimationItem.prototype.setCurrentRawFrameValue = function (value) {
    this.currentRawFrame = value;

    this.gotoFrame();
};


AnimationItem.prototype.gotoFrame = function () {
    this.currentFrame = this.isSubframeEnabled ? this.currentRawFrame : ~~this.currentRawFrame; // eslint-disable-line no-bitwise

    if (this.timeCompleted !== this.totalFrames && this.currentFrame > this.timeCompleted) {
        this.currentFrame = this.timeCompleted;
    }

    this.trigger('enterFrame');
    this.renderFrame();
};

AnimationItem.prototype.renderFrame = function () {
    if (this.isLoaded === false) {
        return;
    }
    try {
        this.renderer.renderFrame(this.currentFrame + this.firstFrame);
    } catch (error) {
        this.triggerRenderFrameError(error);
    }
};

注意,在 Lottie Web 中,把 AE 设置的帧数作为一个计算单位,Lottie 并不是根据设计师设置的 30fps(每隔 33.3ms) 进行每一次变化,而是根据 requestAnimationFrame 的间隔(每隔 16.7ms 左右)计算了更细致的变化,保证动画的流畅运行。

小结

文章中简单地介绍了Lottie Web动画实现的基本原理,有关于Lottie Web相关的源代码可以在Github的仓库中查阅。由于自己也是初学Lottie Web相关的,如果文章介绍有误之处,还请路过的大神拍正。如果你在这方面有更多的经验或建议,欢迎在下面的评论中与我们一起共享。

返回顶部