Android开发艺术探索——View的工作原理

这是我在学习过程中总结的知识
目的是希望日后回来看或者需要用的时候可以 一目了然 # 的回顾、巩固、查缺补漏
不追求详细相当于书本的精简版或者说是导读(想看详细的直接对应翻书),但会尽力保证读者都能快速理解和快速使用(随理解加深会总结的更加精简),但必要时会附上一些较详细解释的链接
脚注是空白的:表示还没弄懂的知识,了解后会添加


· 为了更好地自定义View,还需要掌握View的底层工作原理,比如View的测量流程、布局流程以及绘制流程
· 除了View的三大流程,View的常见回调方法也需要熟练掌握,比如构造方法、onAttach、onVisibilityChanged、onDetach等
· 解决相应的滑动冲突

4.1 初识ViewRoot和DecorView

ViewRoot对应于ViewRootImpl类,它是链接WindowManager和DecorView的纽带,View的三大流程均是通过ViewRoot来完成的。

View的绘制流程是从ViewRoot的performTraversals方法开始的,它经过3个过程

  • measure用来测量View的宽度
  • layout用来确定View在父容器中放置的位置
  • draw负责将View绘制在屏幕上

流程图:

这个流程图中的方法先在顶级View中执行完,然后到子元素中重新执行,如此反复完成整个View树的遍历

  • measure过程决定了View的宽和高,可以通过getMeasuredWidth和getMeasuredHeight方法来获取到View测量后的宽和高(存在特殊情况后面会讲到)
  • layout过程决定了View四个顶点的坐标和实际Vew的宽高,可以通过getTop、getBottm、getLeft、getRight来拿到四个顶点的位置,getWidth、getHeight拿到View最终宽高
  • draw决定了View的显示

如图所示,DecorView作为顶级View,它内部包含一个竖直的Linear
Layout,分为上部:标题栏,下部:内容栏(id是content,是一个FrameLayout)
在Activity中我们通过setContentView设置的布局文件是被加到内容栏中的
如何得到content?:ViewGrpoup content =(ViewGroup)findViewById(android.R. id.content)
如何得到我们设置的View?:content.getChildAt(0);

4.2 理解MeasureSpec

为了更好的理解View的测量过程,我们还需要理解MeasureSpec
作用:在测量过程,系统会将View的LayoutParams根据父容器所施加的规则转换成对应的MeausreSpec,然后根据这个measureParams来测量View的宽高(不是一定是最终宽高)

4.2.1 MeasureSpec

MeasureSpec代表一个32位int值,高2位代表SpecMode(测量模式),低30位代表SpecSize(某种测量模式下的规格大小)

// SpecMode有三类
// 父容器不对View有任何限制,要多大给多大,一般用于系统内部,表示一种测量的状态
  UNSPECIFIED
// 父容器已经检测出View所需的精确大小,这个时候View的最终大小就是SpecSize所指定的值。它对应于LayoutParams中的match_parent和具体的数值这两种模式
	EXACTLY
// 父容器指定了SpecSize,对应于wrap_content。
	AT_MOST

MeasureSpec通过将SpecMode和SpecSize打包成一个int值来避免过多的对象内存分配,同时它也可以解包成其原始的SpecMode和SpecSize

4.2.2 Measure和LayoutParams

View测量时,系统会将 LayoutParams 在 父容器 的约束下转换成对应的MeasureSpec,然后再进行测量

对于顶级View(DecorView),其MeasureSpec由窗口的尺寸和其自身的LayoutParams来共同确定
对于普通View,其MeasureSpec由父容器的MeasureSpec和自身的 LayoutParams 来共同确定
MeasureSpec一旦确定后,onMeasure中就可以确定View的测量宽高

对于普通的View(布局中的View),它的measure过程由ViewGroup传递而来,先看ViewGroup的measureChildWithMargins方法:

    protected void measureChildWithMargins(View child,
            int parentWidthMeasureSpec, int widthUsed,
            int parentHeightMeasureSpec, int heightUsed) {
			//自身的LayoutParams
        final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams();

		//父容器的MeasureSpec+设置的padding和margin
        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);
		//以上两者结合来确定子容器的MeasureSpec
        child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
    }

