【總結】自定義控件,View的工作原理

本篇作為自定義View的前置章節(jié)

View的繪制流程

View的繪制一般分為三步,分別為Measure,LayoutDraw,即測量,布局和繪制。
View的事件傳遞從父空間的Measure開始,傳遞過程如 圖1 所示。

圖1 View的繪制流程.png

PS:事實上,View的繪制流程是從ViewRootperformTraversals方法開始的,Measure,Layout和Draw方法之前還分別有名為performMeasure,performLayout和performDraw的方法,這里從簡,不影響邏輯。

MeasureSpec

說到View的繪制流程,就不能不提到 MeasureSpec 這個概念,MeasureSpec的字面意義是測量規(guī)范,這是View繪制流程中非常重要的一個變量,它影響著View的測量過程。

MeasureSpec的內(nèi)容為一個32位的int值,高兩位代表SpecMode,測量模式,低30位代表SpecSize,為某種測量模式下的規(guī)格大小。SpecMode與SpecSize均可通過對MeasureSpec進行解包獲得。


@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);

    ...
    int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
    int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
    ...
}

其中,SpecMode有三類,每一種都有其特殊的含義。

  1. UNSPECIFIED,父容器不對View做任何限制,用于系統(tǒng)內(nèi)部,自己寫控件的時候用不到。
  2. EXACTLY,View所需大小為一個精確值,這種情況下,View的最終大小就是其SpecSize的值。對應于布局文件中,match_parent和具體數(shù)值這兩種情況。
  3. AT_MOST,View的大小被限定在一個范圍之內(nèi),其最大值為父容器所能提供的最大值,但其具體大小要看View的具體實現(xiàn)。對應于布局文件中,wrap_content這種情況。

View的MeasureSpec是在父容器的getChildMeasureSpec()方法中生成,之后才會調(diào)用View的Measure方法,將其傳遞給View。
getChildMeasureSpec()方法作用主要是根據(jù)父容器的MeasureSpec,同時結合View本身的LayoutParams來確定View的MeasureSpec,這其中的生成規(guī)律如圖2,有條件的同學推薦去看看源碼。

圖2 MeasureSpec生成規(guī)律.png

UNSPECIFIED模式用不到,不做討論。
簡單說明下這個表格,橫軸標題為父容器測量模式,縱軸標題為子View的layoutparams參數(shù),六種不同情況下,他們會為子View生成不同的MeasureSpec。

對于這個表格的理解,我們以布局文件中,layout_width屬性為例說明。layout_height屬性與其類似。
先看表頭:
EXACTLY對應于LayoutParams中的match_parent或者是一個準確的大小。那么我們可以認為,在布局文件中,父容器的寬屬性為100dp。
AT_MOST對用于LayoutParams中的wrap_content,我們認為,此時父容器的寬屬性為wrap_content。

那么這個表格可以這么理解:

  1. 當子View的寬是一個精確的數(shù)值(dp/px),那么不論父容器是那種測量模式,子View的測量模式都會是精確數(shù)值(EXACTLY)模式,子View的規(guī)格大小都是自身布局文件中所輸入的大小(childSize)。因為它自身的數(shù)值在其布局文件中已經(jīng)確定了。
  2. 當子View的寬的大小為填充父容器,子View的寬的大小一定會跟父容器相同(parentSize),但是測量模式為會分兩種情況:
    1. 當子View父容器的寬是精確數(shù)值(EXACTLY)模式時,測量模式為精確數(shù)值(EXACTLY)模式。由于父容器為一個精確數(shù)值(上文的假設100dp),那么子View填充父容器,子View的寬也就是確定的,為父容器的寬的數(shù)值(100dp),那么它的測量模式自然是精確模式。
    2. 當子控件父容器的寬為限制最大值(AT_MOST)模式時,測量模式同樣為限制最大值(AT_MOST)模式。由于子View的寬填充父容器,但父容器的寬還未確定, 只有一個限制最大值的范圍,所以子控件的寬也無法確定,也只是有一個最大范圍,這個范圍與父容器的范圍相同。
  3. 這種情況會比較特殊,先要重新明確一個概念,MeasureSpec中,低30位為控件的規(guī)格大小,而非實際大小。當子View的寬為wrap_content時,它的大小是不確定的,所以測量模式為限制最大值(AT_MOST)模式,但是此時,它的規(guī)格大小為父容器的大?。╬arentSize)而非是子容器自身的大小,因為此時子容器的測量過程還未開始,無法計算出這個View實際的大小,而在理論上,這個控件寬度最大可以達到父容器的寬度,所以規(guī)格大小為父容器的大?。╬arentSize)。至于其自身到底有多大,會在子View的onMeasure中進行計算。

