Lottie 动画

本文主要讲解 Lottie 库动态加载 SD 卡上带图片资源的动画,并对各种机型做全屏适配。

Lottie 的优点:

  • 跨平台,支持 Android、iOS、React Native 平台

  • 支持实时渲染 After Effects 动画,让 app 加载动画像加载图片一样简单。

  • 资源动态下载,减小 APP 体积,上线新的动画效果不需要发版

  • 更多优点等你发现

使用场景

  • 做直播软件肯定少不了各种礼物的动画效果,当上线新的礼物时,不仅 Android、ios 客户端需要实现新的动画效果,还很难兼容老版本。

  • 平常过节日的时候,很多 APP 都会做各种活动,修改启动页的图片,更改应用内的按钮图标,如果涉及到动画,那么肯定需要提前一两个版本将节日的动画实现代码预制到应用内

  • 更多应用场景等你探索

现在有了 Lottie,可以让设计师使用 After Effects 进行动画设计,通过 Bodymovin 插件导出 json 文件,将动画资源打包上传到服务器后,客户端通过动态下载资源文件来执行动画。这样上线新的礼物,只需要将资源文件上传,客户端不需要发版完全可以执行新礼物的动画效果。流程如下:

屏幕快照 2017-05-05 10.21.47.png

使用详解

本文就以直播间播放动画为例子来讲解具体的实现方案,先看下动画效果:

直播软件的大礼物一般都是飞机、跑车、航母、花瓣雨等,这些物品不是简单的线条、色块所能绘制,所以使用图片文件来实现动画效果。
导出的动画资源包括一个 json 文件和一组图片文件:

将这些文件打成压缩包上传到后台,客户端下载压缩包进行解压,使用 Lottie 加载本地资源执行动画:

 
  1. File jsonFile = new File(giftDir, "79.json");
  2. File imagesDir = new File(giftDir, "images");
  3. FileInputStream fis = null;
  4. if (jsonFile.exists()) {
  5.     try {
  6.         fis = new FileInputStream(jsonFile);
  7.     } catch (FileNotFoundException e) {
  8.         e.printStackTrace();
  9.     }
  10. }
  11. if (fis == null || !imagesDir.exists()) {
  12.     showLocalAnimation(gift);
  13.     return;
  14. }
  15. final String absolutePath = imagesDir.getAbsolutePath();
  16. //提供一个代理接口从 SD 卡读取 images 下的图片
  17. mLottieAnimationView.setImageAssetDelegate(new ImageAssetDelegate() {
  18.     @Override
  19.     public Bitmap fetchBitmap(LottieImageAsset asset) {
  20.         BitmapFactory.Options opts = new BitmapFactory.Options();
  21.         opts.inScaled = true;
  22.         opts.inDensity = 160;
  23.         return BitmapFactory.decodeFile(absolutePath + File.separator +
  24.                 asset.getFileName(), opts);
  25.     }
  26. });
  27. //从文件流中加载 json 数据
  28. LottieComposition.Factory.fromInputStream(this, fis, new OnCompositionLoadedListener() {
  29.     @Override
  30.     public void onCompositionLoaded(LottieComposition composition) {
  31.         mLottieAnimationView.setVisibility(View.VISIBLE);
  32.         mLottieAnimationView.setComposition(composition);
  33.         mLottieAnimationView.playAnimation();
  34.     }
  35. });

关键的代码就是设置图片资源代理,去 SD 卡解析图片文件,那么怎么知道该解析哪一张图片呢?咱们来看看 json 文件里面的内容:

assets 字段是图片资源的数组,具体的解析的源码如下:

 
  1. private LottieImageAsset(int width, int height, String id, String fileName) {
  2.     this.width = width;
  3.     this.height = height;
  4.     this.id = id;
  5.     this.fileName = fileName;
  6. }
  7. static class Factory {
  8.     private Factory() {}
  9.     static LottieImageAsset newInstance(JSONObject imageJson) {
  10.         return new LottieImageAsset(
  11.                   imageJson.optInt("w"),     //width
  12.                   imageJson.optInt("h"),     //height
  13.                   imageJson.optString("id"), //id
  14.                   imageJson.optString("p")   //fileName
  15.                );
  16.     }
  17. }

直接根据 ImageAssetDelegate 代理类的 fetchBitmap(LottieImageAsset asset) 方法中的 LottieImageAsset 参数获取当前需要解析的图片文件名,去 images 文件夹下面解析对应的文件就OK啦。

这几行代码就实现了从SD卡动态加载动画,那么这样就算完工了吗?看看上面的动画是不是感觉有什么地方不对劲?好吧,作为一个 Android 软件工程师,一定要记住2个字 适配 适配 适配

源码解析

动画是全屏的效果,小幽灵也是从屏幕外飞进来的没有问题,为什么背景图离屏幕两边有空隙呢?

再来看看这个动图,为什么隐藏虚拟按键就全屏了呢?

再来看看 json 文件里面的内容:

