Android降低UI渲染速度的檢測、診斷及修復
一. Slow rendering - jank
- 為了保證UI互動的流暢,必須保證每幀的渲染時間不超過16毫秒,保證60的FPS。
- 一旦介面有較慢的渲染,系統將強制跳幀,使用者就會感覺到卡頓。
- We call this jank.
二. 定位jank
1. 三種定位方法
想要準確定位發生jank的程式碼並不容易,以下三個辦法可以幫助開發者:
- 視覺檢查:可以快速直觀的發現jank介面
- Systrace:能提供更多的細節資訊
- FrameMetricsAggregator:在Firebase Performance Monitoring中可以檢視分析資料
2. 方法一:使用視覺檢查
開啟app,手動切換不同的介面並檢視哪些疑似jank。下面是一些檢查經驗:
1. 應當啟動release版本的app,或不可調式版本的app。因為ART執行時為了支援調式功能,禁用了一些重要的優化。
2. 在開發者選項中,開啟Profile GPU Rendering開關(GPU呈現模式)。Profile GPU Rendering可以直觀地展示繪製耗時。不同的顏色代表了不同的繪製操作。
3. 有一些元件常常會導致jank,比如RecyclerView
。可以重點關注包含這些元件的介面。
4. 有些jank只會發生在冷啟動過程中。
5. 儘量在效能更低的測試機上做檢查,原因你懂的—–讓卡的變的更卡、更明顯。
3. 方法二:使用systrace
Systrace能有效地發現jank,而且系統開銷極小。有兩種啟動方法:
- 通過device monitor來啟動systrace
- 現在AS預設不整合Monitor,可以使用python來啟動(需要python環境):
`python systrace.py --time=10 -o mynewtrace.html sched gfx view wm`
4. 方法三:使用FrameMetricsAggregator
使用FrameMetricsAggregator
來收集app幀渲染的時間,使用Firebase Performance Monitoring來記錄和分析資料。
三. 修復jank
- 你需要堅持哪些幀沒有在16.7毫秒內渲染完成,然後發現問題。
- 一般來說,將耗時任務放在非同步工作執行緒可以有效避免jank。
- 有個有效的辦法:經常注意程式碼執行在哪個執行緒中,並且在耗時程式碼中檢查當前執行緒,如果是主執行緒則發出警告。
- 如果有非常重要且複雜的UI,比如Scrolling List,考慮用Automate UI performance tests
- 下面將列舉一些常見的jank原因
四. 常見jank原因
1. Scrollable lists
ListView
,尤其是RecyclerView
,你應該使用Systrace來檢視它們是否導致jank。
2. RecyclerView: notifyDataSetChanged
- 如果RecyclerView每個item正在重新繫結(將會導致重新佈局和繪製),請不要使用
notifyDataSetChanged()
,setAdapter(Adapter)
, 或者swapAdapter(Adapter, boolean)
來更新很小部分的資料。它們會標示整個列表都發生改變,在Systrace中會顯示為RV FullInvalidate。 - 應當使用
SortedList
或DiffUtil
來進行少量更新或增加。 示例程式碼,考慮從服務端獲取一個新的資訊list,使用
notifyDataSetChanged
:void onNewDataArrived(List<News> news) { myAdapter.setNews(news); myAdapter.notifyDataSetChanged(); }
但這有個很嚴重的潛在問題:如果list變動很小,比如僅僅是新增了一個數據,
RecyclerView
將會清除所有item快取,重新繫結所有的item views。示例程式碼,使用
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
該怎樣檢查list對比結果。
3.RecyclerView: Nested RecyclerViews
- 巢狀RecyclerView,特別是水平滑動list裡面巢狀一個豎直RecyclerViews。
- 當你首次滑動頁面,而如果有太多的內部item,可以在內部
RecyclerView
s中考慮使用RecyclerView.RecycledViewPool
s。 如果你有一打或更多
RecyclerView
需要顯示,而且他們的itemViews
類似,就應該將itemViews
在各個RecyclerView
中共享: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); } ...
如果想要進一步優化,對
LinearLayoutManager
呼叫setInitialPrefetchItemCount(int)
比如每行可以顯示3到5個item,呼叫
innerLLM.setInitialItemPrefetchCount(4);
來通知一個RecyclerView
,當一個水平行將要顯示時,如果UI執行緒有空,它應該預取內部的4個專案。
4. RecyclerView: Too much inflation / Create taking too long
- UI執行緒有空時,使用預取功能使inflation Layout工作更有效率。
- 如果您在一幀中看到 inflation Layout (而不是標記為 RV Prefetch 的部分),請確保您正在測試最近的裝置( Prefetch 目前僅在 Android 5.0 API Level 21 及更高版本上支援),並使用最近版本的Support Library.
- 當新的item顯示在螢幕時,如果發現inflation 導致jank,
RecyclerView
中的view可能就太多了點,需要刪除多餘的view。 - 如果在各個view型別中只有一個圖示,顏色,或一條文字資訊的差別,就有理由合併view型別,在繫結時改變這些資訊。這樣可以避免inflate,同時減少記憶體佔用。
5. RecyclerView: Bind taking too long
- 儘量減少
onBindViewHolder(VH, int)
的呼叫時間,不要在其中做多餘的事情。 - 如果只是一些簡單的pojo資料,儘量不要使用
Data Binding library
(資料繫結庫)。
6. RecyclerView or ListView: layout / draw taking too long
- 儘可能減少佈局層次複雜度。
7. ListView: Inflation
- 確保ListView的快取機制運作正常。快取的View不應當再次inflate。如果每當螢幕顯示item都會inflate(即使它已經顯示過了),說明快取複用機制失效了。
示例程式碼:
view getView(int position, View convertView, ViewGroup parent) { if (convertView == null) { // 僅當第一次顯示時flate,此處應新增到快取 convertView = mLayoutInflater.inflate(R.layout.my_layout, parent, false) } // 這裡繫結相應的資料到convertView return convertView; }
8. layout效能
如果Systrace 顯示Layout的Choreographer#doFrame做了太多事情,或者太過頻繁,那就表示layout效能存在問題。如果View 層次結構改變layout引數或輸入,就會導致layout效能問題。
Layout performance: Cost
如果這部分超過了幾毫秒,很可能的原因就是
RelativeLayouts
或weighted-LinearLayouts
的巢狀佈局碰到了最壞的情況。每個layout都會觸發其子View的多次measure
/layout
,所以這些layout的巢狀會導致layout時間開銷為基於巢狀層次的O(n^2)。有一些方法可以可供參考:
- 重新充足View層次結構
- 自定義Layout,修改其layout部分
- 使用ConstraintLayout
。這個佈局可以滿足類似的需求,同時可以避免效能缺陷。Layout performance: Frequency
當新內容出現時,將會發生新的Layout。例如,當一個新item在RecyclerView
中滾動到螢幕中。如果每幀都有重要的layout,而此時又有可能正在變動layout,這種情況下極有可能會掉幀。修改layout引數會導致重新layout。
想要減少開銷,請使用View屬性動畫(比如
setTranslationX/Y/Z()
,setRotation()
,setAlpha()
等等),它比改變layout屬性(比如padding或margin)的效能開銷小的多。通過觸發
invalidate()
(接下來會在下一幀draw)改變View的屬性,其效能也比改變layout屬性要更好。這將重新記錄無效的檢視的繪製,並且同樣也比佈局效能好的多。
9.Rendering performance: UI Thread
Android UI會在兩個階段開始工作:
- 在UI執行緒中,Record View#draw:
在每個無效的View上繪製(Canvas),並可能呼叫自定義檢視或程式碼。
- 在RenderThread中,DrawFrame
在本地RenderThread上執行,但是將根據`Record View#draw`階段生成的工作進行操作。
Rendering performance: UI Thread
如果
Record View#draw
花費了大量時間,經常可能的原因就是Bitmap
正在UI執行緒中被繪製。繪製Bitmap會用到CPU渲染,所以一般要儘量避免。可以使用Android CPU Profiler
來檢測這個問題。繪製點陣圖通常是在應用程式想要在顯示點陣圖之前修飾點陣圖的時候完成的。比如繪製圓角圖片:
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執行緒中的工作,你可以將這些工作放在後臺啟動的解碼執行緒中,甚至可以放在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); }
這通常也可以用於後臺保護(在點陣圖頂部繪製漸變)和影象過濾(使用
ColorMatrixColorFilter
)。如果由於其他原因(可能將其用作快取)繪製到點陣圖,則嘗試繪製直接傳遞到View或Drawable的硬體加速硬體,如有必要,可考慮使用
LAYER_TYPE_HARDWARE
呼叫setLayerType()
來快取複雜的渲染 輸出,並仍然利用GPU渲染。
Rendering performance: RenderThread
一些
Canvas
操作記錄開銷很小,但會在RenderThread
中觸發昂貴的計算。Systrace會對此作出警告的:Canvas.saveLayer()
: 盡力避免。
它將觸發每幀昂貴的、無快取的離屏渲染,應當儘量避免,或者至少確保傳遞CLIP_TO_LAYER_SAVE_FLAG
(或者呼叫一個不帶flag的變數).Animating large Paths:
當硬體加速Canvas傳遞給Views時,
Canvas.drawPath()
被呼叫,Android首先在CPU上繪製這些路徑,然後將它們上傳到GPU。 如果路徑較大,請避免逐幀編輯,以便快取記憶體和繪製。drawPoints()
,drawLines()
,drawRect/Circle/Oval/RoundRect()
效率更高,即使最終呼叫了更多的draw方法。Canvas.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紋理,並且首次在一幀中顯示點陣圖時,將其上傳到GPU。可以在Systrace中將此視為上傳寬度x高度紋理。這可能需要幾個毫秒(見下圖),但是有必要用GPU顯示影象。
如果這些花費很長時間,請首先檢查trace中的寬度和高度。如果正在顯示的點陣圖比螢幕大很多,就是浪費時間和空間。一般bitmap載入庫會提供簡單的方法來請求大小合適的點陣圖。
在Android 7.0中,一些圖片庫可能會在需要圖片之前呼叫預載入方法
prepareToDraw()
來觸發更早的GPU上傳,此時RenderThread
是空閒狀態。這個操作可以在解碼後做,也可以在將圖片繫結到View時來做。
一般來說,圖片庫會幫你做這個。除此之外,如果想自己管理圖片,或想確定不會在更新的裝置上傳,也可以在合適的地方手動呼叫
prepareToDraw()
。
10.執行緒排程延遲(Thread scheduling delays)
Systrace 會用不同的顏色來指示執行緒狀態:
- 灰色:Sleeping ,睡眠
- 藍色:Runnable,可以執行,但排程器還沒有選擇它執行
- 綠色:Actively running ,正在執行
- 紅色或橙色:Uninterruptible sleep
這在除錯因執行緒排程延遲導致的jank問題時,非常有用。
圖中可以看到,UI執行緒在RenderThread的syncFrameState 正在執行時和bitmap上傳時會被阻塞,另一種情況:RenderThread在使用IPC時會被阻塞:在幀的開始處獲取緩衝區,從中查詢資訊,或者通過
eglSwapBuffers
將緩衝區傳回給合成器。在最近版本的Android中,導致UI執行緒停止的原因常常就是IPC。
而修復措施如下:- 儘量避免遠端呼叫
- 如果必須要用遠端呼叫,請快取資料以便返回,或在後臺執行緒中呼叫遠端方法。
可以通過adb命令來捕獲binder transactions的方法呼叫棧:
$ 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 transactions,並在頻繁呼叫時導致嚴重的問題。定期跟蹤可以快速找到並解決這些問題:上圖顯示,在RV fling中的binder transactions導致UI執行緒睡眠。請保持簡潔的bind邏輯,使用
trace-ipc
來追蹤並刪除遠端呼叫。如果你沒有看到繫結Activity,但仍然沒有看到你的UI執行緒執行,確保沒有等待另一個執行緒的鎖或其他操作。通常,UI執行緒不應該等待來自其他執行緒的結果。
11. 物件分配和GC
Systrace會告訴你GC是否頻繁執行,Android Memory Profiler可以顯示物件分配發生在哪兒。
HeapTaskDaemon thread中,花費94ms的GC。
- 請儘量避免在密集的迴圈中分配物件。