iOS Lottie动画接入过程详解

9,145 阅读11分钟

lottie.gif

Lottie 动画简介

  • Lottie 动画是 airbnb 最新开源的动画解决方案,支持多种平台,可以使我们不费吹灰之力就可以将动画接入 app 中,只需要一个 json 文件即可。再也不需要进行复杂的动画绘制工作了,节约了很多时间。下面我来详细介绍一下 Lottie 在 iOS 端的接入过程以及部分源码解读。
  • 部分效果如下:
    luanchpage.gif

接入过程

首先由 CocoaPods 接入该库

  • $ pod search Lottie
    $ pod 'lottie-ios', '~> 2.5.0'
    然后创建 pod 文件 $ pod init,然后将 lottie-ios 添加到 Podfile 中
    最后执行 $ pod install 就完成了 Lottie 库的接入
    

导出 json 文件

  • 我们可以在 ae 中下载插件,将 aep 文件导出为 json 形式,具体如何操作请看 Lottie开源动画库介绍

  • 导出的 json 文件格式化后类型如下:

    json文件格式.png

    • 其中的部分参数定义为:
      • v :版本号
      • ip:原大小
      • op:目标大小
      • w:宽度
      • h:高度
      • nm:文件名称
      • assets:图片文件
      • fonts:字体
      • layers:动画效果
      • markers:
      • chars:文字效果

将 json 和它所依赖的图片导入自定义 bundle 中

  • 这里有一个问题:当我们在做启动导航图时,有时可能会需要使用到多个 page,需要多个 json 文件,比如像开头的动画那样,需要三个 json 文件。而 UI 导出的图片基本上名字都差不多,这时候如果我们不加分辨,直接导入三个 json 文件,会使得图片出现错乱。因为三个 json 文件中的图片名基本完全相同,这时候我们就需要
    • 打开 json 文件中的 assets 属性,修改其中的图片名。
    • 或者修改图片的上层目录,使得三个 json 文件所指向的文件为三个不同的目录。

使用 LOTAnimationView 加载 json 文件

  • 这里使用了懒加载的方式来加载这个 view, 具体过程如下,首先需要找到自定义 bundle 的地址,然后再在 bundle 中找到 json 文件并且加载。

animationView.png

播放动画

  • 在 Lottie 的 API 中我们可以看到,只需要简单的 [firstAnimationView play] 即可播放动画。到这里简单的需求基本完成,有时我们还需要在动画上添加文字和按钮来让这个 page 能够响应一些事件,这时候我们就需要先将这个 LOTAniamtionView 放到视图层级的最底层,使用 sendSubviewToBack: 这个方法即可简单做到。然后再添加一个 backgroundView 作为容器来盛放 Label,Button 等,当要求文字与按钮也和动画一样有渐入效果时,我们就可以在滑动到这个 page 时做一个简单的动画,使用下面这个方法即可做到

    -(void)animatedForPageOne {
        [self.firstAnimationView playToProgress:1 withCompletion:nil];
        [UIView animateWithDuration:1.5 animations:^{
            self.p1bottomView.alpha = 1;
        }];
    }
    
  • 当用户如果滑动到第三个 page 后并没有进入应用,而是滑动返回到第二个 page 时,例如文章开始时给出的动图,这时我们就需要当动画播放完后,再返回动画的第一帧,否则就会有一个跳帧的效果,非常不好看,具体的方法也很简单,只需要在监听到 ScrollView 滚动翻页时设置 self.firstAnimationView.animationProgress = 0; 即可将上一个 page 的动画调到第一帧,当执行返回时即可从第一帧开始重新播放。

其他效果

  • 当我们在 LOTAniamtionView 上添加其他控件时,会发现有时候会出现与动画不兼容的现象,比如遮挡等,这时候我们可以选择修改控件的位置或者直接将动画的某一部分删除,这个过程也非常简单,我们只需要找到 json 文件,找到相应的控件,把它删除即可。

部分源码分析

