[譯]Android UI 性能優化
本文來源于Google IO 2017上的視頻 Android Performance: UI ,翻譯自Android官網 Slow rendering ;個人覺得非常有價值,比如指出 對象分配 、 垃圾回收(GC) 、 線程調度 以及 Binder調用 是Android系統中常見的卡頓原因,更重要的是給出了定位和解決這些問題的方案;而非簡單地告訴你避免對象分配,減少布局層級,減少過度繪制等蒼白無力的內容。另外,Google開發團隊在各個不同場合不厭其煩地提到了 Systrace 用以解決App中不同維度的問題,這是一個遠被低估的強大的工具。希望對大家有幫助 ^_^
水平有限,翻譯不妥之處請多多指教。
UI渲染是指從App生成幀并顯示在屏幕上的行為。為了保證App用戶體驗的流暢性,App需要在16ms內渲染完一幀以達到60fps的幀率( 為什么是60fps? )。如果你的App UI渲染緩慢,那么系統會強制跳過某些幀,用戶就會感知到app的“口吃”,也就是卡頓。
(下面三段可以認為是Google的廣告,與性能優化無關)為了幫助開發者提高App的質量,Android自動監控了App的卡頓并且把信息展示在Android Vitals dashboard上。如果對這些信息是如何收集的感興趣,參考 Play Console docs 。
如果你的app有卡頓的情況,Android Vitals dashboard這個頁面提供了診斷和解決問題的指引。
PS:Android Vitals dashboard 和Android系統記錄了使用UI Toolkit(App中從Canvas和View繼承樹繪制出來的對用戶可見的部分)的渲染時間統計。如果你的App沒有使用UI Toolkit,比如有的app使用`Vulkan`, `Unity`, `Unreal`或者 `OpenGL`,那么在Android Vitals dashboard中是無法看到渲染時間統計的,可以通過`adb shell dumpsys gfxinfo <package name>`來確定設備是否對某個app記錄了這些信息。
定位卡頓
精準地定位App中引起卡頓問題的代碼是非常困難的,本小結介紹一些定位卡頓問題的方法:
- 直觀分析
- Systrace
- 定制性能監控工具
直觀分析 可以讓你在短時間內查看整個App的卡頓情況,但是它不像Systrace可以提供更多卡頓的細節。 Systrace 可以提供足夠的信息,但如果對App的所有使用場景運用Systrace分析,你會被大量的數據淹沒以至于難以分析問題。直觀分析和Systrace都可以在你的本地設備上檢測卡頓問題,但如果沒辦法在本地設備上重現卡頓問題,你可以構建自定義的性能監控工具,以便在線上運行的設備上測量App特定部分的性能。
直觀分析
直觀分析可以幫助你定位App使用場景中產生卡頓的地方。你可以簡單地打開App然后使用它的各個功能來查看界面是否卡頓。以下是做直觀分析時候的一些建議:
- 使用release版本(至少是非debuggable)的App。ART運行時為了支持debug的某些特性在debug情況下去掉了好幾個非常重要的性能優化點;因此要確保你分析的App是和用戶使用接近的版本。
- 打開 Profile GPU Rendering 。 Profile GPU Rendering 在屏幕上顯示了各種圖表,可以幫助你直觀地看到繪制UI窗口的每一幀相對16ms每幀的標準花費了多長時間。每個顯示欄有各個不同顏色的組件,每個組件都被映射到渲染pipeline的某個階段,因此你可以看到哪一部分花費了最長的時間。例如,如果某一幀在處理輸入的時候花費了較長時間,那你就應該查看一下你的代碼里面處理用戶輸入的部分。
- 某些特定的組件,比如 RecyclerView ,它們是常見的卡頓根源,如果你的App使用了這些組件,最好分析使用了這些組件的部分。
- 盡量使用較慢的設備來惡化卡頓問題以便分析。
一旦發現了產生卡頓的場景,或許你已經知道了造成卡頓的原因,但如果你需要更詳細的信息來分析問題,可以借助Systrace。
使用Systrace
雖然Systrace是展示整個設備在干什么的工具,它對定位卡頓問題也非常有幫助。Systrace有著非常小的運行時開銷,因此你在分析問題的時候可以體驗到真實的卡頓。
使用Systrace來記錄App卡頓場景下的trace(可以通過 Systrace WalkThrough 來查看如何做)。systrace的圖表被進程和線程分為若干個部分,你的app的trace結果大致長這樣:
上圖所示的systrace包含著可以定位卡頓的如下信息:
- Systrace顯示了每一幀繪制的時間段,并且給每一幀都有不同顏色,可以突出較慢的渲染幀時間。與直觀分析相比,這可以幫助你更精確地找到單獨的卡頓的某一幀。更詳細的內容可以參考 Inspecting Frames 。
- Systrace會監測你App中的問題并會在單獨幀和警告欄里面展示警告提示信息;跟著這些提示的指引來分析問題是最好的選擇。
- 某些 Android 框架和庫,比如 RecyclerView 有自定義的trace標記,因此systrace的timeline會展示這些方法在何時執行以及執行了多長時間。
在查看了systrace的輸出結果之后,你可能會發現某些可疑的造成卡頓的方法。比如:如果timeline顯示某一幀過慢是由RecyclerView引起的,你可以給相關代碼 添加Trace標記 ,然后重新運行systrace來獲取更多的信息。新版的systrace的timeline會展示你代碼里面這些方法的調用的時機以及耗費的時間。
如果沒有從systrace中找到為什么UI線程執行較長時間的細節,那么你可能需要使用 Android CPU Profiler 來記錄采樣或者插樁的method trace。不過通常情況下,method trace不適合用來定位卡頓問題,因為它們運行時的開銷太高可能會造成誤報,并且它無法看到線程是在運行還是處于阻塞狀態。但是,method trace可以幫助你定位代碼中耗時長的方法;在定位到耗時方法之后,可以 添加Trace標記 然后重新運行systrace來查看是否是因為這些方法引起的卡頓。
PS:當紀錄systrace的時候,每一個trace標記(一個開始和結束標記對)會帶來10納秒的開銷,為了避卡頓誤報,不要在一幀內被調用很多次的方法里面添加trace標記,也不要在調用耗時200納秒以下的方法里面添加標記。
要了解更詳細的信息,可以參閱 Understanding Systrace 。
定制性能監控工具
如果你無法在本地設備上重現卡頓問題,可以在App內構建自定義的性能監控工具,通過線上設備來幫助定位卡頓問題。
要定制性能監控工具,可以通過 FrameMetricsAggregator 來收集App某個特定部分的幀渲染時間,然后通過 Firebase Preformance Monitoring 來記錄和分析數據。
更詳細的內容參閱 Use Firebase Performance Monitoring with Android Vitals 。
修復卡頓
要修復卡頓問題,首先查看那些沒有在16.7ms內完成的幀,然后查看造成這個的原因在哪。是因為View#draw反常地花費了較長時間,又或者是布局過程耗時?詳細介紹見下文的 常見卡頓原因 。
要避免卡頓問題,耗時較長的任務應該在UI線程之外異步完成;因此需要時刻注意你的代碼運行在哪個線程,并且在post不重要的任務到主線程的時候保持謹慎。
如果你的App有一個復雜而重要的UI,可以考慮 writing instrumentation tests 來自動監測較慢的渲染時間,然后定期運行測試case來避免問題復發。更多內容見 Automated Performance Testing Codelab 。
常見的卡頓原因
下面的小結將介紹一些App中常見卡頓的原因,并提供一些定位它們的最佳實踐。
滾動列表
ListView ,特別是 RecyclerView 被廣泛用于復雜的滾動列表里面,它們是最容易導致卡頓的部分。這兩個控件內部都添加了Systrace標記,因此你可以借助systrace來分析它們是否造成了app的卡頓。在獲取RecyclerView以及你自己添加的systrace標記的時候,必須要給systrace傳遞 `-a your-package-name `,不然就不會輸出這些標記的信息。在systrace里面,你可以點擊RecyclerView的相應標記來看RecyclerView當時在干什么。
RecyclerView:notifyDataSetChanged
如果你觀察到在某一幀內RecyclerView中的每個item都被重新綁定了(并且因此重新布局和重新繪制),請確保你沒有對RecyclerView執行局部更新的時候調用 `notifyDataSetChanged()`, `setAdaper(Adapter)`或者 `swapAdaper(Adaper, boolean)`。這些方法表示 整個列表 內容改變了,并且會在systrace里面顯示為 RV FullInvaludate 。在內容改變或者添加內容的時候應該使用 SortedList 或者 DiffUtil 生成更小的更新操作。
例如,如果app從服務端收到了新的新聞列表消息,當你把信息post到Adapter的時候,可以像下面這樣使用`notifyDataSetChanged()`:
void onNewDataArrived(List<News> news) {
myAdapter.setNews(news);
myAdapter.notifyDataSetChanged();
}
但是這么做有個嚴重的缺陷——如果這是個微不足道的列表更新(也許是在頂部加一條),RecyclerView并不知道這個信息——RecyclerView被告知丟掉它所有item緩存的狀態,并且需要重新綁定所有東西。
更可取的是使用 DiffUtil ,它可以幫你計算和分發細小的更新操作:
void onNewDataArrived(List<News> news) {
List<News> oldNews = myAdapter.getItems();
DiffResult result = DiffUtil.calculateDiff(new MyCallback(oldNews, news));
myAdapter.setNews(news);
result.dispatchUpdatesTo(myAdapter);
只需要自定義一個 DiffUtil.Callback 實現類告訴DiffUtil如何分析你的item,DiffUtil就能自動幫你完成其他的所有事情。
RecyclerView:嵌套RecyclerViews
嵌套RecyclerView是非常常見的事,特別是一個垂直列表里面有一個水平滾動列表的時候(比如Google Play store的主頁)。如果你第一次往下滾動頁面的時候,發現有很多內部的item執行inflate操作,那可能就需要檢查你是否在內部(水平)RecyclerView之間共享了 RecyclerView.RecyclerViewPoo 了。默認情況下,每個RecyclerView有自己堵路的item池。在屏幕上有十幾個itemViews的情況下,如果所有的行都顯示相似的View類型,而itemViews不能被不同的水平列表共享,那就是有問題的。
class OuterAdapter extends RecyclerView.Adapter<OuterAdapter.ViewHolder> {
RecyclerView.RecycledViewPool mSharedPool = new RecyclerView.RecycledViewPool();
...
@Override
public void onCreateViewHolder(ViewGroup parent, int viewType) {
// inflate inner item, find innerRecyclerView by ID…
LinearLayoutManager innerLLM = new LinearLayoutManager(parent.getContext(),
LinearLayoutManager.HORIZONTAL);
innerRv.setLayoutManager(innerLLM);
innerRv.setRecycledViewPool(mSharedPool);
return new OuterAdapter.ViewHolder(innerRv);
}
...
如果你想進行進一步的優化,可以對內部RecyclerView的LinearLayout調用 setInitialPrefetchItemCount(int) 。比如如果你在每一行都是展示三個半item,可以調用 `innerLLM.setInitialItemsPrefetchCount(4);` 這樣當水平列表將要展示在屏幕上的時候,如果UI線程有空閑時間,RecyclerView會嘗試在內部預先把這幾個item取出來。
RecyclerView:Too much inflation/Create taking too long
通過在UI線程空閑的時候提前完成任務,RecyclerView的prefetch可以幫助解決大多數情況下inflate的耗時問題。如果你在某一幀內看到inflate過程(并且不在**RV Prefectch**標記內),請確保你是在最近的設備上(prefect特性現在只支持Android 5.0,API 21
以上的設備)進行測試的,并且使用了較新版本的 Support Library 。
如果你在item顯示在屏幕上的時候頻繁觀察到inflate造成卡頓,需要驗證一下你是否使用了額外的你不需要的View類型。RecyclerView內容的View類型越少,在新item顯示的時候需要的inflation越少。在可能的情況下,可以合并合理的View類型——如果不同類型之間僅僅有圖表,顏色和少許文字不同,你可以在bind的時候動態改變它們,來避免inflate過程。(同時也可以減少內存占用)
如果view的類型是合理的,那么就嘗試減少inflation耗費的時間。減少不必要的容器類ViewGroup或者用來View結構——可以考慮使用 ConstrainLayout ,它可以有效減少View結構。如果還需要優化性能,并且你item的view繼承樹比較簡單而且不需要復雜的theme和style,可以考慮自己調用構造函數(不使用xml)——雖然通常失去XML的簡單和特性是不值的。
RecyclerView:Bind taking too long
綁定過程(也就是 onBindViewHolder(VH, int) 應該是非常簡單的,除了及其復雜的item,其他所有的item的綁定過程耗時應該遠小于1毫秒。onBinderViewHolder應該簡單地從adapter里取出POJO對象,然后對ViewHolder里面的View調用setter方法。如果 RV OnBindView 耗費了較長時間,請驗證一下是否在綁定的代碼里面做了別的工作。
如果你在adapter里使用簡單的POJO對象,那你完全可以借助 Data Binding 庫來避免在onBindViewHolder里面寫綁定代碼。
RecyclerView or ListView:layout/draw taking too long
對于draw和layout造成的問題,查看下文的 布局性能 和 渲染性能 。
ListView:Inflation
ListView中的View復用機制很容易被偶然破壞,如果你看到ListView的每個Item出現在屏幕上的時候都觸發了inflate過程,必須要檢查你的 Adapter.getView() 是否使用、重新綁定并且返回了`convertView`這個參數。如果你的`getView()`實現每次都inflate,那就沒法享受ListView的View復用機制。`getView()`方法的結構應該永遠是下面這個樣子:
view getView(int position, View convertView, ViewGroup parent) {
if (convertView == null) {
// only inflate if no convertView passed
convertView = mLayoutInflater.inflate(R.layout.my_layout, parent, false)
}
// … bind content from position to convertView …
return convertView;
}
布局性能
如果Systrace顯示Layout段的 Choreographer#doFrame 做了大量的工作,或者執行得太頻繁,那么你可能遇到了布局性能問題。App的布局性能取決于View繼承樹的哪一部分改變了布局參數或者輸入。
布局性能:耗時
如果布局的每一段都要花費數毫秒,那么可能是嵌套 RelativeLayout 或者 帶weight的LinearLayout 造成的。這些類型的布局都可能觸發子View的多次測量/布局過程,導致嵌套這些布局可能會造成布局時間的時間復雜度為O(2^n)(n為嵌套深度)。因此,需要避免使用RelativeLayout或者帶weight的LinearLayout,除非它們是View樹的葉子節點。有幾個方式可以做到這一點:
- 重新組織布局結構
- 自定義布局邏輯,詳情可見 優化布局 。
- 嘗試將布局轉換為 ConstraintLayout ,它可以提供類似的特性,但是沒有性能問題。
布局性能:頻率
布局過程通常在新內容出現在屏幕上的時候出現,比如RecyclerView中的某個Item滾動到屏幕可見區域上。如果某個重要的布局在每一幀上都執行了layout過程,那可能是你在移動整個布局,而這通常會引發掉幀。通常情況下,動畫應該操作View的繪制屬性(比如setTranslationX/Y/Z, setRotation, setAlpha),這些操作比改變View的布局屬性(padding,或者margin)要廉價得多。通過invalidate()進而在下一幀觸發 draw(Canvas) 會在View被invalidated的時候重新記錄繪制操作,這個過程通常也比layout廉價得多。
渲染性能
Android UI 繪制工作分為兩個階段:運行在在UI線程的 `View#draw`,以及在RenderThread里執行的`DrawFrame`。第一個階段會執行被標記為invalidated的View的 `draw(Canvas)` 方法,這個方法通常會調用很多你的App代碼里面的自定義View的相關方法;第二個階段發生在native線程RenderThread里面,它會基于第一階段View#draw的調用來執行相應的操作。
渲染性能:UI線程
如果 `View#draw` 調用花費了較長時間,常見的一種情況是在UI線程在繪制一個Bitmap。繪制Bitmap會使用CPU渲染,因此需要盡量避免。你可以通過 Android CPU Profiler 用method tracing來確定是否是這個原因。
通常情況下繪制Bitmap是因為我們想給Bitmap加一個裝飾效果,比如圓角:
Canvas bitmapCanvas = new Canvas(roundedOutputBitmap);
Paint paint = new Paint();
paint.setAntiAlias(true);
// draw a round rect to define shape:
bitmapCanvas.drawRoundRect(0, 0,
roundedOutputBitmap.getWidth(), roundedOutputBitmap.getHeight(), 20, 20, paint);
paint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.MULTIPLY));
// multiply content on top, to make it rounded
bitmapCanvas.drawBitmap(sourceBitmap, 0, 0, paint);
bitmapCanvas.setBitmap(null);
// now roundedOutputBitmap has sourceBitmap inside, but as a circle
如果你的UI線程做的是這種工作,你可以在一個后臺線程里面完成解碼然后在UI線程繪制。在某些情況下(比如本例),甚至可以直接在draw的時候完成,比如如果你的代碼長這樣:
void setBitmap(Bitmap bitmap) {
mBitmap = bitmap;
invalidate();
}
void onDraw(Canvas canvas) {
canvas.drawBitmap(mBitmap, null);
}
可以用如下的代碼來代替:
void setBitmap(Bitmap bitmap) {
mShaderPaint.setShader(
new BitmapShader(bitmap, TileMode.CLAMP, TileMode.CLAMP));
invalidate();
}
void onDraw(Canvas canvas) {
canvas.drawRoundRect(0, 0, mWidth, mHeight, 20, 20, mShaderPaint);
}
要注意的是,上述操作也適用于 background protection(在Bitmap上繪制一個漸變)和 image filtering (用 ColorMatrixColorFilter )這兩個對Bitmap的常見操作。
如果你是因為別的原因而繪制Bitmap,或許你可以使用緩存,嘗試在支持硬件加速的Canvas上直接繪制,或必要的時候調用 setLayerType 設置Canvas 為 LAYER_TYPE_HARDWARE 來緩存復雜的渲染輸出,這樣也可以享受GPU渲染的優勢。
渲染性能:RenderThread
某些Canvas操作在UI線程是非常廉價的,但卻會在RenderThead觸發大量昂貴的計算操作。通常Systrace會給這些調用給出警告提示。
Canvas.saveLayer()
要盡量避免 Cavas.saveLayer() 調用,這個方法會在每一幀觸發昂貴、未被緩存的離屏渲染。雖然在Android 6.0上優化了這個操作的性能(避免了GPU上的渲染目標切換),仍然需要盡可能地避免調用這個方法;如果實在需要調用它,確保給它傳遞 CLIP_TO_LAYER_SAVE_FLAG 。
Animating large Paths
如果在一個支持硬件加速的Canvas上調用 Canvas.drawPath() , 系統會首先在CPU上繪制這些path,然后把它傳遞給GPU。如果你的path對象很大,那最好避免在每一幀之間修改它,這樣path對象就可以被系統緩存起來,使得繪制更加高效。`drawPoints()`, `drawLines()`, `drawRect/Circle/Oval/RoundRect()` 比 `drawPath` 更加高效——因此最好使用它們替代相應的`drawPath`操作,雖然可能用的代碼量更多。
Canvas.clipPath
clipPath(Path) 會觸發昂貴的裁剪操作,因此也需要盡量避免。在可能的情況下,應該盡量直接繪制出需要的形狀,而不是裁剪成相應的圖形;這樣性能更高,并且支持反鋸齒;例如下面這個`clipPath` 操作:
canvas.save();
canvas.clipPath(mCirclePath);
canvas.drawBitmap(mBitmap);
canvas.restore();
可以用如下代替:
// one time init:
mPaint.setShader(new BitmapShader(mBitmap, TileMode.CLAMP, TileMode.CLAMP));
// at draw time:
canvas.drawPath(mCirclePath, mPaint);
Bitmap uploads
Android的顯示系統使用OpenGL,bitmap在底層表現為OpenGL的紋理,因此在bitmap第一次被展示的時候,bitmap會被首先上傳的GPU上。Systrace上標記為 Upload width x height Texture 就是這種情況。這個過程可能會花費數毫秒(如下圖),但是這是GPU顯示圖像的必要過程。
App在上傳一個180萬像素的bitmap時花費了10ms,要么減少圖片的大小,那么使用prepareToDraw提前完成這個操作
如果這個過程花費了較長時間,首先檢查在trace里面顯示的圖片的寬和高,確保圖片的大小不比它顯示出來的區域大太多,因為這樣會浪費上傳時間和內存。常見的圖片加載庫都會提供一個方便的方式來獲取和請求一個合適大小的Bitmap。
在Android 7.0上,圖片加載代碼(通常是圖片加載庫)可以調用 prepareToDraw 在需要的時候提前觸發Bitmap上傳動作;這種方式可以使Bitmpa在RenderThread空閑的時候提前完成。可以在圖片解碼之后或者在Bitmap綁定到View上的時候完成這個操作——理想情況下,圖片加載庫會幫助你完成這些;如果你想要自己掌控圖片加載,或者需要確保不在繪制的時候觸發Bitmap上傳,可以直接在代碼里面調用 `prepareToDraw`。
線程調度延遲
線程調度器是Android操作系統的一部分,操作系統用它來決定系統中的線程如何執行、何時執行以及執行多長時間。某些情況下,App卡頓是因為UI線程被阻塞或者沒有運行。Systrace用不同的顏色(如下圖)來標記某個線程是 Sleep(灰色),Runnable(藍色:可運行狀態,但是調度器沒有選擇讓它運行),Actively running(綠色),或者 Uninterruptible sleep(紅色或者橘黃色),這對解決由于線程調度引起的卡頓非常有幫助。
老版本的Android系統頻繁出現線程調度問題并不是App自己的鍋,Android開發團隊對這一塊進行了持續的改進,因此在debug線程調度的問題的時候,最好使用新版本的Android系統,以確定線程問題確實是App的鍋而非系統問題。
要注意的是,在一幀的某些部分,UI線程或者RenderThread是不期望被運行的。比如,當RenderThread 的`syncFrameState` 執行以及Bitmap上傳的時候UI線程處于阻塞狀態——這樣RenderThead可以安全地從UI線程copy數據。再舉個例子:當RenderThread使用IPC(內部進程通信)的時候它自己也可能處于阻塞狀態:比如在一幀開始的時候獲取buffer,從buffer查詢信息,或者通過`eglSwapBuffers` 把buffer回傳給合成器。
App執行中的長時間停頓通常情況下是由 Binder調用 (Android中的內部進程通信機制)引起的。在最近的一些Android版本上,Binder調用是UI線程暫停執行最常見的原因之一。一般的解決方案是,避免調用IPC函數,緩存調用值,或者把工作放到后臺線程執行。隨著代碼庫越來越大,開發人員很容易就不小心地在某個低層次的方法里面添加了Binder調用的函數——不過我們可以通過tracing很容易滴發現和修復這個問題。
如果app中有binder通信,可以用如下的adb命令來查看調用棧:
$ adb shell am trace-ipc start
… use the app - scroll/animate ...
$ adb shell am trace-ipc stop --dump-file /data/local/tmp/ipc-trace.txt
$ adb pull /data/local/tmp/ipc-trace.txt
有時候某些看起來無害的的方法(比如 getRefreshRate() 會觸發Binder通信,然后如果它們頻繁地被調用就會引發嚴重的性能問題。周期性地對App進行tracing可以幫助你在問題出現的時候快速地定位和解決它們。
由于RecylerView的fling觸發的Binder調用引起UI線程sleeping。保持你的IPC調用方法簡單,然后使用trace-ipc來移除不必要的調用
如果你沒有發現Binder調用,但是UI線程依然處于沒在運行的狀態,那可能是因為UI線程在等待其他線程某個操作的 鎖 。一貫情況下,UI線程不應該等待其他線程的執行結果——別的線程應該在拿到結果之后post給UI線程。
對象分配和垃圾回收
自從Android引入 ART 并且在Android 5.0上成為默認的運行時之后,對象分配和垃圾回收(GC)造成的卡頓已經顯著降低了,但是由于對象分配和GC有額外的開銷,它依然又可能使線程負載過重。 在一個調用不頻繁的地方(比如按鈕點擊)分配對象是沒有問題的,但如果在在一個被頻繁調用的緊密的循環里,就需要避免對象分配來降低GC的壓力。
可以通過Systrace來確定是否發生了頻繁的GC,然后用 Android Memory Profier 分析分配的對象都是些什么。如果你盡可能地做到了避免分配對象(特別是在緊密的循環里),那就幾乎不會遇到這種問題。
發生在HeapTaskThread線程上一個耗時94ms的GC
在最近的Android版本上,GC通常運行在一個叫做HeapTaskDaemon的后臺線程里面。如上圖所示,過多的對象分配意味著CPU將在GC上耗費更多的資源。
來自:https://zhuanlan.zhihu.com/p/27065828