為什么需要擴展Lottie?
原生Lottie的不足
Lottie相信端側開發的同學一定非常熟悉,打一出世就技驚四座,直接將動畫開發的效率提高到了極高的級別,將我們開發從動畫的深淵中一把拽出,可以說沒有Lottie之前遇到動畫的項目頭發掉一地,有了Lottie后的動畫需求真就保溫杯里泡枸杞了。
于是我們可以慢慢欣賞Lottie呈現出以下效果
隨著業務迭代,設計師er將動畫又推向了一個新的高度,已經不僅僅滿足做一下展示型動畫了,他們想在更多的業務場景加入動畫來提高交互體驗,比如拆個紅包,砍個價等等
下圖拆紅包動畫供大家參考:
保溫杯是否還能握得住了?
我們的需求
如上圖所示是一個開紅包的動畫,動畫中的搶按鈕可點擊,紅包結果頁的優惠券信息是接口動態下發,下面的金幣,點贊數,星星數都是用戶獨有的,不知道大家工作中有沒有類似場景呢?
快手電商的場景下則有很多類似涉及動態業務數據的交互動畫,但這類需求我們就沒法繼續使用Lottie了,被迫又回歸到最原始的原生代碼方案,開發效率一下回到解放前,因此這類場景的開發效率亟待提高。
前期準備
方案調研
需求場景明確后接下來就是預研方案了,我們先對功能做個拆解可以發現我們動畫中需要滿足動態替換文本,且文本的背景需要自適應拉伸,應該還有其他場景比如貼圖的替換等,再加上按鈕的點擊交互事件。
了解到我們的目標功能后則需要從Lottie開放或半開放的能力中找到切入點
Lottie給我們提供了替換文本和貼圖的能力,這些能力是否能滿足我們的需求呢?
Lottie可以替換文本和貼圖,因此上述的動畫場景中文本可以動態替換
但做不到:
- 文本的背景自適應拉伸
- 倒計時等動態控件效果
- 支持按鈕點擊事件
簡單版方案
如果暫不考慮按鈕點擊事件的話(有一些比較粗糙的方案來做點擊)和動態控件效果(并不是非常普遍的場景),我們是否有方案可以支持上述功能呢?
我們把思維打開一下,這些動態數據是否和原生的一個xml布局填充數據后非常相似?那既然Lottie支持動態替換貼圖的話,我們是否可以動態生成貼圖然后再進行替換呢?
顯然是可以的,我們可以將動畫中所有動態的部分在動畫中用一張貼圖占位,然后運行時動態將布局轉換成貼圖對占位貼圖做一個替換,這樣我們的動畫就實現了業務數據的動態綁定了
寫了個簡單的demo驗證了該方案是可行的,如下圖
第一步:將動態布局生成bitmap(相關代碼網上很多)
/**
* 獲取已經顯示的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動態替換掉占位貼圖即可
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的繪制邏輯將內容繪制到傳入的畫布中即可
示例代碼:
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;
}
}
復制代碼
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變換數據全部代理給動態布局圖層即可
示例代碼:
//動態布局圖層繪制
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的反向矯正,我們同樣可以參考這塊的實現對區域做一個矯正處理即可
示例代碼:
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因為生命周期已經結束,畫布不在繼續刷新,所對應的驅動力就斷掉了,因此這種場景下我們應該怎么去解決呢?
只需提供一個重繪刷新接口給到控件自己去觸發即可
示例代碼:
/**
* 請求重繪
*/
public void redraw() {
LottieAnimationView lottieAnimationView = getLottieAnimationView();
lottieAnimationView.invalidate();
}
復制代碼
最終效果
最終效果入下圖(左原圖&慢放)
方案的收益
我們的Lottie擴展方案對我們來說有兩個非常大的收益
第一收益就是提效,如果沒有這套方案,我們就得回歸到使用最原生的代碼來實現動畫了,效率之低經歷過的朋友都有體會,至于擴展方案具體提效多少則和動畫的復雜度成正比,越復雜效果越好!
第二個收益就是對Lottie源碼的“掌控”能力,這里用了“掌控”一詞雖然有些托大,但確實只有把Lottie的實現原理全理解了才能對Lottie進行大刀闊斧的擴展,理解原理后我們對Lottie的一些問題都可以自行修改且還可以擴展更多的特性,比如讓Lottie支持音頻?甚至支持視頻資源等一些更高級的能力!
后續計劃
目前方案還有一些不太常見的場景不支持,比如動畫里嵌入一個滾動的列表,再比如動畫的分段播放邏輯(適合做互動小游戲),在后續開發中如果有遇到類似需求則會考慮把相關場景擴展支持下,我們也會同步把方案思路分享給大家,同時該方案也會陸續在我們內部其他項目組中試用,后期迭代穩定成熟后也會有開源的計劃。