PS:上文中,若父容器有內(nèi)邊距,子控件填充時,所得的寬的數(shù)值要將其減去。

View的工作流程

measure過程

View的measure過程

View的measure過程由measure方法調(diào)用onMeasure方法來完成,我們來說說View的measure過程的大致流程。View的各個子類與其流程大致相同。


protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
                getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}

setMeasuredDimension這個方法的作用是設置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;
}

簡單來說,getDefaultSize這個方法,返回的就是measureSpec中的specSize即View的測量大小,UNSPECIFIED的情況不需要在意,我們需要關心的是剩余的兩種情況。
從源碼上可以得知,自定義控件直接繼承View時,需要重寫onMeasure方法,并規(guī)定wrap_content時自身的大小。否則在布局中使用wrap_content與使用match_parent是一個效果。這一點結合圖2以及以上源碼很好理解。
在實踐過程中,我們需要給自定義控件指定一個最小寬高值,當布局文件中使用wrap_content屬性時,設置控件為最小寬高,其他情況使用遵循系統(tǒng)測量值即可。如TextView,EdittextView以及其他所有系統(tǒng)控件,他們對wrap_content都做了特殊處理。

自定義控件時候,是一定要處理wrap_content。

ViewGroup的measure過程

對于ViewGroup來說,除了需要measure自身以外,還需要遍歷所有子控件的measure方法。
ViewGroup這個類是一個抽象類,他沒有對onMeasure做具體實現(xiàn)(onMeasure的實現(xiàn)交由子類來完成,如Linearlayout)。但重要的是,他有一個叫做measureChildren的方法,會遍歷每一個子元素,通過子控件的Layoutparams計算出每一個子控件的的measureSpec,再調(diào)用子控件的measure方法來對子控件進行測量。


    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);
            }
        }
    }

    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);
    }

PS:measureSpec生成規(guī)則見圖2.

上文提到,ViewGroup是一個抽象類,他沒有定義measure的具體過程,這個過程交由他的子類來實現(xiàn),因為ViewGroup的子類有多種多樣如LinearLayout,RelativeLayout,他們的測量過程完全不同,由此無法做統(tǒng)一實現(xiàn)。
例如,LinearLayout的onMeasure會先對所有子控件進行遍歷,得到所有子控件的寬高信息之后,LinearLayout自身的寬高會被測量為所有子控件寬/高的累計的和。RelativeLayout,F(xiàn)rameLayout等均有自己的測量規(guī)則。
View的measure是View三個流程中,最為復雜的一個,measure完成后,就可以通過getMeasureWidth,getMeasureHeigth方法正確的獲得View的測量寬高??傄氖?,只有當measure完全完成后,獲取到的View的寬高才是準確的,而在某些情況中,onMeasure會被執(zhí)行多次,因此不要在onMeasure獲取控件寬高,應在onLayout過程中獲取。

layout過程

