1. 程式人生 > >每日一問:談談對 MeasureSpec 的理解

每日一問:談談對 MeasureSpec 的理解

作為一名 Android 開發,正常情況下對 View 的繪製機制基本還是耳熟能詳的,尤其對於經常需要自定義 View 實現一些特殊效果的同學。

網上也出現了大量的 Blog 講 View 的 onMeasure()onLayout()onDraw() 等,雖然這是一個每個 Android 開發都應該知曉的東西,但這一系列實在是太多了,完全不符合咱們短平快的這個系列初衷。

那麼,今天我們就來簡單談談 measure() 過程中非常重要的 MeasureSpec

對於絕大多數人來說,都是知道 MeasureSpec 是一個 32 位的 int 型別。並且取了最前面的兩位代表 Mode,後 30 位代表大小 Size。

相比也非常清楚 MeasureSpec 有 3 種模式,它們分別是 EXACTLYAT_MOSTUNSPECIFIED

  • 精確模式(MeasureSpec.EXACTLY):在這種模式下,尺寸的值是多少,那麼這個元件的長或寬就是多少,對應 MATCH_PARENT 和確定的值。
  • 最大模式(MeasureSpec.AT_MOST):這個也就是父元件,能夠給出的最大的空間,當前元件的長或寬最大隻能為這麼大,當然也可以比這個小。對應 WRAP_CONETNT
  • 未指定模式(MeasureSpec.UNSPECIFIED):這個就是說,當前元件,可以隨便用空間,不受限制。

通常來說,我們在自定義 View 的時候會經常地接觸到 AT_MOST

EXACTLY,我們通常會根據兩種模式去定義自己的 View 大小,在 wrap_content 的時候使用自己計算或者設定的一個預設值。而更多的時候我們都會認為 UNSPECIFIED 這個模式被應用在系統原始碼中。具體就體現在 NestedScrollViewScrollView 中。

我們看這樣一個 XML 檔案:

<?xml version="1.0" encoding="utf-8"?>
<android.support.v4.widget.NestedScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/scrollView"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    tools:context=".MainActivity">

    <TextView
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:background="@color/colorAccent"
        android:text="Hello World"
        android:textColor="#fff">
    </TextView>

</android.support.v4.widget.NestedScrollView>

NestedScrollView 裡面寫了一個充滿螢幕高度的 TextView,為了更方便看效果,我們設定了一個背景顏色。但我們從 XML 預覽中卻會驚訝的發現不一樣的情況。

我們所期望的是填充滿螢幕的 TextView,但實際效果卻和 TextView 設定高度為 wrap_content 如出一轍。

很明顯,這一定是高度測量出現的問題,如果我們的父佈局是 LinearLayout,很明顯沒有任何問題。所以問題一定出在了 NestedScrollViewonMeasure() 中。

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    if (this.mFillViewport) {
        int heightMode = MeasureSpec.getMode(heightMeasureSpec);
        if (heightMode != 0) {
            if (this.getChildCount() > 0) {
                View child = this.getChildAt(0);
                LayoutParams lp = (LayoutParams)child.getLayoutParams();
                int childSize = child.getMeasuredHeight();
                int parentSpace = this.getMeasuredHeight() - this.getPaddingTop() - this.getPaddingBottom() - lp.topMargin - lp.bottomMargin;
                if (childSize < parentSpace) {
                    int childWidthMeasureSpec = getChildMeasureSpec(widthMeasureSpec, this.getPaddingLeft() + this.getPaddingRight() + lp.leftMargin + lp.rightMargin, lp.width);
                    int childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(parentSpace, 1073741824);
                    child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
                }
            }

        }
    }
}

由於我們並沒有在外面設定 mFillViewport 這個屬性,所以並不會進入到 if 條件中,我們來看看 NestedScrollView 的 super FrameLayoutonMeasure() 做了什麼。

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

    final boolean measureMatchParentChildren =
            MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.EXACTLY ||
            MeasureSpec.getMode(heightMeasureSpec) != MeasureSpec.EXACTLY;
    mMatchParentChildren.clear();

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

    for (int i = 0; i < count; i++) {
        final View child = getChildAt(i);
        if (mMeasureAllChildren || child.getVisibility() != GONE) {
            measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0);
            final LayoutParams lp = (LayoutParams) child.getLayoutParams();
            maxWidth = Math.max(maxWidth,
                    child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin);
            maxHeight = Math.max(maxHeight,
                    child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin);
            childState = combineMeasuredStates(childState, child.getMeasuredState());
            if (measureMatchParentChildren) {
                if (lp.width == LayoutParams.MATCH_PARENT ||
                        lp.height == LayoutParams.MATCH_PARENT) {
                    mMatchParentChildren.add(child);
                }
            }
        }
    }

    // ignore something...
}

注意其中的關鍵方法 measureChildWithMargins(),這個方法在 NestedScrollView 中得到了完全重寫。

protected void measureChildWithMargins(View child, int parentWidthMeasureSpec, int widthUsed, int parentHeightMeasureSpec, int heightUsed) {
    MarginLayoutParams lp = (MarginLayoutParams)child.getLayoutParams();
    int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec, this.getPaddingLeft() + this.getPaddingRight() + lp.leftMargin + lp.rightMargin + widthUsed, lp.width);
    int childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(lp.topMargin + lp.bottomMargin, 0);
    child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
}   

我們看到其中有句非常關鍵的程式碼:

int childHeightMeasureSpec = MeasureSpec.makeMeasureSpec(lp.topMargin + lp.bottomMargin, 0);

NestedScrollView 直接無視了使用者設定的 MODE,直接採用了 UNSPECIFIED 做處理。經過測試發現,當我們重寫 NestedScrollView 的這句程式碼,並且把 MODE 設定為 EXACTLY 的時候,我們得到了我們想要的效果,我已經檢視 Google 的原始碼提交日誌,並沒有找到原因。

實際上,絕大多數開發之前遇到的巢狀 ListView 或者 RecylerView 只展示一行也是由於這個問題,解決方案就是重寫 NestedScrollViewmeasureChildWithMargins() 或者重寫 ListView 或者 RecylerViewonMeasure() 方法讓其展示正確的高度。

我起初猜想是隻有 UNSPECIFIED 才能實現滾動效果,但很遺憾並不是這樣的。所以在這裡丟擲這個問題,希望有知情人士能一起討論