43

Lottie动画原理

 3 years ago
source link: http://mp.weixin.qq.com/s?__biz=MzI1NjEwMTM4OA%3D%3D&%3Bmid=2651233257&%3Bidx=1&%3Bsn=9c025c51904e8a2bdee135beb3ee0856
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

导语:Lottie动画是Airbnb开源的一个支持 Android、iOS 以及 ReactNative。通过AE导出的JSON文件+Lottie库可快速实现动画绘制。本文主要讲述从AE的bodymovin插件导出的JSON文件到OC的数据模型,再将数据模型拆解成独立图层,并为图层添加动画的过程。

Lottie动画原理概述

yY3iaiF.jpg!web

上图是Lottie动画库从AE导出动画到绘制到客户端屏幕的过程,第一阶段是JSON到Model(OC数据模型)的转换过程,主要是将JSON转成OC语言可以识别的数据模型Model, Model实际上是一个Object类型的对象,我们可以通过属性key快速查找数据内容,第二阶段是Model(数据模型)依附到CALayer(图层)上,就像写一个CALayer一样,把Model数据一一赋值给CALayer的属性上,必要时再做特殊处理,最后在图层CALayer上添加Animation(动画)。

Lottie结构图

fU36f2r.jpg!web

上图为Lottie的结构图

  • LOTAnimationView: 承接控制动画的功能,如播放暂停

  • LOTComposition: 主要解析JSON文件内容

  • LOTCompositionContainer: 承载LOTComposition的内容,绘制图层和添加动画

JSON字段解读

一级属性

JSON最外一层的数据,包括一个动画的基础数据:动画帧率、起始/结束关键帧,动画的宽高等,还有子图层的信息和关联的资源信息,如图片,矢量图等。

{
  "v": "5.6.10",   // bodymovin插件版本
  "fr": 25,        // 帧率
  "ip": 0,         // 起始关键帧
  "op": 277,       // 结束关键帧
  "w": 110,        // 视图宽度
  "h": 110,        // 视图高度
  "nm": "cloud",   // 动画名称
  "ddd": 0,        // 是否是3D
  "assets": [...]  // 资源集合
  "layers": [...]  // 图层集合
}

assets 资源集合

assets是一个数组,资源信息包含的是矢量图信息,如形状,大小等等,也包含位图;还可能是预合成层,即对已存在的某些图层进行分组,把它们放置到新的合成中,作为新的一个资源对象,这里layers的对象结构是跟上面一级属性中的layers图层集合是一样的图层结构。

"assets": [
    {
        "id": "image_0",   // 图片唯一识别的id,图层获取图片的标识
        "w": 167,          // 图片的宽度
        "h": 165,          // 图片的高度
        "u": "images/",    // 图片的路径
        "p": "img_0.png",  // 图片的名称
        "layers":  []      // 预合成层
    }
]

layers 图层集合

layers对象也是一个数组,数组中的每个元素对应一个图层,图层信息包括的图层的位置,大小,形状,起始关键帧,结束关键帧等,一个个图层动画叠加起来构成最终的动画效果。

"layers": [
    {
        "ddd": 0,             // 是否是3D图层
        "ind": 1,             // 在AE里的图层标序号
        "ty": 4,              // 图层类型
        "nm": "形状图层 1",    // 在AE下的命名
        "ks": {},             // 动画属性值,下面有进一步拆解
        "shapes": {},         // 矢量图形图层信息,下面有进一步拆解
        "ip": 0,              // 起始关键帧
        "op": 750,            // 结束关键帧
        "refId: 0,            // 引用资源ID
        "parent": 0,          // 父图层的id,默认都添加到根图层上,如果指定了id不为0会寻找父图层并添加到上面
        "masksProperties":[], // 蒙版的数组
        "w": 100,             // 预合成层:宽度
        "h": 100,             // 预合成层:图层高度
        "sw": 0,              // 固态层:宽度
        "sh": 0,              // 固态层:图层高度
        "sc": 0 ,             // 固态层:颜色
    }
]

图层类型ty

图层有6种类型,不同类型的图层获取宽高的方式不同,如图片层需要从关联的refId获取asset,从而获取到图片资源的宽高来作为该图层的宽高等,具体如下:

  • 0 代表 预合成层:从属性值w和h获取

  • 1 代表 固态层:从属性值w和h获取

  • 2 代表 图片层:从图片资源属性获取

  • 3 代表 空层:从根图层获取

  • 4 代表 形状层:从根图层获取

  • 5 代表 位置层:从根图层获取

图层动画ks

  • ks属性:这是一个比较关键的属性,包含图层变换transform的信息,包含透明度、位置、锚点、缩放、旋转等。格式如下

