lottie源码分析

lottie简介

Lottie是Airbnb开源的一个动画渲染库,同时支持Android、IOS、React Native和Web平台,Lottie目前只支持渲染播放AE动画。Lottie使用bobymovin(After Effects插件)导出的json数据作为动画数据源。

image
image
image

lottie的优缺点

优点:

  • 相对与矢量动画,lottie的操作更为简单,生成文件的操作不需要程序猿完成,而且AE相对于一些矢量图制作工具更加强大效果更好
    矢量图在线制作工具 https://shapeshifter.design
  • 使用GIF,使用帧动画占用空间大,Android原生不支持GIF动画的显示。
  • 组合式动画,通过大量代码实现复杂的动画效果,代码复杂,不好调试,也会浪费很多时间成本
  • Android, iOS, 和React Native多平台支持
  • 降低动画设计和开发成本
  • 完美解决设计提供动画效果与实现不一致问题
  • 不需要ui适配

缺点:依然有局限性,对于一些复杂的动画特效,如高斯模糊等部分AE特效无法实现,可能是由于json文件不好描述

框架原理

使用AE工具生成一段json,Lottie使用json文件来作为动画数据源,然后解析json数据,根据解析后的数据建立合适的Drawable绘制到View上面,然后不断触发view的绘制

使用

private void play(String name){
        // 取消播放
        mAnimationView.cancelAnimation();
        // 是否循环播放
        mAnimationView.loop(true);
        // 设置播放速率,例如:2代表播放速率是不设置时的二倍
        //mAnimationView.setSpeed(2f);
        // 开始播放
        mAnimationView.playAnimation();
        // 暂停播放
        mAnimationView.pauseAnimation();
        // 设置播放进度
        //mAnimationView.setProgress(0.5f);
        // 判断是否正在播放
       // mAnimationView.isAnimating();
        mAnimationView.setAnimation(name);
        mAnimationView.loop(false);
        mAnimationView.playAnimation();
    }

   /**
     * 自定义播放动画和时长
     */
    private void playValueAnimator(){
        ValueAnimator valueAnimator = ValueAnimator
                .ofFloat(0f, 1f)
                .setDuration(5000);
        valueAnimator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
            @Override
            public void onAnimationUpdate(ValueAnimator animation) {
                mAnimationView.setProgress((Float) animation.getAnimatedValue());
            }
        });
        valueAnimator.start();
    }

动态属性

在动画播放的过程改变一些属性,如动画,positon等

 /**
     * 设置颜色
     */
    private void setColor(){
        //Shirt,Group 5,Fill 1都是layer的名称
        KeyPath shirt = new KeyPath("Shirt", "Group 5", "Fill 1");
        KeyPath leftArm = new KeyPath("LeftArmWave", "LeftArm", "Group 6", "Fill 1");
        KeyPath rightArm = new KeyPath("RightArm", "Group 6", "Fill 1");

        //关键path,需要改变的属性
        mAnimationView.addValueCallback(shirt, LottieProperty.COLOR,
                new LottieValueCallback<Integer>(mColorArray[mIndex]){});

        mAnimationView.addValueCallback(leftArm, LottieProperty.COLOR,
                new LottieValueCallback<Integer>(mColorArray[mIndex]){});

        mAnimationView.addValueCallback(rightArm, LottieProperty.COLOR,
                new LottieValueCallback<Integer>(mColorArray[mIndex]){});
    }

    /**
     * 设置弹跳高度
     */
    private void setJumpHeight(){
        final PointF pointF = new PointF();
        mAnimationView.addValueCallback(new KeyPath("Body"), LottieProperty.TRANSFORM_POSITION, new SimpleLottieValueCallback<PointF>() {
            @Override
            public PointF getValue(LottieFrameInfo<PointF> frameInfo) {
                float startX = frameInfo.getStartValue().x;
                float startY = frameInfo.getStartValue().y;
                float endY = frameInfo.getEndValue().y;

                if (startY > endY) {
                    startY += mJmupArray[mIndex];
                } else if (endY > startY) {
                    endY += mJmupArray[mIndex];
                }
                pointF.set(startX, MiscUtils.lerp(startY, endY, frameInfo.getInterpolatedKeyframeProgress()));
                return pointF;
            }
        });
    }

