1. 程式人生 > >從-View-繪制談性能優化(轉)

從-View-繪制談性能優化(轉)

有趣 || left 例子 bject create 我想 roo 並且

在開發過程中,往往會聽到 “性能優化” 這個概念,這個概念很大,比如網絡性能優化、耗電量優化等等,對我們開發者而言,最容易做的,或者是影響最大的,應該是 View 的性能優化。一般小項目或許用不上 View 性能優化,然而,當業務愈加龐大、界面愈加復雜的時候,沒有一個良好的開發習慣和 View 布局優化常識,做出來的界面很容易出現 “卡頓” 現象,從而嚴重影響用戶體驗。而對於我們開發者來說,了解一些 View 性能優化的常識,增強開發技巧,可以說是一門必備的功課。

為了更好地理解 View 性能優化的原理,以及造成 “卡頓” 的可能原因,我們從 View 的繪制流程開始討論。之後,會介紹一些寫界面布局常用的一些標簽及使用註意事項。

View 繪制流程

我們都知道,View 的繪制分為三個階段:測量、布局和繪制,這三個階段各自的作用如下:

  • measure: 為整個 View 樹計算實際的大小,即設置實際的高(對應屬性:mMeasureHeight)和寬(對應屬性:mMeasureWidth),每個 View 的控件的實際寬高都是由父視圖和本身視圖所決定的。
  • layout:為將整個根據子視圖的大小以及布局參數將 View 樹放到合適的位置上。
  • draw:利用前兩部得到的參數,將視圖顯示在屏幕上。

當一個 Activity 對象被創建完成之後,會將一個 DecorView 對象添加到 Window 中,同時會創建一個 ViewRootImpl 對象,並將 ViewRootImpl 對象和 DecorView 對象建立聯系,然後繪制流程就會從 ViewGroup 的 performTraversals 方法開始執行,如下圖所示:

技術分享圖片

整個繪制流程從 ViewRootImpl 的 performTraversals() 方法開始,在該方法內會調用 performMeasure() 方法進行測量子 View(也就是根 View,頂級的 ViewGroup)。然後在 performMeasure 中會調用 measure() 方法來執行具體的測量邏輯,這個時候,代碼邏輯就從 ViewRootImp 跳轉到了 View 類中了:

1
2
3
4
5
6
7
8
private void performMeasure(int childWidthMeasureSpec, int childHeightMeasureSpec) {
Trace.traceBegin(Trace.TRACE_TAG_VIEW, "measure");
try {
mView.measure(childWidthMeasureSpec, childHeightMeasureSpec);
} finally {
Trace.traceEnd(Trace.TRACE_TAG_VIEW);
}
}

measure() 方法中,有一個 onMeasure() 方法,用於這個方法用來測量子元素的大小,也將測量流程從父元素傳遞到子元素當中去。緊接著子元素會重復父元素的測量流程,如此反復,就完成了一顆 View 樹的遍歷。當 measure() 方法完成後,會將結果存儲在 LongSparseLongArray 類型的變量 mMeasureCache 中。

performTraversals() 方法中,調用完 performMeasure(),後,會接著調用 performLayout()performDraw() 進行 View 的布局和繪制。這兩個流程和測量的流程差不多,就不再敘述。

而這三個階段分別作了什麽呢?源碼太長就不貼了,主要的作用如下:

Measure 過程

  • 設置本 View 視圖的最終大小。
  • 如果該 View 對象是個 ViewGroup 類型,需要重寫該 onMeasure() 方法,對其子視圖進行遍歷 measure() 過程。
    • measureChildren(),內部使用了一個 for 循環對子視圖進行遍歷,分別調用了子視圖的 measure() 方法。
    • measureChild(),為指定的子視圖 measure,會被 measureChildren 調用。
    • measureChildWidthMargins(),為指定的子視圖考慮了 margin 和 padding 的 measure。

Layout 過程

  • layout() 方法會設置該 View 視圖位於父視圖的坐標軸,即 mLeft, mTop, mRight, mBottom.(調用 setFrame() 方法去實現),接下來回調 onLayout() 方法(如果該 View 是 ViewGroup 對象,需要實現該方法,對每個視圖進行布局);
  • 如果該 View 是個 ViewGroup 類型,需要遍歷每個子視圖 childView。調用該子視圖的 layout() 方法去設置它的坐標值。

