Android —— 自定義View中,你應該知道的知識點

塗程發表於2020-11-23

什麼是自定義View?

在Android開發中,系統提供給我們的UI控制元件是有限的,當我們需要使用一些特殊的控制元件的時候,只靠系統提供的控制元件,可能無法達到我們想要的效果,這時,就需要我們自定義一些控制元件,來完成我們想要的效果了。下面,我就來講講自定義控制元件的那些事。

首先,我來講講Android的控制元件架構。Android的控制元件可以被分為兩類,分別是ViewGroup和View。在ViewGroup中可以包含多個View,並且管理他們。控制元件樹就是有這兩個部分組成的,控制元件樹的上層負責的是下層控制元件的繪製和測量以及互動。我們在Activity中使用的findViewById()方法,就是在控制元件樹中用深度遍歷的方法搜尋到對應的ID的。每一顆控制元件樹的頂部,都有個ViewParent物件,他是整棵樹的核心,負責排程所有的互動事件。在Activity中,我們是使用setContentView()來載入佈局的。每個Activity都是包含著一個Window物件的,在Android中通常是PhoneWindow,他將一個DecorView作為整個視窗的根View,將要顯示的內容呈現在window上。DecorView又分為兩個部分,一個是TitleView,一個是ContentView。ContentView是一個ID為content的Framelayout,佈局檔案就是設定在這裡面的。而TitleView就是我們看到topbar標題欄。這就是activity載入佈局檔案的過程了。

View什麼時候發生繪製

在Android應用中,View的第一次繪製是伴隨這個Activity啟動開始的。當Activity生命週期執行到onCreate時,我們都知道這時候會呼叫setContentView方法。View的繪製就是從這裡開始。
除此之外,當View樹中的檢視發生變化時,會開始View的繪製;或者主動呼叫View的繪製方法,比如invalidate方法。這個都會發起View的繪製。

setContentView之後發生了什麼?

通過在Activity中呼叫setContentView方法開始View的載入。這個過程是通過Window物件載入的。我們可以再PhoneWindow中找到這個方法。在這個方法中可以看到一個installDecor的方法。這個方法的作用就是初始化頂級View,也就是DecorView(這裡不再介紹DecorView的建立過程,想了解的同學可以自行閱讀程式碼)。之後View的工作流程就是從DecorView開始的,這個後面再講。

setContentView只是先將頂級View初始化,還沒有開始View的繪製。接著往下看,ActivityThread繼續執行Activity的生命週期。在ActivityThread執行到handleResumeActivity方法時,這裡呼叫了Activity的生命週期函式onResume。接著在通過WindowManager新增了DecorView,然後才開始了View的工作流程。這裡也解釋了為什麼在Activity在執行完onResume的時候使用者才可以跟App互動。

最後建立了ViewRootImpl的例項,ViewRootImpl的作用就是溝通View和WindowManager,實現兩者所需要的協議,它管理著View的工作流程。 ViewRootImpl中的performTraversals方法中可以看到執行了3個方法。分別是performMeasure、performLayout、performDraw,從名稱上也可以想到,著3個方法執行了View的measure、layout、draw方法

View怎麼測量大小?

View通過measure來確定大小 measure的作用就是決定View到底有多大。在整個View樹種是由View和ViewGroup組成。而measure也分為著兩種繪製方式。View的measure只測試自身大小。ViewGroup除了測量自身大小,還負責測量子View的大小。

MeasureSpec的作用

MeasureSpec封裝了View的規格尺寸引數,包括View的寬高以及測量模式。
它的高2位代表測量模式(通過mode & MODE_MASK計算),低30位代表尺寸。其中測量模式總共有3中。

  • UNSPECIFIED:未指定模式不對子View的尺寸進行限制。
  • AT_MOST:最大模式對應於wrap_content屬性,父容器已經確定子View的大小,並且子View不能大於這個值。
  • EXACTLY:精確模式對應於match_parent屬性和具體的數值,子View可以達到父容器指定大小的值。

對於每一個View,都會有一個MeasureSpec屬性來儲存View的尺寸規格資訊。在View測量的時候,通過makeMeasureSpec來儲存寬高資訊,通過getMode獲取測量模式,通過getSize獲取寬或高。

MeasureSpec是如何產生的

MeasureSpec相當於View測量過程中的一個規格,在View開始測量前需要先生成MeasureSpec來指導View以何種方式測量。
MeasureSpec生成是由父佈局決定的,同時對於頂級ViewDecorView來說是由LayoutParams決定的。
在上面分析View工作流程開始的時候,在ViewRootImpl中開始工作流程前,有一個方法measureHierarchy(),這個方法就是生成DecorView的方式。

