Fish-Lottie:纯Dart如何实现一个高性能动画框架?

2021年09月15日 阅读数:4
这篇文章主要向大家介绍Fish-Lottie:纯Dart如何实现一个高性能动画框架?,主要内容包括基础应用、实用技巧、原理机制等方面,希望对大家有所帮助。

做者:岑彧android

背景

​Lottie是一个由Airbnb开源的横跨Android,iOS,Web等多端的一个动画方案,它以JSON的方式解决了开发者对复杂动画实现的开发成本问题。json

众所周知,闲鱼团队是比较早在客户端侧选择Flutter方案的技术团队,当前的闲鱼工程里也包含不少的Flutter界面。 而官方却一直没有提供Lottie-Flutter方案,当前也有一些第三方开发者提供了相关实现方案,基本上分为两种:canvas

  • 在Native端进行数据解析和渲染,再使用桥接的方式把渲染数据传输到Flutter端进行显示;
  • 在Flutter直接进行数据解析和使用Flutter绘图能力进行渲染显示。

不过当前已经开源的方案都存在一些问题,前者会在性能和显示存在一些问题,例如显示闪烁白屏。后者在一些能力支持上存在一些功能缺陷,例如不支持文本动画等。因此这一直是闲鱼团队乃至整个Flutter开发者团体的一个痛点。segmentfault

项目架构

闲鱼团队在调研了官方开源的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等属性就能够完成对动画的进一步定制。ide

工做流程

总体思路

设计师在使用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中对应的属性值,而后绘制出来的画面就会产生变化。经过这样不断的更新进度,而后从新获取当前进度对应的属性进行重绘,这样就能够实现动画的播放效果。

实现差别

组件层

Android端

对于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,自定义组件通常有三种方式:

  • 原生组件的组合

此处咱们显然不能使用这个方法,由于咱们须要获取系统提供的画布来进行绘制。

  • 实现CustomPainter

在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时根据父布局的约束进行尺寸适配,因此放弃了这个实现方案。

  • 自定义RenderObject

咱们都知道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来进行简单的动画播放就能够了。关键代码以下所示:

@override
  void paint(PaintingContext context, Offset offset) {
    if (_drawable == null) return;
    _drawable.draw(context.canvas, offset & size,
        fit: _fit, alignment: _alignment);
  }

//RenderLottie的paint方法

文本绘制

Android端

Android SDK里的Canvas提供了drawText的方法,可使用画布直接绘制文本。Android实现方案以下:

private void drawCharacter(String character, Paint paint, Canvas canvas) {
    if (paint.getColor() == Color.TRANSPARENT) {
      return;
    }
    if (paint.getStyle() == Paint.Style.STROKE && paint.getStrokeWidth() == 0) {
      return;
    }
    canvas.drawText(character, 0, character.length(), 0, 0, paint);
}

Flutter端

可是在Flutter的Canvas里却没有这种方法,经过调研以后咱们发现Flutter提供了一个专门的TextPainter来进行文本的绘制。Flutter实现方案以下:

void _drawCharacter(
      String character, TextStyle textStyle, Paint paint, Canvas canvas) {
    if (paint.color.alpha == 0) {
      return;
    }
    if (paint.style == PaintingStyle.stroke && paint.strokeWidth == 0) {
      return;
    }

    if (paint.style == PaintingStyle.fill) {
      textStyle = textStyle.copyWith(foreground: paint);
    } else if (paint.style == PaintingStyle.stroke) {
      textStyle = textStyle.copyWith(background: paint);
    }
    var painter = TextPainter(
      text: TextSpan(text: character, style: textStyle),
      textDirection: _textDirection,
    );
    painter.layout();
    painter.paint(canvas, Offset(0, -textStyle.fontSize));
}

贝塞尔曲线

Android 端

咱们在背景中提到过,贝塞尔曲线是组成动画的三元素之一。咱们的动画每每不是线性播放的,若是须要实现先快后慢这样的效果。咱们就须要在经过进度获取属性值的时候,使用贝塞尔曲线才能进行从进度到属性值的映射。Android SDK里提供了PathInterpolator来实现,咱们的JSON文件里使用两个控制点来描述贝塞尔曲线,咱们将这两个控制点的坐标传给PathInterpolator,而后在属性值获取的时候,调用插值器的getInterpolation就能够拿到映射后的值了。如下是关键方法实现:

interpolator = PathInterpolatorCompat.create(cp1.x, cp1.y, cp2.x, cp2.y);

public static Interpolator create(float controlX1, float controlY1,
            float controlX2, float controlY2) {
        if (Build.VERSION.SDK_INT >= 21) {
            return new PathInterpolator(controlX1, controlY1, controlX2, controlY2);
        }
        return new PathInterpolatorApi14(controlX1, controlY1, controlX2, controlY2);
}

public PathInterpolator(float controlX1, float controlY1, float controlX2, float 
                        controlY2) {
        initCubic(controlX1, controlY1, controlX2, controlY2);
}

private void initCubic(float x1, float y1, float x2, float y2) {
        Path path = new Path();
        path.moveTo(0, 0);
        path.cubicTo(x1, y1, x2, y2, 1f, 1f);
        initPath(path);
}

//Andorid内置贝塞尔曲线生成关键方法

Flutter端