Draw 過程

  • 繪制背景
  • 如果要視圖顯示漸變框,這裏會做一些準備工作
  • 繪制視圖本身,即調用 onDraw() 方法。在 view 中,onDraw() 是個空方法,也就是說具體的視圖都啊喲覆蓋該方法來實現自己的顯示(比如 TextView 在這裏實現了繪制文字的過程)。而對於 ViewGroup 則不需要實現該方法,因為作為容器是沒有內容的,其包含了多個子 View,而子 View 已經實現了自己的繪制方法,因此只需要告訴子 View 繪制自己就行了,也就是下面的 dispatchDraw() 方法。
  • 繪制視圖,即 dispatchDraw() 方法。在 View 中這是個空方法,具體的視圖不需要實現該方法,它是專門為容器類準備的,也就是容器必須實現該方法。
  • 如果需要,開始繪制漸變框。
  • 繪制滾動條。

因此,如果我們去掉不必要的背景,去掉漸變框,去掉滾動條,在一定程度上是能加快繪制速度的。

優化

幀率(frame per second,即 FPS),指的是每秒刷新的次數。一般電影的幀率為 24FPS、25FPS 和 30FPS。而遊戲的幀率一般要保持 60FPS 才能叫做流暢,當遊戲的 FPS 低於 30 時,我們就會感受到明顯地卡頓。Android 系統每隔 16ms 觸發一次 UI 刷新操作,這就要求我們的應用都能在 16ms 內繪制完成。如果有一次的界面繪制用了 22ms,那麽,用戶在 32ms 內看見的都是同一個界面。情況嚴重的就會讓用戶感受到應用運行”卡頓“。

因此,優化的目的,主要就是減少繪制時間,盡量保證每個界面都能在 16ms 內完成繪制。而優化的方案,從上面的分析,我們可以分兩個方面:

技術分享圖片

從內優化

  1. 減少 View 層級。這樣會加快 View 的循環遍歷過程。
  2. 去除不必要的背景。由於 在 draw 的步驟中,會單獨繪制背景。因此去除不必要的背景會加快 View 的繪制。
  3. 盡可能少的使用 margin、padding。在測量和布局的過程中,對有 margin 和 padding 的 View 會進行單獨的處理步驟,這樣會花費時間。我們可以在父 View 中設置 margin 和 padding,從而避免在子 View 中每個單獨設置和配置。
  4. 去除不必要的 scrollbar。這樣能減少 draw 的流程。
  5. 慎用漸變。能減少 draw 的流程。

從外優化

  1. 布局嵌套過於復雜。這會直接 View 的層級變多。
  2. View 的過渡繪制。
  3. View 的頻繁重新渲染。
  4. UI 線程中進行耗時操作。在 Android 4.0 之後,不允許在 UI 線程做網絡操作。
  5. 冗余資源及錯誤邏輯導致加載和執行緩慢。簡單的說,就是代碼寫的爛。
  6. 頻繁觸發 GC,導致渲染受阻。當系統在短時間內有大量對象銷毀,會造成內存抖動,頻繁觸發 GC 線程,而 GC 線程的優先級高於 UI 線程,因而會造成渲染受阻。

外部因素最為致命!日常開發中更多的應該關心布局的嵌套層級和冗余資源。

比如,當需要將一個 TextView 和一張圖片放在一起展示時,我們可以考慮使用 TextView 的 drawableLeft(drawableRight、drawableTop、drawableBottom) 屬性來設置圖片,而不是使用一個 LinearLayout 來將 TextView 和 ImageView 封裝在一起,這樣就能減少 View 的繪制層級。

又比如,子元素和父元素都是相同的背景時,就不必在每個子元素中都添加背景屬性,等等。

線性布局和相對布局

線性布局和相對布局是我們平時使用最多的布局方式。在一般開發場景中,兩者的渲染效率沒有明顯差別,但是如果真要較真的話,他們之間還是有細微差別的。