事件绑定

与手势事件绑定,但本质上还是对positon做操作

private void initData() {
        final LottieRelativePointValueCallback largeValueCallback
                = new LottieRelativePointValueCallback(new PointF(0f, 0f));

        mAnimationView.addValueCallback(new KeyPath("First"), LottieProperty.TRANSFORM_POSITION,
                largeValueCallback);

        final LottieRelativePointValueCallback mediumValueCallback
                = new LottieRelativePointValueCallback(new PointF(0f, 0f));

        mAnimationView.addValueCallback(new KeyPath("Fourth"), LottieProperty.TRANSFORM_POSITION,
                mediumValueCallback);

        final LottieRelativePointValueCallback smallValueCallback
                = new LottieRelativePointValueCallback(new PointF(0f, 0f));

        mAnimationView.addValueCallback(new KeyPath("Seventh"), LottieProperty.TRANSFORM_POSITION,
                smallValueCallback);

        //mContainerView的点击拖动事件委托给ViewDragHelper,ViewDragHelper中对mTargetView做相应处理
        ViewDragHelper viewDragHelper = ViewDragHelper.create(mContainerView, new ViewDragHelper.Callback() {

            /**
             * 捕获拖动的这个View
             */
            @Override
            public boolean tryCaptureView(@NonNull View child, int pointerId) {
                return child == mTargetView;
            }

            /**
             * 拖动的这个View的位置发生变化
             *
             * @param changedView  当前拖动的这个View
             * @param left         距离左边的距离
             * @param top          距离右边的距离
             * @param dx           x轴的变化量
             * @param dy           y轴的变化量
             */
            public void onViewPositionChanged(@NonNull View changedView, int left, int top, @Px int dx,
                                              @Px int dy) {
                totalDx += dx;
                totalDy += dy;
                //控制的是圆心然后触发重新绘制,就是位置的距离转换一下设置给新的圆心
                //这个触摸绑定交互可能不具有参考意义,因为动画没有特别复杂,直接canvas画三个圆也能达到同样的效果
                smallValueCallback.setValue(getPoint(totalDx, totalDy, 1.2f));
                mediumValueCallback.setValue(getPoint(totalDx, totalDy, 1f));
                largeValueCallback.setValue(getPoint(totalDx, totalDy, 0.75f));
            }

            public int clampViewPositionHorizontal(@NonNull View child, int left, int dx) {
                return left;
            }

            public int clampViewPositionVertical(@NonNull View child, int top, int dy) {
                return top;
            }

        });
        mContainerView.setViewDragHelper(viewDragHelper);
    }

    private PointF getPoint(float dx, float dy, float factor) {
        return new PointF(dx * factor, dy * factor);
    }

关于协议

{
    "v": "4.11.1",  //使用bodymovin的版本
    "fr": 60,       //帧率
    "ip": 0,        //起始关键帧
    "op": 180,      //结束关键帧
    "w": 300,       //视图的宽度 宽高会根据屏幕密度做转换成scaleWidth
    "h": 300,       //视图的高度
    "nm": "Comp 1", //从源码中未看到对此字段解析
    "ddd": 0,      
    "assets": [],  //图片集合
    "layers": [    //图层集合,为图片的本地路径(assert等等)
        {
            "ddd": 0,
            "ind": 1,     //layer的Id,唯一
            "ty": “sh",    //layer的类型
            "nm": "Shape Layer 1",  //layer的名称,在ae中生成唯一
            "sr": 1,
            "ks": {},      //外观信息
            "ao": 0,
            "shapes": [],  //矢量图形图层的数组
            "ip": 0,       //   该图层的起始关键帧
            "op": 180,     //该图层的结束关键帧
            "st": 0,       
            "bm": 0
        },
        {...},
        {...},
        {...},
    ]
}

ks中的字段

  • a 位置信息
  • p 位移信息
  • s 缩放信息
  • r 翻转信息
  • o 不透明度
  • so 开始时不透明度
  • eo 结束时不透明度