layout的作用是ViewGroup用來確定子控件的位置。
當ViewGroup的位置被確定以后,它會便利所有子元素并且調(diào)用其layout方法,在layout中,onLayout方法又會被調(diào)用。
layout方法確定View自身的位置,onLayout方法確定當前View所有子元素的位置。
layout方法大致的流程如下:假設有三個控件控件A,控件B,控件C。A為B的父控件,B為C的父控件。首先會初始化當前控件(B控件)的mLeft、mRight、mTop、mBottom這四個頂點的位置,將這四個值交給當前控件的父控件(A控件),來確定當前控件(B控件)在其父控件(A控件)中的位置。接著,當前控件(B控件)會調(diào)用自身的onLayout方法,來確定當前控件(B控件)的子控件(C控件)的在當前控件(B控件)中的位置。

因為View的子類(如TextView)沒有子控件,所有onLayout方法在View以及他的子類中是一個空函數(shù),并且View的子類大部分情況下,不會對該方法進行實現(xiàn)。同樣的,ViewGroup沒有對onLayout進行實現(xiàn),因為不同的布局實現(xiàn)方式完全不同。

以LinearLayout的Vertical模式為例,onLayout時會遍歷所有子控件,先看代碼


    void layoutVertical(int left, int top, int right, int bottom) {

        ...
        final int count = getVirtualChildCount();
        for (int i = 0; i < count; i++) {
            final View child = getVirtualChildAt(i);
            if (child == null) {
                childTop += measureNullChild(i);
            } else if (child.getVisibility() != GONE) {
                final int childWidth = child.getMeasuredWidth();
                final int childHeight = child.getMeasuredHeight();
                final LinearLayout.LayoutParams lp =
                (LinearLayout.LayoutParams) child.getLayoutParams();
                int gravity = lp.gravity;
                if (gravity < 0) {
                    gravity = minorGravity;
                }
                final int layoutDirection = getLayoutDirection();
                final int absoluteGravity = Gravity.getAbsoluteGravity(gravity, layoutDirection);
                switch (absoluteGravity & Gravity.HORIZONTAL_GRAVITY_MASK) {
                    case Gravity.CENTER_HORIZONTAL:
                        childLeft = paddingLeft + ((childSpace - childWidth) / 2)
                                + lp.leftMargin - lp.rightMargin;
                        break;
                    case Gravity.RIGHT:
                        childLeft = childRight - childWidth - lp.rightMargin;
                        break;
                    case Gravity.LEFT:
                    default:
                        childLeft = paddingLeft + lp.leftMargin;
                        break;
                }
                if (hasDividerBeforeChildAt(i)) {
                    childTop += mDividerHeight;
                }
                childTop += lp.topMargin;
                setChildFrame(child, childLeft, childTop + getLocationOffset(child),
                        childWidth, childHeight);
                childTop += childHeight + lp.bottomMargin + getNextLocationOffset(child);
                i += getChildrenSkipCount(child, i);
            }
        }
    }

    private void setChildFrame(View child, int left, int top, int width, int height) {        
        child.layout(left, top, left + width, top + height);
    }

可以看到,此方法會遍歷所有子控件,并為所有控件指定對應位置,其中childTop會逐漸增大,這樣的話接下來的子控件就會處于比較靠下的位置,即Vertical模式下,LinearLayout的樣子??梢钥吹?,LinearLayout的onLayout方法在確定自身位置后,會通過setChildFrame方法調(diào)用自身子控件的layout方法,來確定子控件的位置,通過這樣一層層傳遞,就完成了整個View樹的layout過程。

PS:網(wǎng)上流傳一句話,自定義ViewGroup只需重寫onMeasure,onLayout。自定義View只需重寫onMeasure,onDraw。我認為是不準確的。雖然自定義View沒有子控件,不需要調(diào)用onLayout來確定子控件的位置,但自定義View若想獲取自身測量寬高并且做出一些處理的話,可以通過重寫onLayout來完成。

draw過程

