字节面试官:说说为什么自定义view的wrap_content会失效?

Posted 涂程

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了字节面试官:说说为什么自定义view的wrap_content会失效?相关的知识,希望对你有一定的参考价值。

前言

面试官:为什么自定义View中wrap_content会失效?

要想回答这个问题,我们需要了解view绘制的前世今生

view什么时候被绘制?

view是在Activity的哪个生命周期被绘制的?

onResume之后

初识ViewRoootImpl

我们知道onResume方法实际只是个回调方法,前面的调用是

handleResumeActivity -> performResumeActivity -> onResume

onResume结束之后,就会回到handleResumeActivity,紧接着会执行addView,将可见的view添加到window中,这里的window起到显示器的作用,

  public void handleResumeActivity(IBinder token, boolean finalStateRequest, boolean isForward, String reason) {
    		//调用onResume
        final ActivityClientRecord r = performResumeActivity(token, finalStateRequest, reason);

      ViewManager wm = a.getWindowManager();
      if (a.mVisibleFromClient) {
                if (!a.mWindowAdded) {
                    a.mWindowAdded = true;
                    wm.addView(decor, l); // 将decor添加到wm中
                }
      }
    ...
    //  wm是一个WindowManagerGlobal,持有 ViewRoootImpl
    public void addView(View view, ViewGroup.LayoutParams params,
                          Display display, Window parentWindow, int userId) {

			root.setView(view, wparams, panelParentView, userId);// ViewRoootImpl
    }

这里的root就是ViewRoootImpl,进入setView之后,就是ViewRootImpl的工作啦,ViewRootImpl就是window添加view的工具

ViewRootImpl在绘制View中起的作用

setView的代码很多,主要是调用了requestLayout()

requestLayout做了两件事情

  1. 检查当前线程是否是创建View的线程,如果不是,抛出异常

        void checkThread() {
            if (mThread != Thread.currentThread()) {
                throw new CalledFromWrongThreadException(
                        "Only the original thread that created a view hierarchy can touch its views.");
            }
        }
    
  2. 执行scheduleTraversals

    mTraversalBarrier表示往handler里面插入一个同步屏障,表示接下来要发到handler的任务为最高优先级,需要马上处理(屏幕刷新确实往往需要是最高优先级)

    mChoreographer是一个线程,将会执行绘制任务,绘制任务就在传入的mTraversalRunnable

    void scheduleTraversals() {
        if (!mTraversalScheduled) {
            mTraversalScheduled = true;
            mTraversalBarrier = mHandler.getLooper().getQueue().postSyncBarrier();
          	// 注意传入的mTraversalRunnable
            mChoreographer.postCallback(
                    Choreographer.CALLBACK_TRAVERSAL, mTraversalRunnable, null);
            notifyRendererOfFramePending();  
        }
    }
    

执行具体绘制

    final class TraversalRunnable implements Runnable {
        @Override
        public void run() {
            doTraversal();
        }
    }

    void doTraversal() {
        if (mTraversalScheduled) {
						...
            performTraversals();
						...
        }
    }
    private void performTraversals() {}

会执行到performTraversals,这个方法有很多调用可以直接到ViewGroup和View,如下(图片来自网络)

performTraversals会分别调用 performMeasure, performLayout,performDraw

而这三个方法,我想你应该能猜到,他们会启动onMesure,onLayout,onDraw方法

小结

回到我们刚刚的问题

view什么时候被绘制?是在onResume之后由viewRootImpl一手包办的,终点就是view的那三个绘制方法

所以在onResume之前的Activity,是无法获取view的宽高的,因为view的宽高是在onLayout中才最终确定

但是在onCreate中,却可以通过View.post()方法获取,这是为什么?

其实也很简单,无非就是阻塞了一下,放到队列里面,等绘制好了,通知一下,就获得宽高即可,我们来看看源代码

    public boolean post(Runnable action) {
        final AttachInfo attachInfo = mAttachInfo;
      	//这里不为null表示view已经被添加到window,早就绘制完成了
        if (attachInfo != null) {
            return attachInfo.mHandler.post(action);
        }

        //如果为null,表示view还没好,放到队列里面
        getRunQueue().post(action);
        return true;
    }

你以为他能预测未来?其实要么是吹牛,要么是他等到【未来】已经发生了之后才告诉你

接下来我们讨论view的具体绘制过程

View的绘制过程

如果你不了解View,那就说明你没有真正入门android

无论是TextView小控件,还是LineLayout这种大容器,都是View演化而来,TextView也继承自View

public class TextView extends View implements ViewTreeObserver.OnPreDrawListener {}

LineLayout这类布局控件特殊一点,来自ViewGroup,而ViewGroup继承自View

public class RelativeLayout extends ViewGroup {}
public abstract class ViewGroup extends View implements ViewParent, ViewManager {}

可以把view比作水,很多的水聚在一起是一滩水(viewGroup),但是本质上还是水(view)

除了展示之外,View必须要有完善的滑动,点击策略,这是手机上最高频的操作,接下来,我们就详细了解View的展示,滑动,事件和绘制原理。

展示方法

要想展示,知道哪个控件放在哪,就需要精确定位,这里我们使用坐标系,有两种

  • android坐标绝对定位

    最简单的是是将左上角作为坐标原点,右侧是x轴正方向,下侧是y轴正方向

    使用getRawX()和getRawY()方法获取x,y坐标,这是一种绝对定位的方法

  • view坐标相对定位

    由于android中的空间是层层嵌套的,所以一个子控件可以通过其对于父控件的相对位置来看位置,具体方法如下(图来自网络)

常用的比如获取view的宽高,

width = getRight() - getLeft();
height = getBottom() - getTop ();

当然,系统已经有getWidth和getHeight方法了,而他们内部逻辑也是这个

/**
 * Return the width of your view.
 *
 * @return The width of your view, in pixels.
 */
@ViewDebug.ExportedProperty(category = "layout")
public final int getWidth() {
    return mRight - mLeft;
}

滑动事件

在滑动方面,android和其他语言写的UI一样,都是点击的时候,记录下Down的坐标,然后记录手指滑动后的UP坐标,算出偏移量,通过偏移量来修改View的坐标,当手指在手机上滑动的时候,会触发onTouchEvent事件,如果你想自定义操作,可以重写这个方法

public boolean onTouchEvent(MotionEvent event) {
switch (action) {
         case MotionEvent.ACTION_UP:
         case MotionEvent.ACTION_DOWN:
         case MotionEvent.ACTION_CANCEL://?
         case MotionEvent.ACTION_MOVE:
}

另外三个都好理解,ACTION_CANCEL是什么情况?

举个例子,比如你一个LineLayout中滑动一个View,但是滑到了LineLayout之外的区域,此时的View肯定不能出去,此时就可以触发ACTION_CANCEL,你可以设置回到原位,或者是让View留在边缘

在ViewGroup中还有一个onInterceptTouchEvent方法,再配合上android中的各种嵌套的View,这也是令很多人困惑的地方,这里涉及到事件消费的问题。

事件处理

为什么要有这个问题?

试想一下,手机上巴掌大的地方,嵌套view肯定是到处都有的

当我点击蓝色区域的TextView的时候,实际上也在点击RelativeLayout和LinearLayout

那么,android如何知道点击的是哪个控件呢?

方法就是事件拦截,点击是一个事件,哪一层拦截这个事件并执行对应逻辑,就是一次事件消费,如果拦截到了,不执行逻辑,就会放掉,给其他控件拦截,依次递归下去

上面加粗的拦截执行对应了view中的两个方法 onIntercerptTouchEvent和onTouchEvent

显然,第一个拦截到的view是最外层的view,LinearLayout

  • onIntercerptTouchEvent

    如果你对外层的LinearLayout重写了onIntercerptTouchEvent方法,返回值为false,表示他放掉这个事件,进入内部的RelativeLayout,同理,哪个控件的onIntercerptTouchEvent方法返回为true,表示哪个控件要拦截此事件。

    注意,android为了高效,拦截到的传入事件仅仅只有down(参考上文中onTouchEvent的不同case),当确认onIntercerptTouchEvent的返回值为true后,拦截事件的move,up等会和down直接传入到当前控件的onTouchEvent开始执行

    如果返回值为false,证明当前控件放掉此事件,那么move和down一起会留在当前控件的onIntercerptTouchEvent中,一并传入下一个拦截控件。

    注意onIntercerptTouchEvent只在ViewGroup中有,原因很简单,因为只有他能嵌套View,而默认返回值是false,一般ViewGoup不轻易处理事件,而是交给子View,这也符合我们对他“容器”的直观感受。

  • onTouchEvent

    假设最后传到了TextView,他没有办法往下传了,难道他必须消费此事件吗?

    不是的,他的onTouchEvent也有返回值,return false表示不愿意消费此事件,这样,打包好的事件(down,up,move等)会一并返回RelativeLaout中

    如图

另外还有一个dispatchTouchEvent()方法,负责分发事件,在这里单独说,是方便大家拆开理解,更简单些,上面的逻辑虽然闭环了,但是还缺一个,当用户点击控件的时候,控件是怎么能够拦截到的呢?

dispatchTouchEvent内部包含一个onTouchListener,这个东西放在activity或者fragement中首先拿到事件,交给dispatchTouchEvent统一管理,然后分发给对应ViewGroup的onIntercerptTouchEvent,就可以走上面的逻辑了。由于本文更多是理解原理,所以不做具体实现。

绘制

上面的讲解都是为了本流程服务的,是分散的知识点,接下来,我们将其串起来

View的工作流程就是,测量,布局和绘制,分别通过三个方法,如果要自定义View,则需要对其进行重写

measure

View中的measure必定会测量View自己,但是如果这个View是一个ViewGroup,还会遍历里面的View,调用他们自己的measure来测量他们自己,这是一个递归的过程

 protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
   		// 注意这里的widthMeasureSpec和getDefaultSize
        setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
                getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
 }

这里的源代码只有一行,也就是获取默认宽高并测量

相信你一定用过wrap_content,就是让控件大小刚好包裹住内容,如果在xml中设置宽高为定值,就不需要measure了,正是因为我们会设置wrap_content或者match_parent,此时就会调用view的onMeasure()方法

  • match_parent

    对于match_parent,只需要知道当前View的父控件,将他的Size赋值给到当前View即可,所以我们要做两件事,1. 找到最初的ViewGroup控件测量,2. 将测量数据往下传递到最小的View

  • wrap_content

    wrap_content是刚刚好包裹住内部内容的最小值,所以刚好相反,是算出子控件的大小

widthMeasureSpec的作用?

ViewGroup如何传信息给到子View?

MeasureSpec类,这个类保存两个数据

  1. 子View的父控件具体尺寸
  2. 父控件对子View的限制类型

第一个好理解,毕竟match_parent传递就靠这个,而且子view不能超过这个大小

第二个的限制类型有三种

private static final int MODE_SHIFT = 30;
public static final int UNSPECIFIED = 0 << MODE_SHIFT; //不限制大小
public static final int EXACTLY     = 1 << MODE_SHIFT; //代表 match_parent
public static final int AT_MOST     = 2 << MODE_SHIFT; //代表 wrap_content

所以整个测量的方法就是:

父布局先measure自己,然后在自己的onMeasure,调用child.measure,然后子View会根据父布局的限制信息,再结合自己的content大小,综合测量自己的尺寸,然后通过setMeasuredDimension方法保存数据,

wrap_content失效问题

在onMesure中还有一个getDefaultSize方法,是真正的获取view的size的方法

我们来看看,面试官,这就是你要的答案!

    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; // 这里的spectSize代表match_parent
            break;
        }
        return result;
    }