源码解析

一个动画文件的播放过程大概可以分为三部分

  • 解析json文件
  • view绘制
  • 动画播放

解析json文件

从setAnimation方法点进来,看到在执行解析asset文件夹下文件

public void setAnimation(final String assetName) {
    this.animationName = assetName;
    animationResId = 0; 
    setCompositionTask(LottieCompositionFactory.fromAsset
    (getContext(), assetName));
  }

LottieCompositionFactory这个类有很多解析方法包括raw,asset等文件夹下

public static LottieTask<LottieComposition> fromAsset(Context context, final String fileName) {
    // Prevent accidentally leaking an Activity.
   final Context appContext = context.getApplicationContext();
    //如果之前缓存过,取缓存,线程同步的方法,会阻塞主线程
    return cache(fileName, new 
    Callable<LottieResult<LottieComposition>>() {  
      @Override public LottieResult<LottieComposition> call() {
         //该方法就是拿到了json文件的字节流
        return fromAssetSync(appContext, fileName);
      }
    });
  }

拿到文件的字节流后对内容进行解析

LottieComposition composition = LottieCompositionParser.parse(reader);

解析时会对LottieComposition进行赋值,拿到以下很多的字段

    float scale = Utils.dpScale();
    float startFrame = 0f;
    float endFrame = 0f;
    float frameRate = 0f;
    final LongSparseArray<Layer> layerMap = new LongSparseArray<>();
    final List<Layer> layers = new ArrayList<>();
    int width = 0;
    int height = 0;
    Map<String, List<Layer>> precomps = new HashMap<>();
    Map<String, LottieImageAsset> images = new HashMap<>();
    Map<String, Font> fonts = new HashMap<>();
    List<Marker> markers = new ArrayList<>();
    SparseArrayCompat<FontCharacter> characters = new SparseArrayCompat<>();

LottieTask是一个线程池,LottieResult是LottieComposition的结果或者exception,监听回调中得到解析后composition数据结构

private final LottieListener<LottieComposition> loadedListener = new LottieListener<LottieComposition>() {
    @Override public void onResult(LottieComposition composition) {
      //得到解析后composition
      setComposition(composition);
    }
  };

drawable的绘制

比较核心的两个类
LottieComposition和LottieDrawable将会在下面专门进行分析,他们分别进行了两个重要的工作:json文件的解析和动画的绘制。

LottieAnimationView中的setComposition讲数据结构交给了lottieDrawable

  public void setComposition(@NonNull LottieComposition composition) {
    if (L.DBG) {
      Log.v(TAG, "Set Composition \n" + composition);
    }
    lottieDrawable.setCallback(this);
    this.composition = composition;
    //lottieDrawable对解析后composition数据做了加工
    boolean isNewComposition = 
lottieDrawable.setComposition(composition);
    enableOrDisableHardwareLayer();
    if (getDrawable() == lottieDrawable && !isNewComposition) {
      return;
    }
    setImageDrawable(null);
    setImageDrawable(lottieDrawable);
    requestLayout();
    for (LottieOnCompositionLoadedListener lottieOnCompositionLoadedListener : lottieOnCompositionLoadedListeners) {     lottieOnCompositionLoadedListener.onCompositionLoaded(composition);
    }
  }

lottieDrawable中的setComposition方法中的buildCompositionLayer开始真正的解析layer和绘制
layer算是lottie原理中一个比较重要的概念,就是图层
layer的类型与 AE中的图层的对应关系为:

  • ShapeLayer:形状图层
  • CompositionLayer:预合成图层
  • SolidLayer:纯色图层
  • ImageLayer:图片素材图层
  • NullLayer:空图层
  • TextLayer:文本图层

在android层面可以理解为图层就是view,在一个布局viewGroup中有很多的view,就是不断的绘制这些view来完成这些动画的,LottieComposition对Layer进行数据的映射,在CompositionLayer中为每一个layer生成一个对应的LayerView
简单说就是解析json->layer对象的映射->layer对象为layerview构造出各种path等->数据全部准备好就是不断的驱使draw方法完成绘制