RelativeLayout 在測量子 View 排列方式是基於彼此的依賴關系,這種依賴關系導致了子 View 的顯示順序不一定和布局中的 View 的順序相同,在確定所有子 View 的時候,會先對所有的 View 進行排序,同時,由於 RelativeLayout 允許 “A在橫向上依賴於 B,B 在縱向上依賴於 A“,因此會測量兩次,導致測量效率較低。而 LinearLayout 由於有 orientation 屬性,則測量就很簡單了。

LinearLayout 在設置 weight 屬性的時候,也會導致二次測量:首先會遍歷測量沒有 weight 屬性的 View,然後再遍歷測量包含 weight 屬性的 View。

布局比較

技術分享圖片

選擇布局容器的基本準則:

  • 盡可能的使用 RelativeLayout 以減少 View 層級,使 View 樹趨於扁平化。
  • 不影響層級深度的情況下,使用 LinearLayout 和 FrameLayout 而不是 RelativeLayout

布局標簽

說到布局標簽,我想大概很多人都用過一些。為了說明 Android 系統對於這些標簽的處理,我們先看一下 XML 布局是如何解析繪制到屏幕的。

在 Activity 的 onCreate() 方法中,我們一般會調用 setContentView() 方法,這個方法負責將 XML 文件解析繪制到屏幕上,這個方法很簡單:

1
2
3
4
public void setContentView(@LayoutRes int layoutResID) {
getWindow().setContentView(layoutResID);
initWindowDecorActionBar();
}

這個方法第一行是調用 Window 類的 setContentView(),第二行是初始化 ActionBar。Window 類是一個抽象類,它是所有視圖相關類的頂層類,其唯一一個實現類是 PhoneWindow,在 PhoneWindow 類的 setContentView() 方法中,會先移除掉所有的 view 視圖,然後再調用 LayoutInflater.inflate() 方法繪制,在 LayoutInflater 的 inflate() 方法中,會創建一個 XmlResourceParser 解析器,然後再進行解析。我們來看看 inflate() 方法裏面幹了什麽:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public View inflate(XmlPullParser parser, @Nullable ViewGroup root, boolean attachToRoot) {
View result = root;
......
final String name = parser.getName();
// 這裏的 TAG_MERGE 其實就是 merge 標簽
if (TAG_MERGE.equals(name)) {
if (root == null || !attachToRoot) {
throw new InflateException("<merge /> can be used only with a valid " + "ViewGroup root and attachToRoot=true");
}
rInflate(parser, root, inflaterContext, attrs, false);
} else {
// 這裏的 createViewFromTag() 方法創建的是根布局
final View temp = createViewFromTag(root, name, inflaterContext, attrs);
......
ViewGroup.LayoutParams params = null;
if (root != null) {
// 根據 xml 屬性生成布局參數
params = root.generateLayoutParams(attrs);
// 生成子元素的布局
rInflateChildren(parser, temp, attrs, true);
if (root != null && attachToRoot) {
root.addView(temp, params);
}
......
}
......
}
return result;
}

為了方便閱讀,我將一些代碼省略掉,從上面可以看出大致的解析流程:先判斷是否有 merge 標簽,然後檢查其合理性,註意源碼已經說明了,merge 標簽只能用在 ViewGroup 的根布局中,並且 attachToRoot 必須要設置為 true。然後調用 rInflate() 方法;如果沒有 merge 標簽,就會調用 rInflateChildren() 方法生成子元素的布局,而這個 rInflateChildren() 方法最終也是輾轉到前面的 rInflate() 方法中,我們來看一下這個方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
void rInflate(XmlPullParser parser, View parent, Context context,
AttributeSet attrs, boolean finishInflate) throws XmlPullParserException, IOException {
final int depth = parser.getDepth();
int type;
while (((type = parser.next()) != XmlPullParser.END_TAG ||
parser.getDepth() > depth) && type != XmlPullParser.END_DOCUMENT) {
if (type != XmlPullParser.START_TAG) {
continue;
}
final String name = parser.getName();
// 判斷是否有標簽 requestFocus
if (TAG_REQUEST_FOCUS.equals(name)) {
// parseRequestFocus() 方法裏面其實就是 parent.requestFocus() 方法!就這麽簡單!
parseRequestFocus(parser, parent);
}
// 判斷是否有標簽 tag
else if (TAG_TAG.equals(name)) {
parseViewTag(parser, parent, attrs);
}
// 判斷是否有標簽 include,該標簽不能用在根元素中。
else if (TAG_INCLUDE.equals(name)) {
if (parser.getDepth() == 0) {
throw new InflateException("<include /> cannot be the root element");
}
parseInclude(parser, context, parent, attrs);
}
// 判斷是否有標簽 merge,該標簽只能用在根元素中。由於這裏是處理 View 元素的布局,因此直接拋異常
else if (TAG_MERGE.equals(name)) {
throw new InflateException("<merge /> must be the root element");
} else {
// 這裏的 createViewFromTag() 方法會根據 View 標簽創建 View 對象。
final View view = createViewFromTag(parent, name, context, attrs);
final ViewGroup viewGroup = (ViewGroup) parent;
final ViewGroup.LayoutParams params = viewGroup.generateLayoutParams(attrs);
rInflateChildren(parser, view, attrs, true);
viewGroup.addView(view, params);
}
}

if (finishInflate) {
parent.onFinishInflate();
}
}

