酷炫的动画库——Lottie源码解析(二)

酷炫的动画库——Lottie 源码解析 第二章

在上一节,我们分析了LottieView的playAnimation()的整体流程,我们在最后也提到了,Lottie的动画就是通过一层一层的Layer实现的,其中有CompositionLayer、BaseLayer比较重要,起到了通知更新、分发更新的作用。但是上一节没有具体分析Lottie从 Json文件到动画文件(Layer)到底做了什么,是怎么解析的。这一节的内容,我们就来看下这一部分的内容。

首先,通过LottieView的使用可以看的出来,解析json并且给LottieView设置,是通过如下代码:

1
2
3
4
5
6
LottieCompositionFactory.fromAsset(context, "assertName").addListener{
lottieView.setComposition(it)
// lottieView.playAnimation()
}.addFailureListener{
//Load Error
}

通过这段代码实际上可以看的出来,Lottie是将一个assert文件解析为Composition这个对象,然后给LottieView,那么这个 LottieCompositionFactory.fromAssert() 就是解析文件的过程,所以,首先从这个方法看起:

1
2
3
4
5
6
7
8
9
10
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() {
return fromAssetSync(appContext, fileName);
}
});
}

可以看到这里调用了一个 cache() 方法, 从方法名就可以看出来,这是一个和缓存有关的方法,并且传入了一个Callable,那接下来看看cache的实现:

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
private static LottieTask<LottieComposition> cache(
@Nullable final String cacheKey, Callable<LottieResult<LottieComposition>> callable) {
//如果cache不为空,判断LottieCompositionCache中是否有cacheKey对应的缓存
final LottieComposition cachedComposition = cacheKey == null ? null : LottieCompositionCache.getInstance().get(cacheKey);
//如果缓存不为空,则构造一个带有结果的LottieTask,直接返回缓存。
if (cachedComposition != null) {
return new LottieTask<>(new Callable<LottieResult<LottieComposition>>() {
@Override
public LottieResult<LottieComposition> call() {
return new LottieResult<>(cachedComposition);
}
});
}
//如果缓存不存在,则去任务的缓存(taskCache)中查找是否有任务的缓存。
if (cacheKey != null && taskCache.containsKey(cacheKey)) {
return taskCache.get(cacheKey);
}

//没有任务缓存,则生成一个新的LottieTask,并将callbale传入
LottieTask<LottieComposition> task = new LottieTask<>(callable);
//添加监听,并且当加载成功回调之后,将结果缓存起来
task.addListener(new LottieListener<LottieComposition>() {
@Override
public void onResult(LottieComposition result) {
if (cacheKey != null) {
LottieCompositionCache.getInstance().put(cacheKey, result);
}
taskCache.remove(cacheKey);
}
});
//加载失败的回调
task.addFailureListener(new LottieListener<Throwable>() {
@Override
public void onResult(Throwable result) {
taskCache.remove(cacheKey);
}
});
taskCache.put(cacheKey, task);
return task;
}

cache() 方法的调用过程注释都写的很清楚,可以看到Lottie对动画做了缓存,但是从代码也可以看出来,这个缓存是以动画文件名称做key的,所以,如果说你更新了动画文件,需要重启App才能够生效了。

其次,这里的LottieTask有点类似AsyncTask,其内部包含了一个线程池用来处理异步任务,但是具体实现实在上面代码中的callbale,关键就是 fromAssetSync(appContext, fileName); 这句代码, 看下实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@WorkerThread
public static LottieResult<LottieComposition> fromAssetSync(Context context, String fileName) {
try {
String cacheKey = "asset_" + fileName;
//判断是否为zip包,是的话需要先解压
if (fileName.endsWith(".zip")) {
return fromZipStreamSync(new ZipInputStream(context.getAssets().open(fileName)), cacheKey);
}
//不是zip包则当作Json字符串流解析
return fromJsonInputStreamSync(context.getAssets().open(fileName), cacheKey);
} catch (IOException e) {
return new LottieResult<>(e);
}
}

