支援點選互動的Lottie-Android篇

語言: CN / TW / HK

theme: orange highlight: a11y-dark


為什麼需要擴充套件Lottie?

原生Lottie的不足

Lottie相信端側開發的同學一定非常熟悉,打一出世就技驚四座,直接將動畫開發的效率提高到了極高的級別,將我們開發從動畫的深淵中一把拽出,可以說沒有Lottie之前遇到動畫的專案頭髮掉一地,有了Lottie後的動畫需求真就保溫杯裡泡枸杞了。

於是我們可以慢慢欣賞Lottie呈現出以下效果

隨著業務迭代,設計師er將動畫又推向了一個新的高度,已經不僅僅滿足做一下展示型動畫了,他們想在更多的業務場景加入動畫來提高互動體驗,比如拆個紅包,砍個價等等

下圖拆紅包動畫供大家參考:

保溫杯是否還能握得住了?

我們的需求

如上圖所示是一個開紅包的動畫,動畫中的搶按鈕可點選,紅包結果頁的優惠券資訊是介面動態下發,下面的金幣,點贊數,星星數都是使用者獨有的,不知道大家工作中有沒有類似場景呢?

快手電商的場景下則有很多類似涉及動態業務資料的互動動畫,但這類需求我們就沒法繼續使用Lottie了,被迫又迴歸到最原始的原生程式碼方案,開發效率一下回到解放前,因此這類場景的開發效率亟待提高。

前期準備

方案調研

需求場景明確後接下來就是預研方案了,我們先對功能做個拆解可以發現我們動畫中需要滿足動態替換文字,且文字的背景需要自適應拉伸,應該還有其他場景比如貼圖的替換等,再加上按鈕的點選互動事件。

瞭解到我們的目標功能後則需要從Lottie開放或半開放的能力中找到切入點

Lottie給我們提供了替換文字和貼圖的能力,這些能力是否能滿足我們的需求呢?

Lottie可以替換文字和貼圖,因此上述的動畫場景中文字可以動態替換

但做不到: - 文字的背景自適應拉伸 - 倒計時等動態控制元件效果 - 支援按鈕點選事件

簡單版方案

如果暫不考慮按鈕點選事件的話(有一些比較粗糙的方案來做點選)和動態控制元件效果(並不是非常普遍的場景),我們是否有方案可以支援上述功能呢?

我們把思維開啟一下,這些動態資料是否和原生的一個xml佈局填充資料後非常相似?那既然Lottie支援動態替換貼圖的話,我們是否可以動態生成貼圖然後再進行替換呢?

顯然是可以的,我們可以將動畫中所有動態的部分在動畫中用一張貼圖佔位,然後執行時動態將佈局轉換成貼圖對佔位貼圖做一個替換,這樣我們的動畫就實現了業務資料的動態綁定了

寫了個簡單的demo驗證了該方案是可行的,如下圖

第一步:將動態佈局生成bitmap(相關程式碼網上很多) ```java /* * 獲取已經顯示的view的bitmap * @param view * @return / public static Bitmap getCacheBitmapFromView(View view) { final boolean drawingCacheEnabled = true; view.setDrawingCacheEnabled(drawingCacheEnabled); view.buildDrawingCache(drawingCacheEnabled); final Bitmap drawingCache = view.getDrawingCache(); Bitmap bitmap = null; if (drawingCache != null) { bitmap = Bitmap.createBitmap(drawingCache); view.setDrawingCacheEnabled(false); } return bitmap; }

/* * 獲取未顯示的view的bitmap * @param view * @param width * @param height * @return / public static Bitmap getBitmapFromView(View view, int width, int height) { layoutView(view, width, height); return getCacheBitmapFromView(view); }

/* * 佈局控制元件 * @param view * @param width * @param height / private static void layoutView(View view, int width, int height) { view.layout(0, 0, width, height); int measuredWidth = View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY); int measuredHeight = View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY); view.measure(measuredWidth, measuredHeight); view.layout(0, 0, view.getMeasuredWidth(), view.getMeasuredHeight()); } ```

第二步:通過LottieAssetDelegate動態替換掉佔位貼圖即可

