Android 過度繪製優化

Android 從一誕生到現在已經發布的 7.0 版本,卡頓和不流暢問題卻一直被人們所詬病。客觀地來講,Android 的流暢性確實一直不給力,哪怕是某些大廠的 App ,也都不同程度地存在卡頓問題。從開發角度來說,每個開發者都應該關注下性能優化,在平時的開發工作中注意一些細節,儘可能地去優化應用。本文作爲性能優化系列的開篇,先從過度繪製優化講起。

過度繪製(Overdraw)的概念

過度繪製(Overdraw)描述的是屏幕上的某個像素在同一幀的時間內被繪製了多次。在多層次重疊的 UI 結構裏面,如果不可見的 UI 也在做繪製的操作,會導致某些像素區域被繪製了多次,同時也會浪費大量的 CPU 以及 GPU 資源。

在 Android 手機的開發者選項中,有一個『調試 GPU 過度繪製』的選項,該選項開啓之後,手機顯示如下,顯示出來的藍色、綠色的色塊就是過度繪製信息。

比如上面界面中的『調試 GPU 過度繪製 』的那個文本顯示爲藍色,表示其過度繪製了一次,因爲背景是白色的,然後文字是黑色的,導致文字所在的區域就會被繪製兩次:一次是背景,一次是文字,所以就產生了過度重繪。

在官網的 Debug GPU Overdraw Walkthrough 說明中對過度重繪做了簡單的介紹,其中屏幕上顯示不同色塊的具體含義如下所示:

每個顏色的說明如下:

  • 原色:沒有過度繪製
  • 藍色:1 次過度繪製
  • 綠色:2 次過度繪製
  • 粉色:3 次過度繪製
  • 紅色:4 次及以上過度繪製

過度繪製的存在會導致界面顯示時浪費不必要的資源去渲染看不見的背景,或者對某些像素區域多次繪製,就會導致界面加載或者滑動時的不流暢、掉幀,對於用戶體驗來說就是 App 特別的卡頓。爲了提升用戶體驗,提升應用的流暢性,優化過度繪製的工作還是很有必要做的。

優化原則

  • 一些過度繪製是無法避免的,比如之前說的文字和背景導致的過度繪製,這種是無法避免的。
  • 應用界面中,應該儘可能地將過度繪製控制爲 2 次(綠色)及其以下,原色和藍色是最理想的。
  • 粉色和紅色應該儘可能避免,在實際項目中避免不了時,應該儘可能減少粉色和紅色區域。
  • 不允許存在面積超過屏幕 1/4 區域的 3 次(淡紅色區域)及其以上過度繪製。

優化方法

以下部分是根據我在公司項目的實踐來整理出來的一些實際的優化步驟和方法,避免像看完大部分性能優化的文章,然後發現『懂得太多道理還是寫不好一個 App』的尷尬局面。

  1. 移除默認的 Window 背景

    一般應用默認繼承的主題都會有一個默認的 windowBackground ,比如默認的 Light 主題:

    <style name="Theme.Light">
        <item name="isLightTheme">true</item>
        <item name="windowBackground">@drawable/screen_background_selector_light</item>
        ...
    </style>
    

    但是一般界面都會自己設置界面的背景顏色或者列表頁則由 item 的背景來決定,所以默認的 Window 背景基本用不上,如果不移除就會導致所有界面都多 1 次繪製。

    可以在應用的主題中添加如下的一行屬性來移除默認的 Window 背景:

    <item name="android:windowBackground">@android:color/transparent</item>
    <!-- 或者 -->
    <item name="android:windowBackground">@null</item>
    

    或者在 BaseActivity 的 onCreate() 方法中使用下面的代碼移除:

    getWindow().setBackgroundDrawable(null);
    // 或者
    getWindow().setBackgroundDrawableResource(android.R.color.transparent);
    

    移除默認的 Window 背景的工作在項目初期做最好,因爲有可能有的界面未設置背景色,這就會導致該界面顯示成黑色的背景,如下所示,如果是後期移除的,就需要檢查移除默認 Window 背景之後的界面是否顯示正常。

  2. 移除不必要的背景

    還是上面的那個界面,因爲移除了默認的 Window 背景,所以在佈局中設置背景爲白色:

    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout
        xmlns:android="http://schemas.android.com/apk/res/android"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/white"
        android:orientation="vertical">
           
        <android.support.v7.widget.RecyclerView
            android:id="@+id/rv_apps"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:visibility="visible"/>
           
    </LinearLayout>
    

    然後在列表的 item 的佈局如下所示:

    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:background="@color/white"
        android:orientation="horizontal"
        android:padding="@dimen/mid_dp">
    
        <ImageView
            android:id="@+id/iv_app_icon"
            android:layout_width="40dp"
            android:layout_height="40dp"
            tools:src="@mipmap/ic_launcher"/>
    
        <TextView
            android:id="@+id/tv_app_label"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_gravity="center_vertical"
            android:layout_marginLeft="@dimen/mid_dp"
            android:textColor="@color/text_gray_main"
            android:textSize="@dimen/mid_sp"
            tools:text="test"/>
    </LinearLayout>
    

    看起來是沒問題的,但是因爲我界面的背景和 item 佈局的背景都是白色,所以 item 佈局中的背景是不必要的,可以移除。優化前後的過度繪製結果如下:

    很明顯優化後過度繪製比之前均少了一次,但是這種場景還是比較特殊的,因爲界面背景和 item 的背景色一樣,假如不一樣的話,就無法避免多 1 次過度繪製了。

    還有一個比較常見的可優化場景:ViewPager 加多個 Fragment 組成的首頁界面,如果你的每個 Fragment 都設置有背景色的話, 你就可以不用給 Activity 的根佈局設置背景,如果你還給 ViewPager 還設置了背景,那個這個背景是沒必要的,同樣可以移除。

    如果你不知道存在哪些無用的背景,你可以藉助 Hierarchy View 來查看。

  3. 寫合理且高效的佈局

    由於 Android 的佈局是通過編寫 xml 來實現,相對比較簡單,這也就導致很多開發者在寫佈局時很隨意,而不會考慮性能、過度重繪等問題。

    比如上面列表佈局中的分割線,可以按照如下編寫佈局來實現:

    <?xml version="1.0" encoding="utf-8"?>
    <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
        xmlns:tools="http://schemas.android.com/tools"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:paddingBottom="8dp"
        android:background="@color/divider_gray">
    
        <LinearLayout
            android:padding="@dimen/mid_dp"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:orientation="horizontal"
            android:background="@color/white">
    
            <ImageView
                android:id="@+id/iv_app_icon"
                android:layout_width="40dp"
                android:layout_height="40dp"
                tools:src="@mipmap/ic_launcher"/>
    
            <TextView
                android:id="@+id/tv_app_label"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_gravity="center_vertical"
                android:layout_marginLeft="@dimen/mid_dp"
                android:textColor="@color/text_gray_main"
                android:textSize="@dimen/mid_sp"
                tools:text="test"/>
        </LinearLayout>
    
    </LinearLayout>
    

    這種改變佈局實現分割線的方式雖然很快捷方便,但是存在不少問題的:

    • 加深了佈局層級,和之前的佈局相比多了一級

    • 多了 2 次過度繪製

    解決方式有兩種:

    1. 一種是使用 RelativeLayout 將分割線添加在 item 的佈局中,但是這樣會導致佈局複雜度增加,同時因爲 RelativeLayout 佈局的兩次測量,也會延長 View 測量的時間,在解決這種需求時並不是一個好的方式。
    2. 另一種是使用 RecyclerView 的 addItemDecoration(ItemDecoration decor) 方法添加分割線,這種方式在你自定義好一個分割線 ItemDecoration 時是很方便的,網上有很多關於這方面的例子(如果你使用 ListView 的話,則使用 setDivider(Drawable divider) 方法)。

    我們採用第二種解決方法,優化前後的對比如下:

    優化後的佈局 ImageView 和 item 背景區域均比優化前少了 2 次過度重繪,佈局層級也沒增加,需求也實現了。

    注:很多開發者在開發中一般很少注意這種小細節,一般以完成需求爲目的,可能還認爲這麼點細節優化不優化其實也沒什麼,但是積少成多,小的細節優化多了,整體性能和體驗可能就上升了,相反,這個細節不注意那個細節無所謂,最終就導致應用卡頓,體驗糟糕。注重細節的開發者運氣一般都不會太差。: )

  4. 自定義控件使用 clipRect() 和 quickReject() 優化

    當某些控件不可見時,如果還繼續繪製更新該控件,就會導致過度繪製。但是通過 Canvas clipRect() 方法可以設置需要繪製的區域,當某個控件或者 View 的部分區域不可見時,就可以減少過度繪製。

    先看一下 clipRect() 方法的說明:

    Intersect the current clip with the specified rectangle, which is expressed in local coordinates.

    顧名思義就是給 Canvas 設置一個裁剪區,只有在這個裁剪矩形區域內的纔會被繪製,區域之外的都不繪製。 DrawerLayout 就是一個很不錯的例子,先來看一下使用 DrawerLayout 佈局的過度繪製結果:

    按道理左邊的抽屜佈局出來時,應該是和主界面的佈局疊加起來的,但是爲什麼抽屜的背景過度繪製只有一次呢?如果是疊加的話,那最少是主界面過度繪製次數 +1,但是結果並不是這樣。直接看源碼:

    @Override
    protected boolean drawChild(Canvas canvas, View child, long drawingTim
        final int height = getHeight();
        final boolean drawingContent = isContentView(child);
        int clipLeft = 0, clipRight = getWidth();
        final int restoreCount = canvas.save();
        if (drawingContent) {
            final int childCount = getChildCount();
            for (int i = 0; i < childCount; i++) {
                final View v = getChildAt(i);
                if (v == child || v.getVisibility() != VISIBLE
                        || !hasOpaqueBackground(v) || !isDrawerView(v)
                        || v.getHeight() < height) {
                    continue;
                }
                if (checkDrawerViewAbsoluteGravity(v, Gravity.LEFT)) {
                    final int vright = v.getRight();
                    if (vright > clipLeft) clipLeft = vright;
                } else {
                    final int vleft = v.getLeft();
                    if (vleft < clipRight) clipRight = vleft;
                }
            }
            canvas.clipRect(clipLeft, 0, clipRight, getHeight());
        }
        ......                       
    }
    

    在 DrawerLayout 的 drawChild() 方法一開始會判斷是是否是 DrawerLayout 的 ContentView,即非抽屜佈局,如果是的話,則遍歷 DrawerLayout 的 child view,拿到抽屜佈局,如果是左邊抽屜,則取抽屜佈局的右邊邊界作爲裁剪區的左邊界,得到的裁剪矩形就是下圖中的紅色框部分,然後設置裁剪區域。右邊抽屜同理。

    這樣一來,只有裁剪矩形內的界面需要繪製,自然就減少了抽屜佈局的過度繪製。自定義控件時可以參照這個來優化過度繪製問題。

    除了 clipRect() 以外,還可以使用 canvas.quickreject() 來判斷和某個矩形相交,如果相交的話,則可以跳過相交的區域減少過度繪製。