"ks": {
    "o": {          // 透明度
        "k": 100
    },
    "p": {          // 位置
        "k": [
            126.5,
            963,
            0
        ]
    }
}
  • 属性对应的值主要通过K值获取, 如上面的例子中透明度o为 100 , 位置p为 (126.5,963,0)

  • k对应的值有如下几种情况:

    • 数字或3个数字组成的数组:不带动画。表示对应属性的值。比如透明度 100 , 位置 (126.5,963,0) 等。

    • 数组类型并且数字第一个对象的t有值:带帧动画。第一个对象表示动画开始的属性,第二个对象表示动画结束的属性。通过以下参数可以拼装出关键帧的属性值,关键帧时间点,关键帧之间的时间函数,t表示开始/结束帧,s和e表示开始/结束属性值,i和o决定动画的时间函数。

  • 举个例子:

比如下面的动画,是有个矩形从上往下的动画。

An6zYru.gif

从导出的JSON文件截取以下片段:

"ks": {
    ...
    "p": {               // 位置信息
        "a": 1,
        "k": [           // 数组类型并且数字第一个对象的t有值
            {
                "t": 0,  // 起始关键帧
                "s": [
                    300,
                    700,
                    0
                ],
            },
            {
                "t": 49, // 结束关键帧
                "s": [
                    250,
                    1800,
                    0
                ]
            }
        ],
        "ix": 2
    }
},

从以上片段中我们读到位置p的k值是一个数组,并且是带有t的元素, 即为帧动画。从内容我们可以读出关键帧帧为0时,位置信息为(300,700,0) , 变换到关键帧为49时,位置信息变为(250,1800,0)。

图层形状shapes

shape是一个形状图层的数组,对应AE中图层的内容中的形状设置,描述形状的特征,通过描边信息、颜色填充等信息的组合形成一个个矢量图。

"shapes": {
    "ty": "gr",              //  形状的类型
    "it": [
        {
            "ty": "rc",
            "d": 1,
            "s": {           // 形状的大小
                "k": [
                    450.094,
                    140.297
                ]
            },
            "p": {
                "k": [
                    0,
                    0
                ]
            },
            "nm": "矩形路径 1"
        }
    ]
}
  • 形状类型ty

ty为形状的类型,对应的类型值如下:

gr(ShapeGroup): 图形组合
st(ShapeStroke): 图形描边
fl(ShapeFill): 图形填充
tr(ShapeTransform): 图形变换
sh(ShapePath): 图形路径
rc(RectPath): 矩形路径
el(EllipsePath): 椭圆路径
tm(trimPath): 裁剪路径

生成OC数据模型

LOTComposition类

ryaYvqn.jpg!web

LOTComposition类是记录动画信息的类,继承 NSObject, 作为整个json文件内容的映射,用于记录所有动画信息的类。在这个类中我们可以看到动画的基础信息,包含创建AE文件时的设置:合成名称、宽高、帧速率(帧/秒),也是JSON文件中一级属性的映射。以下是一个LOTComposition的实例信息:

quMvAjr.jpg!web

LOTLayerGroup 和 LOTLayer

从上图我们可以看到两个集合类,LOTLayerGroup记录图层信息的数组,对应JSON对象中layers数组,由一个个LOTLayer组成。一个LOTLayer是一个图层,是一个动画被拆解的最小单位个体。

例如以下云朵动画

BR7rimb.gif

可以看出云朵的运动速度是不一样的,因此可以判断他们并不是在一个图层中,而是由多个图层的动画叠加起来的效果,即每个云朵为一个图层, LOTLayer就是记录一个图层单位的信息

BviEVvR.png!web

以下是一个LOTLayer携带的信息

Ub2YR32.jpg!web

LOTAssetsGroup 和 LOTAsset

LOTAssetsGroup是记录资源信息,对应JSON对象中的assets数组,若图层需要依赖资源,可以通过自身信息refId关联到对应的资源ID寻找资源。如以上云朵动画,每个云朵即为一个资源,LOTAsset为记录一个资源的信息。

3EbEBzQ.jpg!web

数据模型转为图层

Lottie底层原理实际是用到了CALayer 和 Core Animation。我们经常可以直观感受到iOS设备中内容的切换很流畅,就如下图,弹框不是一闪而出,而是有很平滑从小到大和透明度从0到1的过渡效果。这是因为在一个图层中,当我们修改一个图层属性时,比如宽度从100px到200px, 它会产生很平滑地从一个值过渡到下一个值这种动画效果,这个图层就是CALayer, 执行动画效果的是Core Animation,我们将这一行为称为隐式动画。而Lottie使用的正是这种机制。

URNvayq.gif

图片引用自 https://juejin.im/post/5de481226fb9a0717b5fce84

图层绘制

lottie绘制图层过程用到了两个主要的类:LOTCompositionContainer 和 LOTLayerContainer。