背景图的宽高和画布的宽高是一样的,那么为什么有虚拟按键的时候背景图就不全屏呢?原因其实很简单,来看一下 Lottie 是怎么解析 json 数据:

 
  1. static LottieComposition fromJsonSync(Resources res, JSONObject json) {
  2.       Rect bounds = null;
  3.       float scale = res.getDisplayMetrics().density;
  4.       int width = json.optInt("w", -1);
  5.       int height = json.optInt("h", -1);
  6.       if (width != -1 && height != -1) {
  7.         int scaledWidth = (int) (width * scale);
  8.         int scaledHeight = (int) (height * scale);
  9.         bounds = new Rect(0, 0, scaledWidth, scaledHeight);
  10.       }
  11.       long startFrame = json.optLong("ip", 0);
  12.       long endFrame = json.optLong("op", 0);
  13.       int frameRate = json.optInt("fr", 0);
  14.       LottieComposition composition =
  15.           new LottieComposition(bounds, startFrame, endFrame, frameRate, scale);
  16.       JSONArray assetsJson = json.optJSONArray("assets");
  17.       parseImages(assetsJson, composition);
  18.       parsePrecomps(assetsJson, composition);
  19.       parseLayers(json, composition);
  20.       return composition;
  21. }

解析出了动画的宽高、帧率等信息,这里将解析出来的宽高乘上了屏幕的像素密度,然后设置渲染区域的边界,为了便于理解,本文将其称为画布。我这台手机是 1080P 的分辨率,density = 3,scaledWidth = 2250,scaledHeight = 4002,现在缩放后的画布宽高比手机屏幕大了太多,如果动画在这种尺寸下进行渲染肯定不行。所以 LottieAnimationView 加载 Composition 时判断了画布的宽高如果大于手机屏幕的宽高就进行等比例缩小:

 
  1. public void setComposition(@NonNull LottieComposition composition) {
  2.     if (L.DBG) {
  3.       Log.v(TAG, "Set Composition \n" + composition);
  4.     }
  5.     lottieDrawable.setCallback(this);
  6.     boolean isNewComposition = lottieDrawable.setComposition(composition);
  7.     if (!isNewComposition) {
  8.       // We can avoid re-setting the drawable, and invalidating the view, since the composition
  9.       // hasn't changed.
  10.       return;
  11.     }
  12.     //重点在这里,根据屏幕宽高对画布进行等比例缩放
  13.     int screenWidth = Utils.getScreenWidth(getContext());
  14.     int screenHeight = Utils.getScreenHeight(getContext());
  15.     int compWidth = composition.getBounds().width();
  16.     int compHeight = composition.getBounds().height();
  17.     //如果画布的宽高大于屏幕宽高,计算缩放比
  18.     if (compWidth > screenWidth ||
  19.         compHeight > screenHeight) {
  20.       float xScale = screenWidth / (float) compWidth;
  21.       float yScale = screenHeight / (float) compHeight;
  22.       //按比例缩小
  23.       setScale(Math.min(xScale, yScale));
  24.       Log.w(L.TAG, String.format(
  25.           "Composition larger than the screen %dx%d vs %dx%d. Scaling down.",
  26.           compWidth, compHeight, screenWidth, screenHeight));
  27.     }
  28.     // If you set a different composition on the view, the bounds will not update unless
  29.     // the drawable is different than the original.
  30.     setImageDrawable(null);
  31.     setImageDrawable(lottieDrawable);
  32.     this.composition = composition;
  33.     requestLayout();
  34. }

计算出宽和高的缩放比后,为了让画布小于屏幕,所以取较小的一个比例,调用 setScale 方法将缩放比设置到 lottieDrawable 上:

 
  1. public void setScale(float scale) {
  2.     lottieDrawable.setScale(scale);
  3.     if (getDrawable() == lottieDrawable) {
  4.       setImageDrawable(null);
  5.       setImageDrawable(lottieDrawable);
  6.     }
  7. }

lottieDrawable 的 setScale 方法保存了缩放比,并且更新了绘制的矩形范围:

 
  1. public void setScale(float scale) {
  2.     this.scale = scale;
  3.     updateBounds();
  4. }

这里可以看到矩形的范围是根据画布的宽高进行了等比例的缩放

 
  1. private void updateBounds() {
  2.     if (composition == null) {
  3.       return;
  4.     }
  5.     setBounds(0, 0, (int) (composition.getBounds().width() * scale),
  6.         (int) (composition.getBounds().height() * scale));
  7. }

现在画布被缩放了,然而背景图呢?来看一下 lottieDrawable 的绘制代码:

 
  1. public void draw(@NonNull Canvas canvas) {
  2.     if (compositionLayer == null) {
  3.       return;
  4.     }
  5.     matrix.reset();
  6.     matrix.preScale(scale, scale);
  7.     compositionLayer.draw(canvas, matrix, alpha);
  8.   }