優化實踐

前面其實已經講了很多了,但是實際去優化過度繪製時,可能還是會比較懵,看着屏幕上的大片大片的紅色,不知道從何下手。接下來就以實際項目中的過度繪製優化經歷來談談,如何進行優化?

先上圖,前面是未開啓 『調試 GPU 過度繪製』 的界面圖,中間的是優化前的過度繪製結果,後面的是優化後的過度繪製結果,不難看出來,中間那張圖過度繪製是很嚴重的,一眼看過去一片紅,很顯然不符合優化原則。

優化步驟如下:

  1. 先分析每個地方最少可以繪製幾次,不合理的地方就可以優化。

    例如:中間那張圖顯示的每個 item 的背景是綠色的,也就是 2 次過度繪製,這肯定是不合理的。因爲整個界面大背景是灰色的,item 背景是白色的,按道理應該就 1 次過度繪製。檢查下來發現沒去掉默認的 Window 背景,移除之後 item 背景就變成了藍色了,也就是 1 次過度繪製。

  2. 疊加的佈局,過度繪製次數是否合理遞增

    還是看中間那張圖,item 的背景過度繪製是 2 次,按道理九宮格圖片每張圖應該是過度繪製 3 次,但是卻顯示成紅色的,顯然沒有合理遞增而出現了跳躍。

    先猜測是不是因爲給九宮格圖片控件設置了白色背景?但是想一下就排除了,因爲圖片間隙的過度繪製次數和 item 背景是相同的。

    那就是每個 ImageView 有問題了,後來發現之前設置佔位圖的時候,給每個 ImageView 設置了一個灰色的背景色:

    imageView.setBackgroundColor(Color.parseColor("#eeeeee"));
    

    這也就導致了每個 ImageView 的過度繪製直接多了 1 次。

    這兩步優化後,再看最後一張圖中的優化結果,基本是可以的了。

  3. 在 優化方法 中講到的 ViewPager 佈局加 Fragment 實現的首頁佈局,一個不注意很容易出現過度繪製嚴重的問題,在移除 ViewPager 和 Activity 根佈局的白色背景後,以及默認的 Window 背景,原來紅成一片的首頁現在基本上是大部分藍色和小部分綠色了。

小插曲

最後來個小插曲,因爲開啓 『調試 GPU 過度繪製』比較麻煩,我就想找個比較方便快捷的方式,一開始想着寫個桌面插件應用,一鍵切換。

  • 查文檔發現沒有相關的設置的 API

  • 直接翻源碼,發現相關的 API 是隱藏的,集中在 SystemProperties 類中,可以通過如下代碼設置:

    SystemProperties.set(HardwareRenderer.DEBUG_OVERDRAW_PROPERTY, "show");
    
  • 直接編譯源碼拿到了沒隱藏的 jar 包,暫時能調用到該類,但是運行之後發現需要系統權限才能設置

  • 通過一些方式企圖讓這個 App 獲取到系統權限,但是均失敗了 : (

如果你對相關的知識有所瞭解,請聯繫我和我探討下,謝謝。

不過最後也算是找到了一個比較方便的方法,省去了去設置裏面一步步點。直接運行 adb 指令:

開啓『調試 GPU 過度繪製』:

adb shell setprop debug.hwui.overdraw show

關閉『調試 GPU 過度繪製』:

adb shell setprop debug.hwui.overdraw false

再取個指令別名,使用起來還是很方便的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章