上述方法会对子元素进行measure,measure之前会先通过getChildMeasureSpec方法来得到子元素的MeasureSpec,此外我们可以看出还和View的margin和padding有关

具体可以看ViewGroup的getChildMeasureSpec,偷懒可以看下面的表格

    public static int getChildMeasureSpec(int spec, int padding, int childDimension) {
        int specMode = MeasureSpec.getMode(spec);
        int specSize = MeasureSpec.getSize(spec);

        int size = Math.max(0, specSize - padding);

        int resultSize = 0;
        int resultMode = 0;

        switch (specMode) {
        // Parent has imposed an exact size on us
        case MeasureSpec.EXACTLY:
            if (childDimension >= 0) {
                resultSize = childDimension;
                resultMode = MeasureSpec.EXACTLY;
            } else if (childDimension == LayoutParams.MATCH_PARENT) {
                // Child wants to be our size. So be it.
                resultSize = size;
                resultMode = MeasureSpec.EXACTLY;
            } else if (childDimension == LayoutParams.WRAP_CONTENT) {
                // Child wants to determine its own size. It can't be
                // bigger than us.
                resultSize = size;
                resultMode = MeasureSpec.AT_MOST;
            }
            break;

        // Parent has imposed a maximum size on us
        case MeasureSpec.AT_MOST:
            if (childDimension >= 0) {
                // Child wants a specific size... so be it
                resultSize = childDimension;
                resultMode = MeasureSpec.EXACTLY;
            } else if (childDimension == LayoutParams.MATCH_PARENT) {
                // Child wants to be our size, but our size is not fixed.
                // Constrain child to not be bigger than us.
                resultSize = size;
                resultMode = MeasureSpec.AT_MOST;
            } else if (childDimension == LayoutParams.WRAP_CONTENT) {
                // Child wants to determine its own size. It can't be
                // bigger than us.
                resultSize = size;
                resultMode = MeasureSpec.AT_MOST;
            }
            break;

        // Parent asked to see how big we want to be
        case MeasureSpec.UNSPECIFIED:
            if (childDimension >= 0) {
                // Child wants a specific size... let him have it
                resultSize = childDimension;
                resultMode = MeasureSpec.EXACTLY;
            } else if (childDimension == LayoutParams.MATCH_PARENT) {
                // Child wants to be our size... find out how big it should
                // be
                resultSize = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size;
                resultMode = MeasureSpec.UNSPECIFIED;
            } else if (childDimension == LayoutParams.WRAP_CONTENT) {
                // Child wants to determine its own size.... find out how
                // big it should be
                resultSize = View.sUseZeroUnspecifiedMeasureSpec ? 0 : size;
                resultMode = MeasureSpec.UNSPECIFIED;
            }
            break;
        }
        //noinspection ResourceType
        return MeasureSpec.makeMeasureSpec(resultSize, resultMode);
    }

再通过一个表对getChildMeasureSpec的工作原理进行梳理

这个表在书的182页最下面

由表可以看出,只要提供父容器的MeasureSpec和子元素的LayoutParams就可以快速确定出子元素的MeasureSpec了

4.3 View的工作流程

工作流程主要是指之前说的三大流程measure、layout、draw

4.3.1 measure过程

如果只是一个原始的View,直接通过measure方法就完成了其测量过程
如果是一个ViewGroup,除了完成自己的测量过程,还会遍历去调用子元素的measure方法,再递归继续执行

1.View的measure过程

源代码路径:View-measure-onMeasure-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和自身的size返回一个测量后的大小

View的最终大小是在layout阶段确定的
几乎所有情况下View的测量大小和最终大小是相等的

关于getDefaultSize中的UNSPECIFIED和onMeasure中的 getSuggestMinimumWidth()
简单来说,在 getSuggestMinimumWidth 中
如果View没有设置背景,那么就会返回android:minWidth 这个属性所指定的值,可以为0
如果设置了背景,就返回android:minWidth和背景的最小宽度中的最大值
getSuggestMinimumWidth 和 getSuggestMinimumHeight的返回值就是View在UNSPECIFIED情况下测量的宽高

