做為一名Android開發者,自定義View應該是我們工作中繞不開的話題,畢竟系統提供的View有限,有時很難滿足我們的需求,此時就需要結合具體的場景來編寫自定義View,通過自定義View不僅可以實現特定的效果,還可以簡化代碼。自定義View的過程,也是我們自己造小輪子的過程,說不定在你的其它項目中就可以用到,對提高生產力還是大大有幫助的。
雖說自定義View是工作中繞不開的話題,但也是Android中最容易把開發者繞進去的知識點,經歷過了從入門到放棄的再重新入門的辛酸過程,也該是用正確的姿勢學習自定義View了。
View是什么呢?......就是Android中所有控件的基類,我們經常聽到的ViewGroup也是View的一個子類。
直接上來就說如何自定義View未免有些空泛,所以我們從View底層的工作原理開始聊起,知其然也要知其所以然。App中我們所用到看到的一個個控件,都要經過measure(測量)、layout(布局)、draw(繪制)三大流程才會呈現在我們的眼前。其中measure用來測量View的大小,layout用來確定被測量后的View最終的位置,draw則是將View渲染繪制出來。其實View的工作流程在我們生活中也能找到類似的原型,比如,我們要畫一個西瓜,首先要確定西瓜的大小,接下來要確定畫在紙上的那個位置,最后才進行繪制。
在分析View的工作流程前,我們先要了解一個重要的知識點---MeasureSpec,MeasureSpec代表一個View的測量規格,它是一個32位的int值,高兩位代表測量模式(SpecMode),低30位代表在對應測量模式下的大小(SpecSize)。通過MeasureSpec的getMode()、getSize()方法可以得到對應View寬\高的測量模式以及大小。
SpecMode,即測量模式有以下三種:
- EXACTLY:父容器已經檢測出View所需要的精確大小,此時View的大小就是SpecSize,對應于LayoutParams中的具體數值和match_parent兩種類型。
- AT_MOST:父容器指定了一個可用的大小即SpecSize,View的大小由其具體的實現決定,但不能大于SpecSize,對用于LayoutParams中的wrap_content。
- UNSPECIFIED:父容器不對View大小做限制,View需要多大就給多大,這種測量模式一般用于系統內容,在我們自定義View中很少用到。
View的MeasureSpec是如何確定的呢?其實是由View自身的LayoutParams和父容器的MeasureSpec共同決定的。具體的細節我們來看源碼,在ViewGroup中有一個measureChildWithMargins方法:
protected void measureChildWithMargins(View child,
int parentWidthMeasureSpec, int widthUsed,
int parentHeightMeasureSpec, int heightUsed) {
final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();
final int childWidthMeasureSpec = 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);
}
在measureChildWithMargins()方法中,通過getChildMeasureSpec()方法得到了View寬\高對應的測量模式childWidthMeasureSpec 、childHeightMeasureSpec,接下來重點看getChildMeasureSpec()的實現細節:
/**
* @param spec 父View寬/高的測量規格
* @param padding 父View在寬/高上已經占用的空間大小
* @param childDimension 子View的寬/高
*/
public static int getChildMeasureSpec(int spec, int padding, int childDimension) {
int specMode = MeasureSpec.getMode(spec);//得到父View在寬/高上的測量模式
int specSize = MeasureSpec.getSize(spec);//得到父View在對應測量模式下的寬/高
int size = Math.max(0, specSize - padding);//計算子View在寬/高上可用的空間大小
int resultSize = 0;
int resultMode = 0;
// 開始根據父View的測量規格以及子View的LayoutParams判斷子View的測量規格
switch (specMode) {
// 當父View的測量模式為精確的大小時(包括具體的數值和match_parent兩種)
case MeasureSpec.EXACTLY:
// 如果子View的LayoutParams的寬/高是固定的數值,那么它的測量模式為MeasureSpec.EXACTLY,
// 大小為LayoutParams對應的寬/高數值,這樣測量規格就確定了。
if (childDimension >= 0) {
resultSize = childDimension;
resultMode = MeasureSpec.EXACTLY;
}
// 如果子View的LayoutParams的寬/高為match_parent,那么子View的寬/高和父View尺寸相等,即為size,
// 因為父View的尺寸已經確定,則子View的測量模式為MeasureSpec.EXACTLY。
else if (childDimension == LayoutParams.MATCH_PARENT) {
resultSize = size;
resultMode = MeasureSpec.EXACTLY;
}
// 如果子View的LayoutParams的寬/高為wrap_content,就是說子View想根據實現方式來自己確定自己的大小,
// 這個當然可以, 但是寬/高不能超過父View的尺寸,最大為size,則對應的測量模式為MeasureSpec.AT_MOST。
else if (childDimension == LayoutParams.WRAP_CONTENT) {
resultSize = size;
resultMode = MeasureSpec.AT_MOST;
}
break;
// 當父View的測量模式為最大模式時,即父View目前也不知道自己的具體大小,但不能大于size
case MeasureSpec.AT_MOST:
// 既然子View的寬/高已經確定,雖然父View的尺寸尚未確定也要優先滿足子View,
// 則子View的寬/高為自身大小childDimension,對應的測量模式為MeasureSpec.EXACTLY。
if (childDimension >= 0) {
resultSize = childDimension;
resultMode = MeasureSpec.EXACTLY;
}
// 如果子View的LayoutParams的寬/高為match_parent,雖說父View的大小為size,但具體的數值并不能確定,
// 所以子View寬/高不能超過父View的最大尺寸,即size,
// 此時子View的寬高為最大為size,則對應的測量模式為MeasureSpec.AT_MOST
else if (childDimension == LayoutParams.MATCH_PARENT)
resultSize = size;
resultMode = MeasureSpec.AT_MOST;
}
// 如果子View的LayoutParams的寬/高為wrap_content,即子View想自己來決定自己的大小,這個當然可以
// 同理,因為父View尺寸的不確定性,所以子View最終自我決定的尺寸不能大于size,
// 對應的測量模式為MeasureSpec.AT_MOST。
else if (childDimension == LayoutParams.WRAP_CONTENT) {
resultSize = size;
resultMode = MeasureSpec.AT_MOST;
}
break;
// 這種情況下,View的尺寸不受任何限制,主要用于系統內部,在我們日常開發中幾乎用不到,就不分析了。
case MeasureSpec.UNSPECIFIED:
if (childDimension >= 0) {
resultSize = childDimension;
resultMode = MeasureSpec.EXACTLY;
} else if (childDimension == LayoutParams.MATCH_PARENT) {
resultSize = 0;
resultMode = MeasureSpec.UNSPECIFIED;
} else if (childDimension == LayoutParams.WRAP_CONTENT) {
resultSize = 0;
resultMode = MeasureSpec.UNSPECIFIED;
}
break;
}
//根據最終子View的測量大小和測量模式得到相應的測量規格。
return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
}
所以View的測量規格是由自身的LayoutParams和父View的MeasureSpec共同決定的,它們之間具體的組合關系如下圖:
既然搞清楚了MeasureSpec是怎么回事,接下來具體來看一下View的工作流程measure、layout、draw。
1、measure
首先測量過程要區分是View還是ViewGroup,如果只是單純的View,則是需要測量自身就好了,如果是ViewGroup則需要先測量自身,再去遞歸測量所有的的子View。
1.1、當自定義View是單純的View時
在View類中有如下方法:
public final void measure(int widthMeasureSpec, int heightMeasureSpec) {
}
View通過該方法來進行大小測量,但是這是final方法,我們并不能重寫,但是在它內部調用了View類的另外一個方法:
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}
好熟悉的感覺,這就是我們在自定義View的時候通常重寫的onMeasure()方法。
其中setMeasuredDimension()方法,用來存儲測量后的View的寬/高,存儲之后,我們才可以調用View的getMeasuredWidth()、getMeasuredHeight()的到對應的測量寬/高。
重點看一下其中的getDefaultSize()方法:
/**
* @param size View的默認尺寸
* @param measureSpec View的測量規格
*/
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;
}
可以發現,當View的測量模式為MeasureSpec.AT_MOST、MeasureSpec.EXACTLY時,它最終的測量尺寸都為specSize ,竟然相等。再結合上邊的MeasureSpec關系圖對比下,可以看到當View最終的測量規格為MeasureSpec.AT_MOST時,其最終的尺寸為父View的尺寸。所以當自定義View在布局中的使用wrap_content和match_parent時的效果是一樣的,View都將占滿父View剩余的空間,但這并不是我們愿意看到的,所以我們需要在View的布局寬/高為wrap_content時,重新計算View的測量尺寸,其它情況下直接使用系統的測量值即可,重新測量的模板代碼如下:
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
measureChildren(widthMeasureSpec, heightMeasureSpec);
int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
//View的布局參數為wrap_content時,需要重新計算的View的測量寬/高
int measureWidth = 0;
int measureHeight = 0;
if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
//確定measureWidth、measureHeight
setMeasuredDimension(measureWidth, measureHeight);
} else if (widthSpecMode == MeasureSpec.AT_MOST) {
//確定measureWidth
setMeasuredDimension(measureWidth, heightSpecSize);
} else if (heightSpecMode == MeasureSpec.AT_MOST) {
//確定measureHeight
setMeasuredDimension(widthSpecSize, measureHeight);
}
}
至于如何確定measureWidth、measureHeight的值,就需要結合具體的業務需求了。
1.2、當自定義View是一個ViewGroup時
ViewGroup是一個抽象類,繼承與View類,但它沒有重寫onMeasure()方法,所以需要ViewGroup的子類去實現onMeasure()方法以進行具體測量。既然View類對onMeasure()方法方法做了統一的實現,為什么ViewGroup類沒有呢?因為View類不牽扯子View的布局,而ViewGroup中的子View可能有不同的布局情況,實現細節也有差別,所以無法做統一的處理,只能交給子類根據業務需求來重寫。
在ViewGroup類里有一個measureChildren()方法:
protected void measureChildren(int widthMeasureSpec, int heightMeasureSpec) {
final int size = mChildrenCount;
final View[] children = mChildren;
for (int i = 0; i < size; ++i) {
final View child = children[i];
if ((child.mViewFlags & VISIBILITY_MASK) != GONE) {
measureChild(child, widthMeasureSpec, heightMeasureSpec);
}
}
}
該方法通過遍歷子View,進而調用measureChild()方法得到子View的測量規格:
protected void measureChild(View child, int parentWidthMeasureSpec,
int parentHeightMeasureSpec) {
final LayoutParams lp = child.getLayoutParams();
final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
mPaddingLeft + mPaddingRight, lp.width);
final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,
mPaddingTop + mPaddingBottom, lp.height);
child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}
可以看到該方法和我們上邊分析的measureChildWithMargins()方法類似,都是通過getChildMeasureSpec()完成對子View規格的測量。所以一般情況下,我們自定義View如果繼承ViewGroup,則需要在重寫onMeasure()方法時首先進行measureChildren()操作來確定子View的測量規格。
在1.1中我們提到,如果View在布局中寬/高為wrap_content時,需要重寫onMeasure(),來重新計算View的測量寬/高,同樣的道理,當我們自定義的View是一個ViewGroup的話也需要重新計算ViewGroup的測量寬/高,當然這里的計算一般要考慮子View的數量以及測量規格等情況。
2、layout
layout的作用是來確定View本身的位置,在View類中源碼如下:
public void layout(int l, int t, int r, int b) {
if ((mPrivateFlags3 & PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT) != 0) {
onMeasure(mOldWidthMeasureSpec, mOldHeightMeasureSpec);
mPrivateFlags3 &= ~PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT;
}
int oldL = mLeft;
int oldT = mTop;
int oldB = mBottom;
int oldR = mRight;
boolean changed = isLayoutModeOptical(mParent) ?
setOpticalFrame(l, t, r, b) : setFrame(l, t, r, b);
if (changed || (mPrivateFlags & PFLAG_LAYOUT_REQUIRED) == PFLAG_LAYOUT_REQUIRED) {
onLayout(changed, l, t, r, b);
mPrivateFlags &= ~PFLAG_LAYOUT_REQUIRED;
ListenerInfo li = mListenerInfo;
if (li != null && li.mOnLayoutChangeListeners != null) {
ArrayList<OnLayoutChangeListener> listenersCopy =
(ArrayList<OnLayoutChangeListener>)li.mOnLayoutChangeListeners.clone();
int numListeners = listenersCopy.size();
for (int i = 0; i < numListeners; ++i) {
listenersCopy.get(i).onLayoutChange(this, l, t, r, b, oldL, oldT, oldR, oldB);
}
}
}
mPrivateFlags &= ~PFLAG_FORCE_LAYOUT;
mPrivateFlags3 |= PFLAG3_IS_LAID_OUT;
}
其中有這么一段:
boolean changed = isLayoutModeOptical(mParent) ?
setOpticalFrame(l, t, r, b) : setFrame(l, t, r, b);
用來設置View四個邊的位置,即mLeft、mTop、mBottom、mRight的值,這樣也就確定了View本身的位置。
接下來通過onLayout(changed, l, t, r, b);
來確定子View在父View中的位置:
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
}
是個空方法哦,所以當我們自定義ViewGroup時需要重寫onLayout()方法,來確定子View的位置。View類中的layout方法有這么一段注釋:
Derived classes should not override this method.
Derived classes with children should override onLayout.
In that method, they should call layout on each of their children.
大概的意思是這樣的,View的派生類一般不需要重寫layout方法,應該在其派生類中重寫onLayout()方法,并在onLayout()方法中調用layout()方法來確定子View的位置。
其實,這也符合我們平時自定義View時如果繼承ViewGroup時的情況,我們一般都會重寫onLayout()方法,然后通過layout()方法確定子View的具體位置。
當我們自定義的View如果繼承View類的話,一般就不需要重寫onLayout()方法了哦,畢竟沒有子View么。
執行完layout方法后,我們的View具體位置也就確定了,此時可以通過getWidth()、getHeight()方法得到View的最終寬/高:
public final int getWidth() {
return mRight - mLeft;
}
public final int getHeight() {
return mBottom - mTop;
}
還記得我們在分析measure過程時提到,通過getMeasuredWidth()、getMeasuredHeight()可以得到View的測量寬/高,這兩組方法有什么區別呢?其實一般情況下View的測量寬/高和最終的寬/高相等,只是賦值的時間點不同,但在某些特殊的情況下就有差別了。拿getWidth()方法來說,它的返回值是mRight - mLeft,即View右邊位置和左邊位置的差值,我們假設一個自定義ViewGroup中某個子View的四邊的位置分別為:l、t、r、b,一般情況下我們會這樣確定子View的位置:
childView.layout(l, t, r, b);
這種情況View的測量寬度和最終寬度是相等的,但如果按照如下的寫法:
childView.layout(l, t, r + 100, b);
此時View的最終寬度會比測量寬度大100px的。在measure過程中有一點需要注意,如果View的結構比較復雜,則可能需要多次的進行測量才能得到最終的測量結果,這也會導致我們得到的測量尺寸不準確。所以,所以要得到View最終的正確尺寸,應該通過getWidth()或者getHeight()方法。
3、draw
經歷了measure、layout的過程,View的尺寸和位置已經確定,接下來就差最后一步了,那就是draw,具體的繪制流程是什么樣的呢?查看一下View類中draw方法的源碼:
public void draw(Canvas canvas) {
final int privateFlags = mPrivateFlags;
final boolean dirtyOpaque = (privateFlags & PFLAG_DIRTY_MASK) == PFLAG_DIRTY_OPAQUE &&
(mAttachInfo == null || !mAttachInfo.mIgnoreDirtyState);
mPrivateFlags = (privateFlags & ~PFLAG_DIRTY_MASK) | PFLAG_DRAWN;
/*
* Draw traversal performs several drawing steps which must be executed
* in the appropriate order:
*
* 1. Draw the background
* 2. If necessary, save the canvas' layers to prepare for fading
* 3. Draw view's content
* 4. Draw children
* 5. If necessary, draw the fading edges and restore layers
* 6. Draw decorations (scrollbars for instance)
*/
// Step 1, draw the background, if needed
int saveCount;
if (!dirtyOpaque) {
drawBackground(canvas);
}
// skip step 2 & 5 if possible (common case)
final int viewFlags = mViewFlags;
boolean horizontalEdges = (viewFlags & FADING_EDGE_HORIZONTAL) != 0;
boolean verticalEdges = (viewFlags & FADING_EDGE_VERTICAL) != 0;
if (!verticalEdges && !horizontalEdges) {
// Step 3, draw the content
if (!dirtyOpaque) onDraw(canvas);
// Step 4, draw the children
dispatchDraw(canvas);
// Overlay is part of the content and draws beneath Foreground
if (mOverlay != null && !mOverlay.isEmpty()) {
mOverlay.getOverlayView().dispatchDraw(canvas);
}
// Step 6, draw decorations (foreground, scrollbars)
onDrawForeground(canvas);
// we're done...
return;
}
//此處省略N行代碼......
}
繪制的流程很清晰,基本按照如下幾個步驟:
- 1、Draw the background 繪制View的背景
drawBackground(canvas)
- 2、If necessary, save the canvas' layers to prepare for fading 保存畫布層,準備漸變
- 3、Draw view's content 繪制內容,也就是View自身
onDraw(canvas)
- 4、Draw children 繪制子View
dispatchDraw(canvas)
- 5、If necessary, draw the fading edges and restore layers 繪制漸變,保存圖層
- 6、Draw decorations (scrollbars for instance) 繪制裝飾物
onDrawForeground(canvas)
我們關心的是步驟3、4的onDraw()和dispatchDraw()方法。
先看onDraw()方法:
protected void onDraw(Canvas canvas) {
}
是一個空方法,這也可以理解,畢竟不同的View呈現的效果不同,所以需要子類重寫來實現具體的細節。當我們自定義View繼承View類時,通常會重寫onDraw()方法,來繪制線條或各種形狀、圖案等。
再看一下View類的dispatchDraw()方法:
protected void dispatchDraw(Canvas canvas) {
}
依然是空方法,需要子類去重寫,所以ViewGroup類中重寫了dispatchDraw()方法,遍歷所有的子View,其中有一行代碼是drawChild(canvas, transientChild, drawingTime);
正是用來繪制子View的,再看下細節:
protected boolean drawChild(Canvas canvas, View child, long drawingTime) {
return child.draw(canvas, this, drawingTime);
}
其中child.draw(canvas, this, drawingTime);
是子View調用了View類的draw()方法,則子View得到了最終的繪制。同樣的道理ViewGroup中的所有子View得到繪制。所以當我們自定義的View是ViewGroup的子類時,必要時可以考慮重寫dispatchDraw()方法來繪制相應的內容。
到這里我們View的工作流程就分析完畢了,掌握這些基本的原理只是第一步,但也是必須的,繼續加油吧。