Lottie是如何解析json文件的?

    1. 从 LOTAniamtionView 的源文件中找到了答案,首先我们可以看到在它的头文件中找到很多初始化方法
    @interface LOTAnimationView : LOTView
    
    // 默认从 main bundle 中加载 json 文件和图片
    + (nonnull instancetype)animationNamed:(nonnull NSString *)animationName NS_SWIFT_NAME(init(name:));
    
    // 从给定的 bundle 中加载 json 文件和图片,这个 animationName 参数实际上就是 json 文件的文件名
    + (nonnull instancetype)animationNamed:(nonnull NSString *)animationName inBundle:(nonnull NSBundle *)bundle NS_SWIFT_NAME(init(name:bundle:));
    
    // 直接从给定的 json 文件中加载动画,默认从 mainBundle 中加载
    + (nonnull instancetype)animationFromJSON:(nonnull NSDictionary *)animationJSON NS_SWIFT_NAME(init(json:));
    
    // 从一个文件 url 中加载动画,但是不能够使用 web url 来作为参数
    + (nonnull instancetype)animationWithFilePath:(nonnull NSString *)filePath NS_SWIFT_NAME(init(filePath:));
    
    // 给定一个反序列化的 json 文件和一个特定的 bundle 名来初始化动画
    + (nonnull instancetype)animationFromJSON:(nullable NSDictionary *)animationJSON inBundle:(nullable NSBundle *)bundle NS_SWIFT_NAME(init(json:bundle:));
    
    /// 直接使用 LOTCompostion 来创建动画效果
    - (nonnull instancetype)initWithModel:(nullable LOTComposition *)model inBundle:(nullable NSBundle *)bundle;
    
    /// 异步的从指定的 url 中加载动画,这个 url 为 webUrl
    - (nonnull instancetype)initWithContentsOfURL:(nonnull NSURL *)url;
    

    至于最后是如何初始化的,我们可以根据初始化方法在 LOTComposition 类中找到👇这个方法,这个方法用来解析 json 文件

    - (void)_mapFromJSON:(NSDictionary *)jsonDictionary
         withAssetBundle:(NSBundle *)bundle {
      NSNumber *width = jsonDictionary[@"w"]; // 宽度
      NSNumber *height = jsonDictionary[@"h"]; // 高度
      if (width && height) {
        CGRect bounds = CGRectMake(0, 0, width.floatValue, height.floatValue);
        _compBounds = bounds;
      }
      
      _startFrame = [jsonDictionary[@"ip"] copy]; // 初始 frame
      _endFrame = [jsonDictionary[@"op"] copy]; // 动画结束 frame
      _framerate = [jsonDictionary[@"fr"] copy];  // 动画变化率
      
       // 根据动画的这三个变量来判断动画需要执行的时间
      if (_startFrame && _endFrame && _framerate) {
        NSInteger frameDuration = (_endFrame.integerValue - _startFrame.integerValue) - 1;
        NSTimeInterval timeDuration = frameDuration / _framerate.floatValue;
        _timeDuration = timeDuration;  
      }
      
         
       // 图片文件数组
      NSArray *assetArray = jsonDictionary[@"assets"];
      if (assetArray.count) {
        _assetGroup = [[LOTAssetGroup alloc] initWithJSON:assetArray withAssetBundle:bundle withFramerate:_framerate];
      }
      
       
      // 动画layer层数组
      NSArray *layersJSON = jsonDictionary[@"layers"];
      if (layersJSON) {
        _layerGroup = [[LOTLayerGroup alloc] initWithLayerJSON:layersJSON
                                                withAssetGroup:_assetGroup
                                                 withFramerate:_framerate];
      }
      
      [_assetGroup finalizeInitializationWithFramerate:_framerate];
    }
    

    在这个方法中,我们注意到了它调用了图片数组的初始化方法和 layer 层数组的初始化方法,具体来看一下:

    // LOTAssetGroup 初始化方法到最后我们在 LOTAsset 中找到了这个解析 json 数据的方法
    - (void)_mapFromJSON:(NSDictionary *)jsonDictionary
          withAssetGroup:(LOTAssetGroup * _Nullable)assetGroup
           withFramerate:(NSNumber *)framerate {
      _referenceID = [jsonDictionary[@"id"] copy];  // 指定图片的 referenceID,这个 id 是 json 文件自动为每张图片编号的,表示他的唯一标识符
     
      if (jsonDictionary[@"w"]) {  // 图片宽度
        _assetWidth = [jsonDictionary[@"w"] copy];
      }
      
      if (jsonDictionary[@"h"]) { // 图片高度
        _assetHeight = [jsonDictionary[@"h"] copy];
      }
      
      if (jsonDictionary[@"u"]) {   // 图片的路径,这个路径表示储存图片的文件夹
        _imageDirectory = [jsonDictionary[@"u"] copy];
      }
      
      if (jsonDictionary[@"p"]) {   // p 表示图片的真实id,而不是 referenceID
        _imageName = [jsonDictionary[@"p"] copy];
      }
    
      NSArray *layersJSON = jsonDictionary[@"layers"]; // 对图片 layer 的配置,具体来看一下
      if (layersJSON) {
        _layerGroup = [[LOTLayerGroup alloc] initWithLayerJSON:layersJSON
                                                withAssetGroup:assetGroup
                                                 withFramerate:framerate];
      }
               
    

    LOTALayerGroup 文件中对 json 文件的解析

    - (void)_mapFromJSON:(NSArray *)layersJSON
          withAssetGroup:(LOTAssetGroup * _Nullable)assetGroup
           withFramerate:(NSNumber *)framerate {
      
      NSMutableArray *layers = [NSMutableArray array];
      NSMutableDictionary *modelMap = [NSMutableDictionary dictionary];
      NSMutableDictionary *referenceMap = [NSMutableDictionary dictionary];
      
      for (NSDictionary *layerJSON in layersJSON) {
        LOTLayer *layer = [[LOTLayer alloc] initWithJSON:layerJSON
                                          withAssetGroup:assetGroup
                                           withFramerate:framerate];
        [layers addObject:layer];
        modelMap[layer.layerID] = layer;
        if (layer.referenceID) {
          referenceMap[layer.referenceID] = layer;
        }
      }
      
      _referenceIDMap = referenceMap;
      _modelMap = modelMap;
      _layers = layers;
    }
    
    // 在这个方法内部更深层的调用了 LOTALayer 的初始化方法
    - (void)_mapFromJSON:(NSDictionary *)jsonDictionary
          withAssetGroup:(LOTAssetGroup *)assetGroup
           withFramerate:(NSNumber *)framerate {
    
      _layerName = [jsonDictionary[@"nm"] copy];  // layer 的名字
      _layerID = [jsonDictionary[@"ind"] copy];  // layer 的 id,表示是第几个 layer
      
      NSNumber *layerType = jsonDictionary[@"ty"];  // 表示 layer 的类型,这个变量是一个枚举类型
            //   typedef enum : NSInteger {
      		//		LOTLayerTypePrecomp, 
     		//		LOTLayerTypeSolid,
      		//		LOTLayerTypeImage,
      		//		LOTLayerTypeNull,
      		//		LOTLayerTypeShape,
      		//		LOTLayerTypeUnknown
    		//	} LOTLayerType;
               
      _layerType = layerType.integerValue;
      
      if (jsonDictionary[@"refId"]) {  // 这里的 refId 和图片文件的 referenceID 指向的是同一个标识符,表示这个 layer 动画会作用在 referenceID 指向的图片上
        _referenceID = [jsonDictionary[@"refId"] copy];
      }
      
      _parentID = [jsonDictionary[@"parent"] copy]; // 父layer
      
      if (jsonDictionary[@"st"]) { 
        _startFrame = [jsonDictionary[@"st"] copy];  // 开始的 frame
      }
      _inFrame = [jsonDictionary[@"ip"] copy];  // 开始的 frame,通常和 startFrame 值相同
      _outFrame = [jsonDictionary[@"op"] copy];  // 最后一帧的 frame
    
      if (jsonDictionary[@"sr"]) { // 缩放
        _timeStretch = [jsonDictionary[@"sr"] copy];
      } else {
        _timeStretch = @1;
      }
    
               // 判断 layer 是哪种类型,并且做相应的处理
      if (_layerType == LOTLayerTypePrecomp) {
        _layerHeight = [jsonDictionary[@"h"] copy];  // 高度
        _layerWidth = [jsonDictionary[@"w"] copy];  // 宽度
        [assetGroup buildAssetNamed:_referenceID withFramerate:framerate];
      } else if (_layerType == LOTLayerTypeImage) {
        [assetGroup buildAssetNamed:_referenceID withFramerate:framerate];
        _imageAsset = [assetGroup assetModelForID:_referenceID];
        _layerWidth = [_imageAsset.assetWidth copy];
        _layerHeight = [_imageAsset.assetHeight copy];
      } else if (_layerType == LOTLayerTypeSolid) {
        _layerWidth = jsonDictionary[@"sw"];
        _layerHeight = jsonDictionary[@"sh"];
        NSString *solidColor = jsonDictionary[@"sc"];
        _solidColor = [UIColor LOT_colorWithHexString:solidColor];
      }
      
      _layerBounds = CGRectMake(0, 0, _layerWidth.floatValue, _layerHeight.floatValue);
      
      NSDictionary *ks = jsonDictionary[@"ks"];
      
      NSDictionary *opacity = ks[@"o"]; // 不透明度
      if (opacity) {
        _opacity = [[LOTKeyframeGroup alloc] initWithData:opacity];
        [_opacity remapKeyframesWithBlock:^CGFloat(CGFloat inValue) {
          return LOT_RemapValue(inValue, 0, 100, 0, 1);
        }];
      }
    
      NSDictionary *timeRemap = jsonDictionary[@"tm"];
      if (timeRemap) {
        _timeRemapping = [[LOTKeyframeGroup alloc] initWithData:timeRemap];
        [_timeRemapping remapKeyframesWithBlock:^CGFloat(CGFloat inValue) {
          return inValue * framerate.doubleValue;
        }];
      }
      
      NSDictionary *rotation = ks[@"r"]; // 旋转
      if (rotation == nil) {
        rotation = ks[@"rz"];
      }
      if (rotation) {
        _rotation = [[LOTKeyframeGroup alloc] initWithData:rotation];
        [_rotation remapKeyframesWithBlock:^CGFloat(CGFloat inValue) {
          return LOT_DegreesToRadians(inValue);
        }];
      }
      
      NSDictionary *position = ks[@"p"]; // 位置
      if ([position[@"s"] boolValue]) {
        // Separate dimensions
        _positionX = [[LOTKeyframeGroup alloc] initWithData:position[@"x"]];
        _positionY = [[LOTKeyframeGroup alloc] initWithData:position[@"y"]];
      } else {
        _position = [[LOTKeyframeGroup alloc] initWithData:position ];
      }
      
      NSDictionary *anchor = ks[@"a"]; //锚点
      if (anchor) {
        _anchor = [[LOTKeyframeGroup alloc] initWithData:anchor];
      }
      
      NSDictionary *scale = ks[@"s"];  // 缩放比例
      if (scale) {
        _scale = [[LOTKeyframeGroup alloc] initWithData:scale];
        [_scale remapKeyframesWithBlock:^CGFloat(CGFloat inValue) {
          return LOT_RemapValue(inValue, -100, 100, -1, 1);
        }];
      }
      
      _matteType = [jsonDictionary[@"tt"] integerValue];
      
      
      NSMutableArray *masks = [NSMutableArray array];
      for (NSDictionary *maskJSON in jsonDictionary[@"masksProperties"]) {
        LOTMask *mask = [[LOTMask alloc] initWithJSON:maskJSON];
        [masks addObject:mask];
      }
      _masks = masks.count ? masks : nil;
      
      NSMutableArray *shapes = [NSMutableArray array];
      for (NSDictionary *shapeJSON in jsonDictionary[@"shapes"]) { // 尺寸
        id shapeItem = [LOTShapeGroup shapeItemWithJSON:shapeJSON];
        if (shapeItem) {
          [shapes addObject:shapeItem];
        }
      }
      _shapes = shapes;
        
               // 额外效果
      NSArray *effects = jsonDictionary[@"ef"];
      if (effects.count > 0) {
        
        NSDictionary *effectNames = @{ @0: @"slider",
                                       @1: @"angle",
                                       @2: @"color",
                                       @3: @"point",
                                       @4: @"checkbox",
                                       @5: @"group",
                                       @6: @"noValue",
                                       @7: @"dropDown",
                                       @9: @"customValue",
                                       @10: @"layerIndex",
                                       @20: @"tint",
                                       @21: @"fill" };
                                 
        for (NSDictionary *effect in effects) {
          NSNumber *typeNumber = effect[@"ty"];
          NSString *name = effect[@"nm"];
          NSString *internalName = effect[@"mn"];
          NSString *typeString = effectNames[typeNumber];
          if (typeString) {
            NSLog(@"%s: Warning: %@ effect not supported: %@ / %@", __PRETTY_FUNCTION__, typeString, internalName, name);
          }
        }
      }
    }
    

    由上可以看到,在 LOTComposition , LOTLayer, LOTAssets 这几个类中完成了 json 数据的解析。

LOTAniamtionView 的使用方法

  • 首先我们可以通过上述方法初始化一个 animationView, 然后继续观察 API 中的属性和方法:

    /// 判断是否正在播放动画
    @property (nonatomic, readonly) BOOL isAnimationPlaying;
    
    /// 判断动画是否需要循环播放
    @property (nonatomic, assign) BOOL loopAnimation;
    
    /// 如果这个属性值和 loopAnimation 都为 YES, 那么动画会先倒序播放,然后再正序播放
    @property (nonatomic, assign) BOOL autoReverseAnimation;
    
    // 设置 progress 的值 从 0 ~ 1,表示这个动画执行的程度,当设定为 1 时表示动画结束
    @property (nonatomic, assign) CGFloat animationProgress;
    
    // 为动画设置一个播放速度,如果想倒序播放,则可以把这个值设定为负数
    @property (nonatomic, assign) CGFloat animationSpeed;
    
    // 只读属性,获取的值为当 speed = 1 时动画的播放秒数
    @property (nonatomic, readonly) CGFloat animationDuration;
    
    // 是否缓存动画, 默认为 YES, 对于只需要播放一次的动画,比如启动页动画,可以设定为 NO
    @property (nonatomic, assign) BOOL cacheEnable;
    
    /// 当动画播放完毕所调用的 block
    @property (nonatomic, copy, nullable) LOTAnimationCompletionBlock completionBlock;
    
    /// 直接设定播放数据,也就是解析 json 文件后的数据模型
    @property (nonatomic, strong, nullable) LOTComposition *sceneModel;
    

    在上述属性中我们可以看到一个缓存 cache 属性,等一下我们将会一起来探究 Lottie 究竟是如何缓存动画的,紧接着我们来接着看 LOTAniamtionView 的 API 中所定义的部分方法:

    /*
     * 这个方法表示 animation 将从当前的 position 播放到指定的 progress
     * 当 loopAnimation 属性为 YES 时,这个动画将从当前 position 到指定 progress 并且无限循环
     * 当 loopAnimation 属性为 YES 时,这个动画将从当前 position 到指定 progress 然后停到 progress 的那一帧
     * 当动画完成后会调用指定 block
     */
    - (void)playToProgress:(CGFloat)toProgress
            withCompletion:(nullable LOTAnimationCompletionBlock)completion;
    
    /*
     * 和上面的方法差不多,主要是开始的 progress 也可以由我们来指定
     */
    - (void)playFromProgress:(CGFloat)fromStartProgress
                  toProgress:(CGFloat)toEndProgress
              withCompletion:(nullable LOTAnimationCompletionBlock)completion;
    
    /*
     * 从当前的 position 动画到指定的 frame
     */
    - (void)playToFrame:(nonnull NSNumber *)toFrame
         withCompletion:(nullable LOTAnimationCompletionBlock)completion;
    
    /*
     * 设置开始的 frame 并且动画到指定的 frame
     */
    - (void)playFromFrame:(nonnull NSNumber *)fromStartFrame
                  toFrame:(nonnull NSNumber *)toEndFrame
           withCompletion:(nullable LOTAnimationCompletionBlock)completion;
    
    
    /**
     * 从当前的 position 完成到结束 position
     **/
    - (void)playWithCompletion:(nullable LOTAnimationCompletionBlock)completion;
    
    /// 简单的播放到动画结尾,播放完成后会调用完成block
    - (void)play;
    
    /// 暂停动画并且调用完成block
    - (void)pause;
    
    /// 停止当前动画并且倒回到最开始的那一帧,完成后调用block
    - (void)stop;
    
    /// 设置当前的动画到指定的 frame,如果当前动画正在播放则会停止动画并且调用完成block
    - (void)setProgressWithFrame:(nonnull NSNumber *)currentFrame;
    
    /// 强制对现在的 frame 进行升级
    - (void)forceDrawingUpdate;
    
    /// 打印所有继承链上的 keypath
    - (void)logHierarchyKeypaths;
    

Lottie 是如何缓存动画的

  • 在 LOTAniamtionCache 的实现文件中可以看到以下方法:

    // 表示是否支持缓存
    - (void)setCacheEnable:(BOOL)cacheEnable {
      _cacheEnable = cacheEnable;
      if (!self.sceneModel.cacheKey) {
        return;
      }
      if (cacheEnable) {
    //      如果支持,则向 cache 中添加这个key所代表的对象已经向字典中添加这个 key 以及它对应的 value 值,也就是动画数据对象
        [[LOTAnimationCache sharedCache] addAnimation:_sceneModel forKey:self.sceneModel.cacheKey];
      } else {
    //      如果不支持,则从字典中移除这个 key,和这个 key 所代表的对象,以及数组中的 key
        [[LOTAnimationCache sharedCache] removeAnimationForKey:self.sceneModel.cacheKey];
      }
    }
    
    // 具体方法如下:
    // 可以看到,LOTAnimationCache 维护了一个添加 key 和 value 到字典和一个数组
    @implementation LOTAnimationCache {
      NSMutableDictionary *animationsCache_;
      NSMutableArray *lruOrderArray_;
    }
    
    // 当添加动画时:首先需要判断当前数组的 size 是否已经大于了最大的 size,如果是的话,则先清除最前面缓存的动画,然后再添加新的动画,而这个 const NSInteger kLOTCacheSize = 50;最大值为 50
    - (void)addAnimation:(LOTComposition *)animation forKey:(NSString *)key {
      if (lruOrderArray_.count >= kLOTCacheSize) {
        NSString *oldKey = lruOrderArray_[0];
        [animationsCache_ removeObjectForKey:oldKey];
        [lruOrderArray_ removeObject:oldKey];
      }
      [lruOrderArray_ removeObject:key];
      [lruOrderArray_ addObject:key];
      [animationsCache_ setObject:animation forKey:key];
    }
    
    // 当移除动画时:则直接将缓存有该动画的数组中移除这个 key,并且在 cache 字典中也移除这个 key 和它所对应的对象。
    - (void)removeAnimationForKey:(NSString *)key {
      [lruOrderArray_ removeObject:key];
      [animationsCache_ removeObjectForKey:key];
    }
    

    接下来在阅读 LOTAniamtionCache 的实现文件,可以发现,整个 cache 是一个单例,也就是存在于 app 的整个生命周期中不会被销毁,一旦 app 关闭,由于数据存储也仅仅是简单的使用一个数组和一个字典来存储,并未进行持久化处理,单例中所缓存的数据也会被销毁,所以我们对于动画的缓存仅限于我们在使用 app 时。

    + (instancetype)sharedCache {
      static LOTAnimationCache *sharedCache = nil;
      static dispatch_once_t onceToken;
      dispatch_once(&onceToken, ^{
        sharedCache = [[self alloc] init];
      });
      return sharedCache;
    }
    
    - (instancetype)init {
      self = [super init];
      if (self) {
        animationsCache_ = [[NSMutableDictionary alloc] init];
        lruOrderArray_ = [[NSMutableArray alloc] init];
      }
      return self;
    }
    

总结

  • 此次仅仅是从表面上简单的分析了 Lottie 这个库是如何解析 json 文件,以及其常用的几个方法和如何进行动画缓存的,以后如果有时间的话,会继续学习这个库的实现。