LOTCompositionContainer

  • 顾名思义,LOTCompositionContainer 是 LOTComposition的container(容器),承载LOTComposition的内容。LOTComposition是JSON映射的OC数据模型

  • LOTCompositionContainer 继承CALayer , 是一个图层,动画的根图层。我们设定的动画内容,都会放置在这个图层中

  • 执行子图层的循环,并且将所有子图层赋在该根图层上

// LOTCompositionContainer.m
// ps:  代码有删减
 NSArray *reversedItems = [[childGroup.layers reverseObjectEnumerator] allObjects]; // 获取到子图层的数据模型

 for (LOTLayer *layer in reversedItems) {
    child = [[LOTLayerContainer alloc] initWithModel:layer inLayerGroup:childGroup]; // 将子图层数据模型处理的一个个图层
   }

[self.wrapperLayer addSublayer:child]; // 将子图层添加到该根图层上

LOTLayerContainer

LOTLayerContainer是一个很重要的类,它相当于我们上文中讲到的LOTLayer,也即是整个动画拆解成的最小单元的一个层级,不需要依赖其他图层就可以完整实现自身动画。这是一个继承CALayer的类。

我们可以在这里回顾下CALayer图层绘制时需要做的事情:

  • 创建一个CALayer实例: CALayer *layer = [CALayer layer];

  • 添加到根图层: [self.view.layer addSublayer:layer];

  • 创建动画

    CABasicAnimation *animation = [CABasicAnimation animationWithKeyPath:@"position"];
    animation.fromValue = [NSValue valueWithCGPoint:CGPointMake(50, 0)];
    animation.toValue = [NSValue valueWithCGPoint:CGPointMake(150, 0)];
  • 给图层添加动画

在Lottie中也一样实现了上面四个步骤:

LOTLayerContainer 类继承CALayer, 在初始化时执行以下步骤:

  • CALayer属性: LOTComposition中有一个属性 CALayer *wrapperLayer 写入当前图层的信息,从类型可以看出是一个CALayer,因此我们可以在CALayer中使用隐式动画,也就是文中开头所讲的内容。

  • 添加宽高信息:在LOTComposition初始化时,会先判断当前的layer是什么类型, 图片/立方体/预补偿层,如果是图片,会将图片的宽高,锚点等信息作为该图层 wrapperLayer 的宽高,锚点等。

// LOTLayerContainer.m
  if (layer.layerType == LOTLayerTypeImage ||
      layer.layerType == LOTLayerTypeSolid ||
      layer.layerType == LOTLayerTypePrecomp) {
    _wrapperLayer.bounds = CGRectMake(0, 0, layer.layerWidth.floatValue, layer.layerHeight.floatValue);
    _wrapperLayer.anchorPoint = CGPointMake(0, 0);
    _wrapperLayer.masksToBounds = YES;
    DEBUG_Center.position = LOT_RectGetCenterPoint(self.bounds);
  }
  • 添加Transform信息:接下来寻找Transform(位置/旋转/锚点/缩放/透明度)信息,添加在该图层 wrapperLayer

  • 填充资源:当图层类型为图片时,需要为 wrapperLayer 添加 content 属性内容,即图片的内容。 _setImageForAsset 方法实现了判断图片类型,并赋值在 content 属性上

// LOTLayerContainer.m
  if (layer.layerType == LOTLayerTypeImage) {
    [self _setImageForAsset:layer.imageAsset];
  }
  • 填充图形:当图层类型为形状shape时,shape是对矢量图的信息携带,这在lottie动画中被大量使用。因为矢量图要比位图加载更快,并且也会大大减少对设备内存的使用。这里的 buildContents 方法实现了对矢量图进行描边、填充颜色等操作。

// LOTLayerContainer.m
if (layer.layerType == LOTLayerTypeShape &&
  layer.shapes.count) {
   [self buildContents:layer.shapes];
}
  • 如何绘制矢量图

    • 初始化LOTRenderGroup,LOTRenderGroup作为一个矢量图形的类,包含了LOTRenderNode 和 LOTAnimatorNode 拥有的属性和方法。

    • 渲染节点:LOTRenderNode 类中有属性 CAShapeLayer * _Nonnull outputLayer ,它负责计算线条颜色,线宽,填充色等

    • 动画节点:LOTAnimatorNode 计算构成形状的线条

  • 遮罩层:判断是否有遮罩层并赋给 wrapperLayer

  • 添加到父图层:在上面过程中已经准备好一个CALayer的绘制属性:宽高、转换信息、资源内容、图形绘制内容、遮罩层等。这儿的self.wrapperLayer并非上述几个过程的wrapperLayer,而是根图层中的属性

// LOTCompositionContainer.m
[self.wrapperLayer addSublayer:child];

动画合成

CALayer添加动画

