Android动画原理与Lottie库源码分析

基本动画原理

1.动画类型

严格意义上来说,动画其实只有2种:

  • 逐帧动画:

    逐帧动画就是动画的每一帧都独立地保存在媒体内,连续播放这些帧即形成了连续动画,而用户需要保存每一帧的所有像素数据。

    比如一个30FPS(Frame Per Second),像素大小为(480*240)的逐帧动画,用户需要保存30张图片的像素数据,并在1秒内将这30张图片顺序播放。

    其缺点主要有2点:

    1. 所占空间较大,对于上面这个动画,需要占用30*480*240*pixel大小的空间。当然也可以将这些像素打包到一起减少空间,比如gif格式等。
    2. 渲染时需要替换每一帧的纹理,很容易造成CPU计算负担过重(State Validation阶段)引起卡顿。

    从广义上来说,所有的视频都属于这个范畴。而在Android中,系统提供了AnimationDrawable这个类来实现帧动画。

  • 关键帧动画:

    关键帧动画就是不保存动画的每一帧,而通过前后两个关键帧来自动计算中间的过度画面。计算机可以根据前后两个关键帧的数据自动插值补全中间的动画。

    比如以位移动画来说,一个图像从a点移动到b点,在a点会有一个关键帧,在b点会有另一个关键帧,至于关键帧之间的中间帧,计算机会自动插值计算。还可以通过更改插值器等操作来修改这一过程。

    对比逐帧动画,由于实现关键帧动画不需要保存每一帧图像,只需要基础纹理和关键帧的数据,所以占用的空间较少,这是它的优点。

    相应的,由于关键帧动画中间的数据都通过计算而来,对于比较复杂的动画,对于CPU的负担会比较重,容易造成卡顿情况,比如加入动态蒙层、实时阴影的动画,由于需要多次渲染(即最终的一幅画面要渲染好几次才能得到),由于要遍历渲染管线好几次,CPU的负担已经远超过帧动画替换每一帧图像的开销了,这时候可以考虑用逐帧动画代替。

    而对于简单的动画,无论从所占空间的大小,还是运行的效率,都是关键帧动画要更好一些。

    在Android中,系统提供了View Animation(视图动画),(Property Animation)属性动画,Drawable Callback这些接口来实现关键帧动画。注:当然也可以通过这些接口更新每一帧的图像实现逐帧动画,这里分类时暂不考虑这种情况。

  • 小结:

    可以认为逐帧动画的每一帧图像都是在其他地方预先渲染好的(PS,AE等),而关键帧动画的每一帧图像依赖于显示设备的实时计算,这是它们的本质区别。

2.动画原理

动画的原理其实很简单,人的眼睛对图像有短暂的记忆效应,所以当眼睛看到多张图片连续快速的切换时,就会被认为是一段连续播放的动画了。所以要实现动画,只要保证每一帧画面有变化(无论是通过预先渲染好画面的帧动画还是通过实时计算得到每一帧画面的关键帧动画)即可。

(1).In Android

Android的消息事件循环是在ActivityThread的Main函数中,通过Looper的Loop函数,利用epoll机制处理消息事件的。

Android提供了以下几种接口可以实现动画:

  1. AnimationDrawable (逐帧动画)

  2. 视图动画(关键帧动画)

  3. 属性动画(关键帧动画)

  4. Drawable.Callback接口(关键帧动画)

  5. 直接通过Handler向Looper的MessageQueue队列发送消息,更新下一帧图像(逐帧动画或者关键帧动画都可实现)

其实本质上1,2,3,4 都是Android给开发者封装的接口,底层都是通过第5来实现的。

这里以视图动画与属性动画为例:

最终都是通过计算下一帧的图像数据,然后通过Handler通知消息队列,以便下次绘图时画出更新数据后的下一帧图像,这样实现动画。

Lottie源码解析

1.Lottie简介

Lottie是一个同时支持Android和IOS设备的一个开源动画框架。用户只要先用Adobe After Effects 软件做出动画,再将动画文件通过Bodymovin导出为json文件,就可以通过Lottie来解析这些json文件实现动画了。

Lottie最新版本已经支持API 14,包体大小也比较小,最新版本导出的AAR包体积为171KB。

2.基本用法

1
2
3
4
LottieAnimationView animationView = (LottieAnimationView) findViewById(R.id.animation_view);
// hello-world.json就是AE导出的动画数据,导出的图像也要放在assets下面。
animationView.setAnimation("hello-world.json");
animationView.loop(true);

LottieAnimation是依靠LottieDrawable实现的,也可以直接使用LottieDrawable

1
2
3
4
LottieDrawable drawable = new LottieDrawable();
LottieComposition.Factory.fromAssetFileName(getContext(), "hello-world.json", (composition) -> {
drawable.setComposition(composition);
});

用法就是这么简单。

3.实现原理

开门见山,先说原理,再分析源码

Bodymovin导出的json文件定义了动画的关键帧数据,Lottie用LottieComposition这个类解析json动画数据。得到关键帧数据后,通过插值器计算出下一帧的数据,更新数据,再调用invalidate刷新画面。

