Android UI優化

MagaretTrow 7年前發布 | 6K 次閱讀 安卓開發 Android開發 移動開發

Android 的 UI 優化學習筆記和總結,包括一些導致卡頓的原因和一些解決方案,歡迎大家一起學習交流!

16ms

Android 系統每隔 16ms 發出 VSYNC 信號觸發對UI進行渲染,那么就要求每一幀都要在 16ms 內繪制完成(包括發送給 GPU 和 CPU 繪制到緩沖區的命令,這樣就能夠達到流暢的畫面所需要的60fps。

如果你的某個操作花費時間是24ms,系統在得到 VSYNC 信號的時候就無法進行正常渲染,這樣就發生了丟幀現象。那么用戶在 32ms 內看到的會是同一幀畫面。

丟幀原因

有很多原因可以導致丟幀,這里列舉一些常見的:

  • layout 太過復雜,層次過多
  • UI 上有層疊太多的繪制單元,過度繪制
  • CPU 或者 GPU 負載過重
  • 動畫執行的次數過多
  • 頻繁 GC,主要是內存抖動
  • UI 線程執行耗時操作
  • 等等

分析

接下來逐個分析導致原因以及解決方案:

布局太過復雜,層次過多

layout 布局是一棵樹,樹根是 window 的 decorView,套嵌的子 view 越深,樹就越復雜,渲染就越費時間。每個 View 都會經過 measure、layout 和 draw 三個流程,都是從樹根開始,那么選父布局的時候就要考慮渲染的性能問題:這里分析一下常見的布局控件 LinearLayout 、 RelativeLayout 和 FrameLayout :

LinearLayout

LinearLayout 在 measure 的時候,在橫向或者縱向會去測量子 View 的寬度或高度,且只會測量一次,但是當設置 layout_weight 屬性的時候會去測量兩次才能獲得精確的展示尺寸。

public class LinearLayout extends ViewGroup {
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        if (mOrientation == VERTICAL) {
            measureVertical(widthMeasureSpec, heightMeasureSpec);
        } else {
            measureHorizontal(widthMeasureSpec, heightMeasureSpec);
        }
    }

    void measureVertical(int widthMeasureSpec, int heightMeasureSpec) {
        //blablabla......
        final int count = getVirtualChildCount();

        for (int i = 0; i < count; ++i) {
            //blablabla......
            if (heightMode == MeasureSpec.EXACTLY && lp.height == 0 && lp.weight > 0) {
                //blablabla......
            } else {
                //blablabla......

                // Determine how big this child would like to be. If this or
                // previous children have given a weight, then we allow it to
                // use all available space (and we will shrink things later
                // if needed).
                measureChildBeforeLayout(
                       child, i, widthMeasureSpec, 0, heightMeasureSpec,
                       totalWeight == 0 ? mTotalLength : 0);
                //blablabla......
            }
        }
        //blablabla......
        if (skippedMeasure || delta != 0 && totalWeight > 0.0f) {
            for (int i = 0; i < count; ++i) {
                LinearLayout.LayoutParams lp = (LinearLayout.LayoutParams) child.getLayoutParams();

                float childExtra = lp.weight;
                if (childExtra > 0) {
int share = (int) (childExtra * delta / weightSum);
                    weightSum -= childExtra;
                    delta -= share;

                    final int childWidthMeasureSpec = getChildMeasureSpec(widthMeasureSpec,
                            mPaddingLeft + mPaddingRight +
                                    lp.leftMargin + lp.rightMargin, lp.width);

                    if ((lp.height != 0) || (heightMode != MeasureSpec.EXACTLY)) {
                        // child was measured once already above...
                        // base new measurement on stored values
                        int childHeight = child.getMeasuredHeight() + share;
                        if (childHeight < 0) {
                            childHeight = 0;
                        }

                        child.measure(childWidthMeasureSpec,
                                MeasureSpec.makeMeasureSpec(childHeight, MeasureSpec.EXACTLY));
                    } else {
                        // child was skipped in the loop above.
                        // Measure for this first time here      
                        child.measure(childWidthMeasureSpec,
                                MeasureSpec.makeMeasureSpec(share > 0 ? share : 0,
                                        MeasureSpec.EXACTLY));
                    }
                }
            }
        } else {
            alternativeMaxWidth = Math.max(alternativeMaxWidth,
                                           weightedMaxWidth);


            // We have no limit, so make all weighted views as tall as the largest child.
            // Children will have already been measured once.
            if (useLargestChild && heightMode != MeasureSpec.EXACTLY) {
                for (int i = 0; i < count; i++) {
                    final View child = getVirtualChildAt(i);

                    if (child == null || child.getVisibility() == View.GONE) {
                        continue;
                    }

                    final LinearLayout.LayoutParams lp =
                            (LinearLayout.LayoutParams) child.getLayoutParams();

                    float childExtra = lp.weight;
                    if (childExtra > 0) {
                        child.measure(
                                MeasureSpec.makeMeasureSpec(child.getMeasuredWidth(),
                                        MeasureSpec.EXACTLY),
                                MeasureSpec.makeMeasureSpec(largestChildHeight,
                                        MeasureSpec.EXACTLY));
                    }
                }
            }
        }
        //blablabla......
    }
}