private boolean measureHierarchy(final View host, final WindowManager.LayoutParams lp,
        final Resources res, final int desiredWindowWidth, final int desiredWindowHeight) {
    ...
    if (baseSize != 0 && desiredWindowWidth > baseSize) {
        childWidthMeasureSpec = getRootMeasureSpec(baseSize, lp.width);
        childHeightMeasureSpec = getRootMeasureSpec(desiredWindowHeight, lp.height);
    }
    ...
}

在程式碼中可以看到通過getRootMeasureSpec()方法獲取了DecorView的MeasureSpec。

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

getRootMeasureSpec()也不復雜,在方法中可以看出如果是LayoutParams.MATCH_PARENT,那麼DecorView的大小就是Window的大小;如果是LayoutParams.WRAP_CONTENT,那麼DecorView的大小不確定。
對於普通的View,MeasureSpec來自於父佈局(ViewGroup)生成。

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

在這裡可以看到生成子View的MeasureSpec時與父佈局的MeasureSpec以及padding相關,同時也與View本身的margin有關。

MeasureSpec中UNSPECIFIED的用途

UNSPECIFIED主要在一線父View不限制子View寬高的情況下使用,比如ScrollView

1.UNSPECIFIED會在ScrollView的measure方法裡傳給子View
2.子View收到UNSPECIFIED,會根據自己的實際內容大小來決定高度
3.UNSPECIFIED與AT_MOST的區別就是,它沒有最大size限定這也說明UNSPECIFIED在ScrollView裡很實用,因為ScrllView不需要限定子View的大小,它可以滾動嘛

如何自定義FlowLayout

實現自定義View主要需要解決以下3個問題

1.自定義控制元件的大小,也就是寬和高分別設定多少;
2.如果是 ViewGroup,如何合理安排其內部子 View 的擺放位置。
3.如何根據相應的屬性將 UI 元素繪製到介面;

以上 3 個問題依次在如下 3 個方法中得到解決:
onMeasure,onLayout,onDraw

FlowLayout的onMeasure方法

因為自定義的控制元件是一個容器,onMeasure 方法會更加複雜一些。因為 ViewGroup 在測量自己的寬高之前,需要先確定其內部子 View 的所佔大小,然後才能確定自己的大小。
如下所示:

//測量控制元件的寬和高
 @Override
 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
     super.onMeasure(widthMeasureSpec, heightMeasureSpec);
     //獲得寬高的測量模式和測量值
     int widthMode = MeasureSpec.getMode(widthMeasureSpec);
     int widthSize = MeasureSpec.getSize(widthMeasureSpec);
     int heightSize = MeasureSpec.getSize(heightMeasureSpec);
     int heightMode = MeasureSpec.getMode(heightMeasureSpec);

     //獲得容器中子View的個數
     int childCount = getChildCount();
     //記錄每一行View的總寬度
     int totalLineWidth = 0;
     //記錄每一行最高View的高度
     int perLineMaxHeight = 0;
     //記錄當前ViewGroup的總高度
     int totalHeight = 0;
     for (int i = 0; i < childCount; i++) {
         View childView = getChildAt(i);
         //對子View進行測量
         measureChild(childView, widthMeasureSpec, heightMeasureSpec);
         MarginLayoutParams lp = (MarginLayoutParams) childView.getLayoutParams();
         //獲得子View的測量寬度
         int childWidth = childView.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
         //獲得子View的測量高度
         int childHeight = childView.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
         if (totalLineWidth + childWidth > widthSize) {
             //統計總高度
             totalHeight += perLineMaxHeight;
             //開啟新的一行
             totalLineWidth = childWidth;
             perLineMaxHeight = childHeight;
         } else {
             //記錄每一行的總寬度
             totalLineWidth += childWidth;
             //比較每一行最高的View
             perLineMaxHeight = Math.max(perLineMaxHeight, childHeight);
         }
         //當該View已是最後一個View時,將該行最大高度新增到totalHeight中
         if (i == childCount - 1) {
             totalHeight += perLineMaxHeight;
         }
     }
     //如果高度的測量模式是EXACTLY,則高度用測量值,否則用計算出來的總高度(這時高度的設定為wrap_content)
     heightSize = heightMode == MeasureSpec.EXACTLY ? heightSize : totalHeight;
     setMeasuredDimension(widthSize, heightSize);
 }

