1. 程式人生 > >Android 系統性能優化(34)---Android UI 效能優化

Android 系統性能優化(34)---Android 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 RenderingProfile GPU Rendering在螢幕上顯示了各種圖表,可以幫助你直觀地看到繪製UI視窗的每一幀相對16ms每幀的標準花費了多長時間。每個顯示欄有各個不同顏色的元件,每個元件都被對映到渲染pipeline的某個階段,因此你可以看到哪一部分花費了最長的時間。例如,如果某一幀在處理輸入的時候花費了較長時間,那你就應該檢視一下你的程式碼裡面處理使用者輸入的部分。
  • 某些特定的元件,比如 RecyclerView,它們是常見的卡頓根源,如果你的App使用了這些元件,最好分析使用了這些元件的部分。
  • 儘量使用較慢的裝置來惡化卡頓問題以便分析。

一旦發現了產生卡頓的場景,或許你已經知道了造成卡頓的原因,但如果你需要更詳細的資訊來分析問題,可以藉助Systrace。

使用Systrace

雖然Systrace是展示整個裝置在幹什麼的工具,它對定位卡頓問題也非常有幫助。Systrace有著非常小的執行時開銷,因此你在分析問題的時候可以體驗到真實的卡頓。

使用Systrace來記錄App卡頓場景下的trace(可以通過 Systrace WalkThrough 來檢視如何做)。systrace的圖表被程序和執行緒分為若干個部分,你的app的trace結果大致長這樣:

上圖所示的systrace包含著可以定位卡頓的如下資訊:
  1. Systrace顯示了每一幀繪製的時間段,並且給每一幀都有不同顏色,可以突出較慢的渲染幀時間。與直觀分析相比,這可以幫助你更精確地找到單獨的卡頓的某一幀。更詳細的內容可以參考 Inspecting Frames
  2. Systrace會監測你App中的問題並會在單獨幀和警告欄裡面展示警告提示資訊;跟著這些提示的指引來分析問題是最好的選擇。
  3. 某些 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納秒以下的方法裡面新增標記。

定製效能監控工具

如果你無法在本地裝置上重現卡頓問題,可以在App內構建自定義的效能監控工具,通過線上裝置來幫助定位卡頓問題。

修復卡頓

要修復卡頓問題,首先檢視那些沒有在16.7ms內完成的幀,然後檢視造成這個的原因在哪。是因為View#draw反常地花費了較長時間,又或者是佈局過程耗時?詳細介紹見下文的常見卡頓原因

要避免卡頓問題,耗時較長的任務應該在UI執行緒之外非同步完成;因此需要時刻注意你的程式碼執行在哪個執行緒,並且在post不重要的任務到主執行緒的時候保持謹慎。

常見的卡頓原因

下面的小結將介紹一些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) 會觸發昂貴的裁剪操作,因此也需要儘量避免。在可能的情況下,應該儘量直接繪製出需要的形狀,而不是裁剪成相應的圖形;這樣效能更高