```java public class LottieAssetDelegate implements ImageAssetDelegate {

private Context context; private String replaceImgName; private Bitmap replaceBitmap; private String imagesFolder;

public LottieAssetDelegate(Context context, String replaceImgName, Bitmap replaceBitmap, String imagesFolder) { this.context = context; this.replaceImgName = replaceImgName; this.replaceBitmap = replaceBitmap; if (!TextUtils.isEmpty(imagesFolder) && imagesFolder.charAt(imagesFolder.length() - 1) != '/') { this.imagesFolder = imagesFolder + '/'; } else { this.imagesFolder = imagesFolder; } }

@Nullable @Override public Bitmap fetchBitmap(LottieImageAsset asset) { if (replaceImgName.equals(asset.getFileName())) { return replaceBitmap; } return getBitmap(asset); }

private Bitmap getBitmap(LottieImageAsset asset) {

Bitmap bitmap = null;
String filename = asset.getFileName();
BitmapFactory.Options opts = new BitmapFactory.Options();
opts.inScaled = true;
opts.inDensity = 160;
InputStream is;
try {
  is = context.getAssets().open(imagesFolder + filename);
} catch (IOException e) {
  return null;
}
try {
  bitmap = BitmapFactory.decodeStream(is, null, opts);
} catch (IllegalArgumentException e) {
  return null;
}
return bitmap;

} }

```

進階版方案

簡單版方案可以滿足一些需求,但是不夠完美,很多場景受限,如果我需要替換的部分是一個倒計時呢?如上圖裡面的優惠券即將過期的哪個文字是個倒計時,設計師需要倒計時執行起來的,但簡單版的方案因為是生成靜態貼圖無法做到更新,所以簡單版本的方案是還不錯,但總覺得沒有血肉,不夠健壯有力!

成年人的世界為什麼不能全都要?我們要支援未來可能遇到的所有場景,我們要完美的支援點選,我們要完美的支援動態業務資料,我們也要完美的支援動態元件,我們要Lottie能像我們希望的那樣支援我們的功能。

那就讓我們把思路徹底開啟,是否可以將佔位貼圖替換成原生的佈局控制元件呢? 也即是在渲染佔位貼圖的時候直接換成渲染原生布局,這樣動畫和原生布局就無縫銜接在一起

原理示例圖

我們的選擇

| | 場景覆蓋 | 業務邏輯 | 動態佈局 | 點選互動 | 擴充套件性 | | --- | ---- | ---- | ---- | ---- | --- | | 簡單版 | 60% | 支援 | 不支援 | 不支援 | 差 | | 進階版 | 100% | 支援 | 支援 | 支援 | 好 |

和簡單版方案做個對比就可以很容易做出選擇

方案介紹

核心原理

方案的核心原理是建立一個動態佈局圖層DynamicLayoutLayer,和Lottie裡面支援的ImageLayer、TextLayer、CompostionLayer一樣,由自己來實現繪製邏輯,然後在執行期間hook原動畫佔位圖層(ImageLayer),替換成DynamicLayoutLayer,佔位圖層上所有屬性變換都代理到DynamicLayoutLayer上,從而實現無縫替換。

類圖如下:

核心問題

要實現該方案需要解決其中幾個核心的問題,首先要解決圖層的同層渲染問題讓替換的圖層和原始佔位圖層在同一個層級進行渲染,才能實現無縫銜接,其次原始圖層的動畫效果也需要同步給替換的圖層,這樣作用在原始圖層上的動畫變換效果才能在替換圖層上體現,最後需要解決下點選互動事件和佈局動態重新整理的問題,才能完整的支援所有需求場景,下面會對每個核心問題做詳細方案分析。

下文貼的程式碼均非正式程式碼,只做大致原理理解

  • 同層渲染

Lottie的每個圖層都會呼叫自身的draw來繪製到canvas上,如果要做到替換後實現同層渲染則也需要將native控制元件按照佔點陣圖層層級繪製到Lottie的canvas上,因此我們的解決方案就是將佔點陣圖層的繪製代理到DynamicLayoutLayer,將Lottie的畫布傳入,然後呼叫DynamicLayoutLayer的繪製邏輯將內容繪製到傳入的畫布中即可

示例程式碼:

java static BaseLayer forModel(       Layer layerModel, LottieDrawable drawable, LottieComposition composition) {     switch (layerModel.getLayerType()) {       case SHAPE:         return new ShapeLayer(drawable, layerModel);       case PRE_COMP:         return new CompositionLayer(drawable, layerModel,             composition.getPrecomps(layerModel.getRefId()), composition);       case SOLID:         return new SolidLayer(drawable, layerModel);       case IMAGE:         //判斷是否是動態佈局圖層 是則替換成DynamicLayoutLayer         if (isDynamicLayout(layerModel)) {           return new DynamicLayoutLayer(drawable, layerModel);         }         return new ImageLayer(drawable, layerModel);       case NULL:         return new NullLayer(drawable, layerModel);       case TEXT:         return new TextLayer(drawable, layerModel);       case UNKNOWN:       default:         // Do nothing         L.warn("Unknown layer type " + layerModel.getLayerType());         return null;     }   }

```java public class DynamicLayoutLayer extends BaseLayer{

......   @Override   void drawLayer(Canvas canvas, Matrix parentMatrix, int parentAlpha) {     //動態佈局繪製   } } ```

  • 動畫同步

替換後圖層的層級問題解決了,但是圖層上繫結的動畫也需要同步到替換圖層上,這是我們需要解決的第二個難題,動畫的問題我們需要從Lottie動畫的原理來入手,需要了解兩個概念幀時間軸和Matrix變換

幀時間軸

Lottie動畫資料是由無數個關鍵幀組成的,設計師在每一個關鍵幀上設定屬性資料,則兩個關鍵幀之間就是資料的變換,我把這個稱做幀時間軸,Lottie動畫的原理就是隨著幀軸執行時計算出當前幀的屬性資料,再把資料設定給圖層,通過每個圖層在對應幀同步對應的屬性資料從而達到動畫的效果。

舉個簡單的例子,我在第1幀設定了一個縮放的關鍵幀,資料設定成100%,然後在第5幀上設定一個縮放關鍵幀,資料設定成50%,再在第10幀設定縮放關鍵幀,資料150%,則呈現出來的動畫效果就是該圖層從開始原始大小在5幀的時間內縮小到50%,再5幀的時間內從50%放大到150%,然後再動畫執行的時候隨著動畫播放到的幀數計算當前幀的資料,比如第一幀的時候資料為100%,然後播放到第2幀的時候計算出資料為90%,把資料設定給圖層,以此類推每一幀都計算出自己的資料進行設定,串起來就形成的動畫效果

Matrix變換

Matrix是一種矩陣變換,一般影象處理上會使用到,在Android中也有大量應用場景,我們熟知的View的一些屬性變換效果都是Matrix來實現的,通過Matrix的變換可以改變View的屬性,比如縮放值、位移值、旋轉角度等,而Lottie的動畫效果也是使用Matrix資料變換來得到的,AE裡面匯出的資料會轉換成一組Matix,在每幀渲染的時候計算出對應的Matrix資料然後設定給layer,從而實現了圖層的屬性變換效果,而圖層就是組成Lottie動畫的基礎元素,所有圖層結合起來就是完整的Lottie動畫了

關於Matrix的相關知識點可自行學習,這裡只引入概念

通過對動畫原理的分析我們要解決動畫同步的問題就很簡單了,只需要將原本動畫中應用到佔位圖層上的基礎資料和matrix變換資料全部代理給動態佈局圖層即可

示例程式碼:

java //動態佈局圖層繪製 void drawLayer(Canvas canvas, Matrix parentMatrix, int parentAlpha) {   View view = getReplaceView();   //重點是下面這段程式碼,將matrix設定給畫布,再將原生控制元件繪製到當前畫布上   canvas.save();   canvas.concat(parentMatrix);    view.draw(canvas); }

  • 點選事件

解決了以上兩個問題,我們的方案大致完成了60%,但Lottie動畫的一個最大的痛點問題就是點選事件,大部分的Lottie動畫即便沒有動態的業務資料但是按鈕點選的需求是大概率會有的,而在之前我使用Lottie的時候遇到點選的需求則直接在Lottie動畫之上對應位置新增一個虛擬的點選區域,是不是很粗糙暴力?那如果使用我們現在這個方案那點選事件是不是就不是問題了?

其實還是有一點點小小的問題,因為我們的動態控制元件是替換佔位圖層的,動畫中會存在一些matrix的變換,變換後的控制元件位置就不是初始位置,也就是說你的matrix變換可能有位移或者縮放,導致點選區域錯位,那這個問題怎麼解決呢?

其實我們可以參考屬性動畫,為什麼屬性動畫縮放或者平移後點擊區域也跟著調整了呢?其實屬性動畫的內部有做一個matrix的反向矯正,我們同樣可以參考這塊的實現對區域做一個矯正處理即可

示例程式碼:

```java private MotionEvent getTransformedMotionEvent(MotionEvent event, View child) {     final float offsetX = mScrollX - child.mLeft;     final float offsetY = mScrollY - child.mTop;     final MotionEvent transformedEvent = MotionEvent.obtain(event);     transformedEvent.offsetLocation(offsetX, offsetY);     if (!child.hasIdentityMatrix()) {         transformedEvent.transform(child.getInverseMatrix());     }     return transformedEvent; }

public final Matrix getInverseMatrix() {     ensureTransformationInfo();     if (mTransformationInfo.mInverseMatrix == null) {         mTransformationInfo.mInverseMatrix = new Matrix();     }     final Matrix matrix = mTransformationInfo.mInverseMatrix;     mRenderNode.getInverseMatrix(matrix);     return matrix; }  ```

  • 佈局動態重新整理

支援以上3個功能就已經滿足我們大部分日常使用的場景了,畢竟Lottie設計之初就是給我提供一個動畫展示的框架,並不能支援各種定製和功能擴充套件,且他的生命週期則很明確動畫執行到結束(非迴圈動畫),如果動畫有130幀,那Lottie就是從第一幀開始渲染,到130幀渲染結束,但如果有超出這個生命週期的動態佈局還需要有更新則怎麼處理呢?比如我們上面紅包開出來優惠券的說明裡面的有效期不是靜態的文字而是一個倒計時,那在Lottie播放到最後一幀後這個倒計時控制元件就沒有辦法繼續走下去了,因為驅動倒計時重繪的是Lottie的畫布,Lottie因為生命週期已經結束,畫布不在繼續重新整理,所對應的驅動力就斷掉了,因此這種場景下我們應該怎麼去解決呢?

只需提供一個重繪重新整理介面給到控制元件自己去觸發即可

示例程式碼:

java /**  * 請求重繪  */ public void redraw() {   LottieAnimationView lottieAnimationView = getLottieAnimationView();   lottieAnimationView.invalidate(); }

最終效果

最終效果入下圖(左原圖&慢放)

方案的收益

我們的Lottie擴充套件方案對我們來說有兩個非常大的收益

第一收益就是提效,如果沒有這套方案,我們就得迴歸到使用最原生的程式碼來實現動畫了,效率之低經歷過的朋友都有體會,至於擴充套件方案具體提效多少則和動畫的複雜度成正比,越複雜效果越好!

第二個收益就是對Lottie原始碼的“掌控”能力,這裡用了“掌控”一詞雖然有些託大,但確實只有把Lottie的實現原理全理解了才能對Lottie進行大刀闊斧的擴充套件,理解原理後我們對Lottie的一些問題都可以自行修改且還可以擴充套件更多的特性,比如讓Lottie支援音訊?甚至支援視訊資源等一些更高階的能力!

後續計劃

目前方案還有一些不太常見的場景不支援,比如動畫裡嵌入一個滾動的列表,再比如動畫的分段播放邏輯(適合做互動小遊戲),在後續開發中如果有遇到類似需求則會考慮把相關場景擴充套件支援下,我們也會同步把方案思路分享給大家,同時該方案也會陸續在我們內部其他專案組中試用,後期迭代穩定成熟後也會有開源的計劃。

hi, 我是快手電商的HD

快手電商無線技術團隊正在招賢納士🎉🎉🎉! 我們是公司的核心業務線, 這裡雲集了各路高手, 也充滿了機會與挑戰. 伴隨著業務的高速發展, 團隊也在快速擴張. 歡迎各位高手加入我們, 一起創造世界級的電商產品~

熱招崗位: Android/iOS 高階開發, Android/iOS 專家, Java 架構師, 產品經理(電商背景), 測試開發... 大量 HC 等你來呦~

內部推薦請發簡歷至 >>>我們的郵箱: [email protected] <<<, 備註我的花名成功率更高哦~ 😘