動畫生萬物

一、動畫生萬物

動畫需求可能是很多Android開發者揮之不去的夢魘,是否還記得那些千奇百怪的動畫需求,記得那些與視覺埋頭苦幹的日日夜夜,記得獨自一人照著小視頻擼動畫的心酸歷程。不過非常抱歉,熟讀本文並非但不能解決上述問題,反而會讓上述過程更加痛苦,請意志不堅定者不要輕易嘗試。

動畫除了可以實現App里的5毛錢特效,實際跟UI性能息息相關。單純站在UI的角度看,App的本質就是響應用戶輸入產生UI變化的過程,而UI變化的過程就是廣義上的動畫過程。

比如說,我們可以把Scroller,computeScroll + invalidate的過程理解成一個逐幀動畫;可以把列表滑動,onTouchEvent + offsetTopAndBottom的過程也理解成一個持續性的動畫過程;同樣我們也可以利用在onLayout來不斷改變View的布局狀態,形成一個持續性的動畫;我們使用的Webp、Gif等動畫素材,Lottie、SVGA等動畫庫,本質不也是一個draw + invalidate的動畫過程;同時系統也默默為我們完成了很多動畫過程,比如隨處可見的水波紋動畫、比如Window發生切換時的窗口動畫。

有人可能會說,我們的App里不用動畫,我們就追求那種一閃而過的快感。那麼那些一閃而過的UI變化,嚴格來說是否也可以理解成只有1幀的動畫呢?

總而言之,無論有意無意,大部分UI變化的過程都需要依賴動畫來完成,可以說動畫過程就是UI變化的過程,優化UI性能本質就是優化動畫性能。

真可謂是動畫生萬物,萬物皆動畫。

二、動畫優化方向

前文說到了,廣義上的動畫性能優化就是UI性能優化,但本文會更聚焦於狹義上的動畫過程,只會涉及一些相關的內容(所以前面的內容大家白看了,我也白寫了)。不過,相信掌握動畫優化的原理和方法會讓理解UI優化事半功倍。

另外,說到Android中的動畫實現,首先繞不開是就是Choreographer中現行的這套垂直同步的繪製機制,但關於繪製渲染、垂直同步的介紹,網路上已經有很多講解了,本文就不再過多贅述,還不熟悉的小夥伴可以自行百度。

進入正題,在垂直同步的機制下,保持UI刷新流暢就只有一條,

每隔16ms把要顯示的內容準備好,繪製到屏幕上

這樣就引出兩個子因素:

a. 繪製的過程要短於16ms;

b. UI線程要保持空閑,及時響應Vsync信號。

對於動畫來說,控制動畫每一幀的繪製時間,往往就是要求我們選擇正確的動畫實現,控制最小的動畫區域;

而關於第二點,也是動畫和普通UI變化最大的不同點,動畫過程是一個相對連續的繪製過程,對UI線程空閑更加敏感,動畫過程中即使UI線程被短暫佔用,也會發生比較明顯的丟幀。

(不知道大家有沒有體會,關於動畫,實際有一個很大的痛點——很多時候,我們之所以需要顯示動畫,就是想利用動畫來掩蓋一些耗時操作,但如果這些耗時操作一定要放在UI線程實現(比如inflate新的布局、比如截圖),那動畫也是無法正常工作的。在這種場景下,你會發現我們根本沒有辦法來實現平滑的過度,卡頓是必然的。)

根據上面兩點,我們可以把動畫優化概括成下面二個方向:

1. 使用最合理的實現方式;

2. 排查不必要的UI線程耗時;

同時還有一個事半功倍的優化方向,但在實踐中感覺容易被遺漏,所以單列出來,

3. 關注首幀和尾幀繪製。

下文,我們會通過一個優化實例,講解下這三個方向。不過在此之前,我們先看一下動畫的常見實現方式都有哪些。

三、動畫天梯榜

如表1,自上而下,性能逐漸衰退。

表1 動畫性能天梯榜