是的,原理也是这么简单。

4.源码分析

(1).解析json文件

调用动画第一步是要反序列化json代码。

1
2
3
LottieComposition.Factory.fromAssetFileName(getContext(), "hello-world.json", (composition) -> {
drawable.setComposition(composition);
});

LottieComposition负责解析具体的json数据,并用这些成员变量保存到内存中,这些成员变量保存了从json解析出来动画的关键帧数据,Lottie这里支持同步和异步加载。

1
2
3
4
5
6
7
8
9
private final Map<String, List<Layer>> precomps = new HashMap<>();
private final Map<String, ImageAsset> images = new HashMap<>();
private final LongSparseArray<Layer> layerMap = new LongSparseArray<>();
private final List<Layer> layers = new ArrayList<>();
private final Rect bounds;
private final long startFrame;
private final long endFrame;
private final int frameRate;
private final float scale;

AE导出的Json文件大概是这样的,这里以位移动画的json文件举例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
{
"assets": [
{
"id": "image_0", //图片的ID,layer获得图片的标识
"w": 400, //图片的宽高
"h": 211,
"u": "images/", //图片路径,实际未被使用
"p": "img_0.png" //图片文件名
}
],
"layers": [ //每一层(每一个layer都要draw一次)
{
"ddd": 0,
"ind": 0, //layer的ID,唯一
"ty": 2, //layer type:Solid,Image,Text,Shape等等
"nm": "weaccept.jpg", //layer名字
"cl": "jpg",
"refId": "image_0",
"ks": { //关键帧
"o": { //透明度Opacity
... //省略了,记录了每一个关键帧的透明度,下面类似
},
"r": { //旋转Rotation
...
},
"p": { //位置Position
...
},
"a": { //锚点Anchor
...
},
"s": { //Scale
...
},
]
}
},
"ao": 0,
"ip": 0,
"op": 61.0000024845809,
"st": 0,
"bm": 0,
"sr": 1
}
],
"v": "4.5.0",
"ddd": 0,
"ip": 0, //开始帧
"op": 61.0000024845809, //结束帧
"fr": 29.9700012207031, //帧率fps
"w": 600,
"h": 600
}

大致含义如上面注释,如果需要知道具体的每个字段含义,可参阅Bodymovin这个开源项目。

(2).将反序列化的Json动画数据封装成关键帧对象

当Json文件被反序列化成LottieComposition后,会调用

1
drawable.setComposition(composition);

这里的drawableLottieDrawable对象,它的setComposition方法如下:

1
2
3
4
5
6
7
boolean setComposition(LottieComposition composition) {
...
buildLayersForComposition(composition);
...
setProgress(getProgress());
return true;
}

然后会调用LottieDrawablebuildLayersForComposition方法,这个方法如下:

1
2
3
4
5
6
7
8
private void buildLayersForComposition(LottieComposition composition) {
...
for (int i = composition.getLayers().size() - 1; i >= 0; i--) {
...
layerView = new LayerView(layer, composition, this, canvasPool);
...
}
}

在这里会按LottileCompositionlayers创建每个layer对象。Layer对象可以理解为需要绘制的每一层,并最终调用AnimatableLayeraddAnimation方法,对于每个AnimatableLayer对象初始化它的成员变量animations。

1
2
3
4
5
class AnimatableLayer extends Drawable {
...
private final List<BaseKeyframeAnimation<?, ?>> animations = new ArrayList<>();
...
}

这里的BasekeyframeAnimation就是最终保存的关键帧对象。

(3).动画调用流程

LottieDrawable调用PlayAnimation,该方法如下:

1
2
3
4
5
6
7
8
9
void playAnimation() {
if (layers.isEmpty()) {
playAnimationWhenLayerAdded = true;
reverseAnimationWhenLayerAdded = false;
return;
}
animator.setCurrentPlayTime((long) (getProgress() * animator.getDuration()));
animator.start();
}

会调用animatorstart()方法,animator是在LottieDrawable中定义的属性动画,它的updatelistener如下:

1
2
3
4
5
6
7
8
9
10
animator.addUpdateListener(new ValueAnimator.AnimatorUpdateListener() {
@Override public void onAnimationUpdate(ValueAnimator animation) {
if (systemAnimationsAreDisabled) {
animator.cancel();
setProgress(1f);
} else {
setProgress((float) animation.getAnimatedValue());
}
}
});

开启后会调用setProgress方法,setProgress方法是LottieDrawable基类AnimatableLayer中的方法,如下

1
2
3
4
5
6
7
8
9
10
public void setProgress(@FloatRange(from = 0f, to = 1f) float progress) {
this.progress = progress;
// LottieDrawable的animations对象为空,只有需要绘制的layer
for (int i = 0; i < animations.size(); i++) {
animations.get(i).setProgress(progress);
}
for (int i = 0; i < layers.size(); i++) {
layers.get(i).setProgress(progress);
}
}

LottieDrawable对象的animations成员变量为空,这里会调用LottieDrawablelayers成员变量:

1
final List<AnimatableLayer> layers = new ArrayList<>();

这里的layers会调用AnimatableLayer的另一个子类LayerViewsetProgress方法。

实现如下:

1
2
3
4
5
6
7
@Override public void setProgress(@FloatRange(from = 0f, to = 1f) float progress) {
progress *= layerModel.getTimeStretch();
super.setProgress(progress);
if (matteLayer != null) {
matteLayer.setProgress(progress);
}
}

这里又会调用它的父类的setProgress方法,即AnimatableLayersetProgress方法:

1
2
3
4
5
6
7
8
9
10
11
public void setProgress(@FloatRange(from = 0f, to = 1f) float progress) {
this.progress = progress;
// 每层的animations不为空,这里也是保存关键帧数据的地方
for (int i = 0; i < animations.size(); i++) {
animations.get(i).setProgress(progress);
}
// 每一层的的layers通常为空
for (int i = 0; i < layers.size(); i++) {
layers.get(i).setProgress(progress);
}
}

对于每层layeranimations,这个就是保存保存关键帧的地方:

1
private final List<BaseKeyframeAnimation<?, ?>> animations = new ArrayList<>();

即步骤2中所得到的关键帧。然后会调用BaseKeyframeAnimation具体实现类相应的setProgress方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// BaseKeyframeAnimation的setProgress方法
void setProgress(@FloatRange(from = 0f, to = 1f) float progress) {
if (progress < getStartDelayProgress()) {
progress = 0f;
} else if (progress > getEndProgress()) {
progress = 1f;
}
if (progress == this.progress) {
return;
}
this.progress = progress;
A value = getValue();
for (int i = 0; i < listeners.size(); i++) {
listeners.get(i).onValueChanged(value);
}
}

这里以Color关键帧ColorKeyframeAnimation为例,它的getValue()方法如下:

1
2
3
4
5
@Override public Integer getValue(Keyframe<Integer> keyframe, float keyframeProgress) {
int startColor = keyframe.startValue;
int endColor = keyframe.endValue;
return GammaEvaluator.evaluate(keyframeProgress, startColor, endColor);
}

这里按json文件提供的关键帧数据计算得到动画在某一过程中的value,并调用其onValueChanged方法:

1
2
3
4
5
6
7
8
9
10
11
12
private final KeyframeAnimation.AnimationListener<Integer> colorChangedListener =
new KeyframeAnimation.AnimationListener<Integer>() {
@Override
public void onValueChanged(Integer value) {
onColorChanged();
}
};
// 设置paint在对应progress的颜色,并通知UI重绘
private void onColorChanged() {
paint.setColor(color.getValue());
invalidateSelf();
}

至此,完整整个动画流程的调用。

三、总结

Lottie这样作为一款开源动画库,解决了UE和RD对于同一份动画文件要重复实现2次的问题,可以说直接提高了开发效率,但需要注意的是,Lottie目前只支持AE的部分属性:

  • Keyframe Interpolation 关键帧插值
  • Solids 固态层
  • Masks 蒙版
  • Track Mattes 遮罩模式
  • Parenting 父子关系
  • Shape Layers 形状层
  • Stroke (shape layer) 描边(形状层)
  • Fill (shape layer) 填充(形状层)
  • Trim Paths (shape layer) 修剪路径(形状层)

如果UE在制作动画的过程中,在AE中用到了Lottie不支持的属性,那么就不能成功加载。

另外从效率上来说,和GIF等帧动画相比,由于逐帧动画需要每帧都替换纹理,所以对于没有蒙版遮罩等简单的动画,Lottie的运行效率是要好于帧动画的。当然这个临界点就是CPU每帧替换纹理的State validation开销大于CPU计算得出下一帧数据的开销。

另外和自己实现属性动画相比,多了一个解析JSON的开销,不过Lottie对此也提供了异步加载以及缓存机制来减少这一点。

在这里简单的总结一下Lottie的优缺点。

优点

  1. 如果是没有mask和mattes等简单的动画,那么性能和内存非常好,没有bitmap创建,大部分操作都是简单的cavas绘制。

缺点

Lottie发布后在Github上的ISSUE大多已经被解决,仍然open的主要有以下几点:

  1. 不支持硬件加速(最新的Lottie版本已经支持)
    由于Lottie使用了一些不支持硬件加速的API,所以暂时不能支持硬件加速。这一点和开启硬件加速的动画相比性能损失较大。
  2. 对于存在蒙版、遮罩等复杂的动画,性能较差

附:Android开发中一个常见的误区就是不能在非UI线程更新UI,这句话其实是不对的,正确的说法应该是不能在没有绑定渲染API context的线程更新UI。
而在非UI线程更新UI是可以做到的,拿开启硬件加速后的渲染流程举例。不能在非UI线程调用渲染方法是因为底层Opengl的context绑定了UI线程,而Opengl的同一个context只能绑定一个线程,在没有绑定其context的线程调用它的方法当然会出错。如果将OpenGl的context绑定另一个线程,是可以在那个线程调用渲染方法的,当然需要处理一些线程交互问题。