下载.jpeg

CompositionLayer中的构造方法

public CompositionLayer(LottieDrawable lottieDrawable, Layer layerModel, List<Layer> layerModels,
      LottieComposition composition) {
    super(lottieDrawable, layerModel);

    AnimatableFloatValue timeRemapping = layerModel.getTimeRemapping();
    if (timeRemapping != null) {
      this.timeRemapping = timeRemapping.createAnimation();
      addAnimation(this.timeRemapping);
      //noinspection ConstantConditions
      this.timeRemapping.addUpdateListener(this);
    } else {
      this.timeRemapping = null;
    }

    //hashmap的优化数据结构
    LongSparseArray<BaseLayer> layerMap =
        new LongSparseArray<>(composition.getLayers().size());

    BaseLayer mattedLayer = null;
    //遍历layer图层
    for (int i = layerModels.size() - 1; i >= 0; i--) {
      Layer lm = layerModels.get(i);
      BaseLayer layer = BaseLayer.forModel(lm, lottieDrawable, composition);
      if (layer == null) {
        continue;
      }
      layerMap.put(layer.getLayerModel().getId(), layer);
      if (mattedLayer != null) {
        mattedLayer.setMatteLayer(layer);
        mattedLayer = null;
      } else {
        layers.add(0, layer);
        switch (lm.getMatteType()) {
          case ADD:
          case INVERT:
            mattedLayer = layer;
            break;
        }
      }
    }

    //将layer生成各种layerView完成绘制
    for (int i = 0; i < layerMap.size(); i++) {
      long key = layerMap.keyAt(i);
      BaseLayer layerView = layerMap.get(key);
      // This shouldn't happen but it appears as if sometimes on pre-lollipop devices when
      // compiled with d8, layerView is null sometimes.
      // https://github.com/airbnb/lottie-android/issues/524
      if (layerView == null) {
        continue;
      }
      BaseLayer parentLayer = layerMap.get(layerView.getLayerModel().getParentId());
      if (parentLayer != null) {
        layerView.setParentLayer(parentLayer);
      }
    }
  }

父类中根据不同类型,绘制不同的图层

 static BaseLayer forModel(
      Layer layerModel, LottieDrawable drawable, LottieComposition composition) {
    switch (layerModel.getLayerType()) {
      //形状图层,调用最频繁
      case SHAPE:
        return new ShapeLayer(drawable, layerModel);
      //预合成图层
      case PRE_COMP:
        return new CompositionLayer(drawable, layerModel,
            composition.getPrecomps(layerModel.getRefId()), composition);
      //纯色图层
      case SOLID:
        return new SolidLayer(drawable, layerModel);
      //有些会是zip压缩包中会有图片,在这里解析成bitmap
      case IMAGE:
        return new ImageLayer(drawable, layerModel);
      //空图层
      case NULL:
        return new NullLayer(drawable, layerModel);
      //文本图层
      case TEXT:
        return new TextLayer(drawable, layerModel);
      case UNKNOWN:
      default:
        // Do nothing
        L.warn("Unknown layer type " + layerModel.getLayerType());
        return null;
    }
  }

然后就是通过setImageDrawable(lottieDrawable)将图像显示出来,显示第一帧动画。

动画播放

LottieDrawable构造方法中设置,animator的监听,在animator播放的时候,这个回调就会开始更新progress

public LottieDrawable() {
    animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
      @Override
      public void onAnimationUpdate(ValueAnimator animation) {
        if (compositionLayer != null) {
          //根据animator的进度,不断调整compositionLayer的progress
          compositionLayer.setProgress(animator.getAnimatedValueAbsolute());
        }
      }
    });
  }

通过CompositionLayer将setProgress实现的显示具体进度动画

@Override 
 public void setProgress(@FloatRange(from = 0f, to = 1f) float progress) {
    super.setProgress(progress);
    if (timeRemapping != null) {
      float duration = lottieDrawable.getComposition().getDuration();
      long remappedTime = (long) (timeRemapping.getValue() * 1000);
      progress = remappedTime / duration;
    }
    if (layerModel.getTimeStretch() != 0) {
      progress /= layerModel.getTimeStretch();
    }

    progress -= layerModel.getStartProgress();
    for (int i = layers.size() - 1; i >= 0; i--) {
      layers.get(i).setProgress(progress);
    }
  }