從動畫實現的原理就可以看出它們性能和能力的差異:

  • 窗口動畫和Render Thread動畫,都是可以在非UI線程實現的動畫,由於前文提到的動畫跟UI線程耦合這個痛點,能跟UI線程解耦的動畫肯定是性能最好的,後面我們會再簡單討論一下這兩種動畫;
  • 屬性動畫,可以廣義的理解成可以直接操作View屬性——也就是RenderNode對應的Api——的動畫,這種動畫方式通過直接修改渲染線程的RenderProperty實現,可以避免觸發View Tree重繪,也就避免了UI線程繪製的過程,只需要Render線程重新渲染。但這種動畫的局限是只能使用RenderNode的Api,只有一些相對基本的矩陣變換,不能實現太過複雜的繪製需求;
  • 補間動畫,這是一種比較古老的動畫實現,是在硬體加速出現之前,用來優化繪製效率的一種動畫形式,它的性能高於直接重繪;但由於代碼向下兼容的原因,在硬體加速時代,它的性能會略低於屬性動畫;
  • Draw動畫和幀動畫,都是需要利用invalidate觸發View Tree重繪的動畫方式,這種動畫需要觸發UI線程重繪,自然也需要Render線程重新渲染,根據View Tree的具體結構,性能上的差異可能會比較大。但draw動畫的原理決定了它可以實現所有繪製指令的更新,所以這也是應用最普遍的一種動畫形式;
  • Layout動畫,是利用requestLayout觸發View Tree重新布局的動畫,這種動畫除了需要UI線程重繪,還需要對View Tree重新measure和layout,尤其布局結構不合理時,耗時是災難性的。

從榜單也可以看出,往往能力越強的動畫性能就越差,這也說明我們還是要根據實際需求來選擇最合適的動畫。

1、屬性動畫和補間動畫

前文已經提到,補間動畫是早期的動畫實現,所以設計的初衷是優化非硬體加速下的動畫效率。因此在非硬體加速的狀態下,實際它的效率才是最高的(高於屬性動畫和逐幀繪製);但在硬體加速的狀態下,它的功能已經被屬性動畫完美覆蓋。所以兩者到底怎麼選擇,還得看具體的情況,不過考慮到目前絕大部分情況下,都是硬體加速繪製,屬性動畫顯然更勝一籌。

補間動畫的種種實際跟View Tree的繪製流程息息相關,是了解繪製流程的一個很好的切入點。但在這裡就不多討論了,後續可能會專門寫一篇文章來對比兩者的差別。另外這兩種動畫最直觀的差異就是它們的基類不同,補間是Animation,屬性是Animator。

2、轉場動畫

在榜單中,有3種轉場相關的動畫實現,分別是Window切換動畫、ActivityOptions轉場動畫和Fragment切換動畫。

Window切換動畫不是運行在本進程,是在SystemServer中由WindowManager實現,所以Window的切換動畫可以完全不受App的UI線程影響,不過Window切換動畫的功能比較有限,使用場景也很固定。

ActivityOptions轉場動畫是普通Window切換動畫的升級版本,由於窗口動畫是跨進程實現,所以可支持的功能非常有限;而ActivityOptions轉場動畫是在本進程實現的,可以支持非常多的動畫效果,而且從源碼可以看出,ActivityOptions的內部基本都利用屬性動畫實現,而且用到了部分不太常用的API,大家有興趣可以閱讀一下相關源碼,應該會對動畫優化很有幫助。

Fragment切換動畫主要有兩個小問題,一個是它默認的動畫實現是補間動畫,除非自己去重載Fragment的onCreateAnimator介面,這一點往往不容易被關注,而且很多時候我們也懶於去實現這個介面;另一點是動畫start時間偏早,容易發生卡頓。

總之,雖然ActivityOptions轉場動畫和Fragment切換動畫雖然都在不同維度上對Window切換動畫進行了補充,但它們的性能表現還是不可同日而語的。很多時候,我們要評估打開一個新的頁面是用一個新的Window實現(Activity、Dialog、Pop Window等)還是Add一個Fragment,過度動畫的性能可能也是一個值得考慮的因素。

3、Render Thread動畫

關於Render Thread動畫,在老羅的博客中有比較詳細的分析了,大家感興趣的話可以自行閱讀下(Android應用程序UI硬體加速渲染的動畫執行過程分析)。我想Render Thread動畫的出現,主要是解決前文提到的動畫跟UI線程耦合的這一痛點的。

在L版本上,RippleCompount和RevealAnimator這兩種動畫就率先使用了Render動畫;而在N版本上,AnimatedVectorDrawable也改用Render動畫實現。

另外不知道大家有沒有注意到,原生ProgressBar的默認樣式就換成了用AnimatedVectorDrawable實現,可以說基本上解決了前文提到的——UI線程有耗時操作,無法進行Loading動畫——這個問題。大家日後使用ProgressBar時,也可以多嘗試用AnimatedVectorDrawable來實現。