上述 onMeasure 方法的主要目的有 2 個:
1.呼叫 measureChild 方法遞迴測量子 View;
2.通過疊加每一行的高度,計算出最終 FlowLayout 的最終高度 totalHeight。

FlowLayout的onLayout方法

上面的 FlowLayout 中的 onMeasure 方法只是計算出 ViewGroup 的最終顯示寬高,但是並沒有規定某一個子 View 應該顯示在何處位置。要定義 ViewGroup 內部子 View 的顯示規則,則需要複寫並實現 onLayout 方法。
onLayout是一個抽象方法,也就是說每一個自定義 ViewGroup 都必須主動實現如何排布子 View,具體就是遍歷每一個子 View,呼叫 child.(l, t, r, b) 方法來為每個子 View 設定具體的佈局位置。四個引數分別代表左上右下的座標位置,一個簡易的 FlowLayout 實現如下:

@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
    mAllViews.clear();
    mPerLineMaxHeight.clear();
    //存放每一行的子View
    List<View> lineViews = new ArrayList<>();
    //記錄每一行已存放View的總寬度
    int totalLineWidth = 0;
    //記錄每一行最高View的高度
    int lineMaxHeight = 0;
    /****遍歷所有View,將View新增到List<List<View>>集合中**********/
    //獲得子View的總個數
    int childCount = getChildCount();
    for (int i = 0; i < childCount; i++) {
        View childView = getChildAt(i);
        MarginLayoutParams lp = (MarginLayoutParams) childView.getLayoutParams();
        int childWidth = childView.getMeasuredWidth() + lp.leftMargin + lp.rightMargin;
        int childHeight = childView.getMeasuredHeight() + lp.topMargin + lp.bottomMargin;
        if (totalLineWidth + childWidth > getWidth()) {
            mAllViews.add(lineViews);
            mPerLineMaxHeight.add(lineMaxHeight);
            //開啟新的一行
            totalLineWidth = 0;
            lineMaxHeight = 0;
            lineViews = new ArrayList<>();
        }
        totalLineWidth += childWidth;
        lineViews.add(childView);
        lineMaxHeight = Math.max(lineMaxHeight, childHeight);
    }
    //單獨處理最後一行
    mAllViews.add(lineViews);
    mPerLineMaxHeight.add(lineMaxHeight);
    /************遍歷集合中的所有View並顯示出來************/
    //表示一個View和父容器左邊的距離
    int mLeft = 0;
    //表示View和父容器頂部的距離
    int mTop = 0;
    for (int i = 0; i < mAllViews.size(); i++) {
        //獲得每一行的所有View
        lineViews = mAllViews.get(i);
        lineMaxHeight = mPerLineMaxHeight.get(i);
        for (int j = 0; j < lineViews.size(); j++) {
            View childView = lineViews.get(j);
            MarginLayoutParams lp = (MarginLayoutParams) childView.getLayoutParams();
            int leftChild = mLeft + lp.leftMargin;
            int topChild = mTop + lp.topMargin;
            int rightChild = leftChild + childView.getMeasuredWidth();
            int bottomChild = topChild + childView.getMeasuredHeight();
            //四個引數分別表示View的左上角和右下角
            childView.layout(leftChild, topChild, rightChild, bottomChild);
            mLeft += lp.leftMargin + childView.getMeasuredWidth() + lp.rightMargin;
        }
        mLeft = 0;
        mTop += lineMaxHeight;
    }
}

一道滴滴面試題

之前在面試滴滴時碰到了這樣一首題目,這個問題如果你如果理解了,相信你已經充分掌握了自定義View的measure過程
Activity內根佈局LinearLayout,背景顏色為紅色,寬高為wrap_content
內部包含View背影顏色為藍色,寬高也為wrap_content
求介面顏色

<LinearLayout
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:background="@color/red"
    xmlns:android="http://schemas.android.com/apk/res/android">
    <View
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:background="@color/blue"
    />
</LinearLayout>

答案是藍色

在下當時想當然的認為,既然都是wrap_content,介面顏色應該是白色。但是正確答案是藍色
下面就來分析下具體原因

LinearLayout的onMeasure()

onMeasure()中比較簡單,但是這裡我們需要明確一下,這個方法的引數是什麼含義:

MeasureSpec就不用多說了,記錄當前View的尺寸和測量模式
另外明確一點,這裡的MeasureSpec是父View的

/**
 * @param widthMeasureSpec horizontal space requirements as imposed by the parent.
 * @param heightMeasureSpec vertical space requirements as imposed by the parent.
 */
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    if (mOrientation == VERTICAL) {
        measureVertical(widthMeasureSpec, heightMeasureSpec);
    } else {
        measureHorizontal(widthMeasureSpec, heightMeasureSpec);
    }
}