而Flutter里没有提供这样现成的路径插值器,咱们只有根据源码来自行实现。查看Android相关源码以后,我发现咱们只须要将JSON里两个控制点的坐标传入Flutter path中的cubicTo方法就能够生成该贝塞尔曲线,而后再自行实现一个入参为时间t,结果为映射后进度p的方法就能够,而具体的实现参考PathInterpolator中的getInterpolation就能够完成。如下是关键方法实现:

interpolator = PathInterpolator.cubic(cp1.dx, cp1.dy, cp2.dx, cp2.dy);

factory PathInterpolator.cubic(
      double controlX1, double controlY1, double controlX2, double controlY2) {
    return PathInterpolator(
        _initCubic(controlX1, controlY1, controlX2, controlY2));
}

static Path _initCubic(
      double controlX1, double controlY1, double controlX2, double controlY2) {
    final path = Path();
    path.moveTo(0.0, 0.0);
    path.cubicTo(controlX1, controlY1, controlX2, controlY2, 1.0, 1.0);
    return path;
}

自定义Flutter贝塞尔曲线生成关键方法

效果对比

咱们当前已经使用fish-lottie实现了一个闭环Demo工程,在里面也一样选取了lottie-android工程里的lottie json文件来进行测试,发如今release包不管是从流畅度,仍是动画还原度上,都达到了官方示例App的水准,下面我会用一些动图来对比进行说明:

上述中,前者是使用fish-lottie在flutter页面播放的动画,后者是lottie-android在native页面播放的动画,不难看出fish-lottie不管是从渲染仍是播放,均可以达到和lottie-android媲美的程度。

上述中,前者是使用fish-lottie的动态文本动画,后者是lottie-android的动态文本动画,能够看出fish-lottie在动态的属性和文本实时渲染方面也能够提供不输于lottie-android的效果。并且由于咱们的文本绘制实现方案与原生有必定的差别,咱们能够更好的将字体样式接口暴露出来,让开发者不止能够对文本进行定制,在样式方面也能够进行实时动态定制,这是目前lottie-android没有提供的功能。

将来展望——从静态到交互

当前Lottie的使用场景都仅仅是一段动画的静态播放。例如点赞以后会出现大拇指的动画,收藏以后会出现心形的动画,最多经过进度来控制一些整个动画的播放。可是在实现整个框架的过程当中,我发现lottie-android其实已经具有一些可交互的能力,使用方法以下:

 val shirt = KeyPath("Shirt", "Group 5", "Fill 1")
 animationView.addValueCallback(shirt, LottieProperty.COLOR) { Colors.XXX } //需定制的颜色

以上代码实现的效果以下图所示:

lottie-android 实现方案

从以上的代码咱们能够看出,要想实现动态属性控制,咱们须要传入三个参数,第一个参数相似于一个定位符,须要经过路径的形式来定位到咱们想进行属性控制的矢量图形内容,第二个参数是一个属性枚举变量,它代表了咱们控制的属性类型,最后一个参数是一个回调函数,须要返回咱们动态改变的目标值。

由于上层组件层和lottie-android有比较大的差别,因此fish-lottie当前只完成了动画播放的能力支持,可交互能力正在开发当中。

fish-lottie 实现思路

由于上层组件的双端实现的差别性和UI构建特性,Flutter中咱们通常不会获取Widget的引用来调用它的方法。因此不能像lottie-android同样直接使用lottieAnimationView.addValueCallback()来进行动态属性控制,咱们在实现动画的进度控制的时候其实也遇到过同样的问题。因此咱们的实现思路这其实和AnimationCtroller同样,咱们也实现一个PropertiesController(属性控制器),把咱们须要修改的一系列的目标图形,目标属性和回调函数传递给这个控制器,再把这个控制器做为LottieAnimationView构造函数的一个参数传递给LottieDrawable,而后由这个属性控制器来发起目标图形绘制类的匹配和回调函数设置。底层的绘制类和帧动画类中的方法和lottie-android保持一致。基本的思路和lottie-android保持一致,只是LottieAnimationView再也不承担属性控制的责任,而是由PropertiesController来承担。

落地方向

有了交互能力,咱们再也不只能控制动画的播放了。咱们能够经过获取用户的点击触摸事件来进行动画上的反馈,以此来实现一些比较复杂的交互动画。

如上图所示,这个搜索框背景的动画效果若是开发者直接进行开发是很难实现的。而经过lottie咱们就有比较清晰的思路,制做一个流动的果冻背景动画,两个内容动画,一个黑夜星月动画,一个白天云彩动画,咱们能够经过点击事件来控制果冻背景动画背景在黑色和蓝紫渐变色之间进行切换,以及改变一下它的局部形状,还有两个内容动画的显示和隐藏。在点击第一个Pillow按钮时把果冻背景动画颜色切换为蓝紫渐变色,而后显示云彩动画。点击第二个Baby按钮时把果冻背景动画的背景色切换为黑色,而后显示星月动画。而后对于云彩动画的3D效果,咱们能够经过手机设备的陀螺仪传感器来获取手机的侧偏移角度,而后根据角度来改变云彩动画各个元素的位置。这样以前开发成本太高甚至没法实现的复杂交互动画效果,就能够经过lottie很轻松的实现出来了。

关注咱们,每周 3 篇移动干货&实践给你思考!