从getDefaultSize方法的实现来看,View 的宽高由specSize决定,所以:

直接继承View的自定义控件需要重写onMeasure方法并设置wrap_content时的自身大小,否则在布局中使用wrap_content就相当于 match_parent

结合上述源代码代码和182页的表,子容器为wrap_content,父容器为AT_MOST最终得到的就是parentSize效果就是match_parent

解决方法是在wrap_content时设置一个宽高就可以,例子可看书186

2.ViewGroup的 measure 过程

ViewGroup 既要完成自己的measure又要遍历调用子元素的measure方法
ViewGroup 是一个抽象类,没有重写 View 的 onMeasure 方法,而是用 measureChildren 的方法

· 源代码地址:ViewGroup-measureChildren
这个方法遍历调用了子元素的 measureChild 方法

· 源代码地址:ViewGroup-measureChild
这个方法取出子元素的 LayoutParams 然后通过getChildMeasureSpec 来创建子元素的 MeasureSpec,接着将 MeasureSpec 直接传递给 View 的 measure 方法进行测量

ViewGroup是一个抽象类,其测量过程的onMeasure方法是根据子类来具体实现的,比如LinearLayout、RelativeLayout.

例子:
通过查看LinearLayout(垂直方向)的onMeasure源码,系统会遍历子元素并对每个子元素执行measureChildBeforeLayout方法,通过这个方法会调用子元素的measure方法
系统通过mTotalLength来存储LinearLayout在竖直方向上的初步高度,测量每个子元素都会累加(子元素高度+竖直的margin),最后测量LinearLayout自己的大小

View的measure完成后,通过getMeasuredWidth方法就可以获得View测量的宽
一个比较好的习惯是在onLayout方法中去获取测量值,比较准确