(為什麼水波紋動畫也要用Render動畫實現,想來也是類似的原因,因為水波紋一般都對應click操作,點擊後大概率會觸發UI變化,為了讓水波紋動畫在UI變化(比如inflate)的同時,可以繼續擴散,用Render動畫也是必然的選擇)

總體來說,目前開放的一些API,局限性都比較強,期待Google在後續版本中能開放更多相關API。

四、優化實例

進行了很多原理性的分析,讓我們看一個動畫優化的實例。我們在實際項目中往往會遇到很多不同的場景,所以這個優化實例主要是拋磚引玉的探討一下,為什麼動畫優化一般都可以概括成前文提到的那三個角度。

圖1 動畫過程截圖

這次討論的問題是之前雲音樂在開發視頻流功能時遇到的。有一個無縫切換需求,是將正在播放中的視頻由圖2-1展開成圖2-3的樣式。動畫由兩部分組成,頂部的視頻區向上平移;底部的評論區向上平移的同時高度展開。

完成之後,QA反饋展開動畫卡頓比較嚴重,遂進行分析,先看一下systrace。

圖2 優化前的systrace

從systrace看,這個動畫的幀率有些慘不忍睹——300ms時長的動畫,只能繪製3幀,平均1幀的耗時在100ms,遠高於16ms的刷新頻率。

1、關注首幀和尾幀繪製

首幀和尾幀的耗時是最容易被忽視的一點,首幀就是動畫的第一幀,尾幀是動畫的最後一幀,這兩幀發生卡頓,往往最容易被用戶注意到,卻也最容易被開發忽視。

1)AnimatorListener回調

圖3 動畫尾幀的systrace

先看這個動畫的尾幀,可以明顯看到尾幀結束前UI線程中有耗時,從systrace可以看到耗時來自Choreographer doFrame中animation這個回調,這裡有個小知識點:

AnimatorListener的回調都是發生的doFrame過程中的。對於onAnimationEnd來說,回調觸發時,最後一幀還沒有繪製。

查看代碼發現,onAnimationEnd確實有間接初始化UI的操作,對應systrace中inflate的耗時。最簡單的改法就是把onAnimationEnd中的耗時操作改為非同步執行,先讓動畫繪製完成,再執行需要的耗時操作,簡單修改後的systrace如下,尾幀終於順利繪製出來了。

圖4 優化後尾幀的systrace

可見AnimatorListener的回調中,不宜進行耗時操作,最好只進行跟動畫相關的控制邏輯。

尾幀的丟幀往往都會是這種場景,因為我們在動畫結束後,一般都需要處理一些頁面轉換或者其他善後操作,一旦沒有注意調用時機,同時邏輯中有一些隱性的耗時,就會導致尾幀繪製不流暢。

2)先繪製再動畫

首幀的優化與尾幀相比,有一個相反的原則——如果對尾幀來說,我們要保證先動畫再切換,那對於首幀來說,就是先繪製再動畫。

先看下修改前首幀的systrace,

圖5 動畫首幀的systrace

這裡先簡單描述下這裡頁面展開的邏輯,在展開時,會在視頻View的下方add一個fragment,這個fragment中主要維護展開後頁面下方的視頻信息。在每次展開之前,我們會先add這個fragment,同時start展開動畫的animator。

從systrace我們可以看到,動畫第一幀performTraversal的耗時比之後幾幀都要長很多,這是因為我們在add了fragment之後,View Tree發生了較大變化,View Tree上剛添加的和其他受到影響的控制項都需要重新進行measure、layout和draw,也就是說幾乎整個View Tree都需要重建,這就導致第一幀的繪製比其他幀要慢很多。

我們在進行轉場動畫時,往往都需要重建UI,所以必然會遇到類似問題,前文提到的Fragment切換動畫容易發生卡頓,也是同樣原因。

想要優化這類問題,就需要在動畫開始前,保證界面先繪製完一幀,然後再開始動畫,這樣總體的耗時雖沒有差異,但是動畫的流暢度得到了保障,在體驗上有很大提升。

實際上先繪製再動畫的思想在很多地方都有體現,不管是在Android源碼中,還是第三方開源源碼中,都經常可以看到類似如下的邏輯,

圖6.1 ProgressBar中相關的邏輯

圖6.2 ViewPropertyAnimator中相關的邏輯

核心思路都是保證界面先draw一次,然後在下一幀開始動畫,強烈建議大家寫一個utils類來封裝這種先繪製後動畫的邏輯。

