Android常用Layout源碼總結—FrameLayout

前言

通過學習Android官方Layout的源碼,可以幫助自己更好的理解Android的UI框架系統,瞭解內部便捷的封裝好的API調用,有助於進行佈局優化和自定義view實現等工作。這裏把學習結果通過寫博客進行總結,便於記憶,不至於將來遺忘。

本篇博客中源碼基於Android 8.1

FrameLayout特點

FrameLayout是Android開發中最常用的Layout之一,它的特點就是子view們是層疊覆蓋,後添加的子view會覆蓋在其他子view之上。

源碼探究

構造函數

FrameLayout的構造函數很簡單,處理一個FrameLayout的屬性measureAllChildren:

public FrameLayout(@NonNull Context context, @Nullable AttributeSet attrs,
        @AttrRes int defStyleAttr, @StyleRes int defStyleRes) {
    super(context, attrs, defStyleAttr, defStyleRes);

    final TypedArray a = context.obtainStyledAttributes(
            attrs, R.styleable.FrameLayout, defStyleAttr, defStyleRes);

    if (a.getBoolean(R.styleable.FrameLayout_measureAllChildren, false)) {
        setMeasureAllChildren(true);
    }

    a.recycle();
}

measureAllChildren屬性作用是設置是否在測量寬高時計算所有的子view。默認爲false,即在measure階段不會考慮狀態爲GONE的子view

LayoutParams

FrameLayout中定義了靜態內部類LayoutParams繼承自MarginLayoutParams,含有一個成員gravity:

public int gravity = UNSPECIFIED_GRAVITY;

因此支持子view設置父佈局對齊方式。

測量onMeasure

由於FrameLayout幀佈局的特點,它不像LinearLayout和RelativeLayout需要權重或相對關係等,只需要遍歷子view,依次調用child測量,然後設置自身尺寸即可。但是也有細分不同情況,當FrameLayout的MeasureSpec模式爲EXACTLY時,只需按常規流程進行即可。當模式爲AT_MOST時,意味着FrameLayout自身尺寸不明確,需要反向依賴最大的那個child的尺寸,因此在遍歷的同時需要記錄最大尺寸。若同時存在child的LayoutParams設置了MATCH_PARENT,則意味着child又依賴父佈局尺寸,因此在FrameLayout設置完自身尺寸後,需要再對它們進行一次測量。

FrameLayout中的寬高測量分爲兩部分。上部分爲計算子view中的最大寬高,從而設置自身寬高。下部分爲二次計算在上部分中未能精確計算寬高的子view的寬高,此時傳給child的測量規格是根據FrameLayout測量後的寬高生成。

一、上部分:計算最大寬高

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int count = getChildCount();

	// 該變量用於判斷是否記錄需要二次測量子view(若FrameLayout的父佈局給定的測量規格中未指明精確的大小,則爲true)。
    final boolean measureMatchParentChildren =
            MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.EXACTLY ||
            MeasureSpec.getMode(heightMeasureSpec) != MeasureSpec.EXACTLY;
    // mMatchParentChildren爲一個ArrayList集合,用於緩存需要二次測量寬高的子view。
    mMatchParentChildren.clear();

    int maxHeight = 0;
    int maxWidth = 0;
    int childState = 0;

	// 遍歷子view
    for (int i = 0; i < count; i++) {
        final View child = getChildAt(i);
        // 判斷child是否爲GONE,或設置了measureAllChildren屬性。
        if (mMeasureAllChildren || child.getVisibility() != GONE) {
        	// 調用child測量。
            measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0);
            final LayoutParams lp = (LayoutParams) child.getLayoutParams();
            // child測量完成後,獲取child的測量寬高值,並加上margin值,計算最大寬高值。
            maxWidth = Math.max(maxWidth,
                    child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin);
            maxHeight = Math.max(maxHeight,
                    child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin);
            // 合併child的測量狀態。(getMeasuredState會獲取child的測量狀態。狀態有寬state和高state,
            // 分別儲存於mMeasuredWidth成員和mMeasuredHeight成員中的高8位。獲取到儲存的state後,
            // 將寬state設置在一個int中的第一字節位置,高state設置在第三字節位置,最後將這個組合好的int返回)
            childState = combineMeasuredStates(childState, child.getMeasuredState());
            if (measureMatchParentChildren) {
                if (lp.width == LayoutParams.MATCH_PARENT ||
                        lp.height == LayoutParams.MATCH_PARENT) {
                    // 若measureMatchParentChildren爲true,且child的LayoutParams設置爲填充父佈局,
                    // 則需要加入List中,待FrameLayout計算完自身寬高後,再進行二次測量。
                    mMatchParentChildren.add(child);
                }
            }
        }
    }

    // Account for padding too
    // 增加計算padding
    maxWidth += getPaddingLeftWithForeground() + getPaddingRightWithForeground();
    maxHeight += getPaddingTopWithForeground() + getPaddingBottomWithForeground();

    // Check against our minimum height and width
    // 與最小寬高值比較。不能小於最小寬高值。
    maxHeight = Math.max(maxHeight, getSuggestedMinimumHeight());
    maxWidth = Math.max(maxWidth, getSuggestedMinimumWidth());

    // Check against our foreground's minimum height and width
    // 若存在前景圖(與背景圖相對的圖),不能小於前景圖的最小寬高值。
    final Drawable drawable = getForeground();
    if (drawable != null) {
        maxHeight = Math.max(maxHeight, drawable.getMinimumHeight());
        maxWidth = Math.max(maxWidth, drawable.getMinimumWidth());
    }

	// 設置FrameLayout自身的測量寬高值
	// (resolveSizeAndState方法會根據父佈局給定的測量規格和自身計算出的寬高值判斷返回一個新的寬高值,並在這個新的寬高值上設置MeasuredState)
    setMeasuredDimension(resolveSizeAndState(maxWidth, widthMeasureSpec, childState),
            resolveSizeAndState(maxHeight, heightMeasureSpec,
                    childState << MEASURED_HEIGHT_STATE_SHIFT));

	// 下部分...
}

