Android 从 0 开始自定义控件之 View 的 measure 过程(七)

2,285 阅读6分钟

转载请标明出处: blog.csdn.net/airsaid/art…
本文出自:周游的博客

前言

经过前面2篇的铺垫,终于到正式学习 View 的三大流程:测量、布局、绘制流程了,这一篇就先从学习 measure 过程开始吧。

measure 过程要分两种情况,第一种是 View,第二种是 ViewGroup。如果是 View 的话,那么只通过 measure 方法就完成其测量过程,但是如果是 ViewGroup 的话,不仅需要完成自己的测量过程,还需要完成它所有子 View 的测量过程。如果子 View 又是一个 ViewGroup,那么继续递归这个流程。下面先从 View 开始,详细了解下 View 的 measure 过程。

View 的 measure 过程

View 的测量过程是由 View 的 measure 方法来完成的,但是该方法是一个 finall 方法,所以不能被重写。在 measure 方法中会去调用 onMeasure() 方法,因此我们只需在 View 中重写 onMeasure() 方法来完成 View 的测量即可。那么 View 默认的 measure 实现是怎样的呢? 来看下 View 的 onMeasure() 方法:

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

可以看到,该方法的实现很简单,直接调用了 setMeasuredDimension() 方法来设置测量的尺寸。关键就在于 getDefaultSize() 方法上, 继续跟进,看看 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;
}

从上述代码上可以看到,关于我们关心的 AT_MOST 和 EXACTLY 测量模式,其实 getDefaultSize() 方法返回的就是 MeasureSpec 的 specSize。
而这个 MeasureSpec 如果阅读过上篇文章后,就应该知道是 ViewGroup 传递而来的。如果不太了解,建议返回去看下上篇文章,这里就不重复介绍了。

到这里也就理解了,为什么当我们在布局中写 wrap_content,如果不重写 onMeasure() 方法,则默认大小是父控件的可用大小了。
当我们在布局中写 wrap_content 时,那么测量模式就是: AT_MOST,在该模式下,它的宽高等于 specSize。而 specSize 由 ViewGroup 传递过来时就是 parentSize,也就是父控件的可用大小。
当我们在布局中写 match_parent 时,那么不用多说,宽高当然也是 parentSize。这时候,我们只需对 AT_MOST 测量模式进行处理:

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    int widthMode = MeasureSpec.getMode(widthMeasureSpec);
    int heightMode = MeasureSpec.getMode(heightMeasureSpec);
    int widthSize = MeasureSpec.getSize(widthMeasureSpec);
    int heightSize = MeasureSpec.getSize(heightMeasureSpec);

    int width = 0;
    int height = 0;

    if(widthMode == MeasureSpec.AT_MOST){
        width = ...
    }

    if(heightMode == MeasureSpec.AT_MOST){
        height = ...
    }

    setMeasuredDimension(widthMode != MeasureSpec.AT_MOST ? widthSize : width,
            heightMode != MeasureSpec.AT_MOST? heightSize : height);
}

上述代码,判断当测量模式是最大模式时,自己计算 View 的宽高。其他情况,直接使用 specSize。

至于 UNSPECIFIED 这种情况,则是使用的第一个参数的值,也就是:getSuggestedMinimumWidth()和getSuggestedMinimumHeight()方法,一般用于系统内部的测量过程。
这两个方法的源码如下:

protected int getSuggestedMinimumHeight() {
    return (mBackground == null) ? mMinHeight : max(mMinHeight, mBackground.getMinimumHeight());

}
protected int getSuggestedMinimumWidth() {
    return (mBackground == null) ? mMinWidth : max(mMinWidth, mBackground.getMinimumWidth());
}

大概意思就是,判断 View 有没有背景,没有背景的话,那么值就是 View 最小的宽度或高度,也就是对应 xml 中的:Android:minWidth、android:minHeight 属性,如果属性没有指定的话,默认为0。
有背景的话,那么值就是 View 最小的宽度或高度 和 背景的最小宽度或高度,取两者中最大的一个值。这个值就是当测量模式是 UNSPECIFIED 时 View 的测量宽/高。

