View 繪製流程分析

磊少工作室_CTO發表於2019-01-26

掌握 View 繪製流程能對檢視的各個繪製時機有更深刻的認識,並且能寫出更好的自定義 View, 反正看原始碼(SDK28)就完了。

一、介紹

二、原始碼分析

  1. measure
  2. layout
  3. draw

三、總結

一、介紹

Activity 是通過 Window 與 View系統進行互動,而 Window 則是通過 ViewRootImpl 與 根View(DecorView)互動,View 最關鍵的三個步驟就是測量(measure)、佈局(layout)、繪製(draw), 最開始繪製的入口是 ViewRootImpl 類的 performTravesals 方法,下圖對整體流程做了個概述:

繪製流程

二、原始碼分析

1. measure

MeasureSpec: 這個關鍵物件貫穿在測量流程中,我們可以把它理解成一個 View 自身的「測量規格」, 它包含兩個變數一個是 mode(測量模式),另一個是 size(測量尺寸)。

我覺得原始碼有一點設計的特別巧妙,但也很難理解,那就是用位操作來表示某個狀態值。這麼做的原因是能節省更多的記憶體以及計算更快。MeasureSpec 是一個資料結構,但是它主要是用來製作一個 int 整型的變數,這個變數高 2 位表示測量模式,低 30 位表示測量尺寸,這是根據模式的數量決定的,總共就三種模式,因此用兩位就很夠了,如 01000000000000000000001111010101 粗體即表示模式。兩個變數合併成一個變數了,看到這種方式簡直就像發現新大陸一般。。但不推薦自己寫程式碼的時候用這種方式,因為別人不一定看得懂,可讀性差。。

三種模式:

  • UNSPECIFIED: 父檢視不強加任何約束給子檢視,子檢視想多大就多大,此模式一般不會用到,以下討論就略過這個模式了。
  • EXACTLY: 精確模式,父檢視已經知道子檢視確切的尺寸,一般對應 match_parent 和 具體數值。
  • AT_MOST: 最大模式,在父檢視允許的範圍內,子檢視儘量的大,一般對應 wrap_content。

LayoutParams: 佈局引數。每個 View 都有自身的佈局引數,最最基礎的就是寬高,我們平時最常見的就是設定width 和 height 為 match_parent 或 wrap_content。然後不同的 LayoutParams 有不同的屬性,如 LinearLayout.LayoutParams 就增加了 margin 相關的屬性。

View 自身的 MeasureSpec 是由父檢視的 MeasureSpec 和 自身的 LayoutParams 一起決定的,接著 View 根據自身的 MeasureSpec 來確定自身測量後的寬/高。

從入口 ViewRootImpl.java 的 performTraversals 方法開始看,它呼叫 performMeasure 之前做了如下操作:

// ViewRootImpl.java
...
int childWidthMeasureSpec = getRootMeasureSpec(mWidth, lp.width);
int childHeightMeasureSpec = getRootMeasureSpec(mHeight, lp.height);
performMeasure(childWidthMeasureSpec, childHeightMeasureSpec);
...
複製程式碼

mWidth, mHeight 表示螢幕的寬高,lp.width, lp.height 表示 DecorView 的寬高屬性,對於 DecorView 來說其 width 和 height 都是 match_parent,因此它的尺寸就是螢幕的尺寸,看下 getRootMeasureSpec 方法做了啥:

private static int getRootMeasureSpec(int windowSize, int rootDimension) {
    int measureSpec;
    switch (rootDimension) {
    case ViewGroup.LayoutParams.MATCH_PARENT:
        // Window can't resize. Force root view to be windowSize.
        measureSpec = MeasureSpec.makeMeasureSpec(windowSize, MeasureSpec.EXACTLY);
        break;
    case ViewGroup.LayoutParams.WRAP_CONTENT:
        // Window can resize. Set max size for root view.
        measureSpec = MeasureSpec.makeMeasureSpec(windowSize, MeasureSpec.AT_MOST);
        break;
    default:
        // Window wants to be an exact size. Force root view to be that size.
        measureSpec = MeasureSpec.makeMeasureSpec(rootDimension, MeasureSpec.EXACTLY);
        break;
    }
    return measureSpec;
}
複製程式碼

