写点什么

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

  • 2020-08-07
  • 本文字数:8049 字

    阅读完需:约 26 分钟

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

背景

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 动画支持。



fish-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,自定义组件一般有三种方式:


  • 原生组件的组合

  • 此处我们显然不能使用这个方法,因为我们需要获取系统提供的画布来进行绘制。

  • 实现 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 来进行简单的动画播放就可以了。

  • 关键代码如下所示:

安卓端文本绘制

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


privatevoid 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);} elseif(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 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 = newPath(); 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));}

staticPath _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 很轻松的实现出来了。


本文转载自公众号淘系技术(ID:AlibabaMTT)。


原文链接


https://mp.weixin.qq.com/s?__biz=MzAxNDEwNjk5OQ==&mid=2650409308&idx=1&sn=2d333a536a9de91b92118ad88457eaeb&chksm=8396c144b4e14852e508588972a97840a4237e90385f6c9cdcd10ec9cf3a609b96559d80a1c6&scene=27#wechat_redirect


2020-08-07 10:051548

评论

发布
暂无评论
发现更多内容
Fish-Lottie: 纯Dart如何实现一个高性能动画框架?_开源_岑彧_InfoQ精选文章