RelativeLayout

RelativeLayout 在 measure 的時候會在橫向和縱向各測量一次。

public class RelativeLayout extends ViewGroup {
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        //blablabla......

        View[] views = mSortedHorizontalChildren;
        int count = views.length;
        for (int i = 0; i < count; i++) {
            View child = views[i];
            if (child.getVisibility() != GONE) {
                LayoutParams params = (LayoutParams) child.getLayoutParams();
                int[] rules = params.getRules(layoutDirection);

                applyHorizontalSizeRules(params, myWidth, rules);
                measureChildHorizontal(child, params, myWidth, myHeight);

                if (positionChildHorizontal(child, params, myWidth, isWrapContentWidth)) {
                    offsetHorizontalAxis = true;
                }
            }
        }
        //blablabla......

        views = mSortedVerticalChildren;
        count = views.length;
        for (int i = 0; i < count; i++) {
            final View child = views[i];
            if (child.getVisibility() != GONE) {
                final LayoutParams params = (LayoutParams) child.getLayoutParams();

                applyVerticalSizeRules(params, myHeight, child.getBaseline());
                measureChild(child, params, myWidth, myHeight);
                if (positionChildVertical(child, params, myHeight, isWrapContentHeight)) {
                    offsetVerticalAxis = true;
                }

                if (isWrapContentWidth) {
                    if (isLayoutRtl()) {
                        if (targetSdkVersion < Build.VERSION_CODES.KITKAT) {
                            width = Math.max(width, myWidth - params.mLeft);
                        } else {
                            width = Math.max(width, myWidth - params.mLeft - params.leftMargin);
                        }
                    } else {
                        if (targetSdkVersion < Build.VERSION_CODES.KITKAT) {
                            width = Math.max(width, params.mRight);
                        } else {
                            width = Math.max(width, params.mRight + params.rightMargin);
                        }
                    }
                }

                if (isWrapContentHeight) {
                    if (targetSdkVersion < Build.VERSION_CODES.KITKAT) {
                        height = Math.max(height, params.mBottom);
                    } else {
                        height = Math.max(height, params.mBottom + params.bottomMargin);
                    }
                }

                if (child != ignore || verticalGravity) {
                    left = Math.min(left, params.mLeft - params.leftMargin);
                    top = Math.min(top, params.mTop - params.topMargin);
                }

                if (child != ignore || horizontalGravity) {
                    right = Math.max(right, params.mRight + params.rightMargin);
                    bottom = Math.max(bottom, params.mBottom + params.bottomMargin);
                }
            }
        }
        //blablabla......
    }
}

簡析

如果帶有 weight 屬性的 LinearLayout 或者 RelativeLayout 被套嵌使用,measure 所費時間可能會呈指數級增長(兩個套嵌的葉子 view 會有四次 measure,三個套嵌的葉子 view 會有8次的 measure)。為了縮短這個時間,保持樹形結構盡量扁平(深度低),而且盡量要移除所有不需要渲染的 view。

Hierarchy Viewer

Hierarchy Viewer 可以很方便可視化的查看屏幕上套嵌 view 結構,這個工具在 sdk 的 tools 文件里面。

栗子

MsgNumberView 是一個自定義控件,其 measure、layout 和 draw 共花費 3ms 的時間,可以發現布局中多了一層 LinearLayout,而該 LinearLayout 而進行了測量等操作,共花費 1.4ms 時間。當我們去除中間的 LinearLayout 后再分析看:

去除之后發現,總體在渲染上下降了很多時間,變為了 0.25ms。

你可能已經注意到了每個 view 里黃色、綠色等圓圈。它們表示該 view 在那一層樹形結構里 measure,layout 和 draw 所花費的相對時間。綠色表示最快的前 50%,黃色表示最慢的前 50%,紅色表示那一層里面最慢的 view 。

