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來進行簡單的動畫播放就可以了。

    關鍵代碼如下所示:

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

安卓端文本繪製

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章