若佈局引數中的寬/高是 MATCH_PARENT, 那麼它最終得到的「測量規格」的 mode 是 EXACTLY, size 是螢幕寬/高,MeasureSpec.makeMeasureSpec 方法就是合併了 mode 和 size, 製作了一個 measureSpec 變數;若佈局引數中的寬或高是 WRAP_CONTENT, 那麼它最終得到的「測量規格」的 mode 是 AT_MOST, size 是螢幕寬/高,乍一看其實尺寸和 MATCH_PARENT 是一樣的,所以一般系統定義的控制元件或者我們自定義 View 都會對 WRAP_CONTENT 進行處理,否則其實它的效果在大部分情況下和 MATCH_PARENT 並無一致;若是其他值(一般使用者提供了精確的大小),那麼它最終得到的「測量規格」的 mode 是 EXACTLY, size 是使用者給定的值。

在求出 DecorView 的「測量規格」後,呼叫 performMeasure 方法,內部主要是呼叫了 DecorView 的 measure 方法。由於 measure 方法用 final 修飾了,因此子類無法重寫此方法,所有的檢視都統一經過 View 中的 measure 這個方法。

// View.java
public final void measure(int widthMeasureSpec, int heightMeasureSpec) {
    
    // 前半部分程式碼主要做了優化,若寬高都不變的情況下
    // 或沒有強制重新佈局的標誌位,那就不重新 measure 了
    ...
   		onMeasure(widthMeasureSpec, heightMeasureSpec);
   	...
}
複製程式碼

可以把 measure 方法看做是一個統一的測量入口,做了一些通用的事情,真正的測量是在 onMeasure 方法,這個方法是 View 提供給各個子類去實現的,這裡大家能自定義很多測量邏輯,如 LinearLayout 佈局容器就是通過此方法獲取垂直、水平線性佈局時自身的寬/高,反正總之就是一句話, measure 流程就是為了求出自身測量後的寬/高,並儲存下來。現在看下 View 預設的 onMeasure 實現:

// View.java
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
            getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}
複製程式碼

getSuggestedMinimumWidth 方法就是看下是否有背景,如果有就獲取背景的寬度,否則看下是否設定了 minWidth 屬性,getSuggestedMinimumHeight同理。在這裡直接就無視這兩個情況吧,正常來說這個方法返回值是 0, 看下 getDefaultSize :

public static int getDefaultSize(int size, int measureSpec) {
    int result = size;
    int specMode = MeasureSpec.getMode(measureSpec);
    int specSize = MeasureSpec.getSize(measureSpec);
    switch (specMode) {
    case MeasureSpec.UNSPECIFIED:
        result = size;
        break;
    case MeasureSpec.AT_MOST:
    case MeasureSpec.EXACTLY:
        result = specSize;
        break;
    }
    return result;
}
複製程式碼

根據「測量規格」獲取測量模式和測量尺寸, 跳過 UNSPECIFIED 模式,當模式為 AT_MOST 和 EXACTLY 時,最原始的 View 檢視無論是指定 match_parent 還是 wrap_content 模式,最後的 size 都是「測量規格」的 size, 所以對於不重寫 onMeasure 方法的 View 來說,這兩個模式沒差別。setMeasuredDimension 也是一個 final 修飾的方法,任何檢視都統一將寬/高儲存成全域性變數以便之後使用。以上就是 View 預設的測量流程,下面看下 ViewGroup 自定義實現的 onMeasure 方法。

由於 DecorView 繼承自 FrameLayout,因此接下來的流程其實會呼叫到 FrameLayout 中的 onMeasure, 不過本文不分析 FrameLayout ,而是分析比較常用的 LinearLayout 重寫的 onMeasure 方法,我們只分析垂直方向的:

// LinearLayout.java
void measureVertical(int widthMeasureSpec, int heightMeasureSpec) {
	
	for (int i = 0; i < count; ++i) {
    	final View child = getVirtualChildAt(i);
    	......
        measureChildBeforeLayout(child, i, widthMeasureSpec, 0,
              heightMeasureSpec, usedHeight);
        final int childHeight = child.getMeasuredHeight();
        ......
        final int totalLength = mTotalLength;
		mTotalLength = Math.max(totalLength, totalLength + childHeight + lp.topMargin 				+ lp.bottomMargin + getNextLocationOffset(child));
		......
   }
   
    maxWidth = Math.max(maxWidth, getSuggestedMinimumWidth());
    setMeasuredDimension(resolveSizeAndState(maxWidth, widthMeasureSpec, 						childState), heightSizeAndState);    
}
複製程式碼