再來看一個栗子:

該 LinearLayout 里面有三個子 View,其中兩個也是 LinearLayout ,并且子 LinearLayout 中是兩個 TextView,對于最外層的 LinearLayout 來說,渲染共花費了 3.6ms 左右。那么處理一下,減少深度:

發現渲染減到了 1ms 左右。當然這里的修改不僅僅是布局上的修改,在 java 代碼上也有一些改動,之前上邊的 TextView 是作為 Label 控件,那么現在 Label 和 真正顯示數據的 TextView 合并成一個,在 Java 代碼中也進行了處理,包括 Label 的字體顏色與顯示控件的字體顏色不一樣,通過 Html 或者 Spannable 進行修飾等等。

優化

  • 避免復雜的 View 層級
  • 避免 layout 頂層使用 RelativeLayout
  • 布局層次相同的情況下,使用 LinearLayout
  • 復雜布局建議采用 RelativeLayout 而不是多層次的 LinearLayout
  • <include/> 標簽復用
  • <merge/> 標簽減少嵌套
  • 盡量避免 layout_weight
  • 視圖按需加載或者使用 ViewStub

層疊太多,過度繪制

跟 measure 一樣, View 的繪制也是從樹根開始一層一層往葉子繪制,就難免導致葉子的繪制擋住了其父節點的一些繪制的內容。過渡繪制是一個術語,表示某些組件在屏幕上的一個像素點的繪制次數超過 1 次。過度繪制導致的問題是花了太多的時間去繪制那些堆疊在下面的、用戶看不到的東西,浪費了 CPU 周期和渲染時間。

調試 GPU 過度繪制

藍色,淡綠,淡紅,深紅代表了4種不同程度的 Overdraw 情況,我們的目標就是盡量減少紅色 Overdraw,看到更多的藍色甚至白色區域。

栗子

這里展示的是帖子的詳情頁 Activity,在做這里的過度繪制的優化的時候,我從 xml 文件和 Java 代碼兩個層面去進行優化,在 xml 中去除無用的 background 等,點擊態的 normal 狀態統一用 transparent,在 Java 代碼中,當 loading 結束后,修改 loading 的背景由灰色變為白色顏色等。

優化

  • 去除重復或者不必要的 background
  • 點擊態中的 normal 盡量設置成 transparent
  • 去除 window 中的 background(這個可以通過處理 decorView 或者設置 Theme 的方式)
  • 若是自定義控件的話,通過 canvas.clipRect() 幫助系統識別那些可見的區域

上面的示例圖中顯示了一個自定義的 View,主要效果是呈現多張重疊的卡片。這個 View 的 onDraw 方法如下圖所示:

打開開發者選項中的顯示過度渲染,可以看到我們這個自定義的 View 部分區域存在著過度繪制。下面的代碼顯示了如何通過 clipRect 來解決自定義 View 的過度繪制,提高自定義 View 的繪制性能:

下面是優化過后的效果:

負載過重

UI 線程是應用的主線程,很多的性能和卡頓問題是由于在主線程中做了大量的工作。除了主線程外,子線程占用過多 CPU 資源也會導致渲染性能問題。

在 UI 渲染的過程中,是 CPU 和 GPU 共同合作完成的,其中 CPU 負責把 UI 組件計算成 Polygons,Texture 紋理,然后交給 GPU 進行柵格化渲染。

GPU 呈現模式分析

通過在 Android 設備的開發者選項里啟動 “ GPU 呈現模式分析 ” ,可以得到最近 128 幀 每一幀渲染的時間。在 Android 6.0 之前,界面上顯示的柱狀圖主要是三個顏色,分別是黃、紅和藍色。

通俗點來講,黃色代表 CPU 通知 GPU,當 CPU 有太多事情做的時候,黃色的線就會長一些;紅色代表渲染時間,比如層次深的情況下,渲染時間就會長一點,紅色的線也會長一些;藍色代表執行 onDraw() 時間。而橫著的綠色的那條線代表 16ms 分割線。

栗子

這是一個選擇照片的功能的一個頁面,用的 RecyclerView,兩張圖的唯一區別在于 Adapter 中加入了一段異步耗時操作:

public class MediaPhotoAdapter extends RecyclerView.Adapter<MediaPhotoViewHolder> {
    @Override
    public void onBindViewHolder(MediaPhotoViewHolder holder, int position) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 10000; i++) {
                    YLog.i("tag", "i-->" + i);
                }
            }
        }).start();
        //blablabla.....
}