父类中layer通知进度的改变

 void setProgress(@FloatRange(from = 0f, to = 1f) float progress) {
    // Time stretch should not be applied to the layer transform.
    transform.setProgress(progress);
    if (mask != null) {
      for (int i = 0; i < mask.getMaskAnimations().size(); i++) {
        mask.getMaskAnimations().get(i).setProgress(progress);
      }
    }
    if (layerModel.getTimeStretch() != 0) {
      progress /= layerModel.getTimeStretch();
    }
    if (matteLayer != null) {
      // The matte layer's time stretch is pre-calculated.
      float matteTimeStretch = matteLayer.layerModel.getTimeStretch();
      matteLayer.setProgress(progress * matteTimeStretch);
    }
    for (int i = 0; i < animations.size(); i++) {
      //animations会更新BaseKeyframeAnimation.AnimationListener回调onValueChanged触发LottieDrawable重绘
      //会调用invalidateSelf()方法,该方法会触发LottieAnimationView的invalidateDrawable,然后
      animations.get(i).setProgress(progress);
    }
  }

BaseKeyframeAnimation.AnimationListener会粗发invalidateDrawable的方法

@Override 
public void invalidateDrawable(@NonNull Drawable dr) {
    if (getDrawable() == lottieDrawable) {
      // We always want to invalidate the root drawable so it redraws the whole drawable.
      // Eventually it would be great to be able to invalidate just the changed region.
      super.invalidateDrawable(lottieDrawable);
    } else {
      // Otherwise work as regular ImageView
      super.invalidateDrawable(dr);
    }
}

在LottieDrawable的setComposition()的方法中会开始执行一个ValueAnimation动画,这个动画会驱使baseLayer的draw()方法不断执行

  @Override void drawLayer(Canvas canvas, Matrix parentMatrix, int parentAlpha) {
    L.beginSection("CompositionLayer#draw");
    canvas.save();
    newClipRect.set(0, 0, layerModel.getPreCompWidth(), layerModel.getPreCompHeight());
    parentMatrix.mapRect(newClipRect);
    
    for (int i = layers.size() - 1; i >= 0 ; i--) {
      boolean nonEmptyClip = true;
      if (!newClipRect.isEmpty()) {
        nonEmptyClip = canvas.clipRect(newClipRect);
      }
      if (nonEmptyClip) {
        BaseLayer layer = layers.get(i);
        layer.draw(canvas, parentMatrix, parentAlpha);
      }
    }
    canvas.restore();
    L.endSection("CompositionLayer#draw");
  }

总结

  1. 创建 LottieAnimationView
  2. 在LottieAnimationView中创建LottieDrawable
  3. 在LottieAnimationView中创建compositionLoader,进行json文件解析得到LottieComposition,完成数据到对象的映射。
  4. 解析完后通过setComposition方法把LottieComposition给lottieDrawable,lottieDrawable在setComposition方法中转换成各种Layer为绘制做准备比如path,maritx,bitmap等等
  5. 在LottieAnimationView中把lottieDrawable设置setImageDrawable
  6. 然后开始动画lottieDrawable.playAnimation()。

demo地址

源码中添加了很多注释
https://github.com/Johncuiqiang/LottieSource

参考

https://blog.csdn.net/weixin_37618354/article/details/84072783
https://blog.csdn.net/dcsff/article/details/80482841
https://blog.csdn.net/xiexiangyu92/article/details/78525456

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 195,980评论 5 462
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 82,422评论 2 373
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 143,130评论 0 325
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,553评论 1 267
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,408评论 5 358
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,326评论 1 273
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,720评论 3 386
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,373评论 0 254
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,678评论 1 294
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,722评论 2 312
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,486评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,335评论 3 313
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,738评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,009评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,283评论 1 251
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,692评论 2 342
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,893评论 2 335

推荐阅读更多精彩内容