關於 attachToRoot ,還記得我們在自定義 View 的時候有一句代碼:LayoutInflater.from(parent.getContext).inflater(R.layout.xxx, parent, true),這段代碼最後一個參數 true,就是講 attachToRoot 設置為 true。

這裏面一共涉及到了四個標簽:,, 和 。下面來分別說一下:

requestFocus

requestFocus 標簽就是讓標簽內的 View 獲取焦點,其內部就是使用 view.requestFocus() 方法實現的。

tag

tag 標簽是 API 21 裏面新增的,用來給 View 對象添加額外的信息。從 Android 1.0 開始,Android 就開始支持給 View 對象調用 setTag(Object) 和 getTag(Object) 來添加和獲取標簽信息,到了 Android 1.6 ,添加和獲取標簽信息有了新的方法:setTag(int, Object)。而在 Android 4.0 之後,setTag(int, Object) 的內部實現改為非靜態的 SparseArray 來實現。Android 5.0 的時候,提供了一種全新的寫法,就是 tag 標簽。

舉一個例子來說明這個標簽怎麽用,先編寫一個 XML 布局文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
<?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="wrap_content"
android:orientation="horizontal">

<Button
android:id="@+id/btn_negative"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="cancel">

<tag
android:id="@+id/btn_state_negative"
android:value="@string/btn_state_negative" />

</Button>

<Button
android:id="@+id/btn_positive"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="ok">

<tag
android:id="@+id/btn_state_positive"
android:value="@string/btn_state_positive" />
</Button>

</LinearLayout>

然後我們就可以通過下面的方式獲取標簽信息:

1
2
Button btn_negative = (Button) findViewById(R.id.btn_negative);
String tag = (String) btn_negative.getTag(R.id.btn_state_negative);

tag 標簽是這四個標簽中唯一一個需要指定 id 屬性的!

include

我們來看看 處理 include 標簽的方法 parseInclude() 裏面的邏輯:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
private void parseInclude(XmlPullParser parser, Context context, View parent,
AttributeSet attrs) throws XmlPullParserException, IOException {
// ATTR_LAYOUT 即 layout 屬性,
int layout = attrs.getAttributeResourceValue(null, ATTR_LAYOUT, 0);
......
// 使用了 include 標簽而沒有為其設置 layout 屬性,是會拋異常的!
if (layout == 0) {
final String value = attrs.getAttributeValue(null, ATTR_LAYOUT);
throw new InflateException("You must specify a valid layout "
+ "reference. The layout ID " + value + " is not valid.");
} else {
......
final String childName = childParser.getName();
if (TAG_MERGE.equals(childName)) {
// The <merge> tag doesn‘t support android:theme, so
// nothing special to do here.
rInflate(childParser, parent, context, childAttrs, false);
} else {
// createViewFromTag() 方法采用反射的方式,根據標簽名創建一個 view 對象
// 註意這裏的標簽並不包括之前提到的 merge 和 include 等等,而是指的 Button、TextView 等 View 相關標簽。
final View view = createViewFromTag(parent, childName, context, childAttrs, hasThemeOverride);
ViewGroup.LayoutParams params = null;
try {
params = group.generateLayoutParams(attrs);
} catch (RuntimeException e) {
// Ignore, just fail over to child attrs.
}
if (params == null) {
params = group.generateLayoutParams(childAttrs);
}
view.setLayoutParams(params);
// Inflate all children.
rInflateChildren(childParser, view, childAttrs, true);
// 設置 View 對象的 id 屬性
if (id != View.NO_ID) {
view.setId(id);
}
// 設置 View 對象的可見性
switch (visibility) {
case 0:
view.setVisibility(View.VISIBLE);
break;
case 1:
view.setVisibility(View.INVISIBLE);
break;
case 2:
view.setVisibility(View.GONE);
break;
}
group.addView(view);
}
}
......
LayoutInflater.consumeChildElements(parser);
}