应用
假如我们打算在Activity启动的时候就做一件需要获取View的宽高的任务,并不能保证在onCreate、onStart、onResume中获取测量值
下面给出4中解决方法

  1. 使用onWindowFocusChanged(宽高已经改变好了),注意当Activity得到和失去焦点都会被调用一次
  2. view.post(runnable),使用post把一个runnable投递到消息队列的尾部,轮到的时候View已经初始化好了
    @Override
    protected void onStart() {
        super.onStart();
        view.post(new Runnable() {

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

3.ViewTreeObserve中的OnGlobalLayoutListener接口,当View树状态发生改变或者内部的View可见性发生改变时,onGlobalLayout方法会被回调,这个时候来获取View的宽高

    @Override
    protected void onStart() {
        super.onStart();

        ViewTreeObserver observer = view.getViewTreeObserver();
        observer.addOnGlobalLayoutListener(new OnGlobalLayoutListener() {

            @SuppressWarnings("deprecation")
            @Override
            public void onGlobalLayout() {
                view.getViewTreeObserver().removeGlobalOnLayoutListener(this);
                int width = view.getMeasuredWidth();
                int height = view.getMeasuredHeight();
            }
        });
    }

4.view.measure,这个方法比较复杂,不建议使用

4.3.2 layout过程

Layout的作用是确定元素的位置,当然ViewGroup的位置被确定后,它的onLayout会遍历所有子元素调用其layout方法,如此反复确定下所有子元素的位置

  • 源码地址:View-layout

layout大致流程就是:确定四个顶点位置(确定好父容器位置),然后传入给onLayout方法(继续确定子容器位置)

  • 源码地址:LinearLayout-onLayout-layoutVertical

使用for循环遍历子元素,其中一个childTop变量会慢慢增大(垂直效果)传入子元素应在位置的四个顶点给子元素的layout方法

注意:View的测量宽高和最终宽高有什么区别?
测量宽高形成于measure、最终宽高形成于layout,所以没有意外是完全相同的,除非重写layout的方法

4.3.3 draw过程

作用是将View按顺序绘制到屏幕上

  1. 绘制背景background.draw(canvas)
  2. 绘制自己、onDraw
  3. 绘制子元素、dispatchDraw
  4. 绘制装饰、onDrawScrollBars
  5. 源码地址:View-draw

View的一个特殊的方法setWillNotDraw
这是通过设置标记位来判断是否要进行绘制优化
默认情况下,View是关闭的,ViewGroup是启用的
当我们自定义控件继承于ViewGroup并且不具备绘制功能时,就开启这个功能进行优化.当确定要使用onDraw来绘制的时候,就要显式地关闭

4.4 自定义View

4.4.1 自定义View的分类

1.继承View重写onDraw方法

主要用于实现一些不规则的效果(不能通过布局组合的方式来实现),这种方法要支持wrap_content、和自己处理padding

2.继承ViewGroup派生特殊的Layout

用于实现自定义布局,除LinearLayout那种之外的.需要合适地处理ViewGroup的侧脸布局两个过程,并处理子元素的测量和布局过程

3.继承特定的View(例如TextView)

比较常用,扩展一些已有的View的功能,不需要自己支持warp_content和padding

4.继承特定的ViewGroup(例如LinearLayout)

比较常用,当某种效果看起来是几种View组合在一起的时候可以使用,不需要自己处理ViewGroup的测量和布局过程,比方法2更简便

4.4.2 自定义View须知

保证View的正常使用和防止内存泄漏
1. 让View支持warp_content
如果不在onMeasure中对wrap_content做特殊处理,那外界在布局中使用wrap_content时就无法达到预期的效果
2. 如果有必要,让自定义View支持padding
继承于View的需要在draw方法中处理padding
继承自ViewGroup的需要在onMeasure和onLayout中考虑padding和子元素的margin对其造成的影响
3. 尽量不要在View中使用Handler,没必要
View内部提供了post系列的方法,可以代替Handler.除非要使用Handler来发送消息(msg)
4. View中如果有线程或动画,需要及时停止
一般是在onDetachedFromWindow(在包含View的Activity退出或者remove这个View的时候回调用此方法)停止它们,与此对应的方法是onAttachedToWindow.所以当View变得不可见的时候就需要停止线程和动画防止内存泄漏
5. View带有滑动嵌套的时候,处理滑动冲突
看之前的

4.4.3 自定义View示例

1. 继承View重写onDraw方法

自定义一个圆,必须要考虑到wrap_content和padding

public class CircleView extends View {

    private int mColor = Color.RED;
    private Paint mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);

    public CircleView(Context context) {
        super(context);
        init();
    }

    public CircleView(Context context, AttributeSet attrs) {
        this(context, attrs, 0);
    }

    public CircleView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CircleView);
        mColor = a.getColor(R.styleable.CircleView_circle_color, Color.RED);
        a.recycle();
        init();
    }

    private void init() {
        mPaint.setColor(mColor);
    }

	//这里是针对wrap_content问题的解决方法,指定一个默认的宽高就可以
    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
		/*
		先获取测量格式和测量值
		*/
        int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
        int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
        int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
        int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
		//判断是不是wrap_content
        if (widthSpecMode == MeasureSpec.AT_MOST
                && heightSpecMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(200, 200);
        } else if (widthSpecMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(200, heightSpecSize);
        } else if (heightSpecMode == MeasureSpec.AT_MOST) {
            setMeasuredDimension(widthSpecSize, 200);
        }
    }

    @Override
    protected void onDraw(Canvas canvas) {
        super.onDraw(canvas);
		//这是针对padding无效问题的解决方式
        final int paddingLeft = getPaddingLeft();
        final int paddingRight = getPaddingRight();
        final int paddingTop = getPaddingTop();
        final int paddingBottom = getPaddingBottom();
		//注意宽受padding的影响
        int width = getWidth() - paddingLeft - paddingRight;
        int height = getHeight() - paddingTop - paddingBottom;
		//实现一个在自己中心点以宽高最小值为直径画一个红色实心圆
        int radius = Math.min(width, height) / 2;
        canvas.drawCircle(paddingLeft + width / 2, paddingTop + height / 2,
                radius, mPaint);
    }
}

自定义View的使用方法

    <com.ryg.chapter_4.ui.CircleView
        android:id="@+id/circleView1"
        android:layout_width="wrap_content"
        android:layout_height="100dp"
        android:layout_margin="20dp"
        android:background="#000000"
        android:padding="20dp"
        app:circle_color="@color/light_green" />