FrameLayout在onMeasure方法中,首先遍歷了子view,進行子view測量,並比較出最大的子view的寬高值,同時還把沒有精確設置寬高值的view加入列表緩存。然後用最大寬高值設置FrameLayout自身的寬高。

其中獲取padding、獲取Minimum寬高、組合尺寸值和狀態等API爲測量操作提供了極大便利,在自定義佈局時,可以學習靈活調用。

MeasuredState補充說明:mMeasuredWidth和mMeasuredHeight這兩個成員變量的高8位用於儲存MeasuredState,其餘24位儲存尺寸值。有點類似MeasureSpec高2位儲存模式,其餘30位儲存尺寸值。MeasuredState作用是,當view測量自身寬高時,若寬高值超過父佈局給定的測量規格中的尺寸,則可以設置state爲MEASURED_STATE_TOO_SMALL,請求父佈局放寬尺寸限制。

二、下部分:二次測量子view
在完成了FrameLayout對自身寬高的計算後,再對列表中的子view進行二次測量。

@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
	// 上部分...

	count = mMatchParentChildren.size();
    if (count > 1) {
    	// 至少有兩個設置了MATCH_PARENT的子view時才執行二次測量。
        for (int i = 0; i < count; i++) {
            final View child = mMatchParentChildren.get(i);
            final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();

			// 重新給該child指定寬度測量規格
            final int childWidthMeasureSpec;
            if (lp.width == LayoutParams.MATCH_PARENT) {
            	// 寬度值爲MATCH_PARENT時,獲取FrameLayout自身的測量寬度值,減去padding和margin值,
            	// 計算新的寬度值(若小於0,取0),設置測量規格模式爲EXACTLY,組合成新的測量規格。
                final int width = Math.max(0, getMeasuredWidth()
                        - getPaddingLeftWithForeground() - getPaddingRightWithForeground()
                        - lp.leftMargin - lp.rightMargin);
                childWidthMeasureSpec = MeasureSpec.makeMeasureSpec(
                        width, MeasureSpec.EXACTLY);
            } else {
            	// 寬度值爲精確的px、dp值或WRAP_CONTENT時,根據父佈局傳入的規格組合測量規格。
                childWidthMeasureSpec = getChildMeasureSpec(widthMeasureSpec,
                        getPaddingLeftWithForeground() + getPaddingRightWithForeground() +
                        lp.leftMargin + lp.rightMargin,
                        lp.width);
            }

			// 指定新的高度測量規格,邏輯同寬度規格。
            final int childHeightMeasureSpec;
            if (lp.height == LayoutParams.MATCH_PARENT) {
                final int height = Math.max(0, getMeasuredHeight()
                        - getPaddingTopWithForeground() - getPaddingBottomWithForeground()
                        - lp.topMargin - lp.bottomMargin);
                childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(
                        height, MeasureSpec.EXACTLY);
            } else {
                childHeightMeasureSpec = getChildMeasureSpec(heightMeasureSpec,
                        getPaddingTopWithForeground() + getPaddingBottomWithForeground() +
                        lp.topMargin + lp.bottomMargin,
                        lp.height);
            }

			// 調用child再次測量。
            child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
        }
    }
}

在這部分中,遍歷緩存集合中的子view,依次生成新的測量規格,之後調用子view再次測量。其中getChildMeasureSpec方法負責根據父佈局傳入的測量規格和padding和child的LayoutParams值生成新的測量規格。

getChildMeasureSpec方法介紹:

/**
 * @param spec 父佈局給定的測量規格
 * @param padding padding和margin之和
 * @param childDimension LayoutParams中的width、height的值
 * @return 新的測量規格
 */
int getChildMeasureSpec(int spec, int padding, int childDimension)

該方法中首先從父佈局測量規則中取出specMode和specSize,將specSize減去padding求出size(若小於0,則取0)。
之後根據specMode、size和childDimension結合條件判斷生成新的測量規格:

specMode⬇️\childDimension➡️ MATCH_PARENT WRAP_CONTENT x px/dp
EXACTLY EXACTLY+size AT_MOST+size EXACTLY+childDimension
AT_MOST AT_MOST+size AT_MOST+size EXACTLY+childDimension
UNSPECIFIED UNSPECIFIED+size UNSPECIFIED+size EXACTLY+childDimension

注意:FrameLayout只有當至少有兩個LayoutParams的width或height爲MATCH_PARENT的子view時,纔會遍歷使用FrameLayout測量後的寬高值對這些子view進行二次測量。若僅有一個view,則是用FrameLayout的父佈局傳入的測量規格,對其進行測量,不再進行二次測量。

佈局onLayout

FrameLayout的onLayout中根據父佈局給定的上下左右,結合子view的gravity、寬高、margin等對子view進行佈局。

@Override
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
    layoutChildren(left, top, right, bottom, false /* no force left gravity */);
}

void layoutChildren(int left, int top, int right, int bottom, boolean forceLeftGravity) {
    final int count = getChildCount();

	// 計算減去padding後的l、t、r、b
    final int parentLeft = getPaddingLeftWithForeground();
    final int parentRight = right - left - getPaddingRightWithForeground();

    final int parentTop = getPaddingTopWithForeground();
    final int parentBottom = bottom - top - getPaddingBottomWithForeground();

    for (int i = 0; i < count; i++) {
        final View child = getChildAt(i);
        // 跳過GONE的child
        if (child.getVisibility() != GONE) {
            final LayoutParams lp = (LayoutParams) child.getLayoutParams();

			// 獲取child的測量寬高值
            final int width = child.getMeasuredWidth();
            final int height = child.getMeasuredHeight();

            int childLeft;
            int childTop;

            int gravity = lp.gravity;
            if (gravity == -1) {
            	// 默認對齊方式(左上角)
                gravity = DEFAULT_CHILD_GRAVITY;
            }

			// 獲取佈局方向(左至右或右至左)
            final int layoutDirection = getLayoutDirection();
            // 獲取相對佈局方向(針對Gravity.START和Gravity.END,根據佈局方向轉換成LEFT和RIGHT)
            final int absoluteGravity = Gravity.getAbsoluteGravity(gravity, layoutDirection);
            // 取出垂直方向對齊方式
            final int verticalGravity = gravity & Gravity.VERTICAL_GRAVITY_MASK;

			// 判斷水平方向的對齊方式
            switch (absoluteGravity & Gravity.HORIZONTAL_GRAVITY_MASK) {
                case Gravity.CENTER_HORIZONTAL:
                	// 水平居中
                	// 計算child左邊位置(這裏有加入計算margin,因此當左右margin不相等時,會有偏移,不完全居中)
                    childLeft = parentLeft + (parentRight - parentLeft - width) / 2 +
                    lp.leftMargin - lp.rightMargin;
                    break;
                case Gravity.RIGHT:
                	// 靠右對齊
                	// 判斷是否強制靠左對齊(默認false,即不強制)
                    if (!forceLeftGravity) {
                        childLeft = parentRight - width - lp.rightMargin;
                        break;
                    }
                case Gravity.LEFT:
                default:
                	// 靠左、默認對齊方式
                    childLeft = parentLeft + lp.leftMargin;
            }

			// 判斷垂直方向對齊方式
            switch (verticalGravity) {
                case Gravity.TOP:
                    childTop = parentTop + lp.topMargin;
                    break;
                case Gravity.CENTER_VERTICAL:
                	// 垂直居中(邏輯與水平居中類似)
                    childTop = parentTop + (parentBottom - parentTop - height) / 2 +
                    lp.topMargin - lp.bottomMargin;
                    break;
                case Gravity.BOTTOM:
                    childTop = parentBottom - height - lp.bottomMargin;
                    break;
                default:
                    childTop = parentTop + lp.topMargin;
            }

			// 調用child佈局
            child.layout(childLeft, childTop, childLeft + width, childTop + height);
        }
    }
}

layoutChildren佈局方法中,通過位運算從int中取出對齊方式,如果設置了START和END會根據RTL、LTR轉換成對應的LEFT和RIGHT,之後依次判斷水平方向對齊方式和垂直方向對齊方式。因爲child的width、height確定,所以水平方向只需計算childLeft,垂直方向只需計算childTop即可。

總結

FrameLayout的核心邏輯即onMeasure和onLayout方法。onMeasure方法中在分發child測量的同時會比較child中的最大寬高值,並且當有child的LayoutParams設置了MATCH_PARENT,意味着他需要依賴父佈局的尺寸,若父佈局的測量模式不是指明明確的尺寸,則將該child添加至待測列表中。遍歷完child後設置自身寬高。之後待測判斷列表中緩存child的數量若至少2個,則使用FrameLayout自身的寬高重新生成測量規格,再調用child二次測量。
onLayout方法中遍歷child,依次根據對齊方式,修改childLeft和childTop,最後調用child佈局。

發佈了27 篇原創文章 · 獲贊 2 · 訪問量 8507
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章