parseInclude() 方法裏面會判斷是否需要處理 merge 標簽,然後根據標簽名(如 Button、TextView 等)調用 createViewFromTag() 方法創建一個 view 對象,然後生成該對象的布局參數,設置 id 屬性,設置可見性等等。

然後註意到 createViewFromTag(),顧名思義,該方法會根據 XML 的標簽來創建 View 對象,這個方法裏面最終會調用到 createView() 方法,是使用反射來創建 View 對象的具體實現。

有個問題不知道大家註意到沒有,這些 id、可見性等等的屬性都是 view 對象的,而 include 標簽和 merge 標簽並並沒有這些屬性,也就是說,如果你在 include 或 merge 標簽中設置了一個 id,然後在代碼中通過 findViewById() 方法企圖找到這個 include 或 merge 的布局,是會報空指針異常的!

我這裏為了方便區分,將 include、merge 等標簽稱為“布局標簽”,它們不能創建為 View 對象,設置 id 屬性對它們沒有意義。而將 XML 中的 Button、TextView 等標簽稱為“視圖標簽”(視圖元素、控件等),因為它們能被創建為 View 對象,可以設置 id 等熟悉。

其他比較有趣的標簽

除了上面介紹的 merge、include、requestFocus 和 tag 等布局標簽外,還有如下常用的 View 標簽:

ViewStub

利用 ViewStub 標簽可以讓布局懶加載。當你界面要顯示很多內容,而其中一些不用立即顯示出來的時候(比如商品詳情、下載進度條等等),可以使用 ViewStub 標簽來隱藏這些內容,當需要的時候再讓它們加載出來。ViewStub 雖然是 View 標簽,但是其本身沒有大小,不會繪制任何東西,因此是一個非常輕量的 View 標簽。

使用 ViewStub 和 include 標簽類似,需要使用 android:layout 屬性來確定需要隱藏的布局,同時,由於 ViewStub 是一個 View 標簽,因此需要使用一個 id 來操作 ViewStub。比如使用 ViewStub 簡單的 XML 如下:

1
2
3
4
5
<ViewStub
android:id="@+id/stab_view"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout="@layout/text_view" />

在 Java 代碼中,對 ViewStub 的操作有兩種方式:

  • 設置 View 的可見性

    1
    findViewById(R.id.stab_view)).setVisibility(View.VISIBLE);
  • 調用 ViewStub 的 inflate() 方法

    1
    2
    ViewStub stubView = (ViewStub) findViewById(R.id.stab_view);
    stubView.inflate();

上面兩種方法都可以加載由 ViewStub 引用的布局。使用 ViewStub 有兩點需要註意:

  1. 當調用了 inflate() 方法後,ViewStub 標簽就從視圖中移除了,也就是說,inflate() 方法不能對同一個 ViewStub 調用兩次。
  2. ViewStub 所引用的布局的根標簽不能為 標簽。

Space

這是是一個空控件,該 View 沒有實現 onDraw() 方法,因此繪制效率比較高。該控件可以用來占用空白(比如代替 padding 和 margin)。

大概差不多了,View 的性能優化還有一些沒有介紹,比如 Overdraw 等,這裏就給個鏈接吧:OverDraw

https://wl9739.github.io/2017/02/26/%E4%BB%8E-View-%E7%BB%98%E5%88%B6%E8%B0%88%E6%80%A7%E8%83%BD%E4%BC%98%E5%8C%96/

從-View-繪制談性能優化(轉)