android高级UI之UI绘制流程(测量布局)
在上一次https://www.cnblogs.com/webor2006/p/11839103.html中对于UI的整体绘制流程有了一个全面的了解,对于我们自定义ViewGroup时都知道会涉及到onMeasure()和onLayout()这两个很重要的流程,所以这一次则集中对这两块的细节给吃透了,还是参考大神的这两篇博客来学习:
https://www.jianshu.com/p/4e3e25092015
https://www.jianshu.com/p/82f23acbc1c2
onMeasure测量流程:
首先还是从上一次的总体绘制流程中来一点点分析测量流程,先来回顾一下整体绘制进入View的流程图:
所以咱们从ViewRootImpl.performTraversals()方法里面找到开始测量的位置:
而它里面则会调用到View.measure()方法:
而mView其实就是DecorView,在进行进一步分析之前,先来对传进来的两个参数进行了解:childWidthMeasureSpec、childHeightMeasureSpec:
关于这块的东东我在之前https://www.cnblogs.com/webor2006/p/7525979.html的UI学习中也已经详细剖析过,其实这个整型里面包含了布局的规格信息了,一个整型是有32位,而用前二位表示一个mode,而剩下的30位表示具体的值,咱们点击看一下getRootMeasureSpec():
其中MeasureSpec的作用是在Measure流程中,系统将View的LayoutParams根据父容器所施加的规则转换成对应的MeasureSpec(规格),然后在onMeasure中根据这个MeasureSpec来确定view的测量宽高。关于这个细节可以看一下博主对于MeasureSpec核心代码的说明:
public static class MeasureSpec { private static final int MODE_SHIFT = 30; private static final int MODE_MASK = 0x3 << MODE_SHIFT; /** * UNSPECIFIED 模式: * 父View不对子View有任何限制,子View需要多大就多大 */ public static final int UNSPECIFIED = 0 << MODE_SHIFT; /** * EXACTYLY 模式: * 父View已经测量出子View所需要的精确大小,这时候View的最终大小 * 就是SpecSize所指定的值。对应于match_parent和精确数值这两种模式 */ public static final int EXACTLY = 1 << MODE_SHIFT; /** * AT_MOST 模式: * 子View的最终大小是父View指定的SpecSize值,并且子View的大小不能大于这个值, * 即对应wrap_content这种模式 */ public static final int AT_MOST = 2 << MODE_SHIFT; //将size和mode打包成一个32位的int型数值 //高2位表示SpecMode,测量模式,低30位表示SpecSize,某种测量模式下的规格大小 public static int makeMeasureSpec(int size, int mode) { if (sUseBrokenMakeMeasureSpec) { return size + mode; } else { return (size & ~MODE_MASK) | (mode & MODE_MASK); } } //将32位的MeasureSpec解包,返回SpecMode,测量模式 public static int getMode(int measureSpec) { return (measureSpec & MODE_MASK); } //将32位的MeasureSpec解包,返回SpecSize,某种测量模式下的规格大小 public static int getSize(int measureSpec) { return (measureSpec & ~MODE_MASK); } //... } 测量模式 EXACTLY :父容器已经测量出所需要的精确大小,这也是childview的最终大小 ------match_parent,精确值是爸爸的 ATMOST : child view最终的大小不能超过父容器的给的 ------wrap_content 精确值不超过爸爸 UNSPECIFIED: 不确定,源码内部使用 -------一般在ScrollView,ListView
好,对于getMode()和getSize()实现的细节其实也比较好理解,用博主的图来说明一下:
getMode解析用了measureSpec & MODE_MASK(解析只要前2位):
getSize解析用了measureSpec & ~MODE_MASK(不要前两位):
好,对于MeasureSpec的细节已经清楚了,接下来回到主流程继续:
而回到咱们的DecorView,我们都知道它是一个FrameLayout,所以此时就转到它里面的onMeasure大概来瞅一下,这里贴一下博主所分析过的:
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { //获取当前布局内的子View数量 int count = getChildCount(); //判断当前布局的宽高是否是match_parent模式或者指定一个精确的大小,如果是则置measureMatchParent为false. final boolean measureMatchParentChildren = MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.EXACTLY || MeasureSpec.getMode(heightMeasureSpec) != MeasureSpec.EXACTLY; mMatchParentChildren.clear(); int maxHeight = 0; int maxWidth = 0; int childState = 0; //遍历所有类型不为GONE的子View for (int i = 0; i < count; i++) { final View child = getChildAt(i); if (mMeasureAllChildren || child.getVisibility() != GONE) { //对每一个子View进行测量 measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0); final LayoutParams lp = (LayoutParams) child.getLayoutParams(); //寻找子View中宽高的最大者,因为如果FrameLayout是wrap_content属性 //那么它的大小取决于子View中的最大者 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()); //如果FrameLayout是wrap_content模式,那么往mMatchParentChildren中添加 //宽或者高为match_parent的子View,因为该子View的最终测量大小会受到FrameLayout的最终测量大小影响 if (measureMatchParentChildren) { if (lp.width == LayoutParams.MATCH_PARENT || lp.height == LayoutParams.MATCH_PARENT) { mMatchParentChildren.add(child); } } } } // Account for padding too 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()); } //保存测量结果 setMeasuredDimension(resolveSizeAndState(maxWidth, widthMeasureSpec, childState), resolveSizeAndState(maxHeight, heightMeasureSpec, childState << MEASURED_HEIGHT_STATE_SHIFT)); //子View中设置为match_parent的个数 count = mMatchParentChildren.size(); //只有FrameLayout的模式为wrap_content的时候才会执行下列语句 if (count > 1) { for (int i = 0; i < count; i++) { final View child = mMatchParentChildren.get(i); final MarginLayoutParams lp = (MarginLayoutParams) child.getLayoutParams(); //对FrameLayout的宽度规格设置,因为这会影响子View的测量 final int childWidthMeasureSpec; /** * 如果子View的宽度是match_parent属性,那么对当前FrameLayout的MeasureSpec修改: * 把widthMeasureSpec的宽度规格修改为:总宽度 - padding - margin,这样做的意思是: * 对于子Viw来说,如果要match_parent,那么它可以覆盖的范围是FrameLayout的测量宽度 * 减去padding和margin后剩下的空间。 * * 以下两点的结论,可以查看getChildMeasureSpec()方法: * * 如果子View的宽度是一个确定的值,比如50dp,那么FrameLayout的widthMeasureSpec的宽度规格修改为: * SpecSize为子View的宽度,即50dp,SpecMode为EXACTLY模式 * * 如果子View的宽度是wrap_content属性,那么FrameLayout的widthMeasureSpec的宽度规格修改为: * SpecSize为子View的宽度减去padding减去margin,SpecMode为AT_MOST模式 */ if (lp.width == LayoutParams.MATCH_PARENT) { final int width = Math.max(0, getMeasuredWidth() - getPaddingLeftWithForeground() - getPaddingRightWithForeground() - lp.leftMargin - lp.rightMargin); childWidthMeasureSpec = MeasureSpec.makeMeasureSpec( width, MeasureSpec.EXACTLY); } else { childWidthMeasureSpec = getChildMeasureSpec(widthMeasureSpec, getPaddingLeftWithForeground() + getPaddingRightWithForeground() + lp.leftMargin + lp.rightMargin, lp.width); } //同理对高度进行相同的处理,这里省略... //对于这部分的子View需要重新进行measure过程 child.measure(childWidthMeasureSpec, childHeightMeasureSpec); } }
具体细节就不细细揣摩了,但是要明白它其实就是遍历了子View然后进行子View的测量,而不像View.onMeasure默认的实现只测量了自身,最后引用博主对于这块测量的一个总结:
onLayout布局流程:
好,接下来则到了布局测量阶段了,还是找入口处:
其中跟进去看主流程:
而此时会调用View.layout()方法,跟进去:
而对于setFrame在进行初始化的时候会对比上一次是否一致,若一致则不会在此进行,若是一致,则会使我们旧的信息直接失效invalidate(sizeChanged);
而这个onLayout()则是一个空实现:
所以对于DecorView来说就得看它具体onLayout是如何摆放元素的了:
然后它里面具体的实现则是遍历它所有的子View,一个个进行子View的摆放:
void layoutChildren(int left, int top, int right, int bottom, boolean forceLeftGravity) { final int count = getChildCount(); 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); if (child.getVisibility() != GONE) { final LayoutParams lp = (LayoutParams) child.getLayoutParams(); 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(); 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: childLeft = parentLeft + (parentRight - parentLeft - width) / 2 + lp.leftMargin - lp.rightMargin; break; case Gravity.RIGHT: 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.layout(childLeft, childTop, childLeft + width, childTop + height); } } }
至此,整个跟咱们自定义相关的绘制流程的细节就分析完了,接下来则咱们以一个自定义的小案例来进行实践一下。
实践:
onMeasure处理:
这个案例的最终样子长这样:
也就是大家很熟悉的瀑布流式布局控件的效果,这个在实际显示各种标签时用得比较普遍,当然这个DEMO只是为了巩固咱们的绘制基础,不可能做成商业那么完善,重点是体会在自定控件时对于onMeasure和onLayout对于自定义控件的意义。下面则开始一点点来实现这个效果:
首先新建一个自定义View,然后声明在xml中,里面整一些卡片子元素,比较简单,直接将代码贴出来了:
<?xml version="1.0" encoding="utf-8"?> <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android" android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical"> <com.android.flowlayout.MyFlowLayout android:layout_width="match_parent" android:layout_height="wrap_content"> <TextView style="@style/text_flag_01" android:text="hello" /> <TextView style="@style/text_flag_01" android:text="hello,hi" /> <TextView style="@style/text_flag_01" android:text="你是我的" android:textSize="18sp" /> <TextView style="@style/text_flag_01" android:text="hello,man" /> <TextView style="@style/text_flag_01" android:text="helloview" /> <TextView style="@style/text_flag_01" android:text="view" /> <TextView style="@style/text_flag_01" android:text="我是你的" android:textSize="20sp" /> <TextView style="@style/text_flag_01" android:text="he" /> <TextView style="@style/text_flag_01" android:text="hello" /> <TextView style="@style/text_flag_01" android:text="textview" /> <TextView style="@style/text_flag_01" android:text="view" /> <TextView style="@style/text_flag_01" android:text="hello,view" /> <TextView style="@style/text_flag_01" android:text="hello,mt" /> <TextView style="@style/text_flag_01" android:text="hel" /> <TextView style="@style/text_flag_01" android:text="hel" /> <TextView style="@style/text_flag_01" android:text="hel2" /> <TextView style="@style/text_flag_01" android:text="hel" /> <TextView style="@style/text_flag_01" android:text="hel2" /> <TextView style="@style/text_flag_01" android:text="hel" /> <TextView style="@style/text_flag_01" android:text="hel2" /> <TextView style="@style/text_flag_01" android:text="hel" /> <TextView style="@style/text_flag_01" android:text="hel2" /> <TextView style="@style/text_flag_01" android:text="hel" /> <TextView style="@style/text_flag_01" android:text="hel2" /> <TextView style="@style/text_flag_01" android:text="kwkkskksks" /> <TextView style="@style/text_flag_01" android:text="大ddddddddddddddddd" /> </com.android.flowlayout.MyFlowLayout> </LinearLayout>
其中用到一个样式和一个背景资源:
<style name="text_flag_01"> <item name="android:layout_width">wrap_content</item> <item name="android:layout_height">wrap_content</item> <item name="android:layout_margin">4dp</item> <item name="android:background">@drawable/flag_01</item> <item name="android:textColor">#ffffff</item> </style>
flag_01.xml:
<?xml version="1.0" encoding="utf-8"?> <shape xmlns:android="http://schemas.android.com/apk/res/android" > <solid android:color="#7690A5" > </solid> <corners android:radius="5dp"/> <padding android:bottom="2dp" android:left="10dp" android:right="10dp" android:top="2dp" /> </shape>
好,此时运行肯定一片空白,这个在之前关于这块的UI学习中也演示过了,因为需要咱们自己来测量及摆放布局:
好,接下来开始聚集MyFlowLayout的实现,对于绘制流程来说,首先会调用onMeasure()方法,默认它只会测量自身,不会测量它里面的子View的,所以接下来需要重写一个onMeasure方法,来具体实现咱们的测量逻辑:
那接下来怎么来决定当前控件的宽高呢?很明显需要根据Mode来了,因为我们在布局中对于控件的尺寸可以声明为:
所以:
接下来则开启判断:
好,针对else的情况接下来则要开启子元素的遍历了:
所以先定义累加宽高的变量:
好,下面则开启具体的遍历计算,当然在遍历时要对具体的每个子View也需要进行一次测量的:
那这样做完之后是不是就可以得到每个子View的宽高信息了?
其实这里需要考虑一个细节,就是每个子View都可以设置margin信息的,所以对于子View的宽高是需要额外加上这个margin才行的,所以下面咱们来获取每个子View的margin,如何获取呢?
但是它只有宽高信息:
那怎么办?此时需要MarginLayoutParams进行转一下:
它是LayoutParams的子类,能不能直接这样写呢?
咱们运行一下就知道行不行了:
抛异常了。。那要怎么整呢?其实这里需要重写一个方法,貌似平常没怎么用了:
好,接下来咱们就可以正常的获取子View的margin信息了,所以咱们就可以来累加了:
但是此时还是有一个问题,当遇到换行的话其整个控件的高度和宽度肯定也会发生变化,所以接下来则需要将换行的逻辑给处理了才行:
那核心就是如何来换行了,下面来做一下:
貌似这个totalCalWidth和totalCalHeight这俩变量的名称取得不够见名之义,应该代表是当前的行信息,所以改一下名称:
貌似整个View的宽高也计算完了,接下来则可以走onLayout布局流程了,但是!!!目前还缺布局的元素,哪些元素该摆放在哪,是摆到第一行,还是第二行?这些信息都没有,很明显这些应该在测量时就应该产出的,所以接下来咱们再来定义两个产生的变量,之后在onLayout时只需要根据产生的这俩变量进行布局既可,如下:
我们在每次换行处理时进行这俩变量的数据更新,如下:
好,一切就绪,接下来则直接进行布局摆放既可。
onLayout处理:
接下来运行试一下,发现界面一片空白,啥也木有显示。。debug发现原来是由于onMeasure执行了两遍,咱们打个日志确认一下:
确实是执行了2遍。。很明显布局需要的那俩变量数据就会有重复了,所以解决办法就是在每一次onMeasure()触发时清除一下,如下:
再运行一下:
那为啥onMeasure会调用两次?这里还是从绘制流程来找原因:
在这个方法里面有一个真正开始绘的代码,其中可以找到答案:
貌似目前的效果完美了,其实这里面显示不全,最后一行没有显示出来:
最后一行的元素是它,很明显它木有出现在目前运行的效果图中,那是为啥呢?很明显是程序有bug,这里直接来修复一下:
好,最后再来运行一下:
ok,完美了!!其实还是不完美的,咱们来修改一下这个view的宽高信息:
此时再运行又出现一片空白了。。咱们最后来解决一下它,其实还是代码写得有bug的原因,咱们简单分析一下:
很明显,此时会进入这个条件,但是很明显我们只确定了当前控件的宽高,但是对于子元素完全没有主动测量嘛,所以。。咱们得把else中的代码挪过来才行,修改如下:
最后整个View的代码如下:
package com.android.flowlayout; import android.content.Context; import android.util.AttributeSet; import android.view.View; import android.view.ViewGroup; import java.util.ArrayList; import java.util.List; public class MyFlowLayout extends ViewGroup { /* 代表每一行的高度 */ List<Integer> lstLineHegiht = new ArrayList<>(); /* 每一行里面显示具体的子控件 */ List<List<View>> lstLineView = new ArrayList<>(); public MyFlowLayout(Context context) { this(context, null); } public MyFlowLayout(Context context, AttributeSet attrs) { this(context, attrs, 0); } public MyFlowLayout(Context context, AttributeSet attrs, int defStyleAttr) { super(context, attrs, defStyleAttr); } @Override protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) { super.onMeasure(widthMeasureSpec, heightMeasureSpec); lstLineView.clear(); lstLineHegiht.clear(); //首先需要测量当前控件自身的宽高,根据MODE来 int measureWidth = 0; int measureHeight = 0; //算出宽高的mode+size int widthMode = MeasureSpec.getMode(widthMeasureSpec); int widthSize = MeasureSpec.getSize(widthMeasureSpec); int heightMode = MeasureSpec.getMode(heightMeasureSpec); int heightSize = MeasureSpec.getSize(heightMeasureSpec); //以下两个变量是根据子控件的不断测量之后进行累加的 int currentLineTotalCalWidth = 0; int currentLineTotalCalHeight = 0; if (widthMode == MeasureSpec.EXACTLY && heightMode == MeasureSpec.EXACTLY) { //说明当前控件设置了精确的值了,则算出来的size则就是整个当前控件的大小了 measureWidth = widthSize; measureHeight = heightSize; //此时则还需要测量子View的宽高 int childCount = getChildCount(); List<View> viewList = new ArrayList<>(); for (int i = 0; i < childCount; i++) { View child = getChildAt(i); //先让子控件测量自己 measureChild(child, widthMeasureSpec, heightMeasureSpec);//第二、三个参数是传的父的measureSpec //获取当前子View的margin信息 MarginLayoutParams layoutParams = (MarginLayoutParams) child.getLayoutParams(); //获取当前子View的实际宽高 final int childWidth = child.getMeasuredWidth() + layoutParams.rightMargin + layoutParams.leftMargin; final int childHegiht = child.getMeasuredHeight() + layoutParams.topMargin + layoutParams.bottomMargin; //处理换行 if (childWidth + currentLineTotalCalWidth > widthSize) { //1、首先将当前的行高信息累加到当前控件的宽高信息上 lstLineHegiht.add(currentLineTotalCalHeight); lstLineView.add(viewList); //2、更新总行的宽高信息,换行了嘛,所以当前行得从头开始算 currentLineTotalCalWidth = childWidth; currentLineTotalCalHeight = childHegiht; viewList = new ArrayList<>(); viewList.add(child); } else { //在当前行可以放得下当前控件,则直接累加既可 currentLineTotalCalWidth += childWidth; currentLineTotalCalHeight = Math.max(currentLineTotalCalHeight, childHegiht); viewList.add(child); } //6.如果正好是最后一行需要换行 if (i == childCount - 1) { //记录当前行的最大宽度,高度累加 //将当前行的viewList添加至总的mViewsList,将行高添加至总的行高List lstLineView.add(viewList); lstLineHegiht.add(currentLineTotalCalHeight); } } } else { int childCount = getChildCount(); List<View> viewList = new ArrayList<>(); for (int i = 0; i < childCount; i++) { View child = getChildAt(i); //先让子控件测量自己 measureChild(child, widthMeasureSpec, heightMeasureSpec);//第二、三个参数是传的父的measureSpec //获取当前子View的margin信息 MarginLayoutParams layoutParams = (MarginLayoutParams) child.getLayoutParams(); //获取当前子View的实际宽高 final int childWidth = child.getMeasuredWidth() + layoutParams.rightMargin + layoutParams.leftMargin; final int childHegiht = child.getMeasuredHeight() + layoutParams.topMargin + layoutParams.bottomMargin; //处理换行 if (childWidth + currentLineTotalCalWidth > widthSize) { //1、首先将当前的行高信息累加到当前控件的宽高信息上 measureWidth = Math.max(measureWidth, currentLineTotalCalWidth); measureHeight += currentLineTotalCalHeight; lstLineHegiht.add(currentLineTotalCalHeight); lstLineView.add(viewList); //2、更新总行的宽高信息,换行了嘛,所以当前行得从头开始算 currentLineTotalCalWidth = childWidth; currentLineTotalCalHeight = childHegiht; viewList = new ArrayList<>(); viewList.add(child); } else { //在当前行可以放得下当前控件,则直接累加既可 currentLineTotalCalWidth += childWidth; currentLineTotalCalHeight = Math.max(currentLineTotalCalHeight, childHegiht); viewList.add(child); } //6.如果正好是最后一行需要换行 if (i == childCount - 1) { //记录当前行的最大宽度,高度累加 measureWidth = Math.max(measureWidth, currentLineTotalCalWidth); measureHeight += currentLineTotalCalHeight; //将当前行的viewList添加至总的mViewsList,将行高添加至总的行高List lstLineView.add(viewList); lstLineHegiht.add(currentLineTotalCalHeight); } } } setMeasuredDimension(measureWidth, measureHeight); } @Override protected void onLayout(boolean changed, int l, int t, int r, int b) { int curLeft = 0; int curTop = 0; int left, top, right, bottom; for (int i = 0; i < lstLineView.size(); i++) { List<View> lineviews = lstLineView.get(i); for (int j = 0; j < lineviews.size(); j++) {//显示一行中的元素 View view = lineviews.get(j); //也得考虑每一个子元素的margin MarginLayoutParams layoutParams = (MarginLayoutParams) view.getLayoutParams(); left = curLeft + layoutParams.leftMargin; top = curTop + layoutParams.topMargin; right = left + view.getMeasuredWidth(); bottom = top + view.getMeasuredHeight(); view.layout(left, top, right, bottom); curLeft += view.getMeasuredWidth() + layoutParams.leftMargin + layoutParams.rightMargin; } curLeft = 0;//当一行布局完了,当前的left=0,curTop则需要往下跑,以便输出下一行元素 curTop += lstLineHegiht.get(i); } lstLineView.clear(); lstLineHegiht.clear(); } @Override public LayoutParams generateLayoutParams(AttributeSet attrs) { return new MarginLayoutParams(getContext(), attrs); } }