這裡不分析 weight 屬性,加上這個屬性就有點複雜了。首先遍歷子檢視,讓每個子檢視都執行自身的 onMeasure 方法,這個過程在 measureChildBeforeLayout 方法內,一會兒在分析。測量子 View 之後,child.getMeasuredHeight() 就能獲得這一波測量後的高度了,mTotalLength 可以看做是目前 child 在豎直方向累加的高度(包括padding, margin)。最後呼叫 setMeasuredDimension 表示這次測量結束,會記錄測量後的寬和高。measureChildBeforeLayout 內部會直接呼叫 measureChildWithMargins, 此方法是父容器測量子檢視的統一入口:

// ViewGroup.java
protected void measureChildWithMargins(View child,
        int parentWidthMeasureSpec, int widthUsed,
        int parentHeightMeasureSpec, int heightUsed) {
    final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
    final int   = getChildMeasureSpec(parentWidthMeasureSpec,
            mPaddingLeft + mPaddingRight + lp.leftMargin + lp.rightMargin
                    + widthUsed, lp.width);
    final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,
            mPaddingTop + mPaddingBottom + lp.topMargin + lp.bottomMargin
                    + heightUsed, lp.height);
    child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
複製程式碼

是否還記得之前說的 View 的「測量規格」是由父檢視的「測量規格」和自身的佈局引數決定的,這裡 childWidthMeasureSpec 就是通過 父檢視的「測量規格」+ 自身的佈局引數 + padding + margin + 已使用的寬/高 決定的。

