背景
Lottie是一个由Airbnb开源的横跨Android,iOS,Web等多端的一个动画方案,它以JSON的方式解决了开发者对复杂动画实现的开发成本问题。
众所周知,闲鱼团队是比较早在客户端侧选择Flutter方案的技术团队,当前的闲鱼工程里也包含很多的Flutter界面。 而官方却一直没有提供Lottie-Flutter方案,当前也有一些第三方开发者提供了相关实现方案,基本上分为两种:
在Native端进行数据解析和渲染,再使用桥接的方式把渲染数据传输到Flutter端进行显示。
在Flutter直接进行数据解析和使用Flutter绘图能力进行渲染显示。
不过当前已经开源的方案都存在一些问题,前者会在性能和显示存在一些问题,例如显示闪烁白屏。后者在一些能力支持上存在一些功能缺陷,例如不支持文本动画等。所以这一直是闲鱼团队乃至整个Flutter开发者团体的一个痛点。
项目架构
闲鱼团队在调研了官方开源的lottie-android库之后,发现不管是数据解析能力,还是图形绘制能力。Flutter都提供了媲美Android的实现方案。所以参考lottie-android库实现了一个功能完备,性能优异的纯Dart Package来提供Flutter上的Lottie动画支持。
如上图所示,整个项目由基础模块,接口层和控件层构成,然后支持矢量图形,填充描边等能力,详情可见Lottie支持能力,支持的能力也和lottie-android大致相同。
基础模块
基础模块是与FlutterSDK提供的各种能力直接交互的地方,主要分为数据模型模块,动画绘制模块,数据解析模块和工具模块。首先对于整个框架来说,我们首先可以拿到包含整个动画信息的JSON文件,所以需要先经过我们的数据解析模块,把JSON文件里面包含的数据和信息解析并传递给数据模型模块,动画绘制模块负责拿到数据模型模块里的对象之后,调用Flutter提供的绘图能力来进行图形的绘制,而工具模块就主要负责获取屏幕信息,字符串处理,日志打印等工具类能力。
接口层
接口层主要负责JSON数据的输入和动画绘制控制和调用,JSON信息经过数据解析模块最终会生成一个LottieComposition对象,这个对象里承载着整个JSON的动画信息。然后将这个对象传递给LottieDrawable,然后LottieDrawable会把对象传递传递给动画绘制模块,这样动画绘制模块就可以拿到动画信息,然后LottieDrawable再调用动画绘制模块来进行动画的绘制和刷新。
组件层
组件层,这里主要是我们继承Flutter的Widget实现的自定义组件,也是框架暴露给开发者的接口。开发者只需要新建一个LottieAnimationView,并把JSON文件的路径传递给它,支持Asset,Url,File三种形式,然后再把LottieAnimationView像一个普通Widget放到FlutterUI里,就可以完成一个简单的Lottie动画播放器了,当然也会暴露动画的控制接口以及控件的布局接口,只需要在新建LottieAnimationView的时候传入AnimationController,width,height,alignment等属性就可以完成对动画的进一步定制。
工作流程
整体思路
设计师在使用AE制作一段动画时,这个动画其实是由不同的图层组成的,AE提供了多个图层供设计师选择,例如纯色层(通常当做背景)、形状层(绘制各种矢量图形)、文本层、图片层等,每一个图层都可以设置平移、旋转、放缩等变换。每个图层可能又包含多个元素,例如形状图层可能由多个基本矢量图形和钢笔路径图形组合成为一个具有设计感的图案,每个元素也可能包含自己的变换,除了基础变换之外,还可以设置颜色、形状这样的变换。以上图层和元素的动画就组成了一个完整的动画。
如上图所示,我们在AE中新建了一个纯色图层并填充上蓝色,然后新建了一个形状图层,并给这个形状图层添加了一个位移动画(即给形状图层1变换中的位置设置两个关键帧,并在关键帧上设置初始值和最终值),然后在形状图层中添加一个矩形路径和一个黄色的填充,然后同样的方法给矩形的大小和圆度设置动画,不过大小的关键帧为0秒到3秒,圆度的关键帧为3秒到5秒。所以就完成了一个矩形从左到右的同时,先变大然后变为圆形的动画。然后我们通过Lottie提供的BodyMovin插件将以上的动画导出为JSON格式的文件,这个JSON文件里就包含了刚刚我们的所有绘制和关键帧信息。
如上图所示,拿到这个JSON文件之后,我们首先通过了数据解析把设计师在AE中制作的各种图层信息和动画信息都解析传递给一个LottieComposition对象,然后LottieDrawable获取到这个LottieComposition对象并调用底层的Canvas来进行图形的绘制,通过AnimationBuilder来进行进度的控制,进度发生变化时通知Drawable进行重绘,绘制模块会获取到处于该进度时的各项属性值,然后就完成了动画的播放。
数据加载和显示
我们的组件层提供三种方式来进行JSON文件的获取,分别为asset(程序内置资源),url(网络资源),file(文件资源)。整个数据的加载和显示的流程图大致如下所示,省略了底层绘制的细节:
这里以fromAsset方式举例,其他两种的加载方式和这种相同,都统一由LottieCompositionFactory进行处理。这里我们根据构造函数的不同将将加载方式分为三种,即asset,file和url。然后根据类型的不同调用LottieCompositionFactory里的不同加载方法将对应的内置资源、网络资源和文件资源加载进来并进行JSON文件的解析,然后最终的产物是一个LottieComposition对象,这个对象经过异步加载解析,在解析完成之后会通知LottieAnimationView进行调用。我们将加载完成的LottieComposition对象传递给我们的绘制类,LottieDrawable会根据composition里的内容建立图层组,图层组里包含如形状,文本层等图层,和设计师在AE制作动画时创建的图层一一对应。每个图层有不同的绘制规则和方法,然后在LottieAnimationView里获取到系统的Canvas传递给LottieDrawable并调用draw方法。这样就可以使用系统画布绘制我们自己的动画内容了。
动画绘制与播放
完成了动画的加载与显示,我们还需要让画面动起来。我们通过AnimationBuilder的方式将AnimationController的value设置为LottieDrawable的progress,然后触发重绘使我们的底层通过progress去获取当前进度的各项动画属性,这样就可以实现动画的效果了。时序图大致如下所示:
我们在LottieAnimationView里通过Flutter内置的AnimationController来控制动画,其中forward方法可以让Animation的progress从零开始增加,这也是我们动画播放的开始。我们不断调用setProgress函数将动画的进度设置到各层,最终到达KeyframeAnimation层,更新当前进度。进度改变之后我们需要通知上层进行界面的重绘,最终将LottieDrawable里的一个isDirty的变量设为true。我们在setProgress函数里,在完成进度设置之后我们获取lottieDrawable的isDirty变量,如果这个变量为true,证明进度已经更新,此时我们调用重写的方法markNeedPaint(),这时候系统会标记当前组件为需要更新的组件,Flutter会调用我们重写的paint函数,对整个画面进行重绘。我们和显示的流程一样,一层层进行绘制,在底层我们会根据当前进度拿到KeyframeAnimation中对应的属性值,然后绘制出来的画面就会产生变化。通过这样不断的更新进度,然后重新获取当前进度对应的属性进行重绘,这样就可以实现动画的播放效果。
实现差异
安卓端组件层
对于lottie-android来说,AnimationView和Drawable组成了整个组件层。AnimationView继承于ImageView,LottieDrawable继承于Drawable。整个工作的流程和上面所说的基本相同,开发者在xml文件中写入LottieAnimationView并设置JSON文件资源路径。然后AnimationView会发起数据获取和解析,解析完成之后把Composition对象传递给LottieDrawable,然后调用重写的draw方法来进行动画展示。
然后整个动画的播放,暂停,进度等控制都是通过开发者在代码中获取AnimationView的引用然后调用各种方法来完成的,但是其实真正的动画控制是由LottieDrawable里的ValueAnimator来控制的。在初始化LottieDrawable的同时也会创建ValueAnimator,它会产生一个0~1的插值,根据不同的插值来设置当前动画进度。LottieAnimationView里的暂停,播放等动画控制方法其实就是调用了这个ValueAnimator自身的对应方法来实现动画的控制。
Flutter组件层
对于Flutter来说,并没有提供类似于ImageView和Drawable这样的组件让我们继承和重写,我们需要自定义一个Widget,自定义组件一般有三种方式:
此处我们显然不能使用这个方法,因为我们需要获取系统提供的画布来进行绘制。
在Flutter中,提供了一个自绘UI的接口CustomPainter,这个接口会提供一块2D画布Canvas,Canvas内部封装了一些基本绘制的API,开发者可以通过Canvas绘制各种自定义图形。我们可以在重写的paint方法中获取到系统的canvas,然后把这个canvas传递给我们的LottieDrawable就可以完成动画的绘制了,然后在属性变化时导致画面需要刷新时在shouldRepaint返回true。但是这个方案会有一些问题无法解决,我们都知道整个LottieAnimationView是作为一个Widget嵌入到FlutterUI当中的,我们往往需要自定义动画播放区域(即LottieAnimationView)的大小,但是当开发者没有设定这个宽高值的时候或者是设定的尺寸大于父布局的尺寸的时候,我们也要根据父布局对子布局的约束来进行尺寸的适配和转换。但是在Flutter提供的这个CustomPainter中,没有暴露相应的接口让我们获取到这个Widget所对应的RenderObject的constraint属性,也就无法在开发者没有设置LottieAnimationView自身的width和height时根据父布局的约束进行尺寸适配,所以放弃了这个实现方案。
我们都知道Flutter中的Widget只是一些轻量的样式配置信息,真正进行图形渲染的类是RenderObject。所以我们自然也可以重写这个RenderObject类中的paint方法来获取系统画布来进行绘制。这个方案会比上一个方案复杂一些,我们需要先定义一个继承于RenderBox的RenderLottie类,然后重写paint方法来把系统的canvas传递给LottieDrawable,在需要进行刷新的地方调用markNeedPaint方法,就可以完成界面重绘。然后对于RenderObject来说,我们可以获取到当前组件的constraint属性,也就是在开发者没有设置LottieAnimationView的尺寸或者是设置的尺寸超出复布局的时候我们也可以自适应父布局的尺寸了。接下来需要定义一个继承于LeafRenderObjectWidget的组件LeafRenderLottie并重写createRenderObject方法并返回RenderLottie对象,重写updateRenderObject方法更新RenderLottie的进度等各项属性。这就完成了一个LottieWidget的实现。那我们如何来进行动画的播放控制呢,我们的LottieAnimationView是作为一个Widget嵌入到FlutterUI当中的,一般不会去获取它的引用来调用方法,那我们就传入一个Flutter提供的AnimationController,然后在LottieAnimationView的build方法中返回一个AnimationBuilder并把AnimationController的进度值传给LeafRenderLottie,如果开发者没有传入AnimationController,我们就提供一个默认的controller来进行简单的动画播放就可以了。关键代码如下所示:
1. `@override` 2. `void paint(PaintingContext context, Offset offset) {` 3. `if(_drawable == null) return;` 4. `_drawable.draw(context.canvas, offset & size,` 5. `fit: _fit, alignment: _alignment);` 6. `}` 8. `//RenderLottie的paint方法`
安卓端文本绘制
Android SDK里的Canvas提供了drawText的方法,可以使用画布直接绘制文本。Android实现方案如下:
1. `privatevoid drawCharacter(String character, Paint paint, Canvas canvas) {` 2. `if(paint.getColor() == Color.TRANSPARENT) {` 3. `return;` 4. `}` 5. `if(paint.getStyle() == Paint.Style.STROKE && paint.getStrokeWidth() == 0) {` 6. `return;` 7. `}` 8. `canvas.drawText(character, 0, character.length(), 0, 0, paint);` 9. `}`
Flutter文本绘制
但是在Flutter的Canvas里却没有这种方法,通过调研之后我们发现Flutter提供了一个专门的TextPainter来进行文本的绘制。Flutter实现方案如下:
1. `void _drawCharacter(` 2. `String character, TextStyle textStyle, Paint paint, Canvas canvas) {` 3. `if(paint.color.alpha == 0) {` 4. `return;` 5. `}` 6. `if(paint.style == PaintingStyle.stroke && paint.strokeWidth == 0) {` 7. `return;` 8. `}` 10. `if(paint.style == PaintingStyle.fill) {` 11. `textStyle = textStyle.copyWith(foreground: paint);` 12. `} elseif(paint.style == PaintingStyle.stroke) {` 13. `textStyle = textStyle.copyWith(background: paint);` 14. `}` 15. `var painter = TextPainter(` 16. `text: TextSpan(text: character, style: textStyle),` 17. `textDirection: _textDirection,` 18. `);` 19. `painter.layout();` 20. `painter.paint(canvas, Offset(0, -textStyle.fontSize));` 21. `}`
安卓端贝塞尔曲线
我们在背景中提到过,贝塞尔曲线是组成动画的三元素之一。我们的动画往往不是线性播放的,如果需要实现先快后慢这样的效果。我们就需要在通过进度获取属性值的时候,使用贝塞尔曲线才能进行从进度到属性值的映射。Android SDK里提供了PathInterpolator来实现,我们的JSON文件里使用两个控制点来描述贝塞尔曲线,我们将这两个控制点的坐标传给PathInterpolator,然后在属性值获取的时候,调用插值器的getInterpolation就可以拿到映射后的值了。以下是关键方法实现:
1. `interpolator = PathInterpolatorCompat.create(cp1.x, cp1.y, cp2.x, cp2.y);` 3. `public static Interpolator create(float controlX1, float controlY1,` 4. `float controlX2, float controlY2) {` 5. `if(Build.VERSION.SDK_INT >= 21) {` 6. `return new PathInterpolator(controlX1, controlY1, controlX2, controlY2);` 7. `}` 8. `return new PathInterpolatorApi14(controlX1, controlY1, controlX2, controlY2);` 9. `}` 11. `public PathInterpolator(float controlX1, float controlY1, float controlX2, float` 12. `controlY2) {` 13. `initCubic(controlX1, controlY1, controlX2, controlY2);` 14. `}` 16. `private void initCubic(float x1, float y1, float x2, float y2) {` 17. `Path path = newPath();` 18. `path.moveTo(0, 0);` 19. `path.cubicTo(x1, y1, x2, y2, 1f, 1f);` 20. `initPath(path);` 21. `}` 23. `//Andorid内置贝塞尔曲线生成关键方法`
而Flutter里没有提供这样现成的路径插值器,我们只有根据源码来自行实现。查看Android相关源码之后,我发现我们只需要将JSON里两个控制点的坐标传入Flutter path中的cubicTo方法就可以生成该贝塞尔曲线,然后再自行实现一个入参为时间t,结果为映射后进度p的方法就可以,而具体的实现参考PathInterpolator中的getInterpolation就可以完成。以下是关键方法实现:
1. `interpolator = PathInterpolator.cubic(cp1.dx, cp1.dy, cp2.dx, cp2.dy);` 3. `factory PathInterpolator.cubic(` 4. `double controlX1, double controlY1, double controlX2, double controlY2) {` 5. `return PathInterpolator(` 6. `_initCubic(controlX1, controlY1, controlX2, controlY2));` 7. `}` 9. `staticPath _initCubic(` 10. `double controlX1, double controlY1, double controlX2, double controlY2) {` 11. `final path = Path();` 12. `path.moveTo(0.0, 0.0);` 13. `path.cubicTo(controlX1, controlY1, controlX2, controlY2, 1.0, 1.0);` 14. `return path;` 15. `}` 17. `自定义Flutter贝塞尔曲线生成关键方法`
![image](https://imgconvert.csdnimg.cn/aHR0cHM6Ly91cGxvYWQtaW1hZ2VzLmppYW5zaHUuaW8vdXBsb2FkX2ltYWdlcy8yMzEyNDQ4Ni1kZWJmOTJhOTI5ODg3YTQ2?x-oss-process=image/format,png)
上述中,前者是使用fish-lottie在flutter页面播放的动画,后者是lottie-android在native页面播放的动画,不难看出fish-lottie无论是从渲染还是播放,都可以达到和lottie-android媲美的程度。
![image](https://imgconvert.csdnimg.cn/aHR0cHM6Ly91cGxvYWQtaW1hZ2VzLmppYW5zaHUuaW8vdXBsb2FkX2ltYWdlcy8yMzEyNDQ4Ni05YTljMWE5MDlmYzUxZWMy?x-oss-process=image/format,png)
上述中,前者是使用fish-lottie的动态文本动画,后者是lottie-android的动态文本动画,可以看出fish-lottie在动态的属性和文本实时渲染方面也可以提供不输于lottie-android的效果。而且因为我们的文本绘制实现方案与原生有一定的差异,我们可以更好的将字体样式接口暴露出来,让开发者不止可以对文本进行定制,在样式方面也可以进行实时动态定制,这是目前lottie-android没有提供的功能。
后续展望——从静态到交互
当前Lottie的使用场景都仅仅是一段动画的静态播放。例如点赞之后会出现大拇指的动画,收藏之后会出现心形的动画,最多通过进度来控制一些整个动画的播放。但是在实现整个框架的过程中,我发现lottie-android其实已经具备一些可交互的能力,使用方法如下:
1. `val shirt = KeyPath("Shirt", "Group 5", "Fill 1")` 2. `animationView.addValueCallback(shirt, LottieProperty.COLOR) { Colors.XXX } //需定制的颜色`
以上代码实现的效果如下图所示:
因为上层组件的双端实现的差异性和UI构建特性,Flutter中我们一般不会获取Widget的引用来调用它的方法。所以不能像lottie-android一样直接使用lottieAnimationView.addValueCallback()来进行动态属性控制,我们在实现动画的进度控制的时候其实也遇到过一样的问题。所以我们的实现思路这其实和AnimationCtroller一样,我们也实现一个PropertiesController(属性控制器),把我们需要修改的一系列的目标图形,目标属性和回调函数传递给这个控制器,再把这个控制器作为LottieAnimationView构造函数的一个参数传递给LottieDrawable,然后由这个属性控制器来发起目标图形绘制类的匹配和回调函数设置。底层的绘制类和帧动画类中的方法和lottie-android保持一致。基本的思路和lottie-android保持一致,只是LottieAnimationView不再承担属性控制的责任,而是由PropertiesController来承担。
落地方向
有了交互能力,我们不再只能控制动画的播放了。我们可以通过获取用户的点击触摸事件来进行动画上的反馈,以此来实现一些比较复杂的交互动画。
如上图所示,这个搜索框背景的动画效果如果开发者直接进行开发是很难实现的。而通过lottie我们就有比较清晰的思路,制作一个流动的果冻背景动画,两个内容动画,一个黑夜星月动画,一个白天云彩动画,我们可以通过点击事件来控制果冻背景动画背景在黑色和蓝紫渐变色之间进行切换,以及改变一下它的局部形状,还有两个内容动画的显示和隐藏。在点击第一个Pillow按钮时把果冻背景动画颜色切换为蓝紫渐变色,然后显示云彩动画。点击第二个Baby按钮时把果冻背景动画的背景色切换为黑色,然后显示星月动画。然后对于云彩动画的3D效果,我们可以通过手机设备的陀螺仪传感器来获取手机的侧偏移角度,然后根据角度来改变云彩动画各个元素的位置。这样之前开发成本过高甚至无法实现的复杂交互动画效果,就可以通过lottie很轻松的实现出来了。