背景
Lottie提供了播放复杂、酷炫动能力画,在移动端被广泛利用。在我们的应用中也被频繁、大量使用。它使用简单,仅需几行代码就能播放设计师设计的动画,帮助开发节省了时间成本。也正因为使用频繁,在使用过程中我们遇到了一些相关的问题。
使用
Lottie支持加载本地文件播放,也支持远程下载zip,json文件进行播放。这俩者在我们平时开发中都有使用到。
本地播放
本地播放比较简单。可以直接在xml实现,也可以通过代码加载Lottie文件。
xml实现:
<com.airbnb.lottie.LottieAnimationView
android:id="@+id/lottie_test"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:lottie_rawRes="@raw/data"
app:lottie_autoPlay="true"
app:lottie_loop="true" />
代码实现:
findViewById<LottieAnimationView>(R.id.lottie_test).setAnimation(R.raw.data)
远程播放
远程播放相对来说比较复杂,首先要通过网络从远端下载Lottie文件,接着解析下载到本地的文件,最后才是播放。这其中但凡有一个环节出了问题,最终都会导致播放失败,而且还存在crash,需要进行必要的保护。代码实现很简单,仅需一行便可实现远程播放,但其实这背后都是官方帮我们把整个过程做了处理。
findViewById<LottieAnimationView>(R.id.lottie_test).setAnimationFromUrl(lottieUrl)
问题
对于本地播放来说,基本没什么问题,只要能正常播放,上线后表现也是一致的。但对于远程播放来说,都一些不确定的因素,例如网络环境、文件格式、异常处理等。在我们使用过程中出现了类似问题,其中出现问题次数最多的就是Lottie文件格式的问题,最终表现就是导致Lottie无法正常播放,这其中也伴随着crash的产生,当然因为我们早期已加了保护,所以不会导致应用出现崩溃。
实现
文件格式
Lottie主要支持俩种文件格式:json、zip。根据这俩种格式,可以细分出多种表现形式。
后缀为.json
这是官方支持的标准格式,对于第一种纯json的格式来说,可以再次细分出俩种形式:图片格式为base64的json、通过原生canvas实现的json。
图片格式为base64的json:
{"id":"image_1","w":1125,"h":735,"u":"","p":"data:image/jpeg;base64,/9j/4AAQSkZJRgABAQAAAQABAAD...}
原生canvas实现的json:
{"v":"5.4.4","fr":30,"ip":0,"op":20,"w":144,"h":144,"nm":"common loading_Color_json","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":3,"nm":"centre controller","sr":1,"ks":{"o":{"a":0,"k":0,"ix":11},"r":{"a":1,"k":[{"i":{"x":[0.833],"y":[0.833]},"o":{"x":[0.167],"y":[0.167]},"t":0,"s":[0],"e":[90]},{"t":20}],"ix":10},"p":{"a":0,"k":[72,72,0],"ix":2},"a":{"a":0,"k":[0,0,0],"ix":1},"s":{"a":1,"k":[{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":0,"s":[100,100,100],"e":[90,90,100]},{"i":{"x":[0.667,0.667,0.667],"y":[1,1,1]},"o":{"x":[0.333,0.333,0.333],"y":[0,0,0]},"t":10,"s":[90,90,100],"e":[100,100,100]},{"t":20}],"ix":6}},"ao":0,"ip":0,"op":20,"st":0,"bm":0},{"ddd":0,"ind":2,"ty":4,"nm":"huaban 4","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":0,"ix":10},"p":{"a":0,"k":[0,0,0],"ix":2},"a":{"a":0,"k":[125,345,0],"ix":1},"s":{"a":0,"k":[20,20,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-69.036,0],[0,-69.036],[69.035,0],[0,0],[0,12.081],[0,0]],"o":[[69.035,0],[0,69.035],[0,0],[-12.081,0],[0,0],[0,-69.036]],"v":[[0,-125],[125,0],[0,125],[-103.125,125],[-125,103.125],[-125,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"gf","o":{"a":0,"k":100,"ix":10},"r":1,"bm":0,"g":{"p":5,"k":{"a":0,"k":[0,1,0.514,0.235,0.25,1,0.402,0.341,0.5,1,0.29,0.447,0.75,1,0.243,0.524,1,1,0.196,0.6],"ix":9}},"s":{"a":0,"k":[-102,102],"ix":5},"e":{"a":0,"k":[88,-88],"ix":6},"t":1,"nm":"Gradient Fill 1","mn":"ADBE Vector Graphic - G-Fill","hd":false},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[125,125],"e":[125,150],"to":[0,0],"ti":[0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":10,"s":[125,150],"e":[125,125],"to":[0,0],"ti":[0,0]},{"t":20}],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":-45,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":20,"st":0,"bm":0},{"ddd":0,"ind":3,"ty":4,"nm":"huaban 3","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":90,"ix":10},"p":{"a":0,"k":[0,0,0],"ix":2},"a":{"a":0,"k":[125,345,0],"ix":1},"s":{"a":0,"k":[20,20,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-69.036,0],[0,-69.036],[69.035,0],[0,0],[0,12.081],[0,0]],"o":[[69.035,0],[0,69.035],[0,0],[-12.081,0],[0,0],[0,-69.036]],"v":[[0,-125],[125,0],[0,125],[-103.125,125],[-125,103.125],[-125,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"gf","o":{"a":0,"k":100,"ix":10},"r":1,"bm":0,"g":{"p":5,"k":{"a":0,"k":[0,1,0.514,0.235,0.25,1,0.402,0.341,0.5,1,0.29,0.447,0.75,1,0.243,0.524,1,1,0.196,0.6],"ix":9}},"s":{"a":0,"k":[-102,102],"ix":5},"e":{"a":0,"k":[88,-88],"ix":6},"t":1,"nm":"Gradient Fill 1","mn":"ADBE Vector Graphic - G-Fill","hd":false},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[125,125],"e":[125,150],"to":[0,0],"ti":[0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":10,"s":[125,150],"e":[125,125],"to":[0,0],"ti":[0,0]},{"t":20}],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":-45,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":20,"st":0,"bm":0},{"ddd":0,"ind":4,"ty":4,"nm":"huaban 2","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":180,"ix":10},"p":{"a":0,"k":[0,0,0],"ix":2},"a":{"a":0,"k":[125,345,0],"ix":1},"s":{"a":0,"k":[20,20,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-69.036,0],[0,-69.036],[69.035,0],[0,0],[0,12.081],[0,0]],"o":[[69.035,0],[0,69.035],[0,0],[-12.081,0],[0,0],[0,-69.036]],"v":[[0,-125],[125,0],[0,125],[-103.125,125],[-125,103.125],[-125,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"gf","o":{"a":0,"k":100,"ix":10},"r":1,"bm":0,"g":{"p":5,"k":{"a":0,"k":[0,1,0.514,0.235,0.25,1,0.402,0.341,0.5,1,0.29,0.447,0.75,1,0.243,0.524,1,1,0.196,0.6],"ix":9}},"s":{"a":0,"k":[-102,102],"ix":5},"e":{"a":0,"k":[88,-88],"ix":6},"t":1,"nm":"Gradient Fill 1","mn":"ADBE Vector Graphic - G-Fill","hd":false},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[125,125],"e":[125,150],"to":[0,0],"ti":[0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":10,"s":[125,150],"e":[125,125],"to":[0,0],"ti":[0,0]},{"t":20}],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":-45,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":20,"st":0,"bm":0},{"ddd":0,"ind":5,"ty":4,"nm":"huaban 1","parent":1,"sr":1,"ks":{"o":{"a":0,"k":100,"ix":11},"r":{"a":0,"k":270,"ix":10},"p":{"a":0,"k":[0,0,0],"ix":2},"a":{"a":0,"k":[125,345,0],"ix":1},"s":{"a":0,"k":[20,20,100],"ix":6}},"ao":0,"shapes":[{"ty":"gr","it":[{"ind":0,"ty":"sh","ix":1,"ks":{"a":0,"k":{"i":[[-69.036,0],[0,-69.036],[69.035,0],[0,0],[0,12.081],[0,0]],"o":[[69.035,0],[0,69.035],[0,0],[-12.081,0],[0,0],[0,-69.036]],"v":[[0,-125],[125,0],[0,125],[-103.125,125],[-125,103.125],[-125,0]],"c":true},"ix":2},"nm":"Path 1","mn":"ADBE Vector Shape - Group","hd":false},{"ty":"gf","o":{"a":0,"k":100,"ix":10},"r":1,"bm":0,"g":{"p":5,"k":{"a":0,"k":[0,1,0.514,0.235,0.25,1,0.402,0.341,0.5,1,0.29,0.447,0.75,1,0.243,0.524,1,1,0.196,0.6],"ix":9}},"s":{"a":0,"k":[-102,102],"ix":5},"e":{"a":0,"k":[88,-88],"ix":6},"t":1,"nm":"Gradient Fill 1","mn":"ADBE Vector Graphic - G-Fill","hd":false},{"ty":"tr","p":{"a":1,"k":[{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":0,"s":[125,125],"e":[125,150],"to":[0,0],"ti":[0,0]},{"i":{"x":0.667,"y":1},"o":{"x":0.333,"y":0},"t":10,"s":[125,150],"e":[125,125],"to":[0,0],"ti":[0,0]},{"t":20}],"ix":2},"a":{"a":0,"k":[0,0],"ix":1},"s":{"a":0,"k":[100,100],"ix":3},"r":{"a":0,"k":-45,"ix":6},"o":{"a":0,"k":100,"ix":7},"sk":{"a":0,"k":0,"ix":4},"sa":{"a":0,"k":0,"ix":5},"nm":"Transform"}],"nm":"Group 1","np":2,"cix":2,"bm":0,"ix":1,"mn":"ADBE Vector Group","hd":false}],"ip":0,"op":20,"st":0,"bm":0}],"markers":[]}
后缀为.zip
后缀是zip的可以分为只有json文件、包含json文件和image文件夹俩种。
只包含josn文件
第一种是当文件为zip且压缩包里面只有json文件,这种情况官方Lottie库是不支持的,最终会播放失败且会抛出异常。
IllegalStateException("There is no image for " + entry.getValue().getFileName())
包含json文件及image文件夹
这种是官方认定的标准格式,就是当lottie文件的后缀为zip时,这时包含一个json文件和一个image文件夹,且json文件里的图片格式不能是base64,必须是指向image文件夹里图片的路径。如果UED同学给出的Lottie文件是按这个规则生成的,就可以正常播放。但UED同学有时给出的却不是这个格式,给出的是json文件里的图片格式是base64,导致无法正常播放。
优化
鉴于以上俩种无法播放的case,我拉取了官方的源码,进行了修改优化。
后缀为.zip只包含json文件
对于这个case,不能播放的原因是lottie源码对于后缀为zip格式的文件,会读取image文件夹里面的图片,然后将图片decode转换成bitmao,但此时并没有image文件夹,所以获取到的bitmap会为null。具体代码:
// Ensure that all bitmaps have been set.
for (Map.Entry<String, LottieImageAsset> entry : composition.getImages().entrySet()) {
if (entry.getValue().getBitmap() == null) {
return new LottieResult<>(new IllegalStateException("There is no image for " + entry.getValue().getFileName()));
}
}
生成bitmap的代码在这里,由于现在没有image文件夹,没有图片,故无法生成bitmap,此时就会抛出上面的异常。
for (Map.Entry<String, Bitmap> e : images.entrySet()) {
LottieImageAsset imageAsset = findImageAssetForFileName(composition, e.getKey());
if (imageAsset != null) {
imageAsset.setBitmap(Utils.resizeBitmapIfNeeded(e.getValue(), imageAsset.getWidth(), imageAsset.getHeight()));
}
}
针对上面的case,我对官方源码进行了如下修改:
if (images.isEmpty()) {
for (Map.Entry<String, LottieImageAsset> entry : composition.getImages().entrySet()) {
LottieImageAsset asset = entry.getValue();
if (asset == null) {
return null;
}
String filename = asset.getFileName();
BitmapFactory.Options opts = new BitmapFactory.Options();
opts.inScaled = true;
opts.inDensity = 160;
if (filename.startsWith("data:") && filename.indexOf("base64,") > 0) {
// Contents look like a base64 data URI, with the format data:image/png;base64,<data>.
byte[] data;
try {
data = Base64.decode(filename.substring(filename.indexOf(',') + 1), Base64.DEFAULT);
} catch (IllegalArgumentException e) {
Logger.warning("data URL did not have correct base64 format.", e);
return null;
}
asset.setBitmap(BitmapFactory.decodeByteArray(data, 0, data.length, opts));
}
}
}
当images这个集合为空时,读取json里的图片信息,如果此时图片格式为base64,则通过decode将数据转换为bitmap放到composition里的对象里,此时就不会抛出异常,能够正常播放。此修改已被作者合入官方master分支:
https://github.com/airbnb/lottie-android/pull/2110
后缀为.zip包含josn文件和image文件夹,json文件里的图片格式为base64
这个case其实主要是UED同学通过压缩工具压缩Lottie的时候,操作不当,出现了这种情况,那这个case通过技术手段可以支持吗?也是可以的。在这种情况下,如果image文件夹里有图片的话,此时images的size是大于0的,但是也无法正常播放,原因为此时json文件里的图片不是文件路径,而是base64的图片数据,所以匹配时还是会失败:
for (Map.Entry<String, Bitmap> e : images.entrySet()) {
LottieImageAsset imageAsset = findImageAssetForFileName(composition, e.getKey());
if (imageAsset != null) {
imageAsset.setBitmap(Utils.resizeBitmapIfNeeded(e.getValue(), imageAsset.getWidth(), imageAsset.getHeight()));
}
}
这里拿到的imageAsset为null,因为没有匹配的filenName:
private static LottieImageAsset findImageAssetForFileName(LottieComposition composition, String fileName) {
for (LottieImageAsset asset : composition.getImages().values()) {
if (asset.getFileName().equals(fileName)) {
return asset;
}
}
return null;
}
针对这个case做了如下修改,此修改可以把上面提到的这俩个case都cover住:
for (Map.Entry<String, LottieImageAsset> entry : composition.getImages().entrySet()) {
if (entry.getValue().getBitmap() == null) {
LottieImageAsset asset = entry.getValue();
if (asset == null) {
return null;
}
String filename = asset.getFileName();
BitmapFactory.Options opts = new BitmapFactory.Options();
opts.inScaled = true;
opts.inDensity = 160;
if (filename.startsWith("data:") && filename.indexOf("base64,") > 0) {
// Contents look like a base64 data URI, with the format data:image/png;base64,<data>.
byte[] data;
try {
data = Base64.decode(filename.substring(filename.indexOf(',') + 1), Base64.DEFAULT);
} catch (IllegalArgumentException e) {
Logger.warning("data URL did not have correct base64 format.", e);
return null;
}
asset.setBitmap(BitmapFactory.decodeByteArray(data, 0, data.length, opts));
}
}
}
目前我们app使用的就是通过自己打包Lottie代码,支持了以上base,为什么要支持呢?因为我们app大量使用动态配置Lottie功能,尤其是我们首页顶部的氛围模块,是运营经常会用到的一个资源位,很多时候配置的Lottie无法正常播放,需要开发及时排查。经过总结经验来看,大都是Lottie格式错误导致的。每次出现这种情况,需要耗费人力去排查,这样频繁的发生,是一种资源损耗,如果能通过技术手段解决掉这个问题,也是实现了降本提效。所以这是一个值得花时间研究解决的问题。
总结
此次优化,增强了官方Lottie的功能,对于我们业务来说更是有很大的帮助,做到了降本提效。对于后一次的优化,我会提交pr到官方Lottie库,跟作者沟通merge到master分支,这样对于整个行业内使用Lottie库的业务来说,都是一种帮助。