這裡我們們就選measureVertical()追進去,方法裡的邊界條件非常的多,但其中對於子View的測量過程比較的簡單,遍歷所有的子View,挨個呼叫measureChildBeforeLayout()方法,而這個方法最終會走到ViewGroup中的measureChildWithMargins():

protected void measureChildWithMargins(View child,
        int parentWidthMeasureSpec, int widthUsed,
        int parentHeightMeasureSpec, int heightUsed) {
    // 這個方法主要就是做了一件事情:通過子View的LayoutParams和父View的MeasureSpec來決定子View的MeasureSpec
    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);
}

生成子View的MeasureSpec

這部分邏輯主要在getChildMeasureSpec()方法中,我們直接追進去就好了:

public static int getChildMeasureSpec(int spec, int padding, int childDimension) {
    // 省略部分初始化程式碼
    switch (specMode) { 
        case MeasureSpec.EXACTLY: 
            if (childDimension >= 0) { 
                resultSize = childDimension;
                resultMode = MeasureSpec.EXACTLY;
            } else if (childDimension == LayoutParams.MATCH_PARENT) {
                resultSize = size;
                resultMode = MeasureSpec.EXACTLY;
            } else if (childDimension == LayoutParams.WRAP_CONTENT) {
                resultSize = size;
                resultMode = MeasureSpec.AT_MOST;
            }
            break;
        case MeasureSpec.AT_MOST: 
            if (childDimension >= 0) {
                resultSize = childDimension;
                resultMode = MeasureSpec.EXACTLY;
            } else if (childDimension == LayoutParams.MATCH_PARENT) {
                resultSize = size;
                resultMode = MeasureSpec.AT_MOST;
            } else if (childDimension == LayoutParams.WRAP_CONTENT) {
                resultSize = size;
                resultMode = MeasureSpec.AT_MOST;
            }
            break;
        case MeasureSpec.UNSPECIFIED: 
            if (childDimension >= 0) {
                resultSize = childDimension;
                resultMode = MeasureSpec.EXACTLY;
            } else if (childDimension == LayoutParams.MATCH_PARENT) {
                resultSize = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size;
                resultMode = MeasureSpec.UNSPECIFIED;
            } else if (childDimension == LayoutParams.WRAP_CONTENT) {
                resultSize = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size;
                resultMode = MeasureSpec.UNSPECIFIED;
            }
            break;
        }
    return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
}

這部分程式碼,就是Google定的規則,也沒什麼好說的。總結起來就是《Android開發藝術探索》中的那張圖:

看了這個,我們們就可以思考一下我們們開篇遇到的問題:父View(LinearLayout)是wrap_content,子View是wrap_parent,那麼子View的MeasureSpec是什麼樣子?

有了上邊的分析,我們很容易得出答案:parentSize + AT_MOST。因此我們們就知道這種場景下,子View的wrap_parent意味自己的寬高就是父View的寬高。那麼此時父View的寬高是多少呢?

由於這裡的父View已經是根View了,那麼它的外邊便是DecorView,而DecorView的MeasureSpec相對簡單些,直接基於Window的寬高和自身的LayoutParams進行計算。

private static 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;
}

public void setContentView(View view) {
    setContentView(view, new ViewGroup.LayoutParams(MATCH_PARENT, MATCH_PARENT));
}

因此這種場景下,DecorView的MeasureSpec是螢幕寬高 + EXACTLY,那麼父View(LinearLayout)的寬高就很明確了:parentSize + AT_MOST。

1.子View(TextView)的MeasureSpec是parentSize + AT_MOST
2.父View(LinearLayout)的MeasureSpec是parentSize + AT_MOST
3.DecorView的MeasureSpec是螢幕的size + EXACTLY

執行子View的measure()方法

接下來我們們去看一看子View的measure()方法,
上述的部分我們已經知道measureChildWithMargins()方法中會基於父View的MeasureSpec和子View的LayoutParams計運算元View的MeasureSpec,
然後呼叫子View的measure():

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

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的寬度即為父View傳過來的寬高,即螢幕寬高。
因此最終效果為全屏顯示藍色。

小編暫且就寫到這了,後續還會更新更多幹貨請關注我哦!!!

相關學習資料

Android 核心知識點之自定義View
Android 知識點大全之自定義View

想要獲取這份相關學習資料或更多Android 進階資料,請點選【Github地址】檢視獲取方式!

相關文章