这个方法是一个异步方法,解析动画文件(zip或者json文件), 然后 fromJsonInputStreamSync经过一系列调用最终会调用到 fromJsonReaderSyncInternal:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
private static LottieResult<LottieComposition> fromJsonReaderSyncInternal(
com.airbnb.lottie.parser.moshi.JsonReader reader, @Nullable String cacheKey, boolean close) {
try {
LottieComposition composition = LottieCompositionMoshiParser.parse(reader);
LottieCompositionCache.getInstance().put(cacheKey, composition);
return new LottieResult<>(composition);
} catch (Exception e) {
return new LottieResult<>(e);
} finally {
if (close) {
closeQuietly(reader);
}
}
}

可以看到,具体的解析是由LottieCompositionMoshiParser解析的:

1
2
3
4
5
6
7
8
9
10
11
12
13
private static final JsonReader.Options NAMES = JsonReader.Options.of(
"w", // 0
"h", // 1
"ip", // 2
"op", // 3
"fr", // 4
"v", // 5
"layers", // 6
"assets", // 7
"fonts", // 8
"chars", // 9
"markers" // 10
);

上面是 LottieCompositionMoshiParser 类中,对于一些json对象名称的定义,对应的是Lottie动画的Json文件,这些类型就是在解析的时候,区分将当前对象作为什么解析,来看一下parse方法,就能够明白这些类型的作用:

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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
public static LottieComposition parse(JsonReader reader) throws IOException {
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<>(); //若动画包含bitmap,则会用到
Map<String, Font> fonts = new HashMap<>(); //字体
List<Marker> markers = new ArrayList<>(); //遮罩
SparseArrayCompat<FontCharacter> characters = new SparseArrayCompat<>();

LottieComposition composition = new LottieComposition();
reader.beginObject();
while (reader.hasNext()) {
// 以下每种类型,都对应上面声明的类型,可以看到不同的类型,都做了不同的处理。
switch (reader.selectName(NAMES)) {
case 0:
width = reader.nextInt();
break;
case 1:
height = reader.nextInt();
break;
case 2:
startFrame = (float) reader.nextDouble();
break;
case 3:
endFrame = (float) reader.nextDouble() - 0.01f;
break;
case 4:
frameRate = (float) reader.nextDouble();
break;
case 5:
String version = reader.nextString();
String[] versions = version.split("\\.");
int majorVersion = Integer.parseInt(versions[0]);
int minorVersion = Integer.parseInt(versions[1]);
int patchVersion = Integer.parseInt(versions[2]);
if (!Utils.isAtLeastVersion(majorVersion, minorVersion, patchVersion,
4, 4, 0)) {
composition.addWarning("Lottie only supports bodymovin >= 4.4.0");
}
break;
case 6:
parseLayers(reader, composition, layers, layerMap);
break;
case 7:
parseAssets(reader, composition, precomps, images);
break;
case 8:
parseFonts(reader, fonts);
break;
case 9:
parseChars(reader, composition, characters);
break;
case 10:
parseMarkers(reader, composition, markers);
break;
default:
reader.skipName();
reader.skipValue();
}
}
int scaledWidth = (int) (width * scale);
int scaledHeight = (int) (height * scale);
Rect bounds = new Rect(0, 0, scaledWidth, scaledHeight);

//生成composition,回调给LottieView
composition.init(bounds, startFrame, endFrame, frameRate, layers, layerMap, precomps,
images, characters, fonts, markers);

return composition;
}

通过上面的方法,可以将Lottie的动画文件解析为相应的 Layers / images / fonts / markers 等等,然后会全部组装成composition,回调给LottieView。下面是一个示例的动画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
{
"v": "5.1.10", //bodymovin的版本
"fr": 24, //帧率
"ip": 0, //起始关键帧
"op": 277, //结束关键帧
"w": 110, //动画宽度
"h": 110, //动画高度
"nm": "合成 2",
"ddd": 0,
"assets": [...] //资源信息
"layers": [...] //图层信息
}