在上面讲述到绘制图层,但如何将这些图层变成动画呢,在了解之前我们得先知道CALayer方法重绘响应链与runloop机制,如何让图层重新绘制呈现出新的画面,从而形成动画。

  • layer首次加载时会调用 +(BOOL)needsDisplayForKey:(NSString *)key方法来判断当前指定的属性key改变是否需要重新绘制,默认返回NO

  • 当Core Animartion中的key或者keypath等于+(BOOL)needsDisplayForKey:(NSString *)key 方法中指定的key,便会自动调用 setNeedsDisplay 方法

  • 当指定key发生更改时,会触发 actionForKey

  • runloop是一个循环处理事件和消息的方法,CATransaction begin和 CATransaction commit 进行修改和提交新事务。

  • 每个RunLoop周期中会自动开始一次新的事务,即使你不显式的使用[CATranscation begin]开始一次事务,任何在一次RunLoop运行时循环中属性的改变都会被集中起来,执行默认0.25秒的动画,在runloop快结束时,它会调用下一个事务 display

  • CALayer方法重绘响应链

  1. [layer setNeedDisplay] -> [layer displayIfNeed] -> [layer display] -> [layerDelegate displayLayer:]

  2. [layer setNeedDisplay] -> [layer displayIfNeed] -> [layer display] -> [layer drawInContext:] -> [layerDelegate drawLayer: inContext:]

Lottie动画绘制

  • 根图层LOTCompositionContainer继承CALayer ,添加Currentframe 属性,给这个属性添加一个CABaseAnimation 动画

  • 所有的子Layer根据CurrentFrame 属性的变化

  • 子图层layer首次加载时会调用 +(BOOL)needsDisplayForKey:(NSString *)key方法来判断当前指定的属性key改变是否需要重新绘制。在LOTLayerContainer可以看到 needsDisplayForKey 指定了key为 currentFrame 时触发重绘

// LOTLayerContainer.m
+ (BOOL)needsDisplayForKey:(NSString *)key {
  if ([key isEqualToString:@"currentFrame"]) {
    return YES;
  }
  return [super needsDisplayForKey:key];
}
  • actionForKey 是接收指定key被修改时触发的行为操作,在下面代码中看到当key为 currentFrame 时添加一个CABasicAnimation动画

- (id<CAAction>)actionForKey:(NSString *)event {
  if ([event isEqualToString:@"currentFrame"]) {
    CABasicAnimation *theAnimation = [CABasicAnimation
                                      animationWithKeyPath:event];
    theAnimation.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionLinear];
    theAnimation.fromValue = [[self presentationLayer] valueForKey:event];
    return theAnimation;
  }
  return [super actionForKey:event];
}
  • display 方法是在一个runloop即将结束时调用,主要实现重绘的内容。下面是 display 调用的方法,它会根据当前帧是否在该子图层的显示帧范围内,如果不在,则隐藏,否则赋予图层新的动画属性。如下图,当currentFrame在inFrame和outFrame之间时,动画显示,否则隐藏。下图列举了多个Layer的情况,每一个Layer在初始化时已经准备好,时间跟根图层一样从 startFrame 到  endFrame , 在这个时间线中会根据 inFrame 和  outFrame 来判断是否显示

YRFBZv7.jpg!web

- (void)displayWithFrame:(NSNumber *)frame forceUpdate:(BOOL)forceUpdate {
  NSNumber *newFrame = @(frame.floatValue / self.timeStretchFactor.floatValue);
//  if (ENABLE_DEBUG_LOGGING)
      NSLog(@"View %@ Displaying Frame %@, with local time %@", self, frame, newFrame); 
  BOOL hidden = NO;
  if (_inFrame && _outFrame) {
    hidden = (frame.floatValue < _inFrame.floatValue ||
              frame.floatValue > _outFrame.floatValue);
  }
     
  self.hidden = hidden;
  if (hidden) {
    return;
  }
  if (_opacityInterpolator && [_opacityInterpolator hasUpdateForFrame:newFrame]) {
    self.opacity = [_opacityInterpolator floatValueForFrame:newFrame];
  }
  if (_transformInterpolator && [_transformInterpolator hasUpdateForFrame:newFrame]) {
    _wrapperLayer.transform = [_transformInterpolator transformForFrame:newFrame];
  }
  [_contentsGroup updateWithFrame:newFrame withModifierBlock:nil forceLocalUpdate:forceUpdate];
  _maskLayer.currentFrame = newFrame;
}

至此,每个图层的绘制和动画的添加均准备完毕,Lottie提供了 play 播放动画的方式,实际上就是将根节点的动画添加到根图层上,使其可以开始播放动画。

以上讲述的是从AE导出JSON文件到OC读取后转成Model再到绘制图层动画的过程,这有助于我们理解一个动画的内部结构,可方便后续理解整个动画的运作,也对于我们实践开发中遇到的缺陷或者调优有极大的帮助。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK