支持點擊交互的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] <<<, 備註我的花名成功率更高哦~ 😘