每次更新 View 的時候都會開啟新線程做一些耗時的操作,這個線程就用了大部分 CPU 資源,這個過程就跟在 ListView 滑動的時候異步加載圖片類似。

Android System Trace

VSYNC-app 是均勻分布的寬條,每個寬條表示 16 ms。當發出 VSYNC 信號時, surfaceflinger 會去繪制刷新,在理想情況下 surfaceflinger 之間相距也是 16ms,因此如果出現長條空缺則表示 surfaceflinger 丟掉了一次 VSYNC 更新信號,屏幕就沒有及時的刷新。

從圖片上來看,在加載頁面的時候發生過好幾次丟幀的情況,可以通過方法開查看具體什么原因導致的丟幀。Frames 是提供的判斷繪制該幀的情況,分別有綠、黃和紅色,當為空色的時候表示該幀耗時很嚴重,我們就可以從這些紅色的 F 為出發點去分析。

我們可以查從圖片上可以看出,先進行了 dorceView-inflate 操作(UI Thread 綠色那部分),這個操作在 UI 線程,且速度很快,在 1ms 內就完成了,接下來就是兩個藍色的 inflate 操作了。

第一次 inflate 是在 Activity 的 setContent() 中完成,其中看到了之前所說的 MsgNumberView ,第二次 inflate 是發生在 Fragment 中,界面中除了 Titlebar,其他的都是在這個 Fragment 中展示的,所以這個界面的 inflate 比 Activity 的更加耗時。

第一次 inflate 和 第二次 inflate 之間還有一段時間的白色間隙,這是因為初始化 View (比如 findViewById 等)、網絡請求封裝、業務邏輯等操作。在完成第二次 inflate 之后發現后面還有一小段的白色間隙,這是因為等待一下個 VSYNC 信號。

這里的 F 是黃色的,我的猜測這里應該是網絡請求的數據返回回來了,因為這個頁面的數據量巨大,接近百個字段吧,同時數據解析是放在 UI 線程進行的,包括 InputStream 轉 String,String 轉 Json 再解析。同時在下面建議中也說明了建議放在后臺線程中以免阻塞 UI 線程。

這里又發生了在 UI 線程的耗時的 inflate 事情,這是因為對于不同的帖子,這些數據可能會展示可能會不展示,而在需求開發中明確了這些數據不展示的情況大于真是的情況,所以采用了動態的 inflate 操作,也可以采用 ViewStub 哈。

這里又發生了超級耗時的操作, F 都為紅色了,根據描述來分析是因為 Measure 和 Layout 以及 draw() 花費了太多的時間。

(Android System Trace 用的還不是很熟練,有不對的地方輕噴)

內存抖動

在我接觸過的內存抖動中,主要導致原因是頻繁創建大對象或者頻繁創建大量對象,并且這些對象屬于用完就廢棄的,比如 byte[] 。我接觸到的內存抖動是在 Camera 獲取幀數據,在回調函數中 onPreviewFrame(byte[] data, Camera camera) 使用到了 byte[] ,等到下一幀數據回調回來的時候又是一個新的 byte[] 。而 GC 操作或多或少都會 “ stop-the-world “, 比如 GC 操作花費了 5ms 的時間,那么該幀的繪制就會從原來的 16ms 變為 11ms 。

優化

  • 大對象可以使用對象池復用,比如 byte[]
  • 盡量在 16ms 內少創建對象,比如在 onDraw 中創建 Paint 對象,decode Bitmap 之類的

硬件加速

并非所有的都支持硬件加速,其中包括 clipPath() 等;同時也有一些方法在開啟硬件加速之后與不開啟硬件加速效果不一樣,比如 drawBitmapMesh() 等。

Application 級別

<applicationandroid:hardwareAccelerated = "true" ...>

Activity 級別

<activity android:hardwareAccelerated = "true" ...>

Window 級別

getWindow().setFlags(
    WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED,
    WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED);

View 級別

View.setLayerType(View.LAYER_TYPE_HARDWARE, null);

參考

 

來自:http://yydcdut.com/2017/03/10/ui-optimize/

 

 本文由用戶 MagaretTrow 自行上傳分享,僅供網友學習交流。所有權歸原作者,若您的權利被侵害,請聯系管理員。
 轉載本站原創文章,請注明出處,并保留原始鏈接、圖片水印。
 本站是一個以用戶分享為主的開源技術平臺,歡迎各類分享!