到这里就完成了整个 View 的 measure 过程,完成之后我们就可以通过 getMeasureWidth() 和 getMeasureHeight() 方法获取 View 正确的测量宽/高了。但是需要注意的时,在某些极端情况下,系统可能需要再多次 measure 过程后才能确定最终的测量宽/高,在这种情况下,直接在 onMeasure() 方法中获取的测量宽/高可能是不准确的,保险的做法是在 onLayout() 方法中去获取。

ViewGroup 的 measure 过程

ViewGroup 的 measure 过程 和 View 不同,不仅需要完成自身的 measure 过程,还需要去遍历所有子 View 的 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);
        }
    }
}

该方法遍历了所有的子 View,判断如果子 View 没有 GONE 掉的时候,就继续执行 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);
}

该方法获取了子 View 的 LayoutParams,然后通过 getChildMeasureSpec() 方法创建了子 View 的 MeasureSpec,至于是怎么生成的,上一篇关于 MeasureSpec 的文章有写。
创建好子 View 的 MeasureSpec 后,然后将 MeasureSpec 传给了子 VIew 进行 View 的 measure 过程。

通过上面的代码我们可以发现,ViewGroup 并没有定义其具体的测量过程,这是因为 ViewGroup 是一个抽象类,它测量过程的 onMeasure 方法需要它的子类去实现,比如说像 LinearLayout、RelativeLayout等。
它并不像 View 一样,对 onMeasure 方法做了统一实现,这是因为它的子类都有不同的布局特性,就像 LinearLayout 和 RelativeLayout 一样,两者的布局特性截然不同,没有办法做统一实现。

注意事项

由于 View 的 measure 过程和 Activity 的生命周期不是同步的,那么如果直接在 Activity 的生命周期方法,如:onCreate() 、onStart()、onResumt() 中直接获取 View 的宽/高是无法正确获取到的。
因为没办法保证当走这些生命周期回调方法前,View 的 measure 过程已经走完。如果没有走完就直接获取的话,那么得到的只会是 0。下面给出几种解决方法:

  • 方案1:
    重写 onWindowFocusChanged() 方法,在该方法中获取宽/高:
@Override
public void onWindowFocusChanged(boolean hasFocus) {
    super.onWindowFocusChanged(hasFocus);
    if(hasFocus){
        int measuredWidth = view.getMeasuredWidth();
        int measuredHeight = view.getMeasuredHeight();
    }
}

该方法会在当前 Activity 的 Window 获得或失去焦点的时候回调,当回调该方法时,表示 Activtiy 是完全对用户可见的,这时候 View 已经初始化完毕、宽/高都已经测量好了,这时就能获取到宽/高了。

  • 方案2:
view.post(new Runnable() {
    @Override
    public void run() {
        int measuredWidth = view.getMeasuredWidth();
        int measuredHeight = view.getMeasuredHeight();
    }
});

该方案,通过 post 方法将一个 runnable 投递到消息队列的底部,然后等待 Looper 调用该 runnable 时,View 也已经初始化好了,这时就能获取到宽/高了。

  • 方案3:
ViewTreeObserver treeObserver = view.getViewTreeObserver();
treeObserver.addOnGlobalLayoutListener(new ViewTreeObserver.OnGlobalLayoutListener() {
    @Override
    public void onGlobalLayout() {
        view.getViewTreeObserver().removeGlobalOnLayoutListener(this);
        int measuredWidth = view.getMeasuredWidth();
        int measuredHeight = view.getMeasuredHeight();
    }
});

该方案,通过监听 View 树的状态发生改变或者 View 树内部的 View 可见性发生改变时,在 onGlobalLayout 回调中获取 View 的宽/高。需要注意的时,该回调会被调用多次,所以这里在第一次回调中,就移除了监听,避免多次获取。

参考

  • 《Android开发艺术探索》