如何添加自定义属性?
1.在values目录下创建自定义属性的XML,比如attrs.xml

<?xml version="1.0" encoding="utf-8"?>
<resources>
//自定义属性集合:CircleView
    <declare-styleable name="CircleView">
	//属性:格式为color,名字为circle_color
        <attr name="circle_color" format="color" />
    </declare-styleable>

</resources>

其他自定义属性还有,reference是指资源id,dimension是指尺寸以及其他···

  1. 在View的构造方法中解析自定义属性并处理
    public CircleView(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
		//加载自定义属性集合CircleView
        TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.CircleView);
		//默认的是RED
        mColor = a.getColor(R.styleable.CircleView_circle_color, Color.RED);
		//加载完后释放资源
        a.recycle();
        init();
    }

3.在布局文件中使用

     <com.ryg.chapter_4.ui.CircleView
        android:id="@+id/circleView1"
        android:layout_width="wrap_content"
        android:layout_height="100dp"
        android:layout_margin="20dp"
        android:background="#000000"
        android:padding="20dp"
        app:circle_color="@color/light_green" />

注意,使用自定义属性必须在布局文件中添加声明:
xmlns:app=“http://schemas.android.com/apk/res-auto
其中"app"是自定义名字,可随意.但是CircleView中的自定义属性的前缀必须和这里的一致1

2. 继承ViewGroup派生特殊的Layout

这里使用的例子是上一章处理滑动冲突的自定义View:HorizontalScrollViewEx,一个水平方向的ViewPager,他里面的子元素水平滑动的同时,子元素内部还可以竖直滑动

先来看看onMeasure

    @Override
    protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        int measuredWidth = 0;
        int measuredHeight = 0;
        final int childCount = getChildCount();
        measureChildren(widthMeasureSpec, heightMeasureSpec);

        int widthSpaceSize = MeasureSpec.getSize(widthMeasureSpec);
        int widthSpecMode = MeasureSpec.getMode(widthMeasureSpec);
        int heightSpaceSize = MeasureSpec.getSize(heightMeasureSpec);
        int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
		//先判断有没有子元素,如果没有直接将自己的宽高设0
        if (childCount == 0) {
            setMeasuredDimension(0, 0);
			/*
			接下来这部分是看宽和高是否用了wrap_content
			*/
        } else if (widthSpecMode == MeasureSpec.AT_MOST && heightSpecMode == MeasureSpec.AT_MOST) {
            final View childView = getChildAt(0);
            measuredWidth = childView.getMeasuredWidth() * childCount;
            measuredHeight = childView.getMeasuredHeight();
            setMeasuredDimension(measuredWidth, measuredHeight);
        } else if (heightSpecMode == MeasureSpec.AT_MOST) {
            final View childView = getChildAt(0);
            measuredHeight = childView.getMeasuredHeight();
            setMeasuredDimension(widthSpaceSize, childView.getMeasuredHeight());
        } else if (widthSpecMode == MeasureSpec.AT_MOST) {
            final View childView = getChildAt(0);
            measuredWidth = childView.getMeasuredWidth() * childCount;
            setMeasuredDimension(measuredWidth, heightSpaceSize);
        }
    }

上面代码有不规范的地方
1.没有子元素的时候不应该直接设置宽高为0,而应根据LayoutParams中的宽高来做相应的处理
2.没有考虑到margin和padding

下面是onLayout方法

   @Override
    protected void onLayout(boolean changed, int l, int t, int r, int b) {
        int childLeft = 0;
        final int childCount = getChildCount();
        mChildrenSize = childCount;

        for (int i = 0; i < childCount; i++) {
            final View childView = getChildAt(i);
            if (childView.getVisibility() != View.GONE) {
                final int childWidth = childView.getMeasuredWidth();
                mChildWidth = childWidth;
				//从左上角开始放
                childView.layout(childLeft, 0, childLeft + childWidth,
                        childView.getMeasuredHeight());
                childLeft += childWidth;
            }
        }
    }

上面的问题还是没有考虑padding和margin

4.4.4 自定义View的思想

  • 自定义View的基本功:弹性滑动、滑动冲突、绘制原理等
  • 根据分类选择实现思路

  1. ↩︎

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值