//assert中资源信息,如图片
{
"id": "image_0", //图片id
"w": 750, //图片宽度
"h": 1334, //图片高度
"u": "images/", //图片路径
"p": "img_0.png" //图片名称
}

//图层信息
"layers": [
{
"ddd": 0,
"ind": 1, //图层 id
"ty": 2, //图层类型 (包括PRE_COMP、 SOLID、IMAGE、NULL、SHAPE、TEXT、UNKNOWN)
"nm": "eye-right 2",
"parent": 3, //父图层id
"refId": "image_0", //引用资源Id
"sr": 1,
"ks": { //动画属性值
"s": { //s:缩放的数据值
"a": 0,
"k": [
100,
100,
100
],
"ix": 6
}...},
"ip": 0, //inFrame 该图层起始关键帧
"op": 241, //outFrame 该图层结束关键帧
"st": 0, //startFrame 开始关键帧
"bm": 0,
"sw":0, //solidWidth
"sh":0, //solidHeight
"sc":0, //solidColor
"tt":0, //transform

}

所以,经过上述过程,最终会将动画文件包装成composition回调给LottieView,调用 LottieAnimationView.setComposition(composition) :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public void setComposition(@NonNull LottieComposition composition) {
if (L.DBG) {
Log.v(TAG, "Set Composition \n" + composition);
}
lottieDrawable.setCallback(this);

this.composition = composition;
boolean isNewComposition = lottieDrawable.setComposition(composition);
enableOrDisableHardwareLayer();
if (getDrawable() == lottieDrawable && !isNewComposition) {
return;
}

setImageDrawable(null);
setImageDrawable(lottieDrawable);

onVisibilityChanged(this, getVisibility());

requestLayout();

...
}

在这个方法中,就是将composition设置给了LottieDrawable,之后再调用playAnimation的话,就会走我们在第一节中说过的流程了,需要注意的是,这里还调用了requestLayout() ,也就是说,当调用了 setComposition 之后,动画就会显示出来,但是不会播放。

最后给一张加载动画文件整体流程图:

到这里,整个Lottie的工作流程以及解析过程就整理完成了,如果在项目中,对动画效果要求较好,或者有很多复杂动画的话,使用Lottie库还是很不错的。最后再总结一下Lottie中的几个关键点以及一些注意的事项:

关键类

  • LottieComposition

包含LayerLottieImageAssetFontFontCharacter

使用该类来转换AE的数据对象,将json映射到该类。方便之后转换为Drawable。

  • LottieCompositionFactory

创建LottieComposition的工厂类,可以从网络加载,从文件加载,从assert 加载等等。

  • LottieCompositionMoshiParser

LottieComposition解释器, 根据约定的解析规则,将json 数据格式解析为LottieComposition。

  • LottieDrawable

在该类中,将解析后的LottieComposition转换为LottieDrawable,并且是主要的动画承载者。

  • LayoutParser

LottieDrawable会将动画的json文件解析为一个一个的layer,包括CompositionLayer、SolidLayer、ImageLayer、ShapeLayer、TextLayer。最后会通过渲染这些图层、达到动画的效果。

可能存在问题

  • 在有遮罩或者毛玻璃/磨砂的效果的时候,渲染的性能与时间消耗是没有这些特殊效果的一倍以上。可以参考BaseLayer源码中:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//没有mask与matte的情况下,直接返回
if (!hasMatteOnThisLayer() && !hasMasksOnThisLayer()) {
matrix.preConcat(transform.getMatrix());
L.beginSection("Layer#drawLayer");
drawLayer(canvas, matrix, alpha);
L.endSection("Layer#drawLayer");
recordRenderTime(L.endSection(drawTraceName));
return;
}

...
//否则会调用一个saveLayerCompat的方法,这是一个十分消耗性能的方法,需要分配和绘制一个offscreen的缓冲区,渲染的成本增加了一倍。
L.beginSection("Layer#saveLayer");
saveLayerCompat(canvas, rect, contentPaint, true);
L.endSection("Layer#saveLayer");
  • 如果动画的播放会比较卡,原因是什么?(原因可能是没有开启硬件加速)