圖7 優化後首幀的systrace

按上述邏輯調整代碼之後,得到優化後的systrace如下,將第一幀的耗時從動畫過程中移除了。

總結一下,首幀和尾幀的優化雖然是一個比較細小的優化點,但不注意的話,往往實現的每一個動畫都會有同樣的問題;反過來說,只要養成良好的編碼習慣,簡單的幾行代碼,就可以大幅提升動畫性能。

同時,仔細想的話,首尾幀優化其實跟排除UI線程耗時是互相重複的,這裡之所以單獨討論這個場景,是因為UI線程耗時往往跟業務場景是強相關的,會有千奇百怪的原因,我們沒有辦法也沒有必要一一去進行討論,不過整體的優化思路跟首尾幀優化是類似的——就是針對動畫的特殊性,時刻保持動畫優先,把動畫過程中的耗時移動到動畫之外去完成(當然能減少的耗時還是要優化的)。

2、排查不必要的UI線程耗時

從圖3我們還可以看出,在動畫開始的過程中,主線程上有一段較長時間的耗時,這種明顯的耗時,還是比較好定位的。無論是利用Trace View,還是升級版的Android Profiler的CPU火焰圖,都能較快的定位到耗時的函數。

另外,如果對systrace比較熟悉的話,從圖3中圈出的線程state可以看出,動畫發生卡頓時,主線程進入了Uninterruptible Sleep狀態,一般就是在進行IO操作,結合業務場景,這裡很大概率是在主線程中操作播放器。

最終根據排查定位到,這裡會對使用過的播放器進行reset操作,對這部分邏輯進行調整並加入重入保護後,解決了這個問題。

UI線程中的耗時,是性能優化中會遇到的比較通用的問題,所以這裡我們不展開討論太多。

3、使用最合理的實現方式

繼續觀察systrace來看一下動畫過程中的實現,可以看到動畫過程中幀耗時偏高,很明顯每一幀都有measure和layout操作。

圖8 動畫過程中的systrace

查看代碼可以發現,這裡的邏輯是通過setTranslationY來實現視頻區的平移;修改height來實現評論區的垂直展開,所以觸發了requestLayout。

圖9 修改前的動畫邏輯

這就回到了前面討論過的問題,為什麼我們要根據不同應用場景來選擇相對最優的動畫實現?是因為工作量還不夠飽和么?(誤)

我想主要還是因為垂直同步下,留給每一幀繪製、渲染的時間太少了,理論上每一幀繪製、渲染的時間最多也只有16ms。儘管在非同步渲染機制的幫助下可以讓這個時間增加一些,但整體也就是在20ms上下。在20ms這個尺度下,每1ms的優化都變得有意義了;同時越少的邏輯就意味著越穩定的性能表現。

以這裡的動畫實現為例,觸發requestLayout,這個布局measure的時間大約是5~6ms,layout的時間大約2ms,而單純draw的時間只在2ms左右,requestLayout相當於增加了400%的繪製時間。

圖10 每一幀具體的耗時

同時,requestLayout也增加了動畫的不可控性,在後續維護時,布局的複雜程度進一步增加,這個動畫必然變得越來越卡頓;而且不同的布局實現,實際measure、layout的耗時差異也是非常巨大的,可能其他同事在這個布局上不經意的一點修改,就會對這個動畫產生影響。

其次,如果一個動畫平均幀在10ms左右,目測也沒有任何卡頓,此時我們有沒有必要再優化一些邏輯,把這個時間進一步壓縮到8ms、6ms呢?我想肯定也是有意義的,用戶場景千差萬別,尤其當我們的app用戶量級越來越大,不同的設備、不同的運行狀態、不同的操作習慣,都很可能把動畫耗時的差異從10ms vs 6ms放大到30ms vs 18ms,到時就真的是「大潮退去,才知誰在裸泳」了。

最後,繪製效率對功耗的影響也不可忽視,特別對於P0級的頁面,如果一個經常被觸發的動畫,繪製效率非常低,那麼它帶來的功耗損失也是不容小覷的(有沒有有電流計的大佬,來一起測試一些數據)。

進行了一些形而上的討論,讓我們具體來看一下這個動畫該怎麼優化,首先視頻區平移沒什麼難度,用translate屬性或者offsetTopAndBottom都可以實現;評論區的展開也有不少方式,如果是純色背景可以用scale屬性來模擬,如果是非純色背景setClipBounds或者setBottom都可以實現。根據前面討論的天梯榜,可以控制在屬性動畫這個級別。