// ViewGroup.java
public static int getChildMeasureSpec(int spec, int padding, int childDimension) {
	// 這是父容器的測量模式
    int specMode = MeasureSpec.getMode(spec);
    // 這是父容器的測量尺寸(寬/高)
    int specSize = MeasureSpec.getSize(spec);
    int size = Math.max(0, specSize - padding);
    int resultSize = 0;
    int resultMode = 0;
    switch (specMode) {
    // Parent has imposed an exact size on us
    // 父容器是精確模式 EXACTLY 
    case MeasureSpec.EXACTLY:
    	// 子檢視有一個精確的尺寸,那麼它的測量尺寸也就是這個大小,
    	// 並且指定它的模式為 EXACTLY
        if (childDimension >= 0) {
            resultSize = childDimension;
            resultMode = MeasureSpec.EXACTLY;
        }
        // 子檢視佈局的寬/高是 MATCH_PARENT,那麼它的大小就是父容器的大小,
        // 並且指定它的模式為 EXACTLY,這裡就能看出,一般精確值和 MATCH_PARENT 對應 EXACTLY
        else if (childDimension == LayoutParams.MATCH_PARENT) {
            // Child wants to be our size. So be it.
            resultSize = size;
            resultMode = MeasureSpec.EXACTLY;
        } 
        // 子檢視佈局的寬度是 WRAP_CONTENT,那麼它的大小就是父容器的大小,
        // 並且指定它的模式為 AT_MOST,所以一般來說自定義View要重寫onMeasure。
        else if (childDimension == LayoutParams.WRAP_CONTENT) {
            // Child wants to determine its own size. It can't be
            // bigger than us.
            resultSize = size;
            resultMode = MeasureSpec.AT_MOST;
        }
        break;
    // Parent has imposed a maximum size on us
    // 父容器是最大模式 AT_MOST 
    case MeasureSpec.AT_MOST:
    	// 這裡的邏輯和父容器為精確模式時完全一樣,
    	// 看起來子檢視指定了精確值就不受父容器的約束了
        if (childDimension >= 0) {
            // Child wants a specific size... so be it
            resultSize = childDimension;
            resultMode = MeasureSpec.EXACTLY;
        } 
        // 和父容器精確模式相比,大小都是父容器的大小,
        // 測量模式跟隨父容器的模式。
        else if (childDimension == LayoutParams.MATCH_PARENT) {
            // Child wants to be our size, but our size is not fixed.
            // Constrain child to not be bigger than us.
            resultSize = size;
            resultMode = MeasureSpec.AT_MOST;
        } 
        // 依然和父容器精確模式一樣
        else if (childDimension == LayoutParams.WRAP_CONTENT) {
            // Child wants to determine its own size. It can't be
            // bigger than us.
            resultSize = size;
            resultMode = MeasureSpec.AT_MOST;
        }
        break;
    ......
    // 最後製作一個子View自身的「測量規格」
    return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
}
複製程式碼

上面的註釋寫的比較清晰了,總結下獲取子檢視 MeasureSpec 的過程:如果子 View 佈局引數的尺寸是精確值,那麼父容器的 mode 不會影響到子檢視,子檢視都是 EXACTLY 模式 + 精確值尺寸;如果子 View 的寬/高是 MATCH_PARENT, 那麼子檢視跟隨父容器模式 + 父容器尺寸;如果子 View 的寬/高是 WRAP_CONTENT,那麼子檢視是 AT_MOST 模式 + 父容器尺寸。

在獲得子檢視的「測量規格」後直接呼叫子檢視的 measure 方法讓子檢視根據自身的 MeasureSpec 得到測量後的寬高,這個流程和之前講解的又是一樣的。

到此為止 LinearLayout 的 onMeasure 垂直方向大致的流程已經分析完畢。總結下流程:它會先遍歷所有子檢視,通過 LinearLayout 的 MeasureSpec 和子檢視的 LayoutParams 得出子檢視的 MeasureSpec,接著讓子檢視執行 measure 方法 ,計運算元檢視測量後的寬/高。通過累加子檢視的高度,如果 LinearLayout 是 EXACTLY 模式那麼高度還是自身的尺寸,如果 LinearLayout 是 AT_MOST 模式那麼對比子檢視高度總和取較小一方作為 LinearLayout 的高度。同理,寬度也有這麼一個比較過程。關於 weight 屬性,最關鍵的其實是它會讓子檢視 measure 兩次,稍微有點耗時

舉個例子,現在有一個佈局,LinearLayout 中巢狀一個 TextView 和 View 檢視,以下是圖解:

繪製流程

2. layout

layout 和 measure 的流程是類似的,直接上原始碼:

// ViewRootImpl.java
private void performLayout(WindowManager.LayoutParams lp, int desiredWindowWidth,
            int desiredWindowHeight) {
    ......
    
    host.layout(0, 0, host.getMeasuredWidth(), host.getMeasuredHeight());
    
    // 以下主要是對 requestLayout 處理,暫不深究。
    ......
}
複製程式碼

host 就是 DecorView, 直接可以看到 View.layout 方法,雖說此方法沒被 final 修飾,但可以看做統一入口,其他子類貌似並沒有重寫此方法:

public void layout(int l, int t, int r, int b) {
	.....
	boolean changed = isLayoutModeOptical(mParent) ?
                setOpticalFrame(l, t, r, b) : setFrame(l, t, r, b);
    ......
        onLayout(changed, l, t, r, b);
    ......
}
複製程式碼

先解釋下前半部分的程式碼,這裡的 l, t, r, b 分別表示 自身左邊緣與父容器左邊緣的距離、自身上邊緣與父容器上邊緣的距離、自身右邊緣與父容器左邊緣的距離、自身下邊緣與父容器上邊緣的距離,根據這些值就能得出自身的寬度為 r - l, 高度為 b - t, 以及自身的四個頂點。 這裡比較重要的是 setFrame 方法,裡面用全域性變數 mLeft, mTop, mRight, mBottom 分別記錄了 l, t, r, b, 這個時候它的寬/高算是真正的定下來了(注意 measure 階段的測量寬高不一定是最終寬高),並且 setFrame 內部呼叫了, onSizeChanged 方法,於是恍然大悟,怪不得寫自定義 View 的時候要在 onSizeChanged 內拿最終寬高。

接下來解釋下 layout 方法中的 onLayout 方法。View 類並沒有實現 onLayout,也就是說它完全去讓子類去實現了,並且 ViewGroup 將此方法設為抽象方法強制去實現,因此只要是父容器都得實現 onLayout 來控制子檢視的位置,而子檢視沒有特殊需求基本不需要去實現此方法。下面看下 LinearLayout 重寫的 onLayout 方法,同樣只看垂直方向:

void layoutVertical(int left, int top, int right, int bottom) {
	......
    for (int i = 0; i < count; i++) {
		......
		
            setChildFrame(child, childLeft, childTop + getLocationOffset(child),
                        childWidth, childHeight);
    }
}
複製程式碼

依然還是省略了一堆程式碼,只需要解釋關鍵的幾個變數。 childLeft 表示子檢視的左邊緣與父容器的左邊緣的距離,這個變數會被padding, margin, gravity 所影響。childTop 表示子檢視的上邊緣與父容器的上邊緣的距離,受到 padding, 已累加的高度影響(因為是垂直佈局)。childWidth 和 childHeight 分別是子檢視的測量後的寬/高。在 setChildFrame 方法中直接呼叫了 child.layout, 那麼 layout 事件繼續往子容器傳遞,過程和之前解釋的一樣。

對 layout 做個總結:layout 方法的四個引數決定了自身在父容器內的位置儲存為 mLeft, mTop, mRight, mBottom,此方法真正確定了自身的最終寬高。然後如果是繼承 ViewGroup 的父容器,那麼會重寫 onLayout 方法對子檢視進行佈局確定它們的位置,最後會呼叫到子檢視的 layout 方法,按這種步驟一直傳遞。

依然舉個例子,,LinearLayout 中巢狀一個 TextView 和 View 檢視,以下是圖解:

layout

3. draw

performDraw 方法會調到 View 的 draw 方法,重點在於 onDraw 自身的繪製,這也是自定義 View 實現的最關鍵方法,其次是 dispatchDraw, 此方法在 ViewGroup 被重寫主要用來遍歷子檢視並呼叫它們的 draw 方法傳遞繪製事件:

public void draw(Canvas canvas) {
	// 繪製背景
	drawBackground(canvas);
	// 繪製自身內容
	onDraw(canvas);
	// 遍歷子檢視讓它們繪製 draw
	dispatchDraw(canvas);
	// 畫裝飾(前景,滾動條)
	onDrawForeground(canvas);
	// 繪製預設焦點高亮
	drawDefaultFocusHighlight(canvas);
}
複製程式碼

draw 呼叫流程是比較清晰簡單的,但它真正的實現是很複雜的,這一塊是自定義 View 的關鍵部分,需要學很多東西呀。。不過從這裡能看出自定義 View 主要是重寫 onDraw 以及 onMeasure 方法,而自定義 ViewGroup 主要是重寫 onMeasure 以及 onLayout 方法。

三、總結

用文字的形式表達下整個繪製流程:

整個繪製流程的入口是 ViewRootImpl.performTravesals 方法,繪製的先後順序是 measure, layout, draw.

performMeasure 通過計算得出 DecorView 的 MeasureSpec 然後呼叫其 measure 方法,此方法是 View 類的統一入口,主要是做了判斷是否要測量和佈局,如果需要則直接呼叫重寫的 onMeasure 方法(因繼承 ViewGroup 容器的佈局特性所決定的)根據 MeasureSpec 對自身進行測量得出寬/高。父容器會遍歷所有子檢視,根據自身的 MeasureSpec 和 子檢視的 LayoutParams 決定子檢視的 MeasureSpec, 並呼叫子檢視的 measure 方法傳遞測量事件,直到傳遞到整個 View 樹的葉子為止。

performLayout 從 View 樹的頂端開始,依次向下呼叫 layout 方法來確認自身在父容器內的位置,這時最終的寬高被確認,然後呼叫重寫過的 onLayout 方法(根據佈局特性重寫)來確認所有子檢視的位置。

performDraw 也是按照前面測量和佈局的思路傳遞在整個 View 樹中,onDraw 繪製自身的內容是實現自定義View的最關鍵方法。

View 相關的常見問題:

  • requestLayout 為什麼耗時?View 呼叫 requestLayout 方法後,會自下而上傳遞事件,將設定每層 View 的測量和佈局的標誌位,最後會呼叫 performTravesals 方法基本會重新走一遍整棵 View 樹的繪製流程 measure, layout, draw。
  • invalidate 和 postInvalidate?這兩個重繪方法也會呼叫到 performTravesals, 但不會設定測量和佈局的標誌位,所以只會執行 draw 過程。invalidate 在主執行緒中執行,postInvalidate 是非同步繪製,通過 handler 回撥到主執行緒。
  • onMeasure 多次呼叫的情況?繪製過程中可能會出現多次 measure 的情況,如父容器 LinearLayout 使用了 weight 屬性。
  • onSizeChanged 呼叫時機?此方法在 layout 中呼叫,這時已經確認了最終的寬/高,因此這個方法取寬高的時機比 onMeasure 取寬高的時機靠譜。
  • RelativeLayout 和 LinearLayout 效能對比?一般層級比較多的情況下推薦使用 RelativeLayout,因為它可以有效減少 LinearLayout 的層級問題,但只有一層的情況下推薦用 LinearLayout,因為 RelativeLayout 總是會 measure 兩次,而 LinearLayout 不設定 weight 的話只會 measure 一次。RelativeLayout 中優先用 padding 而不是 margin,對margin 的處理比較耗時。
  • 還有啥問題呢。。

最後推薦 ConstraintLayout,還沒有真正去研究這個約束佈局,但它基本一層就能搞定一個佈局,還管你什麼層級的效能問題嗎?應該是完爆其他佈局的。

相關文章