可以看到,默认mode有三种,但是这里的AT_MOST与EXACTLY被当做同一种case,那么为什么是wrap_content失效呢?

注释里面写了,关键在于specSize是怎么来的?

而既然这里必定涉及到父view与子view,显然,答案在ViewGroup中,我们发现了一个getChildMeasureSpec方法,这里我保留了英文解释,比较易懂,大家看看人家为什么要这么做

    public static int getChildMeasureSpec(int spec, int padding, int childDimension) {
			switch (specMode) {
           //父view在EXACTLY 模式下,子view的MATCH_PARENT与WRAP_CONTENT的对应关系是ok的
        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;

        // 父view在AT_MOST模式下,子view的MATCH_PARENT和WRAP_CONTENT都变成了AT_MOST模式
        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;

所以,核心在于,子view不能bigger than 父view,所以默认是父view的AT_MOST,也就是剩余最大空间

所以为什么是wrap_content失效?因为需要填充满父view的剩余最大空间,刚好符合子view的match_parent属性效果

如何解决这一问题?

其实很简单,上面东西再复杂也是Default的,我们只需要在自定义View中的onMeasure自定义我们的宽高,然后通过setMeasuredDimension写回即可

layout

layout用来确认ViewGroup子元素的位置,

 public void layout(int l, int t, int r, int b) {
        if ((mPrivateFlags3 & PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT) != 0) {
            onMeasure(mOldWidthMeasureSpec, mOldHeightMeasureSpec);
            mPrivateFlags3 &= ~PFLAG3_MEASURE_NEEDED_BEFORE_LAYOUT;
        }

        int oldL = mLeft;
        int oldT = mTop;
        int oldB = mBottom;
        int oldR = mRight;

        boolean changed = isLayoutModeOptical(mParent) ?
                setOpticalFrame(l, t, r, b) : setFrame(l, t, r, b);
    		if (changed || (mPrivateFlags & PFLAG_LAYOUT_REQUIRED) == PFLAG_LAYOUT_REQUIRED) {
            onLayout(changed, l, t, r, b);
   			...
 }

可以看到,首先初始化左,顶,底,右坐标,然后setFrame进行设定,当四个顶点确定后,view在其父容器中的位置就定了,哪怕他再奇形怪状,也被关在了这四个坐标构成的矩形里面,接下来,调用onLayout()方法

protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
}

View中的onLayout()为空,表示我们需要自己重写,我们可以看看RelativeLayout中的重写

protected void onLayout(boolean changed, int l, int t, int r, int b) {
        //  The layout has actually already been performed and the positions
        //  cached.  Apply the cached values to the children.
        final int count = getChildCount();

        for (int i = 0; i < count; i++) {
            View child = getChildAt(i);
            if (child.getVisibility() != GONE) {
                RelativeLayout.LayoutParams st =
                        (RelativeLayout.LayoutParams) child.getLayoutParams();
                child.layout(st.mLeft, st.mTop, st.mRight, st.mBottom);
            }
        }
    }

这里获得了所有的子控件,并调用子控件的layout方法,因为RelativeLayout可能嵌套其他Layout,这里的getLayoutParams就是用来获得具体的位置参数的,显然,如果要修改view的位置,可以直接调用setLayoutParams

总结一下,Layout方法确定自己的坐标,然后调用onLayout并执行子控件Layout()方法以获得子控件的坐标

draw

measure是测量View的大小,layout是确定View的位置,万事俱备,只剩下将View绘制出来了,在draw源码中有6个步骤

/*
 * Draw traversal performs several drawing steps which must be executed
 * in the appropriate order:
 *
 *      1\\. Draw the background 	绘制背景
 *      2\\. If necessary, save the canvas' layers to prepare for fading	
 *      3\\. Draw view's content	绘制内容
 *      4\\. Draw children				绘制子控件
 *      5\\. If necessary, draw the fading edges and restore layers
 *      6\\. Draw decorations (scrollbars for instance)		绘制装饰
 */

其中2,5步骤是图层相关操作,但是我们正常开发一般不用,所以可以跳过,在draw()源码中,上面的步骤对应下面的源码

// Step 1, 绘制背景
drawBackground(canvas);
// Step 3, 绘制内容
onDraw(canvas);
// Step 4, 绘制子控件
dispatchDraw(canvas);
// Step 6, 绘制装饰
onDrawForeground(canvas);

当然,我们自定义画一个view也不需要全部都重写,一般onDraw()方法绘制内容即可,其他的使用draw()默认的即可,最简单的onDraw就是画一个圆形,使用Canvas绘制

 protected void onDraw(Canvas canvas) {
   super.onDraw(canvas);
   canvas.drawCircle(width, height, radius, mPaint)
 }

上面的传入的参数只需要自己,这样就可以画出一个View了,更复杂有趣的View,我们将在自定义View中具体实现,这里只需了解原理。

小结

对于View,我们需要掌握他的展示,滑动,事件处理机制和绘制机制,其中

展示需要明白相对位置和绝对位置表示

滑动与事件处理需要明白down,up,move这些事件从用户点击到最终被消费所经历的过程。

绘制需要明白view如何知道自己的宽高,位置和图像

最后

面试官:我就问个wrap_content,你怎么不从盘古开天辟地开始说起?

我:啊,一不小心讲多了,其实关于滑动事件还有一个滑动冲突没讲,

面试官:我订的会议室已经超时了,再面下去外面人要冲进来砍我了,回去等通知吧

为了能帮助到大家更好的学习,我这里为大家整理了一份 Android 系统学习的学习路线图和学习文档大家可以往下看,如需要参考完整版的学习路线图或资料可以去点击下方小卡片访问查阅。


以上是关于字节面试官:说说为什么自定义view的wrap_content会失效?的主要内容,如果未能解决你的问题,请参考以下文章

为什么你的自定义View wrap_content不起作用?

大牛疯狂教学字节跳动8年老Java面试官经验谈

字节跳动面试——算法岗

面试官:你来说说,数据库自增 ID 用完了会咋样?

Java注解是如何玩转的,字节跳动面试官和我聊了半个小时

面试官:说说TCP如何实现可靠传输