不過在優化之後,又發現了一些額外的問題,可以在這裡繼續討論一下。

1)謹防動畫降級

這一點在實際開發過程中很容易遇到,很多時候我們費了半天時間,把動畫優化成用View屬性來實現,正準備去吃個火鍋慶祝一下,臨走前打開systrace看了一眼,發現systrace中每一幀還是有measure和layout的操作。這時候只能默默拿出泡麵,自行「消費降級」了。

我們知道,對於View System來說,requestLayout、invalidate和View屬性這些邏輯,實際都是被View Tree耦合在一起的,所以在動畫過程中,整個動畫性能取決於性能最差的那種動畫操作。

還是以前面討論的這個視頻展開動畫為例,

圖11 被動觸發的requestLayout

之前我們在優化首幀時,發現把動畫延時啟動之後,首幀還是有measure和layout的操作,但實際此時的動畫實現已經全部修改成了屬性動畫。繼續排查代碼可以發現,之前有邏輯,在動畫開始時進行了設置視頻標題等操作,這些操作間接的觸發了requestLayout,所以讓首幀又多餘layout了一次。

圖12 首幀相關邏輯

這裡想討論的是,控制項的api有很多都會隱式的觸發requestLayout和invalidate,往往一不注意就會發生類似問題。總體的原則是,如果相關的api會改變View的大小或內容,那麼一般內部就會調用requestLayout或invalidate。 如果需求決定,一定要在動畫過程中調用類似api,比如TextView的setText,那隻能調整實現方式,比如把TextView的寬度固定來減少requestLayout的影響或者換用自定義View通過控制draw來實現等等。

這裡也有一個小技巧,一般我們在根布局的requestLayout和invalidate函數下斷點(最好自備一個實現了requestLayout和invalidate的根布局),就能夠快速的排查、定位這類問題。

2)控制動畫區域

處理完上述邏輯之後,發現還有另一個需求點需要考慮:在播放豎版視頻時,進行展開動畫的同時,需要把視頻區拉伸成豎版的長寬比。在展開過程中,幀率偏低。

圖13 視頻區展開需求

這裡拉伸視頻區是利用requestLayout實現的,實際利用CoordinatorLayout或者NestedScroll會有更好、更高效的實現方式,但由於這裡耦合了太多邏輯,而且設計之初就沒有考慮滑動頭的問題,所以如果直接改造實現方式工作量巨大,短時間內進行優化比較困難。

這可能也是我們在實際項目中會經常遇到的情況——由於布局、需求的複雜度,歷史原因,或者開發成本,使我們不得不使用低效率的動畫實現。這種情況下,控制動畫影響範圍就顯得非常重要了。

這裡豎版視頻展開過程之所以效率偏低,主要是因為這裡視頻區和評論區是一個上下布局,如果用常見的實現方式(LinearLayout或RelativeLayout),兩者在布局上都是互相有依賴的——當視頻區高度發生變化,會同時觸發評論區重新measure、layout,所以這裡間接觸發了下方評論區列表重新布局。不過好在評論區Fragment內部是用ListView實現的,所以風險還算可控。(關於ListView、RecyclerView重新布局的問題,又是一個很大的話題了,有機會後續可以再仔細研究一下)

所以,主要的優化是如下解除視頻區和評論區在布局上的耦合,

圖14 優化後視頻頁的根布局

然後在視頻區展開時,讓底部的評論區隨之translateY即可。

修改到這裡,基本涉及到的優化點都討論的差不多了,還有一些業務相關的或者比較通用的UI優化,這裡就不再展開討論了。雖然文章寫了很長,但在實操過程中,代碼的修改量很小,整體的工作量也並不大。更多時候還是在編碼的過程中,有意識的調整一下邏輯,更耐心的多寫幾行代碼,就可以寫出性能高效的動畫實現了。

五、總結

最後我們再默默看一眼優化之後的systrace,是不是可以開心的去吃火鍋了。

圖15 優化前後的systrace

一拉到底的結論,動畫優化的三個主要方向,

1. 使用最合理的實現方式 —— 熟記動畫實現排行,謹防動畫降級,控制最小的動畫區域;

2. 排查不必要的UI線程耗時 —— 通用的UI優化方法,日後再說;

3. 關注首幀和尾幀繪製 —— 舉手之勞,生活更多彩。

怎麼樣,讀到這裡的小夥伴,是否覺得實現動畫簡單了許多呢?[狗頭]