1. 程式人生 > >Android檢視繪製流程完全解析,帶你一步步深入瞭解View(二)

Android檢視繪製流程完全解析,帶你一步步深入瞭解View(二)

在上一篇文章中,我帶著大家一起剖析了一下LayoutInflater的工作原理,可以算是對View進行深入瞭解的第一步吧。那麼本篇文章中,我們將繼續對View進行深入探究,看一看它的繪製流程到底是什麼樣的。如果你還沒有看過我的上一篇文章,可以先去閱讀 Android LayoutInflater原理分析,帶你一步步深入瞭解View(一)  。

相信每個Android程式設計師都知道,我們每天的開發工作當中都在不停地跟View打交道,Android中的任何一個佈局、任何一個控制元件其實都是直接或間接繼承自View的,如TextView、Button、ImageView、ListView等。這些控制元件雖然是Android系統本身就提供好的,我們只需要拿過來使用就可以了,但你知道它們是怎樣被繪製到螢幕上的嗎?多知道一些總是沒有壞處的,那麼我們趕快進入到本篇文章的正題內容吧。

要知道,任何一個檢視都不可能憑空突然出現在螢幕上,它們都是要經過非常科學的繪製流程後才能顯示出來的。每一個檢視的繪製過程都必須經歷三個最主要的階段,即onMeasure()、onLayout()和onDraw(),下面我們逐個對這三個階段展開進行探討。

一. onMeasure()

measure是測量的意思,那麼onMeasure()方法顧名思義就是用於測量檢視的大小的。View系統的繪製流程會從ViewRoot的performTraversals()方法中開始,在其內部呼叫View的measure()方法。measure()方法接收兩個引數,widthMeasureSpec和heightMeasureSpec,這兩個值分別用於確定檢視的寬度和高度的規格和大小。

MeasureSpec的值由specSize和specMode共同組成的,其中specSize記錄的是大小,specMode記錄的是規格。specMode一共有三種類型,如下所示:

1. EXACTLY

表示父檢視希望子檢視的大小應該是由specSize的值來決定的,系統預設會按照這個規則來設定子檢視的大小,開發人員當然也可以按照自己的意願設定成任意的大小。

2. AT_MOST

表示子檢視最多隻能是specSize中指定的大小,開發人員應該儘可能小得去設定這個檢視,並且保證不會超過specSize。系統預設會按照這個規則來設定子檢視的大小,開發人員當然也可以按照自己的意願設定成任意的大小。

3. UNSPECIFIED

表示開發人員可以將檢視按照自己的意願設定成任意的大小,沒有任何限制。這種情況比較少見,不太會用到。

那麼你可能會有疑問了,widthMeasureSpec和heightMeasureSpec這兩個值又是從哪裡得到的呢?通常情況下,這兩個值都是由父檢視經過計算後傳遞給子檢視的,說明父檢視會在一定程度上決定子檢視的大小。但是最外層的根檢視,它的widthMeasureSpec和heightMeasureSpec又是從哪裡得到的呢?這就需要去分析ViewRoot中的原始碼了,觀察performTraversals()方法可以發現如下程式碼:

childWidthMeasureSpec = getRootMeasureSpec(desiredWindowWidth, lp.width); childHeightMeasureSpec = getRootMeasureSpec(desiredWindowHeight, lp.height); 可以看到,這裡呼叫了getRootMeasureSpec()方法去獲取widthMeasureSpec和heightMeasureSpec的值,注意方法中傳入的引數,其中lp.width和lp.height在建立ViewGroup例項的時候就被賦值了,它們都等於MATCH_PARENT。然後看下getRootMeasureSpec()方法中的程式碼,如下所示: private int getRootMeasureSpec(int windowSize, int rootDimension) {     int measureSpec;     switch (rootDimension) {     case ViewGroup.LayoutParams.MATCH_PARENT:         measureSpec = MeasureSpec.makeMeasureSpec(windowSize, MeasureSpec.EXACTLY);         break;     case ViewGroup.LayoutParams.WRAP_CONTENT:         measureSpec = MeasureSpec.makeMeasureSpec(windowSize, MeasureSpec.AT_MOST);         break;     default:         measureSpec = MeasureSpec.makeMeasureSpec(rootDimension, MeasureSpec.EXACTLY);         break;     }     return measureSpec; } 可以看到,這裡使用了MeasureSpec.makeMeasureSpec()方法來組裝一個MeasureSpec,當rootDimension引數等於MATCH_PARENT的時候,MeasureSpec的specMode就等於EXACTLY,當rootDimension等於WRAP_CONTENT的時候,MeasureSpec的specMode就等於AT_MOST。並且MATCH_PARENT和WRAP_CONTENT時的specSize都是等於windowSize的,也就意味著根檢視總是會充滿全屏的。

介紹了這麼多MeasureSpec相關的內容,接下來我們看下View的measure()方法裡面的程式碼吧,如下所示:

public final void measure(int widthMeasureSpec, int heightMeasureSpec) {     if ((mPrivateFlags & FORCE_LAYOUT) == FORCE_LAYOUT ||             widthMeasureSpec != mOldWidthMeasureSpec ||             heightMeasureSpec != mOldHeightMeasureSpec) {         mPrivateFlags &= ~MEASURED_DIMENSION_SET;         if (ViewDebug.TRACE_HIERARCHY) {             ViewDebug.trace(this, ViewDebug.HierarchyTraceType.ON_MEASURE);         }         onMeasure(widthMeasureSpec, heightMeasureSpec);         if ((mPrivateFlags & MEASURED_DIMENSION_SET) != MEASURED_DIMENSION_SET) {             throw new IllegalStateException("onMeasure() did not set the"                     + " measured dimension by calling"                     + " setMeasuredDimension()");         }         mPrivateFlags |= LAYOUT_REQUIRED;     }     mOldWidthMeasureSpec = widthMeasureSpec;     mOldHeightMeasureSpec = heightMeasureSpec; } 注意觀察,measure()這個方法是final的,因此我們無法在子類中去重寫這個方法,說明Android是不允許我們改變View的measure框架的。然後在第9行呼叫了onMeasure()方法,這裡才是真正去測量並設定View大小的地方,預設會呼叫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; } 這裡傳入的measureSpec是一直從measure()方法中傳遞過來的。然後呼叫MeasureSpec.getMode()方法可以解析出specMode,呼叫MeasureSpec.getSize()方法可以解析出specSize。接下來進行判斷,如果specMode等於AT_MOST或EXACTLY就返回specSize,這也是系統預設的行為。之後會在onMeasure()方法中呼叫setMeasuredDimension()方法來設定測量出的大小,這樣一次measure過程就結束了。

當然,一個介面的展示可能會涉及到很多次的measure,因為一個佈局中一般都會包含多個子檢視,每個檢視都需要經歷一次measure過程。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);         }     } } 這裡首先會去遍歷當前佈局下的所有子檢視,然後逐個呼叫measureChild()方法來測量相應子檢視的大小,如下所示: 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); } 可以看到,在第4行和第6行分別呼叫了getChildMeasureSpec()方法來去計運算元檢視的MeasureSpec,計算的依據就是佈局檔案中定義的MATCH_PARENT、WRAP_CONTENT等值,這個方法的內部細節就不再貼出。然後在第8行呼叫子檢視的measure()方法,並把計算出的MeasureSpec傳遞進去,之後的流程就和前面所介紹的一樣了。

當然,onMeasure()方法是可以重寫的,也就是說,如果你不想使用系統預設的測量方式,可以按照自己的意願進行定製,比如:

public class MyView extends View {       ......          @Override     protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {         setMeasuredDimension(200, 200);     }   } 這樣的話就把View預設的測量流程覆蓋掉了,不管在佈局檔案中定義MyView這個檢視的大小是多少,最終在介面上顯示的大小都將會是200*200。

需要注意的是,在setMeasuredDimension()方法呼叫之後,我們才能使用getMeasuredWidth()和getMeasuredHeight()來獲取檢視測量出的寬高,以此之前呼叫這兩個方法得到的值都會是0。

由此可見,檢視大小的控制是由父檢視、佈局檔案、以及檢視本身共同完成的,父檢視會提供給子檢視參考的大小,而開發人員可以在XML檔案中指定檢視的大小,然後檢視本身會對最終的大小進行拍板。

到此為止,我們就把檢視繪製流程的第一階段分析完了。

二. onLayout()

measure過程結束後,檢視的大小就已經測量好了,接下來就是layout的過程了。正如其名字所描述的一樣,這個方法是用於給檢視進行佈局的,也就是確定檢視的位置。ViewRoot的performTraversals()方法會在measure結束後繼續執行,並呼叫View的layout()方法來執行此過程,如下所示:

host.layout(0, 0, host.mMeasuredWidth, host.mMeasuredHeight); layout()方法接收四個引數,分別代表著左、上、右、下的座標,當然這個座標是相對於當前檢視的父檢視而言的。可以看到,這裡還把剛才測量出的寬度和高度傳到了layout()方法中。那麼我們來看下layout()方法中的程式碼是什麼樣的吧,如下所示: public void layout(int l, int t, int r, int b) {     int oldL = mLeft;     int oldT = mTop;     int oldB = mBottom;     int oldR = mRight;     boolean changed = setFrame(l, t, r, b);     if (changed || (mPrivateFlags & LAYOUT_REQUIRED) == LAYOUT_REQUIRED) {         if (ViewDebug.TRACE_HIERARCHY) {             ViewDebug.trace(this, ViewDebug.HierarchyTraceType.ON_LAYOUT);         }         onLayout(changed, l, t, r, b);         mPrivateFlags &= ~LAYOUT_REQUIRED;         if (mOnLayoutChangeListeners != null) {             ArrayList<OnLayoutChangeListener> listenersCopy =                     (ArrayList<OnLayoutChangeListener>) 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 &= ~FORCE_LAYOUT; } 在layout()方法中,首先會呼叫setFrame()方法來判斷檢視的大小是否發生過變化,以確定有沒有必要對當前的檢視進行重繪,同時還會在這裡把傳遞過來的四個引數分別賦值給mLeft、mTop、mRight和mBottom這幾個變數。接下來會在第11行呼叫onLayout()方法,正如onMeasure()方法中的預設行為一樣,也許你已經迫不及待地想知道onLayout()方法中的預設行為是什麼樣的了。進入onLayout()方法,咦?怎麼這是個空方法,一行程式碼都沒有?!

沒錯,View中的onLayout()方法就是一個空方法,因為onLayout()過程是為了確定檢視在佈局中所在的位置,而這個操作應該是由佈局來完成的,即父檢視決定子檢視的顯示位置。既然如此,我們來看下ViewGroup中的onLayout()方法是怎麼寫的吧,程式碼如下:

@Override protected abstract void onLayout(boolean changed, int l, int t, int r, int b); 可以看到,ViewGroup中的onLayout()方法竟然是一個抽象方法,這就意味著所有ViewGroup的子類都必須重寫這個方法。沒錯,像LinearLayout、RelativeLayout等佈局,都是重寫了這個方法,然後在內部按照各自的規則對子檢視進行佈局的。由於LinearLayout和RelativeLayout的佈局規則都比較複雜,就不單獨拿出來進行分析了,這裡我們嘗試自定義一個佈局,藉此來更深刻地理解onLayout()的過程。

自定義的這個佈局目標很簡單,只要能夠包含一個子檢視,並且讓子檢視正常顯示出來就可以了。那麼就給這個佈局起名叫做SimpleLayout吧,程式碼如下所示:

public class SimpleLayout extends ViewGroup {       public SimpleLayout(Context context, AttributeSet attrs) {         super(context, attrs);     }       @Override     protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {         super.onMeasure(widthMeasureSpec, heightMeasureSpec);         if (getChildCount() > 0) {             View childView = getChildAt(0);             measureChild(childView, widthMeasureSpec, heightMeasureSpec);         }     }       @Override     protected void onLayout(boolean changed, int l, int t, int r, int b) {         if (getChildCount() > 0) {             View childView = getChildAt(0);             childView.layout(0, 0, childView.getMeasuredWidth(), childView.getMeasuredHeight());         }     }   } 程式碼非常的簡單,我們來看下具體的邏輯吧。你已經知道,onMeasure()方法會在onLayout()方法之前呼叫,因此這裡在onMeasure()方法中判斷SimpleLayout中是否有包含一個子檢視,如果有的話就呼叫measureChild()方法來測量出子檢視的大小。

接著在onLayout()方法中同樣判斷SimpleLayout是否有包含一個子檢視,然後呼叫這個子檢視的layout()方法來確定它在SimpleLayout佈局中的位置,這裡傳入的四個引數依次是0、0、childView.getMeasuredWidth()和childView.getMeasuredHeight(),分別代表著子檢視在SimpleLayout中左上右下四個點的座標。其中,呼叫childView.getMeasuredWidth()和childView.getMeasuredHeight()方法得到的值就是在onMeasure()方法中測量出的寬和高。

這樣就已經把SimpleLayout這個佈局定義好了,下面就是在XML檔案中使用它了,如下所示:

<com.example.viewtest.SimpleLayout xmlns:android="http://schemas.android.com/apk/res/android"     android:layout_width="match_parent"     android:layout_height="match_parent" >          <ImageView          android:layout_width="wrap_content"         android:layout_height="wrap_content"         android:src="@drawable/ic_launcher"         />      </com.example.viewtest.SimpleLayout> 可以看到,我們能夠像使用普通的佈局檔案一樣使用SimpleLayout,只是注意它只能包含一個子檢視,多餘的子檢視會被捨棄掉。這裡SimpleLayout中包含了一個ImageView,並且ImageView的寬高都是wrap_content。現在執行一下程式,結果如下圖所示:

OK!ImageView成功已經顯示出來了,並且顯示的位置也正是我們所期望的。如果你想改變ImageView顯示的位置,只需要改變childView.layout()方法的四個引數就行了。

在onLayout()過程結束後,我們就可以呼叫getWidth()方法和getHeight()方法來獲取檢視的寬高了。說到這裡,我相信很多朋友長久以來都會有一個疑問,getWidth()方法和getMeasureWidth()方法到底有什麼區別呢?它們的值好像永遠都是相同的。其實它們的值之所以會相同基本都是因為佈局設計者的編碼習慣非常好,實際上它們之間的差別還是挺大的。

首先getMeasureWidth()方法在measure()過程結束後就可以獲取到了,而getWidth()方法要在layout()過程結束後才能獲取到。另外,getMeasureWidth()方法中的值是通過setMeasuredDimension()方法來進行設定的,而getWidth()方法中的值則是通過檢視右邊的座標減去左邊的座標計算出來的。

觀察SimpleLayout中onLayout()方法的程式碼,這裡給子檢視的layout()方法傳入的四個引數分別是0、0、childView.getMeasuredWidth()和childView.getMeasuredHeight(),因此getWidth()方法得到的值就是childView.getMeasuredWidth() - 0 = childView.getMeasuredWidth() ,所以此時getWidth()方法和getMeasuredWidth() 得到的值就是相同的,但如果你將onLayout()方法中的程式碼進行如下修改:

@Override protected void onLayout(boolean changed, int l, int t, int r, int b) {     if (getChildCount() > 0) {         View childView = getChildAt(0);         childView.layout(0, 0, 200, 200);     } } 這樣getWidth()方法得到的值就是200 - 0 = 200,不會再和getMeasuredWidth()的值相同了。當然這種做法充分不尊重measure()過程計算出的結果,通常情況下是不推薦這麼寫的。getHeight()與getMeasureHeight()方法之間的關係同上,就不再重複分析了。

到此為止,我們把檢視繪製流程的第二階段也分析完了。

三. onDraw()

measure和layout的過程都結束後,接下來就進入到draw的過程了。同樣,根據名字你就能夠判斷出,在這裡才真正地開始對檢視進行繪製。ViewRoot中的程式碼會繼續執行並創建出一個Canvas物件,然後呼叫View的draw()方法來執行具體的繪製工作。draw()方法內部的繪製過程總共可以分為六步,其中第二步和第五步在一般情況下很少用到,因此這裡我們只分析簡化後的繪製過程。程式碼如下所示:

public void draw(Canvas canvas) {     if (ViewDebug.TRACE_HIERARCHY) {         ViewDebug.trace(this, ViewDebug.HierarchyTraceType.DRAW);     }     final int privateFlags = mPrivateFlags;     final boolean dirtyOpaque = (privateFlags & DIRTY_MASK) == DIRTY_OPAQUE &&             (mAttachInfo == null || !mAttachInfo.mIgnoreDirtyState);     mPrivateFlags = (privateFlags & ~DIRTY_MASK) | DRAWN;     // Step 1, draw the background, if needed     int saveCount;     if (!dirtyOpaque) {         final Drawable background = mBGDrawable;         if (background != null) {             final int scrollX = mScrollX;             final int scrollY = mScrollY;             if (mBackgroundSizeChanged) {                 background.setBounds(0, 0,  mRight - mLeft, mBottom - mTop);                 mBackgroundSizeChanged = false;             }             if ((scrollX | scrollY) == 0) {                 background.draw(canvas);             } else {                 canvas.translate(scrollX, scrollY);                 background.draw(canvas);                 canvas.translate(-scrollX, -scrollY);             }         }     }     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);         // Step 6, draw decorations (scrollbars)         onDrawScrollBars(canvas);         // we're done...         return;     } } 可以看到,第一步是從第9行程式碼開始的,這一步的作用是對檢視的背景進行繪製。這裡會先得到一個mBGDrawable物件,然後根據layout過程確定的檢視位置來設定背景的繪製區域,之後再呼叫Drawable的draw()方法來完成背景的繪製工作。那麼這個mBGDrawable物件是從哪裡來的呢?其實就是在XML中通過android:background屬性設定的圖片或顏色。當然你也可以在程式碼中通過setBackgroundColor()、setBackgroundResource()等方法進行賦值。

接下來的第三步是在第34行執行的,這一步的作用是對檢視的內容進行繪製。可以看到,這裡去呼叫了一下onDraw()方法,那麼onDraw()方法裡又寫了什麼程式碼呢?進去一看你會發現,原來又是個空方法啊。其實也可以理解,因為每個檢視的內容部分肯定都是各不相同的,這部分的功能交給子類來去實現也是理所當然的。

第三步完成之後緊接著會執行第四步,這一步的作用是對當前檢視的所有子檢視進行繪製。但如果當前的檢視沒有子檢視,那麼也就不需要進行繪製了。因此你會發現View中的dispatchDraw()方法又是一個空方法,而ViewGroup的dispatchDraw()方法中就會有具體的繪製程式碼。

以上都執行完後就會進入到第六步,也是最後一步,這一步的作用是對檢視的滾動條進行繪製。那麼你可能會奇怪,當前的檢視又不一定是ListView或者ScrollView,為什麼要繪製滾動條呢?其實不管是Button也好,TextView也好,任何一個檢視都是有滾動條的,只是一般情況下我們都沒有讓它顯示出來而已。繪製滾動條的程式碼邏輯也比較複雜,這裡就不再貼出來了,因為我們的重點是第三步過程。

通過以上流程分析,相信大家已經知道,View是不會幫我們繪製內容部分的,因此需要每個檢視根據想要展示的內容來自行繪製。如果你去觀察TextView、ImageView等類的原始碼,你會發現它們都有重寫onDraw()這個方法,並且在裡面執行了相當不少的繪製邏輯。繪製的方式主要是藉助Canvas這個類,它會作為引數傳入到onDraw()方法中,供給每個檢視使用。Canvas這個類的用法非常豐富,基本可以把它當成一塊畫布,在上面繪製任意的東西,那麼我們就來嘗試一下吧。

這裡簡單起見,我只是建立一個非常簡單的檢視,並且用Canvas隨便繪製了一點東西,程式碼如下所示:

public class MyView extends View {       private Paint mPaint;       public MyView(Context context, AttributeSet attrs) {         super(context, attrs);         mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);     }       @Override     protected void onDraw(Canvas canvas) {         mPaint.setColor(Color.YELLOW);         canvas.drawRect(0, 0, getWidth(), getHeight(), mPaint);         mPaint.setColor(Color.BLUE);         mPaint.setTextSize(20);         String text = "Hello View";         canvas.drawText(text, 0, getHeight() / 2, mPaint);     } } 可以看到,我們建立了一個自定義的MyView繼承自View,並在MyView的建構函式中建立了一個Paint物件。Paint就像是一個畫筆一樣,配合著Canvas就可以進行繪製了。這裡我們的繪製邏輯比較簡單,在onDraw()方法中先是把畫筆設定成黃色,然後呼叫Canvas的drawRect()方法繪製一個矩形。然後在把畫筆設定成藍色,並調整了一下文字的大小,然後呼叫drawText()方法繪製了一段文字。

就這麼簡單,一個自定義的檢視就已經寫好了,現在可以在XML中加入這個檢視,如下所示:

<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"     android:layout_width="match_parent"     android:layout_height="match_parent" >       <com.example.viewtest.MyView          android:layout_width="200dp"         android:layout_height="100dp"         />   </LinearLayout> 將MyView的寬度設定成200dp,高度設定成100dp,然後執行一下程式,結果如下圖所示:

圖中顯示的內容也正是MyView這個檢視的內容部分了。由於我們沒給MyView設定背景,因此這裡看不出來View自動繪製的背景效果。

當然了Canvas的用法還有很多很多,這裡我不可能把Canvas的所有用法都列舉出來,剩下的就要靠大家自行去研究和學習了。

到此為止,我們把檢視繪製流程的第三階段也分析完了。整個檢視的繪製過程就全部結束了,你現在是不是對View的理解更加深刻了呢?感興趣的朋友可以繼續閱讀 Android檢視狀態及重繪流程分析,帶你一步步深入瞭解View(三) 。