掌握 View 繪製流程能對檢視的各個繪製時機有更深刻的認識,並且能寫出更好的自定義 View, 反正看原始碼(SDK28)就完了。
一、介紹
二、原始碼分析
- measure
- layout
- 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 檢視,以下是圖解:
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,還沒有真正去研究這個約束佈局,但它基本一層就能搞定一個佈局,還管你什麼層級的效能問題嗎?應該是完爆其他佈局的。