draw是三個過程中,最簡單的一個步驟了,他的作用是將View繪制在屏幕上。
View的繪制過程是通dispatchDraw方法來實現(xiàn)的,dispatchDraw方法會遍歷所有子控件的draw方法,將事件傳遞下去。
需要在意的是,View有一個特殊方法setWillNotDraw,若一個View沒有需要繪制的內(nèi)容,那么將這個編輯設置為true之后,系統(tǒng)會對其做相應的優(yōu)化,View默認不啟用,但是ViewGroup會默認將其啟用。當我們知道,一個ViewGroup需要通過onDraw來繪制內(nèi)容的時候,需要主動關閉WILL_NOT_DRAW這個標志位。

一些細節(jié)

在Activity中,得到View已經(jīng)被測量完畢的回掉

假設我們需要在Activity啟動的時候,獲取View的狂傲信息,但因為View的測繪流程與Activity的生命周期是一個異步的過程,所以我們無法通過Activity的生命周期來獲取一個View的繪制狀態(tài),因此在Activity的任何一個生命周期狀態(tài)中,我們都不能保證,一定能獲取到View的寬高信息。
所以,我們推薦以下幾種方法。

  1. onWindowFocusChanged
    這個方法在View初始化完成之后會被調(diào)用,但是這個方法不止會被調(diào)用一次,View每次獲取焦點,這個方法都會被調(diào)用。若頻繁的進行onResume,這個方法也會被頻繁調(diào)用。經(jīng)典寫法如下。

    @Override
    public void onWindowFocusChanged(boolean hasFocus) {
        super.onWindowFocusChanged(hasFocus);
        if (hasFocus) {
                int width = view.getMeasuredWidth();
                int height = view.getMeasuredHeight();
        }
    }

  1. view.post(runnable)
    通過post方法,將一個runnable放在消息隊列的尾部,當Looper調(diào)用次runnable的時候,View肯定已經(jīng)初始化好了。為防止執(zhí)行post的時候,view已經(jīng)加在完畢,所以在onStart方法中添加。

    @Override
    protected void onStart() {
        super.onStart();
        view.post(new Runnable() {

            @Override
            public void run() {
                int width = view.getMeasuredWidth();
                int height = view.getMeasuredHeight();
            }
        });
    }

  1. ViewTreeObserver

使用ViewTreeObserver的OnGlobalLayoutListener接口便能很好的解決這個問題,當View樹發(fā)生改變時,onGlobalLayout會被回調(diào)。但是伴隨View樹的改變,onGlobalLayout會被回調(diào)多次。


    @Override
    protected void onStart() {
        super.onStart();
        ViewTreeObserver observer = view.getViewTreeObserver();
        observer.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
            @Override
            public void onGlobalLayout() {
                view.getViewTreeObserver().removeOnGlobalLayoutListener(this);
                int width = view.getMeasuredWidth();
                int height = view.getMeasuredHeight();
            }
        });
    }

  1. view.measure(int widthMeasureSpec, int hightMeasureSpec)
    這個方法太復雜,不推薦使用,此處不表。

getWight/getHight與getMeasureWight/getMeasureHight的區(qū)別

我們以其中一對的對比來說明。
在View的默認實現(xiàn)中,getWight與getMeasureWight是相等的,只不過MeasureWight形成于View的measure過程中,而Wight形成于View的layout過程,即,兩者的賦值機制不同。在開發(fā)過程中,我們盡可以認為兩者為相同的。
當然我們可以在layout函數(shù)中強行改變getWight的數(shù)值,使其兩者不相同,但是這么做對于開發(fā)沒有意義。

getX/getY與getRawX/getRawY

getRawX()、getRawY()返回的是觸摸點相對于屏幕左上角的坐標
而getX()、getY()返回的則是觸摸點相對于View左上角的坐標

附:

我自己看源碼的一個網(wǎng)站:
http://grepcode.com/project/repository.grepcode.com/java/ext/com.google.android/android/
不用翻墻,雖然代碼老了點,但是用來學習源碼沒什么問題。
參考資料:《Android開發(fā)藝術探索》


個人理解,難免有錯誤紕漏,歡迎指正。轉載請注明出處。

?著作權歸作者所有,轉載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務。

相關閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容