lottieDrawable 在绘制的时候对 matrix 设置了缩放比,然后调用了 compositionLayer 去进行具体的绘制。这个 compositionLayer 就是所有图层的一个组合,它有一个List<BaseLayer> layers属性, 这个属性就是 json 文件里面的layers节点解析出来的图层列表,每个图层中间还包含一些属性动画。compositionLayer.draw(canvas, matrix, alpha)方法中主要调用了 drawLayer 抽象方法由图层的具体实现类执行绘制:

 
  1. void drawLayer(Canvas canvas, Matrix parentMatrix, int parentAlpha) {
  2.     for (int i = layers.size() - 1; i >= 0 ; i--) {
  3.       layers.get(i).draw(canvas, parentMatrix, parentAlpha);
  4.     }
  5. }

可以看到 CompositionLayer 类的 drawLayer 方法遍历 layers 集合进行循环绘制,这里是使用图片文件做的动画,对应的 Layer 实现类为 ImageLayer。 看下 ImageLayer 的绘制方法:

 
  1. public void drawLayer(@NonNull Canvas canvas, Matrix parentMatrix, int parentAlpha) {
  2.     Bitmap bitmap = getBitmap();
  3.     if (bitmap == null) {
  4.       return;
  5.     }
  6.     paint.setAlpha(parentAlpha);
  7.     canvas.save();
  8.     canvas.concat(parentMatrix);
  9.     src.set(0, 0, bitmap.getWidth(), bitmap.getHeight());
  10.     dst.set(0, 0, (int) (bitmap.getWidth() * density), (int) (bitmap.getHeight() * density));
  11.     canvas.drawBitmap(bitmap, src, dst , paint);
  12.     canvas.restore();
  13. }

getBitmap() 方法会调用到一开始设置的代理类 ImageAssetDelegate ,从 SD 卡加载图片。

代码中调用了 canvas 的save、restore方法来进行图层的叠加绘制,从 lottieDrawable 的draw方法传递下来的matrix用到了concat方法上,对 bitmap 进行了等比缩放。

整个流程跑下来,Lottie 库的动画渲染机制已经基本了解,背景图没有全屏展示的原因如下:

背景图的长宽比是 16 : 9,手机屏幕的长宽比也是 16 : 9,但是因为底部的虚拟按键占了一部分的高度,屏幕可用空间的长宽比大约为 3 : 2,所以导致背景图不能铺满屏幕

全屏适配

动画不能全屏有两种情况,一种是手机长宽比和画布的长宽比是相同的,但是状态栏、导航栏占了屏幕一部分空间导致不能全屏,使用方案一可以解决问题。还有一种情况是手机屏幕长宽比和画布的长宽比就是不一样,毕竟 Android 机型这么多,有几台奇葩手机很正常,那么使用方案二可以实现全屏。

方案一

在执行动画的界面隐藏虚拟按键,或者将虚拟按键设置为透明浮在布局上面,这样屏幕的长宽比和画布的长宽比一样就没有问题。目前市面上的手机基本上都是 720P、1080P、2K 等分辨率,这些分辨率都是 16 : 9 的尺寸。

状态栏和虚拟按键透明悬浮在布局上面,设置样式:

 
  1. <style name="Theme">
  2.         <item name="android:windowTranslucentStatus">true</item>
  3.         <item name="android:windowTranslucentNavigation">true</item>
  4. </style>

隐藏虚拟按键通过代码设置:

 
  1. Window window = getWindow();
  2. int visibility = View.SYSTEM_UI_FLAG_HIDE_NAVIGATION |
  3.                 View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN |
  4.                 View.SYSTEM_UI_FLAG_LAYOUT_STABLE |
  5.                 View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION;
  6. if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
  7.     window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
  8.     window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS);
  9.     window.setStatusBarColor(ContextCompat
  10.                     .getColor(getActivity(), android.R.color.transparent));
  11.     visibility |= View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY;
  12. } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
  13.     window.addFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS);
  14.     visibility |= View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY;
  15. }
  16. window.getDecorView().setSystemUiVisibility(visibility);

使用代码隐藏虚拟按键需要注意一点:界面的切换会导致 setSystemUiVisibility() 的设置被清空,最好是在 onResume() 或者 onWindowFocusChanged() 方法中进行设置。

方案二

如果要适配其他长宽比的屏幕,咋办呢?两行代码解决问题,只不过图片有一部分会被裁剪。设置控件的宽高为match_parent,设置android:scaleType为centerCrop

 
  1. <com.airbnb.lottie.LottieAnimationView
  2.         android:id="@+id/lottieAnimationView"
  3.         android:layout_width="match_parent"
  4.         android:layout_height="match_parent"
  5.         android:scaleType="centerCrop"/>

总结

Lottie 发布才几个月,很多功能还不够完善,缓存机制也比较弱,像这种从 SD 卡动态加载的方式,需要自己去实现缓存逻辑。但是这点小瑕疵掩盖不了牛逼的事实,就目前这个需求来说,已经大大的降低了开发成本。只不过设计师们需要好好练练 AE 了,动画炫不炫就看设计师给不给力啦

猜你喜欢

转载自blog.csdn.net/Zhangshiting/article/details/81985451