参考转自郭霖博客带你一步步深入了解View系列

  • Android LayoutInflater原理分析

相信接触Android久一点的朋友对于LayoutInflater一定不会陌生,都会知道它主要是用于加载布局的。而刚接触Android的朋友可能对LayoutInflater不怎么熟悉,因为加载布局的任务通常都是在Activity中调用setContentView()方法来完成的。其实setContentView()方法的内部也是使用LayoutInflater来加载布局的,只不过这部分源码是internal的,不太容易查看到。那么今天我们就来把LayoutInflater的工作流程仔细地剖析一遍,也许还能解决掉某些困扰你心头多年的疑惑。

先来看一下LayoutInflater的基本用法吧,它的用法非常简单,首先需要获取到LayoutInflater的实例,有两种方法可以获取到,第一种写法如下:

[java]  view plain copy
  1. LayoutInflater layoutInflater = LayoutInflater.from(context);

当然,还有另外一种写法也可以完成同样的效果:

[java]  view plain copy
  1. LayoutInflater layoutInflater = (LayoutInflater) context
  2. .getSystemService(Context.LAYOUT_INFLATER_SERVICE);

其实第一种就是第二种的简单写法,只是Android给我们做了一下封装而已。得到了LayoutInflater的实例之后就可以调用它的inflate()方法来加载布局了,如下所示:

[java]  view plain copy
  1. layoutInflater.inflate(resourceId, root);

inflate()方法一般接收两个参数,第一个参数就是要加载的布局id,第二个参数是指给该布局的外部再嵌套一层父布局,如果不需要就直接传null。这样就成功成功创建了一个布局的实例,之后再将它添加到指定的位置就可以显示出来了。

下面我们就通过一个非常简单的小例子,来更加直观地看一下LayoutInflater的用法。比如说当前有一个项目,其中MainActivity对应的布局文件叫做activity_main.xml,代码如下所示:

[html]  view plain copy
  1. <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
  2. android:id="@+id/main_layout"
  3. android:layout_width="match_parent"
  4. android:layout_height="match_parent" >
  5. </LinearLayout>

这个布局文件的内容非常简单,只有一个空的LinearLayout,里面什么控件都没有,因此界面上应该不会显示任何东西。

那么接下来我们再定义一个布局文件,给它取名为button_layout.xml,代码如下所示:

[html]  view plain copy
  1. <Button xmlns:android="http://schemas.android.com/apk/res/android"
  2. android:layout_width="wrap_content"
  3. android:layout_height="wrap_content"
  4. android:text="Button" >
  5. </Button>

这个布局文件也非常简单,只有一个Button按钮而已。现在我们要想办法,如何通过LayoutInflater来将button_layout这个布局添加到主布局文件的LinearLayout中。根据刚刚介绍的用法,修改MainActivity中的代码,如下所示:

[java]  view plain copy
  1. public class MainActivity extends Activity {
  2. private LinearLayout mainLayout;
  3. @Override
  4. protected void onCreate(Bundle savedInstanceState) {
  5. super.onCreate(savedInstanceState);
  6. setContentView(R.layout.activity_main);
  7. mainLayout = (LinearLayout) findViewById(R.id.main_layout);
  8. LayoutInflater layoutInflater = LayoutInflater.from(this);
  9. View buttonLayout = layoutInflater.inflate(R.layout.button_layout, null);
  10. mainLayout.addView(buttonLayout);
  11. }
  12. }

可以看到,这里先是获取到了LayoutInflater的实例,然后调用它的inflate()方法来加载button_layout这个布局,最后调用LinearLayout的addView()方法将它添加到LinearLayout中。

现在可以运行一下程序,结果如下图所示:

Button在界面上显示出来了!说明我们确实是借助LayoutInflater成功将button_layout这个布局添加到LinearLayout中了。LayoutInflater技术广泛应用于需要动态添加View的时候,比如在ScrollView和ListView中,经常都可以看到LayoutInflater的身影。

当然,仅仅只是介绍了如何使用LayoutInflater显然是远远无法满足大家的求知欲的,知其然也要知其所以然,接下来我们就从源码的角度上看一看LayoutInflater到底是如何工作的。

不管你是使用的哪个inflate()方法的重载,最终都会辗转调用到LayoutInflater的如下代码中:

[java]  view plain copy
  1. public View inflate(XmlPullParser parser, ViewGroup root, boolean attachToRoot) {
  2. synchronized (mConstructorArgs) {
  3. final AttributeSet attrs = Xml.asAttributeSet(parser);
  4. mConstructorArgs[0] = mContext;
  5. View result = root;
  6. try {
  7. int type;
  8. while ((type = parser.next()) != XmlPullParser.START_TAG &&
  9. type != XmlPullParser.END_DOCUMENT) {
  10. }
  11. if (type != XmlPullParser.START_TAG) {
  12. throw new InflateException(parser.getPositionDescription()
  13. + ": No start tag found!");
  14. }
  15. final String name = parser.getName();
  16. if (TAG_MERGE.equals(name)) {
  17. if (root == null || !attachToRoot) {
  18. throw new InflateException("merge can be used only with a valid "
  19. + "ViewGroup root and attachToRoot=true");
  20. }
  21. rInflate(parser, root, attrs);
  22. } else {
  23. View temp = createViewFromTag(name, attrs);
  24. ViewGroup.LayoutParams params = null;
  25. if (root != null) {
  26. params = root.generateLayoutParams(attrs);
  27. if (!attachToRoot) {
  28. temp.setLayoutParams(params);
  29. }
  30. }
  31. rInflate(parser, temp, attrs);
  32. if (root != null && attachToRoot) {
  33. root.addView(temp, params);
  34. }
  35. if (root == null || !attachToRoot) {
  36. result = temp;
  37. }
  38. }
  39. } catch (XmlPullParserException e) {
  40. InflateException ex = new InflateException(e.getMessage());
  41. ex.initCause(e);
  42. throw ex;
  43. } catch (IOException e) {
  44. InflateException ex = new InflateException(
  45. parser.getPositionDescription()
  46. + ": " + e.getMessage());
  47. ex.initCause(e);
  48. throw ex;
  49. }
  50. return result;
  51. }
  52. }

从这里我们就可以清楚地看出,LayoutInflater其实就是使用Android提供的pull解析方式来解析布局文件的。不熟悉pull解析方式的朋友可以网上搜一下,教程很多,我就不细讲了,这里我们注意看下第23行,调用了createViewFromTag()这个方法,并把节点名和参数传了进去。看到这个方法名,我们就应该能猜到,它是用于根据节点名来创建View对象的。确实如此,在createViewFromTag()方法的内部又会去调用createView()方法,然后使用反射的方式创建出View的实例并返回。

当然,这里只是创建出了一个根布局的实例而已,接下来会在第31行调用rInflate()方法来循环遍历这个根布局下的子元素,代码如下所示:

[java]  view plain copy
  1. private void rInflate(XmlPullParser parser, View parent, final AttributeSet attrs)
  2. throws XmlPullParserException, IOException {
  3. final int depth = parser.getDepth();
  4. int type;
  5. while (((type = parser.next()) != XmlPullParser.END_TAG ||
  6. parser.getDepth() > depth) && type != XmlPullParser.END_DOCUMENT) {
  7. if (type != XmlPullParser.START_TAG) {
  8. continue;
  9. }
  10. final String name = parser.getName();
  11. if (TAG_REQUEST_FOCUS.equals(name)) {
  12. parseRequestFocus(parser, parent);
  13. } else if (TAG_INCLUDE.equals(name)) {
  14. if (parser.getDepth() == 0) {
  15. throw new InflateException("<include /> cannot be the root element");
  16. }
  17. parseInclude(parser, parent, attrs);
  18. } else if (TAG_MERGE.equals(name)) {
  19. throw new InflateException("<merge /> must be the root element");
  20. } else {
  21. final View view = createViewFromTag(name, attrs);
  22. final ViewGroup viewGroup = (ViewGroup) parent;
  23. final ViewGroup.LayoutParams params = viewGroup.generateLayoutParams(attrs);
  24. rInflate(parser, view, attrs);
  25. viewGroup.addView(view, params);
  26. }
  27. }
  28. parent.onFinishInflate();
  29. }

可以看到,在第21行同样是createViewFromTag()方法来创建View的实例,然后还会在第24行递归调用rInflate()方法来查找这个View下的子元素,每次递归完成后则将这个View添加到父布局当中。

这样的话,把整个布局文件都解析完成后就形成了一个完整的DOM结构,最终会把最顶层的根布局返回,至此inflate()过程全部结束。

比较细心的朋友也许会注意到,inflate()方法还有个接收三个参数的方法重载,结构如下:

[java]  view plain copy
  1. inflate(int resource, ViewGroup root, boolean attachToRoot)

那么这第三个参数attachToRoot又是什么意思呢?其实如果你仔细去阅读上面的源码应该可以自己分析出答案,这里我先将结论说一下吧,感兴趣的朋友可以再阅读一下源码,校验我的结论是否正确。

1. 如果root为null,attachToRoot将失去作用,设置任何值都没有意义。

2. 如果root不为null,attachToRoot设为true,则会给加载的布局文件的指定一个父布局,即root。

3. 如果root不为null,attachToRoot设为false,则会将布局文件最外层的所有layout属性进行设置,当该view被添加到父view当中时,这些layout属性会自动生效。

4. 在不设置attachToRoot参数的情况下,如果root不为null,attachToRoot参数默认为true。

好了,现在对LayoutInflater的工作原理和流程也搞清楚了,你该满足了吧。额。。。。还嫌这个例子中的按钮看起来有点小,想要调大一些?那简单的呀,修改button_layout.xml中的代码,如下所示:

[html]  view plain copy
  1. <Button xmlns:android="http://schemas.android.com/apk/res/android"
  2. android:layout_width="300dp"
  3. android:layout_height="80dp"
  4. android:text="Button" >
  5. </Button>

这里我们将按钮的宽度改成300dp,高度改成80dp,这样够大了吧?现在重新运行一下程序来观察效果。咦?怎么按钮还是原来的大小,没有任何变化!是不是按钮仍然不够大,再改大一点呢?还是没有用!

其实这里不管你将Button的layout_width和layout_height的值修改成多少,都不会有任何效果的,因为这两个值现在已经完全失去了作用。平时我们经常使用layout_width和layout_height来设置View的大小,并且一直都能正常工作,就好像这两个属性确实是用于设置View的大小的。而实际上则不然,它们其实是用于设置View在布局中的大小的,也就是说,首先View必须存在于一个布局中,之后如果将layout_width设置成match_parent表示让View的宽度填充满布局,如果设置成wrap_content表示让View的宽度刚好可以包含其内容,如果设置成具体的数值则View的宽度会变成相应的数值。这也是为什么这两个属性叫作layout_width和layout_height,而不是width和height。

再来看一下我们的button_layout.xml吧,很明显Button这个控件目前不存在于任何布局当中,所以layout_width和layout_height这两个属性理所当然没有任何作用。那么怎样修改才能让按钮的大小改变呢?解决方法其实有很多种,最简单的方式就是在Button的外面再嵌套一层布局,如下所示:

[html]  view plain copy
  1. <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
  2. android:layout_width="match_parent"
  3. android:layout_height="match_parent" >
  4. <Button
  5. android:layout_width="300dp"
  6. android:layout_height="80dp"
  7. android:text="Button" >
  8. </Button>
  9. </RelativeLayout>

可以看到,这里我们又加入了一个RelativeLayout,此时的Button存在与RelativeLayout之中,layout_width和layout_height属性也就有作用了。当然,处于最外层的RelativeLayout,它的layout_width和layout_height则会失去作用。现在重新运行一下程序,结果如下图所示:

OK!按钮的终于可以变大了,这下总算是满足大家的要求了吧。

看到这里,也许有些朋友心中会有一个巨大的疑惑。不对呀!平时在Activity中指定布局文件的时候,最外层的那个布局是可以指定大小的呀,layout_width和layout_height都是有作用的。确实,这主要是因为,在setContentView()方法中,Android会自动在布局文件的最外层再嵌套一个FrameLayout,所以layout_width和layout_height属性才会有效果。那么我们来证实一下吧,修改MainActivity中的代码,如下所示:

[java]  view plain copy
  1. public class MainActivity extends Activity {
  2. private LinearLayout mainLayout;
  3. @Override
  4. protected void onCreate(Bundle savedInstanceState) {
  5. super.onCreate(savedInstanceState);
  6. setContentView(R.layout.activity_main);
  7. mainLayout = (LinearLayout) findViewById(R.id.main_layout);
  8. ViewParent viewParent = mainLayout.getParent();
  9. Log.d("TAG", "the parent of mainLayout is " + viewParent);
  10. }
  11. }

可以看到,这里通过findViewById()方法,拿到了activity_main布局中最外层的LinearLayout对象,然后调用它的getParent()方法获取它的父布局,再通过Log打印出来。现在重新运行一下程序,结果如下图所示:

非常正确!LinearLayout的父布局确实是一个FrameLayout,而这个FrameLayout就是由系统自动帮我们添加上的。

说到这里,虽然setContentView()方法大家都会用,但实际上Android界面显示的原理要比我们所看到的东西复杂得多。任何一个Activity中显示的界面其实主要都由两部分组成,标题栏和内容布局。标题栏就是在很多界面顶部显示的那部分内容,比如刚刚我们的那个例子当中就有标题栏,可以在代码中控制让它是否显示。而内容布局就是一个FrameLayout,这个布局的id叫作content,我们调用setContentView()方法时所传入的布局其实就是放到这个FrameLayout中的,这也是为什么这个方法名叫作setContentView(),而不是叫setView()。

最后再附上一张Activity窗口的组成图吧,以便于大家更加直观地理解:

  • Android视图绘制流程完全解析

Android中的任何一个布局、任何一个控件其实都是直接或间接继承自View的,如TextView、Button、ImageView、ListView等。这些控件虽然是Android系统本身就提供好的,我们只需要拿过来使用就可以了,但你知道它们是怎样被绘制到屏幕上的吗?多知道一些总是没有坏处的,那么我们赶快进入到本篇文章的正题内容吧。

要知道,任何一个视图都不可能凭空突然出现在屏幕上,它们都是要经过非常科学的绘制流程后才能显示出来的。每一个视图的绘制过程都必须经历三个最主要的阶段,即onMeasure()、onLayout()和onDraw(),下面我们逐个对这三个阶段展开进行探讨。

一. onMeasure()

measure是测量的意思,那么onMeasure()方法顾名思义就是用于测量视图的大小的。View系统的绘制流程会从ViewRoot的performTraversals()方法中开始,在其内部调用View的measure()方法。measure()方法接收两个参数,widthMeasureSpec和heightMeasureSpec,这两个值分别用于确定视图的宽度和高度的规格和大小。

MeasureSpec的值由specSize和specMode共同组成的,其中specSize记录的是大小,specMode记录的是规格。specMode一共有三种类型,如下所示:

1. EXACTLY

表示父视图希望子视图的大小应该是由specSize的值来决定的,系统默认会按照这个规则来设置子视图的大小,开发人员当然也可以按照自己的意愿设置成任意的大小。

2. AT_MOST

表示子视图最多只能是specSize中指定的大小,开发人员应该尽可能小得去设置这个视图,并且保证不会超过specSize。系统默认会按照这个规则来设置子视图的大小,开发人员当然也可以按照自己的意愿设置成任意的大小。

3. UNSPECIFIED

表示开发人员可以将视图按照自己的意愿设置成任意的大小,没有任何限制。这种情况比较少见,不太会用到。

那么你可能会有疑问了,widthMeasureSpec和heightMeasureSpec这两个值又是从哪里得到的呢?通常情况下,这两个值都是由父视图经过计算后传递给子视图的,说明父视图会在一定程度上决定子视图的大小。但是最外层的根视图,它的widthMeasureSpec和heightMeasureSpec又是从哪里得到的呢?这就需要去分析ViewRoot中的源码了,观察performTraversals()方法可以发现如下代码:

[java]  view plain copy
  1. childWidthMeasureSpec = getRootMeasureSpec(desiredWindowWidth, lp.width);
  2. childHeightMeasureSpec = getRootMeasureSpec(desiredWindowHeight, lp.height);

可以看到,这里调用了getRootMeasureSpec()方法去获取widthMeasureSpec和heightMeasureSpec的值,注意方法中传入的参数,其中lp.width和lp.height在创建ViewGroup实例的时候就被赋值了,它们都等于MATCH_PARENT。然后看下getRootMeasureSpec()方法中的代码,如下所示:

[java]  view plain copy
  1. private int getRootMeasureSpec(int windowSize, int rootDimension) {
  2. int measureSpec;
  3. switch (rootDimension) {
  4. case ViewGroup.LayoutParams.MATCH_PARENT:
  5. measureSpec = MeasureSpec.makeMeasureSpec(windowSize, MeasureSpec.EXACTLY);
  6. break;
  7. case ViewGroup.LayoutParams.WRAP_CONTENT:
  8. measureSpec = MeasureSpec.makeMeasureSpec(windowSize, MeasureSpec.AT_MOST);
  9. break;
  10. default:
  11. measureSpec = MeasureSpec.makeMeasureSpec(rootDimension, MeasureSpec.EXACTLY);
  12. break;
  13. }
  14. return measureSpec;
  15. }

可以看到,这里使用了MeasureSpec.makeMeasureSpec()方法来组装一个MeasureSpec,当rootDimension参数等于MATCH_PARENT的时候,MeasureSpec的specMode就等于EXACTLY,当rootDimension等于WRAP_CONTENT的时候,MeasureSpec的specMode就等于AT_MOST。并且MATCH_PARENT和WRAP_CONTENT时的specSize都是等于windowSize的,也就意味着根视图总是会充满全屏的。

介绍了这么多MeasureSpec相关的内容,接下来我们看下View的measure()方法里面的代码吧,如下所示:

[java]  view plain copy
  1. public final void measure(int widthMeasureSpec, int heightMeasureSpec) {
  2. if ((mPrivateFlags & FORCE_LAYOUT) == FORCE_LAYOUT ||
  3. widthMeasureSpec != mOldWidthMeasureSpec ||
  4. heightMeasureSpec != mOldHeightMeasureSpec) {
  5. mPrivateFlags &= ~MEASURED_DIMENSION_SET;
  6. if (ViewDebug.TRACE_HIERARCHY) {
  7. ViewDebug.trace(this, ViewDebug.HierarchyTraceType.ON_MEASURE);
  8. }
  9. onMeasure(widthMeasureSpec, heightMeasureSpec);
  10. if ((mPrivateFlags & MEASURED_DIMENSION_SET) != MEASURED_DIMENSION_SET) {
  11. throw new IllegalStateException("onMeasure() did not set the"
  12. + " measured dimension by calling"
  13. + " setMeasuredDimension()");
  14. }
  15. mPrivateFlags |= LAYOUT_REQUIRED;
  16. }
  17. mOldWidthMeasureSpec = widthMeasureSpec;
  18. mOldHeightMeasureSpec = heightMeasureSpec;
  19. }

注意观察,measure()这个方法是final的,因此我们无法在子类中去重写这个方法,说明Android是不允许我们改变View的measure框架的。然后在第9行调用了onMeasure()方法,这里才是真正去测量并设置View大小的地方,默认会调用getDefaultSize()方法来获取视图的大小,如下所示:

[java]  view plain copy
  1. public static int getDefaultSize(int size, int measureSpec) {
  2. int result = size;
  3. int specMode = MeasureSpec.getMode(measureSpec);
  4. int specSize = MeasureSpec.getSize(measureSpec);
  5. switch (specMode) {
  6. case MeasureSpec.UNSPECIFIED:
  7. result = size;
  8. break;
  9. case MeasureSpec.AT_MOST:
  10. case MeasureSpec.EXACTLY:
  11. result = specSize;
  12. break;
  13. }
  14. return result;
  15. }

这里传入的measureSpec是一直从measure()方法中传递过来的。然后调用MeasureSpec.getMode()方法可以解析出specMode,调用MeasureSpec.getSize()方法可以解析出specSize。接下来进行判断,如果specMode等于AT_MOST或EXACTLY就返回specSize,这也是系统默认的行为。之后会在onMeasure()方法中调用setMeasuredDimension()方法来设定测量出的大小,这样一次measure过程就结束了。

当然,一个界面的展示可能会涉及到很多次的measure,因为一个布局中一般都会包含多个子视图,每个视图都需要经历一次measure过程。ViewGroup中定义了一个measureChildren()方法来去测量子视图的大小,如下所示:

[java]  view plain copy
  1. protected void measureChildren(int widthMeasureSpec, int heightMeasureSpec) {
  2. final int size = mChildrenCount;
  3. final View[] children = mChildren;
  4. for (int i = 0; i < size; ++i) {
  5. final View child = children[i];
  6. if ((child.mViewFlags & VISIBILITY_MASK) != GONE) {
  7. measureChild(child, widthMeasureSpec, heightMeasureSpec);
  8. }
  9. }
  10. }

这里首先会去遍历当前布局下的所有子视图,然后逐个调用measureChild()方法来测量相应子视图的大小,如下所示:

[java]  view plain copy
  1. protected void measureChild(View child, int parentWidthMeasureSpec,
  2. int parentHeightMeasureSpec) {
  3. final LayoutParams lp = child.getLayoutParams();
  4. final int childWidthMeasureSpec = getChildMeasureSpec(parentWidthMeasureSpec,
  5. mPaddingLeft + mPaddingRight, lp.width);
  6. final int childHeightMeasureSpec = getChildMeasureSpec(parentHeightMeasureSpec,
  7. mPaddingTop + mPaddingBottom, lp.height);
  8. child.measure(childWidthMeasureSpec, childHeightMeasureSpec);
  9. }

可以看到,在第4行和第6行分别调用了getChildMeasureSpec()方法来去计算子视图的MeasureSpec,计算的依据就是布局文件中定义的MATCH_PARENT、WRAP_CONTENT等值,这个方法的内部细节就不再贴出。然后在第8行调用子视图的measure()方法,并把计算出的MeasureSpec传递进去,之后的流程就和前面所介绍的一样了。

当然,onMeasure()方法是可以重写的,也就是说,如果你不想使用系统默认的测量方式,可以按照自己的意愿进行定制,比如:

[java]  view plain copy
  1. public class MyView extends View {
  2. ......
  3. @Override
  4. protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
  5. setMeasuredDimension(200, 200);
  6. }
  7. }

这样的话就把View默认的测量流程覆盖掉了,不管在布局文件中定义MyView这个视图的大小是多少,最终在界面上显示的大小都将会是200*200。

需要注意的是,在setMeasuredDimension()方法调用之后,我们才能使用getMeasuredWidth()和getMeasuredHeight()来获取视图测量出的宽高,以此之前调用这两个方法得到的值都会是0。

由此可见,视图大小的控制是由父视图、布局文件、以及视图本身共同完成的,父视图会提供给子视图参考的大小,而开发人员可以在XML文件中指定视图的大小,然后视图本身会对最终的大小进行拍板。

到此为止,我们就把视图绘制流程的第一阶段分析完了。

二. onLayout()

measure过程结束后,视图的大小就已经测量好了,接下来就是layout的过程了。正如其名字所描述的一样,这个方法是用于给视图进行布局的,也就是确定视图的位置。ViewRoot的performTraversals()方法会在measure结束后继续执行,并调用View的layout()方法来执行此过程,如下所示:

[java]  view plain copy
  1. host.layout(0, 0, host.mMeasuredWidth, host.mMeasuredHeight);

layout()方法接收四个参数,分别代表着左、上、右、下的坐标,当然这个坐标是相对于当前视图的父视图而言的。可以看到,这里还把刚才测量出的宽度和高度传到了layout()方法中。那么我们来看下layout()方法中的代码是什么样的吧,如下所示:

[java]  view plain copy
  1. public void layout(int l, int t, int r, int b) {
  2. int oldL = mLeft;
  3. int oldT = mTop;
  4. int oldB = mBottom;
  5. int oldR = mRight;
  6. boolean changed = setFrame(l, t, r, b);
  7. if (changed || (mPrivateFlags & LAYOUT_REQUIRED) == LAYOUT_REQUIRED) {
  8. if (ViewDebug.TRACE_HIERARCHY) {
  9. ViewDebug.trace(this, ViewDebug.HierarchyTraceType.ON_LAYOUT);
  10. }
  11. onLayout(changed, l, t, r, b);
  12. mPrivateFlags &= ~LAYOUT_REQUIRED;
  13. if (mOnLayoutChangeListeners != null) {
  14. ArrayList<OnLayoutChangeListener> listenersCopy =
  15. (ArrayList<OnLayoutChangeListener>) mOnLayoutChangeListeners.clone();
  16. int numListeners = listenersCopy.size();
  17. for (int i = 0; i < numListeners; ++i) {
  18. listenersCopy.get(i).onLayoutChange(this, l, t, r, b, oldL, oldT, oldR, oldB);
  19. }
  20. }
  21. }
  22. mPrivateFlags &= ~FORCE_LAYOUT;
  23. }

在layout()方法中,首先会调用setFrame()方法来判断视图的大小是否发生过变化,以确定有没有必要对当前的视图进行重绘,同时还会在这里把传递过来的四个参数分别赋值给mLeft、mTop、mRight和mBottom这几个变量。接下来会在第11行调用onLayout()方法,正如onMeasure()方法中的默认行为一样,也许你已经迫不及待地想知道onLayout()方法中的默认行为是什么样的了。进入onLayout()方法,咦?怎么这是个空方法,一行代码都没有?!

没错,View中的onLayout()方法就是一个空方法,因为onLayout()过程是为了确定视图在布局中所在的位置,而这个操作应该是由布局来完成的,即父视图决定子视图的显示位置。既然如此,我们来看下ViewGroup中的onLayout()方法是怎么写的吧,代码如下:

[java]  view plain copy
  1. @Override
  2. protected abstract void onLayout(boolean changed, int l, int t, int r, int b);

可以看到,ViewGroup中的onLayout()方法竟然是一个抽象方法,这就意味着所有ViewGroup的子类都必须重写这个方法。没错,像LinearLayout、RelativeLayout等布局,都是重写了这个方法,然后在内部按照各自的规则对子视图进行布局的。由于LinearLayout和RelativeLayout的布局规则都比较复杂,就不单独拿出来进行分析了,这里我们尝试自定义一个布局,借此来更深刻地理解onLayout()的过程。

自定义的这个布局目标很简单,只要能够包含一个子视图,并且让子视图正常显示出来就可以了。那么就给这个布局起名叫做SimpleLayout吧,代码如下所示:

[java]  view plain copy
  1. public class SimpleLayout extends ViewGroup {
  2. public SimpleLayout(Context context, AttributeSet attrs) {
  3. super(context, attrs);
  4. }
  5. @Override
  6. protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
  7. super.onMeasure(widthMeasureSpec, heightMeasureSpec);
  8. if (getChildCount() > 0) {
  9. View childView = getChildAt(0);
  10. measureChild(childView, widthMeasureSpec, heightMeasureSpec);
  11. }
  12. }
  13. @Override
  14. protected void onLayout(boolean changed, int l, int t, int r, int b) {
  15. if (getChildCount() > 0) {
  16. View childView = getChildAt(0);
  17. childView.layout(0, 0, childView.getMeasuredWidth(), childView.getMeasuredHeight());
  18. }
  19. }
  20. }

代码非常的简单,我们来看下具体的逻辑吧。你已经知道,onMeasure()方法会在onLayout()方法之前调用,因此这里在onMeasure()方法中判断SimpleLayout中是否有包含一个子视图,如果有的话就调用measureChild()方法来测量出子视图的大小。

接着在onLayout()方法中同样判断SimpleLayout是否有包含一个子视图,然后调用这个子视图的layout()方法来确定它在SimpleLayout布局中的位置,这里传入的四个参数依次是0、0、childView.getMeasuredWidth()和childView.getMeasuredHeight(),分别代表着子视图在SimpleLayout中左上右下四个点的坐标。其中,调用childView.getMeasuredWidth()和childView.getMeasuredHeight()方法得到的值就是在onMeasure()方法中测量出的宽和高。

这样就已经把SimpleLayout这个布局定义好了,下面就是在XML文件中使用它了,如下所示:

[html]  view plain copy
  1. <com.example.viewtest.SimpleLayout xmlns:android="http://schemas.android.com/apk/res/android"
  2. android:layout_width="match_parent"
  3. android:layout_height="match_parent" >
  4. <ImageView
  5. android:layout_width="wrap_content"
  6. android:layout_height="wrap_content"
  7. android:src="@drawable/ic_launcher"
  8. />
  9. </com.example.viewtest.SimpleLayout>

可以看到,我们能够像使用普通的布局文件一样使用SimpleLayout,只是注意它只能包含一个子视图,多余的子视图会被舍弃掉。这里SimpleLayout中包含了一个ImageView,并且ImageView的宽高都是wrap_content。现在运行一下程序,结果如下图所示:

OK!ImageView成功已经显示出来了,并且显示的位置也正是我们所期望的。如果你想改变ImageView显示的位置,只需要改变childView.layout()方法的四个参数就行了。

在onLayout()过程结束后,我们就可以调用getWidth()方法和getHeight()方法来获取视图的宽高了。说到这里,我相信很多朋友长久以来都会有一个疑问,getWidth()方法和getMeasureWidth()方法到底有什么区别呢?它们的值好像永远都是相同的。其实它们的值之所以会相同基本都是因为布局设计者的编码习惯非常好,实际上它们之间的差别还是挺大的。

首先getMeasureWidth()方法在measure()过程结束后就可以获取到了,而getWidth()方法要在layout()过程结束后才能获取到。另外,getMeasureWidth()方法中的值是通过setMeasuredDimension()方法来进行设置的,而getWidth()方法中的值则是通过视图右边的坐标减去左边的坐标计算出来的。

观察SimpleLayout中onLayout()方法的代码,这里给子视图的layout()方法传入的四个参数分别是0、0、childView.getMeasuredWidth()和childView.getMeasuredHeight(),因此getWidth()方法得到的值就是childView.getMeasuredWidth() - 0 = childView.getMeasuredWidth() ,所以此时getWidth()方法和getMeasuredWidth() 得到的值就是相同的,但如果你将onLayout()方法中的代码进行如下修改:

[java]  view plain copy
  1. @Override
  2. protected void onLayout(boolean changed, int l, int t, int r, int b) {
  3. if (getChildCount() > 0) {
  4. View childView = getChildAt(0);
  5. childView.layout(0, 0, 200, 200);
  6. }
  7. }

这样getWidth()方法得到的值就是200 - 0 = 200,不会再和getMeasuredWidth()的值相同了。当然这种做法充分不尊重measure()过程计算出的结果,通常情况下是不推荐这么写的。getHeight()与getMeasureHeight()方法之间的关系同上,就不再重复分析了。

到此为止,我们把视图绘制流程的第二阶段也分析完了。

三. onDraw()

measure和layout的过程都结束后,接下来就进入到draw的过程了。同样,根据名字你就能够判断出,在这里才真正地开始对视图进行绘制。ViewRoot中的代码会继续执行并创建出一个Canvas对象,然后调用View的draw()方法来执行具体的绘制工作。draw()方法内部的绘制过程总共可以分为六步,其中第二步和第五步在一般情况下很少用到,因此这里我们只分析简化后的绘制过程。代码如下所示:

[java]  view plain copy
  1. public void draw(Canvas canvas) {
  2. if (ViewDebug.TRACE_HIERARCHY) {
  3. ViewDebug.trace(this, ViewDebug.HierarchyTraceType.DRAW);
  4. }
  5. final int privateFlags = mPrivateFlags;
  6. final boolean dirtyOpaque = (privateFlags & DIRTY_MASK) == DIRTY_OPAQUE &&
  7. (mAttachInfo == null || !mAttachInfo.mIgnoreDirtyState);
  8. mPrivateFlags = (privateFlags & ~DIRTY_MASK) | DRAWN;
  9. // Step 1, draw the background, if needed
  10. int saveCount;
  11. if (!dirtyOpaque) {
  12. final Drawable background = mBGDrawable;
  13. if (background != null) {
  14. final int scrollX = mScrollX;
  15. final int scrollY = mScrollY;
  16. if (mBackgroundSizeChanged) {
  17. background.setBounds(0, 0,  mRight - mLeft, mBottom - mTop);
  18. mBackgroundSizeChanged = false;
  19. }
  20. if ((scrollX | scrollY) == 0) {
  21. background.draw(canvas);
  22. } else {
  23. canvas.translate(scrollX, scrollY);
  24. background.draw(canvas);
  25. canvas.translate(-scrollX, -scrollY);
  26. }
  27. }
  28. }
  29. final int viewFlags = mViewFlags;
  30. boolean horizontalEdges = (viewFlags & FADING_EDGE_HORIZONTAL) != 0;
  31. boolean verticalEdges = (viewFlags & FADING_EDGE_VERTICAL) != 0;
  32. if (!verticalEdges && !horizontalEdges) {
  33. // Step 3, draw the content
  34. if (!dirtyOpaque) onDraw(canvas);
  35. // Step 4, draw the children
  36. dispatchDraw(canvas);
  37. // Step 6, draw decorations (scrollbars)
  38. onDrawScrollBars(canvas);
  39. // we're done...
  40. return;
  41. }
  42. }

可以看到,第一步是从第9行代码开始的,这一步的作用是对视图的背景进行绘制。这里会先得到一个mBGDrawable对象,然后根据layout过程确定的视图位置来设置背景的绘制区域,之后再调用Drawable的draw()方法来完成背景的绘制工作。那么这个mBGDrawable对象是从哪里来的呢?其实就是在XML中通过android:background属性设置的图片或颜色。当然你也可以在代码中通过setBackgroundColor()、setBackgroundResource()等方法进行赋值。

接下来的第三步是在第34行执行的,这一步的作用是对视图的内容进行绘制。可以看到,这里去调用了一下onDraw()方法,那么onDraw()方法里又写了什么代码呢?进去一看你会发现,原来又是个空方法啊。其实也可以理解,因为每个视图的内容部分肯定都是各不相同的,这部分的功能交给子类来去实现也是理所当然的。

第三步完成之后紧接着会执行第四步,这一步的作用是对当前视图的所有子视图进行绘制。但如果当前的视图没有子视图,那么也就不需要进行绘制了。因此你会发现View中的dispatchDraw()方法又是一个空方法,而ViewGroup的dispatchDraw()方法中就会有具体的绘制代码。

以上都执行完后就会进入到第六步,也是最后一步,这一步的作用是对视图的滚动条进行绘制。那么你可能会奇怪,当前的视图又不一定是ListView或者ScrollView,为什么要绘制滚动条呢?其实不管是Button也好,TextView也好,任何一个视图都是有滚动条的,只是一般情况下我们都没有让它显示出来而已。绘制滚动条的代码逻辑也比较复杂,这里就不再贴出来了,因为我们的重点是第三步过程。

通过以上流程分析,相信大家已经知道,View是不会帮我们绘制内容部分的,因此需要每个视图根据想要展示的内容来自行绘制。如果你去观察TextView、ImageView等类的源码,你会发现它们都有重写onDraw()这个方法,并且在里面执行了相当不少的绘制逻辑。绘制的方式主要是借助Canvas这个类,它会作为参数传入到onDraw()方法中,供给每个视图使用。Canvas这个类的用法非常丰富,基本可以把它当成一块画布,在上面绘制任意的东西,那么我们就来尝试一下吧。

这里简单起见,我只是创建一个非常简单的视图,并且用Canvas随便绘制了一点东西,代码如下所示:

[java]  view plain copy
  1. public class MyView extends View {
  2. private Paint mPaint;
  3. public MyView(Context context, AttributeSet attrs) {
  4. super(context, attrs);
  5. mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
  6. }
  7. @Override
  8. protected void onDraw(Canvas canvas) {
  9. mPaint.setColor(Color.YELLOW);
  10. canvas.drawRect(0, 0, getWidth(), getHeight(), mPaint);
  11. mPaint.setColor(Color.BLUE);
  12. mPaint.setTextSize(20);
  13. String text = "Hello View";
  14. canvas.drawText(text, 0, getHeight() / 2, mPaint);
  15. }
  16. }

可以看到,我们创建了一个自定义的MyView继承自View,并在MyView的构造函数中创建了一个Paint对象。Paint就像是一个画笔一样,配合着Canvas就可以进行绘制了。这里我们的绘制逻辑比较简单,在onDraw()方法中先是把画笔设置成黄色,然后调用Canvas的drawRect()方法绘制一个矩形。然后在把画笔设置成蓝色,并调整了一下文字的大小,然后调用drawText()方法绘制了一段文字。

就这么简单,一个自定义的视图就已经写好了,现在可以在XML中加入这个视图,如下所示:

[html]  view plain copy
  1. <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
  2. android:layout_width="match_parent"
  3. android:layout_height="match_parent" >
  4. <com.example.viewtest.MyView
  5. android:layout_width="200dp"
  6. android:layout_height="100dp"
  7. />
  8. </LinearLayout>

将MyView的宽度设置成200dp,高度设置成100dp,然后运行一下程序,结果如下图所示:

图中显示的内容也正是MyView这个视图的内容部分了。由于我们没给MyView设置背景,因此这里看不出来View自动绘制的背景效果。

当然了Canvas的用法还有很多很多,这里我不可能把Canvas的所有用法都列举出来,剩下的就要靠大家自行去研究和学习了

  • Android视图状态及重绘流程分析

大家在平时使用View的时候都会发现它是有状态的,比如说有一个按钮,普通状态下是一种效果,但是当手指按下的时候就会变成另外一种效果,这样才会给人产生一种点击了按钮的感觉。当然了,这种效果相信几乎所有的Android程序员都知道该如何实现,但是我们既然是深入了解View,那么自然也应该知道它背后的实现原理应该是什么样的,今天就让我们来一起探究一下吧。

一、视图状态

视图状态的种类非常多,一共有十几种类型,不过多数情况下我们只会使用到其中的几种,因此这里我们也就只去分析最常用的几种视图状态。

1. enabled

表示当前视图是否可用。可以调用setEnable()方法来改变视图的可用状态,传入true表示可用,传入false表示不可用。它们之间最大的区别在于,不可用的视图是无法响应onTouch事件的。

2. focused

表示当前视图是否获得到焦点。通常情况下有两种方法可以让视图获得焦点,即通过键盘的上下左右键切换视图,以及调用requestFocus()方法。而现在的Android手机几乎都没有键盘了,因此基本上只可以使用requestFocus()这个办法来让视图获得焦点了。而requestFocus()方法也不能保证一定可以让视图获得焦点,它会有一个布尔值的返回值,如果返回true说明获得焦点成功,返回false说明获得焦点失败。一般只有视图在focusable和focusable in touch mode同时成立的情况下才能成功获取焦点,比如说EditText。

3. window_focused

表示当前视图是否处于正在交互的窗口中,这个值由系统自动决定,应用程序不能进行改变。

4. selected

表示当前视图是否处于选中状态。一个界面当中可以有多个视图处于选中状态,调用setSelected()方法能够改变视图的选中状态,传入true表示选中,传入false表示未选中。

5. pressed

表示当前视图是否处于按下状态。可以调用setPressed()方法来对这一状态进行改变,传入true表示按下,传入false表示未按下。通常情况下这个状态都是由系统自动赋值的,但开发者也可以自己调用这个方法来进行改变。

我们可以在项目的drawable目录下创建一个selector文件,在这里配置每种状态下视图对应的背景图片。比如创建一个compose_bg.xml文件,在里面编写如下代码:

[html]  view plain copy
  1. <selector xmlns:android="http://schemas.android.com/apk/res/android">
  2. <item android:drawable="@drawable/compose_pressed" android:state_pressed="true"></item>
  3. <item android:drawable="@drawable/compose_pressed" android:state_focused="true"></item>
  4. <item android:drawable="@drawable/compose_normal"></item>
  5. </selector>

这段代码就表示,当视图处于正常状态的时候就显示compose_normal这张背景图,当视图获得到焦点或者被按下的时候就显示compose_pressed这张背景图。

创建好了这个selector文件后,我们就可以在布局或代码中使用它了,比如将它设置为某个按钮的背景图,如下所示:

[html]  view plain copy
  1. <?xml version="1.0" encoding="utf-8"?>
  2. <LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
  3. android:layout_width="match_parent"
  4. android:layout_height="match_parent"
  5. android:orientation="vertical" >
  6. <Button
  7. android:id="@+id/compose"
  8. android:layout_width="60dp"
  9. android:layout_height="40dp"
  10. android:layout_gravity="center_horizontal"
  11. android:background="@drawable/compose_bg"
  12. />
  13. </LinearLayout>

现在运行一下程序,这个按钮在普通状态和按下状态的时候就会显示不同的背景图片,如下图所示:

这样我们就用一个非常简单的方法实现了按钮按下的效果,但是它的背景原理到底是怎样的呢?这就又要从源码的层次上进行分析了。

我们都知道,当手指按在视图上的时候,视图的状态就已经发生了变化,此时视图的pressed状态是true。每当视图的状态有发生改变的时候,就会回调View的drawableStateChanged()方法,代码如下所示:

[java]  view plain copy
  1. protected void drawableStateChanged() {
  2. Drawable d = mBGDrawable;
  3. if (d != null && d.isStateful()) {
  4. d.setState(getDrawableState());
  5. }
  6. }

在这里的第一步,首先是将mBGDrawable赋值给一个Drawable对象,那么这个mBGDrawable是什么呢?观察setBackgroundResource()方法中的代码,如下所示:

[java]  view plain copy
  1. public void setBackgroundResource(int resid) {
  2. if (resid != 0 && resid == mBackgroundResource) {
  3. return;
  4. }
  5. Drawable d= null;
  6. if (resid != 0) {
  7. d = mResources.getDrawable(resid);
  8. }
  9. setBackgroundDrawable(d);
  10. mBackgroundResource = resid;
  11. }

可以看到,在第7行调用了Resource的getDrawable()方法将resid转换成了一个Drawable对象,然后调用了setBackgroundDrawable()方法并将这个Drawable对象传入,在setBackgroundDrawable()方法中会将传入的Drawable对象赋值给mBGDrawable。

而我们在布局文件中通过android:background属性指定的selector文件,效果等同于调用setBackgroundResource()方法。也就是说drawableStateChanged()方法中的mBGDrawable对象其实就是我们指定的selector文件。

接下来在drawableStateChanged()方法的第4行调用了getDrawableState()方法来获取视图状态,代码如下所示:

[java]  view plain copy
  1. public final int[] getDrawableState() {
  2. if ((mDrawableState != null) && ((mPrivateFlags & DRAWABLE_STATE_DIRTY) == 0)) {
  3. return mDrawableState;
  4. } else {
  5. mDrawableState = onCreateDrawableState(0);
  6. mPrivateFlags &= ~DRAWABLE_STATE_DIRTY;
  7. return mDrawableState;
  8. }
  9. }

在这里首先会判断当前视图的状态是否发生了改变,如果没有改变就直接返回当前的视图状态,如果发生了改变就调用onCreateDrawableState()方法来获取最新的视图状态。视图的所有状态会以一个整型数组的形式返回。

在得到了视图状态的数组之后,就会调用Drawable的setState()方法来对状态进行更新,代码如下所示:

[java]  view plain copy
  1. public boolean setState(final int[] stateSet) {
  2. if (!Arrays.equals(mStateSet, stateSet)) {
  3. mStateSet = stateSet;
  4. return onStateChange(stateSet);
  5. }
  6. return false;
  7. }

这里会调用Arrays.equals()方法来判断视图状态的数组是否发生了变化,如果发生了变化则调用onStateChange()方法,否则就直接返回false。但你会发现,Drawable的onStateChange()方法中其实就只是简单返回了一个false,并没有任何的逻辑处理,这是为什么呢?这主要是因为mBGDrawable对象是通过一个selector文件创建出来的,而通过这种文件创建出来的Drawable对象其实都是一个StateListDrawable实例,因此这里调用的onStateChange()方法实际上调用的是StateListDrawable中的onStateChange()方法,那么我们赶快看一下吧:

[java]  view plain copy
  1. @Override
  2. protected boolean onStateChange(int[] stateSet) {
  3. int idx = mStateListState.indexOfStateSet(stateSet);
  4. if (DEBUG) android.util.Log.i(TAG, "onStateChange " + this + " states "
  5. + Arrays.toString(stateSet) + " found " + idx);
  6. if (idx < 0) {
  7. idx = mStateListState.indexOfStateSet(StateSet.WILD_CARD);
  8. }
  9. if (selectDrawable(idx)) {
  10. return true;
  11. }
  12. return super.onStateChange(stateSet);
  13. }

可以看到,这里会先调用indexOfStateSet()方法来找到当前视图状态所对应的Drawable资源下标,然后在第9行调用selectDrawable()方法并将下标传入,在这个方法中就会将视图的背景图设置为当前视图状态所对应的那张图片了。

那你可能会有疑问,在前面一篇文章中我们说到,任何一个视图的显示都要经过非常科学的绘制流程的,很显然,背景图的绘制是在draw()方法中完成的,那么为什么selectDrawable()方法能够控制背景图的改变呢?这就要研究一下视图重绘的流程了。

二、视图重绘

虽然视图会在Activity加载完成之后自动绘制到屏幕上,但是我们完全有理由在与Activity进行交互的时候要求动态更新视图,比如改变视图的状态、以及显示或隐藏某个控件等。那在这个时候,之前绘制出的视图其实就已经过期了,此时我们就应该对视图进行重绘。

调用视图的setVisibility()、setEnabled()、setSelected()等方法时都会导致视图重绘,而如果我们想要手动地强制让视图进行重绘,可以调用invalidate()方法来实现。当然了,setVisibility()、setEnabled()、setSelected()等方法的内部其实也是通过调用invalidate()方法来实现的,那么就让我们来看一看invalidate()方法的代码是什么样的吧。

View的源码中会有数个invalidate()方法的重载和一个invalidateDrawable()方法,当然它们的原理都是相同的,因此我们只分析其中一种,代码如下所示:

[java]  view plain copy
  1. void invalidate(boolean invalidateCache) {
  2. if (ViewDebug.TRACE_HIERARCHY) {
  3. ViewDebug.trace(this, ViewDebug.HierarchyTraceType.INVALIDATE);
  4. }
  5. if (skipInvalidate()) {
  6. return;
  7. }
  8. if ((mPrivateFlags & (DRAWN | HAS_BOUNDS)) == (DRAWN | HAS_BOUNDS) ||
  9. (invalidateCache && (mPrivateFlags & DRAWING_CACHE_VALID) == DRAWING_CACHE_VALID) ||
  10. (mPrivateFlags & INVALIDATED) != INVALIDATED || isOpaque() != mLastIsOpaque) {
  11. mLastIsOpaque = isOpaque();
  12. mPrivateFlags &= ~DRAWN;
  13. mPrivateFlags |= DIRTY;
  14. if (invalidateCache) {
  15. mPrivateFlags |= INVALIDATED;
  16. mPrivateFlags &= ~DRAWING_CACHE_VALID;
  17. }
  18. final AttachInfo ai = mAttachInfo;
  19. final ViewParent p = mParent;
  20. if (!HardwareRenderer.RENDER_DIRTY_REGIONS) {
  21. if (p != null && ai != null && ai.mHardwareAccelerated) {
  22. p.invalidateChild(this, null);
  23. return;
  24. }
  25. }
  26. if (p != null && ai != null) {
  27. final Rect r = ai.mTmpInvalRect;
  28. r.set(0, 0, mRight - mLeft, mBottom - mTop);
  29. p.invalidateChild(this, r);
  30. }
  31. }
  32. }

在这个方法中首先会调用skipInvalidate()方法来判断当前View是否需要重绘,判断的逻辑也比较简单,如果View是不可见的且没有执行任何动画,就认为不需要重绘了。之后会进行透明度的判断,并给View添加一些标记位,然后在第22和29行调用ViewParent的invalidateChild()方法,这里的ViewParent其实就是当前视图的父视图,因此会调用到ViewGroup的invalidateChild()方法中,代码如下所示:

[java]  view plain copy
  1. public final void invalidateChild(View child, final Rect dirty) {
  2. ViewParent parent = this;
  3. final AttachInfo attachInfo = mAttachInfo;
  4. if (attachInfo != null) {
  5. final boolean drawAnimation = (child.mPrivateFlags & DRAW_ANIMATION) == DRAW_ANIMATION;
  6. if (dirty == null) {
  7. ......
  8. } else {
  9. ......
  10. do {
  11. View view = null;
  12. if (parent instanceof View) {
  13. view = (View) parent;
  14. if (view.mLayerType != LAYER_TYPE_NONE &&
  15. view.getParent() instanceof View) {
  16. final View grandParent = (View) view.getParent();
  17. grandParent.mPrivateFlags |= INVALIDATED;
  18. grandParent.mPrivateFlags &= ~DRAWING_CACHE_VALID;
  19. }
  20. }
  21. if (drawAnimation) {
  22. if (view != null) {
  23. view.mPrivateFlags |= DRAW_ANIMATION;
  24. } else if (parent instanceof ViewRootImpl) {
  25. ((ViewRootImpl) parent).mIsAnimating = true;
  26. }
  27. }
  28. if (view != null) {
  29. if ((view.mViewFlags & FADING_EDGE_MASK) != 0 &&
  30. view.getSolidColor() == 0) {
  31. opaqueFlag = DIRTY;
  32. }
  33. if ((view.mPrivateFlags & DIRTY_MASK) != DIRTY) {
  34. view.mPrivateFlags = (view.mPrivateFlags & ~DIRTY_MASK) | opaqueFlag;
  35. }
  36. }
  37. parent = parent.invalidateChildInParent(location, dirty);
  38. if (view != null) {
  39. Matrix m = view.getMatrix();
  40. if (!m.isIdentity()) {
  41. RectF boundingRect = attachInfo.mTmpTransformRect;
  42. boundingRect.set(dirty);
  43. m.mapRect(boundingRect);
  44. dirty.set((int) boundingRect.left, (int) boundingRect.top,
  45. (int) (boundingRect.right + 0.5f),
  46. (int) (boundingRect.bottom + 0.5f));
  47. }
  48. }
  49. } while (parent != null);
  50. }
  51. }
  52. }

可以看到,这里在第10行进入了一个while循环,当ViewParent不等于空的时候就会一直循环下去。在这个while循环当中会不断地获取当前布局的父布局,并调用它的invalidateChildInParent()方法,在ViewGroup的invalidateChildInParent()方法中主要是来计算需要重绘的矩形区域,这里我们先不管它,当循环到最外层的根布局后,就会调用ViewRoot的invalidateChildInParent()方法了,代码如下所示:

[java]  view plain copy
  1. public ViewParent invalidateChildInParent(final int[] location, final Rect dirty) {
  2. invalidateChild(null, dirty);
  3. return null;
  4. }

这里的代码非常简单,仅仅是去调用了invalidateChild()方法而已,那我们再跟进去瞧一瞧吧:

[java]  view plain copy
  1. public void invalidateChild(View child, Rect dirty) {
  2. checkThread();
  3. if (LOCAL_LOGV) Log.v(TAG, "Invalidate child: " + dirty);
  4. mDirty.union(dirty);
  5. if (!mWillDrawSoon) {
  6. scheduleTraversals();
  7. }
  8. }

这个方法也不长,它在第6行又调用了scheduleTraversals()这个方法,那么我们继续跟进:

[java]  view plain copy
  1. public void scheduleTraversals() {
  2. if (!mTraversalScheduled) {
  3. mTraversalScheduled = true;
  4. sendEmptyMessage(DO_TRAVERSAL);
  5. }
  6. }

可以看到,这里调用了sendEmptyMessage()方法,并传入了一个DO_TRAVERSAL参数。了解Android异步消息处理机制的朋友们都会知道,任何一个Handler都可以调用sendEmptyMessage()方法来发送消息,并且在handleMessage()方法中接收消息,而如果你看一下ViewRoot的类定义就会发现,它是继承自Handler的,也就是说这里调用sendEmptyMessage()方法出的消息,会在ViewRoot的handleMessage()方法中接收到。那么赶快看一下handleMessage()方法的代码吧,如下所示:

[java]  view plain copy
  1. public void handleMessage(Message msg) {
  2. switch (msg.what) {
  3. case DO_TRAVERSAL:
  4. if (mProfile) {
  5. Debug.startMethodTracing("ViewRoot");
  6. }
  7. performTraversals();
  8. if (mProfile) {
  9. Debug.stopMethodTracing();
  10. mProfile = false;
  11. }
  12. break;
  13. ......
  14. }

熟悉的代码出现了!这里在第7行调用了performTraversals()方法,这不就是我们在前面一篇文章中学到的视图绘制的入口吗?虽然经过了很多辗转的调用,但是可以确定的是,调用视图的invalidate()方法后确实会走到performTraversals()方法中,然后重新执行绘制流程。之后的流程就不需要再进行描述了吧,可以参考 Android视图绘制流程完全解析,带你一步步深入了解View(二) 这一篇文章。

了解了这些之后,我们再回过头来看看刚才的selectDrawable()方法中到底做了什么才能够控制背景图的改变,代码如下所示:

[java]  view plain copy
  1. public boolean selectDrawable(int idx) {
  2. if (idx == mCurIndex) {
  3. return false;
  4. }
  5. final long now = SystemClock.uptimeMillis();
  6. if (mDrawableContainerState.mExitFadeDuration > 0) {
  7. if (mLastDrawable != null) {
  8. mLastDrawable.setVisible(false, false);
  9. }
  10. if (mCurrDrawable != null) {
  11. mLastDrawable = mCurrDrawable;
  12. mExitAnimationEnd = now + mDrawableContainerState.mExitFadeDuration;
  13. } else {
  14. mLastDrawable = null;
  15. mExitAnimationEnd = 0;
  16. }
  17. } else if (mCurrDrawable != null) {
  18. mCurrDrawable.setVisible(false, false);
  19. }
  20. if (idx >= 0 && idx < mDrawableContainerState.mNumChildren) {
  21. Drawable d = mDrawableContainerState.mDrawables[idx];
  22. mCurrDrawable = d;
  23. mCurIndex = idx;
  24. if (d != null) {
  25. if (mDrawableContainerState.mEnterFadeDuration > 0) {
  26. mEnterAnimationEnd = now + mDrawableContainerState.mEnterFadeDuration;
  27. } else {
  28. d.setAlpha(mAlpha);
  29. }
  30. d.setVisible(isVisible(), true);
  31. d.setDither(mDrawableContainerState.mDither);
  32. d.setColorFilter(mColorFilter);
  33. d.setState(getState());
  34. d.setLevel(getLevel());
  35. d.setBounds(getBounds());
  36. }
  37. } else {
  38. mCurrDrawable = null;
  39. mCurIndex = -1;
  40. }
  41. if (mEnterAnimationEnd != 0 || mExitAnimationEnd != 0) {
  42. if (mAnimationRunnable == null) {
  43. mAnimationRunnable = new Runnable() {
  44. @Override public void run() {
  45. animate(true);
  46. invalidateSelf();
  47. }
  48. };
  49. } else {
  50. unscheduleSelf(mAnimationRunnable);
  51. }
  52. animate(true);
  53. }
  54. invalidateSelf();
  55. return true;
  56. }

这里前面的代码我们可以都不管,关键是要看到在第54行一定会调用invalidateSelf()方法,这个方法中的代码如下所示:

[java]  view plain copy
  1. public void invalidateSelf() {
  2. final Callback callback = getCallback();
  3. if (callback != null) {
  4. callback.invalidateDrawable(this);
  5. }
  6. }

可以看到,这里会先调用getCallback()方法获取Callback接口的回调实例,然后再去调用回调实例的invalidateDrawable()方法。那么这里的回调实例又是什么呢?观察一下View的类定义其实你就知道了,如下所示:

[java]  view plain copy
  1. public class View implements Drawable.Callback, Drawable.Callback2, KeyEvent.Callback,
  2. AccessibilityEventSource {
  3. ......
  4. }

View类正是实现了Callback接口,所以刚才其实调用的就是View中的invalidateDrawable()方法,之后就会按照我们前面分析的流程执行重绘逻辑,所以视图的背景图才能够得到改变的。

另外需要注意的是,invalidate()方法虽然最终会调用到performTraversals()方法中,但这时measure和layout流程是不会重新执行的,因为视图没有强制重新测量的标志位,而且大小也没有发生过变化,所以这时只有draw流程可以得到执行。而如果你希望视图的绘制流程可以完完整整地重新走一遍,就不能使用invalidate()方法,而应该调用requestLayout()了。这个方法中的流程比invalidate()方法要简单一些,但中心思想是差不多的,这里也就不再详细进行分析了。

  • Android自定义View的实现方法

如果说要按类型来划分的话,自定义View的实现方式大概可以分为三种,自绘控件、组合控件、以及继承控件。那么下面我们就来依次学习一下,每种方式分别是如何自定义View的。

一、自绘控件

自绘控件的意思就是,这个View上所展现的内容全部都是我们自己绘制出来的。绘制的代码是写在onDraw()方法中的,而这部分内容我们已经在 Android视图绘制流程完全解析,带你一步步深入了解View(二) 中学习过了。

下面我们准备来自定义一个计数器View,这个View可以响应用户的点击事件,并自动记录一共点击了多少次。新建一个CounterView继承自View,代码如下所示:

[java]  view plain copy
  1. public class CounterView extends View implements OnClickListener {
  2. private Paint mPaint;
  3. private Rect mBounds;
  4. private int mCount;
  5. public CounterView(Context context, AttributeSet attrs) {
  6. super(context, attrs);
  7. mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
  8. mBounds = new Rect();
  9. setOnClickListener(this);
  10. }
  11. @Override
  12. protected void onDraw(Canvas canvas) {
  13. super.onDraw(canvas);
  14. mPaint.setColor(Color.BLUE);
  15. canvas.drawRect(0, 0, getWidth(), getHeight(), mPaint);
  16. mPaint.setColor(Color.YELLOW);
  17. mPaint.setTextSize(30);
  18. String text = String.valueOf(mCount);
  19. mPaint.getTextBounds(text, 0, text.length(), mBounds);
  20. float textWidth = mBounds.width();
  21. float textHeight = mBounds.height();
  22. canvas.drawText(text, getWidth() / 2 - textWidth / 2, getHeight() / 2
  23. + textHeight / 2, mPaint);
  24. }
  25. @Override
  26. public void onClick(View v) {
  27. mCount++;
  28. invalidate();
  29. }
  30. }

可以看到,首先我们在CounterView的构造函数中初始化了一些数据,并给这个View的本身注册了点击事件,这样当CounterView被点击的时候,onClick()方法就会得到调用。而onClick()方法中的逻辑就更加简单了,只是对mCount这个计数器加1,然后调用invalidate()方法。通过 Android视图状态及重绘流程分析,带你一步步深入了解View(三) 这篇文章的学习我们都已经知道,调用invalidate()方法会导致视图进行重绘,因此onDraw()方法在稍后就将会得到调用。

既然CounterView是一个自绘视图,那么最主要的逻辑当然就是写在onDraw()方法里的了,下面我们就来仔细看一下。这里首先是将Paint画笔设置为蓝色,然后调用Canvas的drawRect()方法绘制了一个矩形,这个矩形也就可以当作是CounterView的背景图吧。接着将画笔设置为黄色,准备在背景上面绘制当前的计数,注意这里先是调用了getTextBounds()方法来获取到文字的宽度和高度,然后调用了drawText()方法去进行绘制就可以了。

这样,一个自定义的View就已经完成了,并且目前这个CounterView是具备自动计数功能的。那么剩下的问题就是如何让这个View在界面上显示出来了,其实这也非常简单,我们只需要像使用普通的控件一样来使用CounterView就可以了。比如在布局文件中加入如下代码:

[html]  view plain copy
  1. <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
  2. android:layout_width="match_parent"
  3. android:layout_height="match_parent" >
  4. <com.example.customview.CounterView
  5. android:layout_width="100dp"
  6. android:layout_height="100dp"
  7. android:layout_centerInParent="true" />
  8. </RelativeLayout>

可以看到,这里我们将CounterView放入了一个RelativeLayout中,然后可以像使用普通控件来给CounterView指定各种属性,比如通过layout_width和layout_height来指定CounterView的宽高,通过android:layout_centerInParent来指定它在布局里居中显示。只不过需要注意,自定义的View在使用的时候一定要写出完整的包名,不然系统将无法找到这个View。

好了,就是这么简单,接下来我们可以运行一下程序,并不停地点击CounterView,效果如下图所示。

怎么样?是不是感觉自定义View也并不是什么高级的技术,简单几行代码就可以实现了。当然了,这个CounterView功能非常简陋,只有一个计数功能,因此只需几行代码就足够了,当你需要绘制比较复杂的View时,还是需要很多技巧的。

二、组合控件


组合控件的意思就是,我们并不需要自己去绘制视图上显示的内容,而只是用系统原生的控件就好了,但我们可以将几个系统原生的控件组合到一起,这样创建出的控件就被称为组合控件。

举个例子来说,标题栏就是个很常见的组合控件,很多界面的头部都会放置一个标题栏,标题栏上会有个返回按钮和标题,点击按钮后就可以返回到上一个界面。那么下面我们就来尝试去实现这样一个标题栏控件。

新建一个title.xml布局文件,代码如下所示:

[html]  view plain copy
  1. <?xml version="1.0" encoding="utf-8"?>
  2. <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
  3. android:layout_width="match_parent"
  4. android:layout_height="50dp"
  5. android:background="#ffcb05" >
  6. <Button
  7. android:id="@+id/button_left"
  8. android:layout_width="60dp"
  9. android:layout_height="40dp"
  10. android:layout_centerVertical="true"
  11. android:layout_marginLeft="5dp"
  12. android:background="@drawable/back_button"
  13. android:text="Back"
  14. android:textColor="#fff" />
  15. <TextView
  16. android:id="@+id/title_text"
  17. android:layout_width="wrap_content"
  18. android:layout_height="wrap_content"
  19. android:layout_centerInParent="true"
  20. android:text="This is Title"
  21. android:textColor="#fff"
  22. android:textSize="20sp" />
  23. </RelativeLayout>

在这个布局文件中,我们首先定义了一个RelativeLayout作为背景布局,然后在这个布局里定义了一个Button和一个TextView,Button就是标题栏中的返回按钮,TextView就是标题栏中的显示的文字。

接下来创建一个TitleView继承自FrameLayout,代码如下所示:

[java]  view plain copy
  1. public class TitleView extends FrameLayout {
  2. private Button leftButton;
  3. private TextView titleText;
  4. public TitleView(Context context, AttributeSet attrs) {
  5. super(context, attrs);
  6. LayoutInflater.from(context).inflate(R.layout.title, this);
  7. titleText = (TextView) findViewById(R.id.title_text);
  8. leftButton = (Button) findViewById(R.id.button_left);
  9. leftButton.setOnClickListener(new OnClickListener() {
  10. @Override
  11. public void onClick(View v) {
  12. ((Activity) getContext()).finish();
  13. }
  14. });
  15. }
  16. public void setTitleText(String text) {
  17. titleText.setText(text);
  18. }
  19. public void setLeftButtonText(String text) {
  20. leftButton.setText(text);
  21. }
  22. public void setLeftButtonListener(OnClickListener l) {
  23. leftButton.setOnClickListener(l);
  24. }
  25. }

TitleView中的代码非常简单,在TitleView的构建方法中,我们调用了LayoutInflater的inflate()方法来加载刚刚定义的title.xml布局,这部分内容我们已经在 Android LayoutInflater原理分析,带你一步步深入了解View(一) 这篇文章中学习过了。

接下来调用findViewById()方法获取到了返回按钮的实例,然后在它的onClick事件中调用finish()方法来关闭当前的Activity,也就相当于实现返回功能了。

另外,为了让TitleView有更强地扩展性,我们还提供了setTitleText()、setLeftButtonText()、setLeftButtonListener()等方法,分别用于设置标题栏上的文字、返回按钮上的文字、以及返回按钮的点击事件。

到了这里,一个自定义的标题栏就完成了,那么下面又到了如何引用这个自定义View的部分,其实方法基本都是相同的,在布局文件中添加如下代码:

[html]  view plain copy
  1. <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
  2. xmlns:tools="http://schemas.android.com/tools"
  3. android:layout_width="match_parent"
  4. android:layout_height="match_parent" >
  5. <com.example.customview.TitleView
  6. android:id="@+id/title_view"
  7. android:layout_width="match_parent"
  8. android:layout_height="wrap_content" >
  9. </com.example.customview.TitleView>
  10. </RelativeLayout>

这样就成功将一个标题栏控件引入到布局文件中了,运行一下程序,效果如下图所示:

现在点击一下Back按钮,就可以关闭当前的Activity了。如果你想要修改标题栏上显示的内容,或者返回按钮的默认事件,只需要在Activity中通过findViewById()方法得到TitleView的实例,然后调用setTitleText()、setLeftButtonText()、setLeftButtonListener()等方法进行设置就OK了。

三、继承控件

继承控件的意思就是,我们并不需要自己重头去实现一个控件,只需要去继承一个现有的控件,然后在这个控件上增加一些新的功能,就可以形成一个自定义的控件了。这种自定义控件的特点就是不仅能够按照我们的需求加入相应的功能,还可以保留原生控件的所有功能,比如 Android PowerImageView实现,可以播放动画的强大ImageView 这篇文章中介绍的PowerImageView就是一个典型的继承控件。

为了能够加深大家对这种自定义View方式的理解,下面我们再来编写一个新的继承控件。ListView相信每一个Android程序员都一定使用过,这次我们准备对ListView进行扩展,加入在ListView上滑动就可以显示出一个删除按钮,点击按钮就会删除相应数据的功能。

首先需要准备一个删除按钮的布局,新建delete_button.xml文件,代码如下所示:

[html]  view plain copy
  1. <?xml version="1.0" encoding="utf-8"?>
  2. <Button xmlns:android="http://schemas.android.com/apk/res/android"
  3. android:id="@+id/delete_button"
  4. android:layout_width="wrap_content"
  5. android:layout_height="wrap_content"
  6. android:background="@drawable/delete_button" >
  7. </Button>

这个布局文件很简单,只有一个按钮而已,并且我们给这个按钮指定了一张删除背景图。

接着创建MyListView继承自ListView,这就是我们自定义的View了,代码如下所示:

[java]  view plain copy
  1. public class MyListView extends ListView implements OnTouchListener,
  2. OnGestureListener {
  3. private GestureDetector gestureDetector;
  4. private OnDeleteListener listener;
  5. private View deleteButton;
  6. private ViewGroup itemLayout;
  7. private int selectedItem;
  8. private boolean isDeleteShown;
  9. public MyListView(Context context, AttributeSet attrs) {
  10. super(context, attrs);
  11. gestureDetector = new GestureDetector(getContext(), this);
  12. setOnTouchListener(this);
  13. }
  14. public void setOnDeleteListener(OnDeleteListener l) {
  15. listener = l;
  16. }
  17. @Override
  18. public boolean onTouch(View v, MotionEvent event) {
  19. if (isDeleteShown) {
  20. itemLayout.removeView(deleteButton);
  21. deleteButton = null;
  22. isDeleteShown = false;
  23. return false;
  24. } else {
  25. return gestureDetector.onTouchEvent(event);
  26. }
  27. }
  28. @Override
  29. public boolean onDown(MotionEvent e) {
  30. if (!isDeleteShown) {
  31. selectedItem = pointToPosition((int) e.getX(), (int) e.getY());
  32. }
  33. return false;
  34. }
  35. @Override
  36. public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX,
  37. float velocityY) {
  38. if (!isDeleteShown && Math.abs(velocityX) > Math.abs(velocityY)) {
  39. deleteButton = LayoutInflater.from(getContext()).inflate(
  40. R.layout.delete_button, null);
  41. deleteButton.setOnClickListener(new OnClickListener() {
  42. @Override
  43. public void onClick(View v) {
  44. itemLayout.removeView(deleteButton);
  45. deleteButton = null;
  46. isDeleteShown = false;
  47. listener.onDelete(selectedItem);
  48. }
  49. });
  50. itemLayout = (ViewGroup) getChildAt(selectedItem
  51. - getFirstVisiblePosition());
  52. RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(
  53. LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
  54. params.addRule(RelativeLayout.ALIGN_PARENT_RIGHT);
  55. params.addRule(RelativeLayout.CENTER_VERTICAL);
  56. itemLayout.addView(deleteButton, params);
  57. isDeleteShown = true;
  58. }
  59. return false;
  60. }
  61. @Override
  62. public boolean onSingleTapUp(MotionEvent e) {
  63. return false;
  64. }
  65. @Override
  66. public void onShowPress(MotionEvent e) {
  67. }
  68. @Override
  69. public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX,
  70. float distanceY) {
  71. return false;
  72. }
  73. @Override
  74. public void onLongPress(MotionEvent e) {
  75. }
  76. public interface OnDeleteListener {
  77. void onDelete(int index);
  78. }
  79. }

由于代码逻辑比较简单,我就没有加注释。这里在MyListView的构造方法中创建了一个GestureDetector的实例用于监听手势,然后给MyListView注册了touch监听事件。然后在onTouch()方法中进行判断,如果删除按钮已经显示了,就将它移除掉,如果删除按钮没有显示,就使用GestureDetector来处理当前手势。

当手指按下时,会调用OnGestureListener的onDown()方法,在这里通过pointToPosition()方法来判断出当前选中的是ListView的哪一行。当手指快速滑动时,会调用onFling()方法,在这里会去加载delete_button.xml这个布局,然后将删除按钮添加到当前选中的那一行item上。注意,我们还给删除按钮添加了一个点击事件,当点击了删除按钮时就会回调onDeleteListener的onDelete()方法,在回调方法中应该去处理具体的删除操作。

好了,自定义View的功能到此就完成了,接下来我们需要看一下如何才能使用这个自定义View。首先需要创建一个ListView子项的布局文件,新建my_list_view_item.xml,代码如下所示:

[html]  view plain copy
  1. <?xml version="1.0" encoding="utf-8"?>
  2. <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
  3. android:layout_width="match_parent"
  4. android:layout_height="match_parent"
  5. android:descendantFocusability="blocksDescendants"
  6. android:orientation="vertical" >
  7. <TextView
  8. android:id="@+id/text_view"
  9. android:layout_width="wrap_content"
  10. android:layout_height="50dp"
  11. android:layout_centerVertical="true"
  12. android:gravity="left|center_vertical"
  13. android:textColor="#000" />
  14. </RelativeLayout>

然后创建一个适配器MyAdapter,在这个适配器中去加载my_list_view_item布局,代码如下所示:

[java]  view plain copy
  1. public class MyAdapter extends ArrayAdapter<String> {
  2. public MyAdapter(Context context, int textViewResourceId, List<String> objects) {
  3. super(context, textViewResourceId, objects);
  4. }
  5. @Override
  6. public View getView(int position, View convertView, ViewGroup parent) {
  7. View view;
  8. if (convertView == null) {
  9. view = LayoutInflater.from(getContext()).inflate(R.layout.my_list_view_item, null);
  10. } else {
  11. view = convertView;
  12. }
  13. TextView textView = (TextView) view.findViewById(R.id.text_view);
  14. textView.setText(getItem(position));
  15. return view;
  16. }
  17. }

到这里就基本已经完工了,下面在程序的主布局文件里面引入MyListView这个控件,如下所示:

[html]  view plain copy
  1. <RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
  2. xmlns:tools="http://schemas.android.com/tools"
  3. android:layout_width="match_parent"
  4. android:layout_height="match_parent" >
  5. <com.example.customview.MyListView
  6. android:id="@+id/my_list_view"
  7. android:layout_width="match_parent"
  8. android:layout_height="wrap_content" >
  9. </com.example.customview.MyListView>
  10. </RelativeLayout>

最后在Activity中初始化MyListView中的数据,并处理了onDelete()方法的删除逻辑,代码如下所示:

[java]  view plain copy
  1. public class MainActivity extends Activity {
  2. private MyListView myListView;
  3. private MyAdapter adapter;
  4. private List<String> contentList = new ArrayList<String>();
  5. @Override
  6. protected void onCreate(Bundle savedInstanceState) {
  7. super.onCreate(savedInstanceState);
  8. requestWindowFeature(Window.FEATURE_NO_TITLE);
  9. setContentView(R.layout.activity_main);
  10. initList();
  11. myListView = (MyListView) findViewById(R.id.my_list_view);
  12. myListView.setOnDeleteListener(new OnDeleteListener() {
  13. @Override
  14. public void onDelete(int index) {
  15. contentList.remove(index);
  16. adapter.notifyDataSetChanged();
  17. }
  18. });
  19. adapter = new MyAdapter(this, 0, contentList);
  20. myListView.setAdapter(adapter);
  21. }
  22. private void initList() {
  23. contentList.add("Content Item 1");
  24. contentList.add("Content Item 2");
  25. contentList.add("Content Item 3");
  26. contentList.add("Content Item 4");
  27. contentList.add("Content Item 5");
  28. contentList.add("Content Item 6");
  29. contentList.add("Content Item 7");
  30. contentList.add("Content Item 8");
  31. contentList.add("Content Item 9");
  32. contentList.add("Content Item 10");
  33. contentList.add("Content Item 11");
  34. contentList.add("Content Item 12");
  35. contentList.add("Content Item 13");
  36. contentList.add("Content Item 14");
  37. contentList.add("Content Item 15");
  38. contentList.add("Content Item 16");
  39. contentList.add("Content Item 17");
  40. contentList.add("Content Item 18");
  41. contentList.add("Content Item 19");
  42. contentList.add("Content Item 20");
  43. }
  44. }

这样就把整个例子的代码都完成了,现在运行一下程序,会看到MyListView可以像ListView一样,正常显示所有的数据,但是当你用手指在MyListView的某一行上快速滑动时,就会有一个删除按钮显示出来,如下图所示:

点击一下删除按钮就可以将第6行的数据删除了。此时的MyListView不仅保留了ListView原生的所有功能,还增加了一个滑动进行删除的功能,确实是一个不折不扣的继承控件。
  • 下面是对之前的复习与总结的一个小例子:

1.自定义View

首先我们要明白,为什么要自定义View?主要是Android系统内置的View无法实现我们的需求,我们需要针对我们的业务需求定制我们想要的View。自定义View我们大部分时候只需重写两个函数:onMeasure()、onDraw()。onMeasure负责对当前View的尺寸进行测量,onDraw负责把当前这个View绘制出来。当然了,你还得写至少写2个构造函数:

    public MyView(Context context) {super(context);}public MyView(Context context, AttributeSet attrs) {super(context, attrs); }

1.1.onMeasure

我们自定义的View,首先得要测量宽高尺寸。为什么要测量宽高尺寸?我在刚学自定义View的时候非常无法理解!因为我当时觉得,我在xml文件中已经指定好了宽高尺寸了,我自定义View中有必要再次获取宽高并设置宽高吗?既然我自定义的View是继承自View类,google团队直接在View类中直接把xml设置的宽高获取,并且设置进去不就好了吗?那google为啥让我们做这样的“重复工作”呢?客官别急,马上给您上茶~

在学习Android的时候,我们就知道,在xml布局文件中,我们的layout_widthlayout_height参数可以不用写具体的尺寸,而是wrap_content或者是match_parent。其意思我们都知道,就是将尺寸设置为“包住内容”和“填充父布局给我们的所有空间”。这两个设置并没有指定真正的大小,可是我们绘制到屏幕上的View必须是要有具体的宽高的,正是因为这个原因,我们必须自己去处理和设置尺寸。当然了,View类给了默认的处理,但是如果View类的默认处理不满足我们的要求,我们就得重写onMeasure函数啦~。这里举个例子,比如我们希望我们的View是个正方形,如果在xml中指定宽高为wrap_content,如果使用View类提供的measure处理方式,显然无法满足我们的需求~。

先看看onMeasure函数原型:

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec)

参数中的widthMeasureSpecheightMeasureSpec是个什么鬼?看起来很像width和height,没错,这两个参数就是包含宽和高的信息。什么?包含?难道还要其他信息?是的!它还包含测量模式,也就是说,一个int整数,里面放了测量模式和尺寸大小。那么一个数怎么放两个信息呢?我们知道,我们在设置宽高时有3个选择:wrap_contentmatch_parent以及指定固定尺寸,而测量模式也有3种:UNSPECIFIEDEXACTLYAT_MOST,当然,他们并不是一一对应关系哈,这三种模式后面我会详细介绍,但测量模式无非就是这3种情况,而如果使用二进制,我们只需要使用2个bit就可以做到,因为2个bit取值范围是[0,3]里面可以存放4个数足够我们用了。那么Google是怎么把一个int同时放测量模式和尺寸信息呢?我们知道int型数据占用32个bit,而google实现的是,将int数据的前面2个bit用于区分不同的布局模式,后面30个bit存放的是尺寸的数据。

那我们怎么从int数据中提取测量模式和尺寸呢?放心,不用你每次都要写一次移位<<和取且&操作,Android内置类MeasureSpec帮我们写好啦~,我们只需按照下面方法就可以拿到啦:

int widthMode = MeasureSpec.getMode(widthMeasureSpec);
int widthSize = MeasureSpec.getSize(widthMeasureSpec);

爱思考的你肯定会问,既然我们能通过widthMeasureSpec拿到宽度尺寸大小,那我们还要测量模式干嘛?测量模式会不会是多余的?请注意:这里的的尺寸大小并不是最终我们的View的尺寸大小,而是父View提供的参考大小。我们看看测量模式,测量模式是干啥用的呢?

测量模式 表示意思
UNSPECIFIED 父容器没有对当前View有任何限制,当前View可以任意取尺寸
EXACTLY 当前的尺寸就是当前View应该取的尺寸
AT_MOST 当前尺寸是当前View能取的最大尺寸

而上面的测量模式跟我们的布局时的wrap_contentmatch_parent以及写成固定的尺寸有什么对应关系呢?

match_parent--->EXACTLY。怎么理解呢?match_parent就是要利用父View给我们提供的所有剩余空间,而父View剩余空间是确定的,也就是这个测量模式的整数里面存放的尺寸。

wrap_content--->AT_MOST。怎么理解:就是我们想要将大小设置为包裹我们的view内容,那么尺寸大小就是父View给我们作为参考的尺寸,只要不超过这个尺寸就可以啦,具体尺寸就根据我们的需求去设定。

固定尺寸(如100dp)--->EXACTLY。用户自己指定了尺寸大小,我们就不用再去干涉了,当然是以指定的大小为主啦。

1.2.动手重写onMeasure函数

上面讲了太多理论,我们实际操作一下吧,感受一下onMeasure的使用,假设我们要实现这样一个效果:将当前的View以正方形的形式显示,即要宽高相等,并且默认的宽高值为100像素。就可以这些编写:

 private int getMySize(int defaultSize, int measureSpec) {int mySize = defaultSize;int mode = MeasureSpec.getMode(measureSpec);int size = MeasureSpec.getSize(measureSpec);switch (mode) {case MeasureSpec.UNSPECIFIED: {//如果没有指定大小,就设置为默认大小mySize = defaultSize;break;}case MeasureSpec.AT_MOST: {//如果测量模式是最大取值为size//我们将大小取最大值,你也可以取其他值mySize = size;break;}case MeasureSpec.EXACTLY: {//如果是固定的大小,那就不要去改变它mySize = size;break;}}return mySize;
}@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {super.onMeasure(widthMeasureSpec, heightMeasureSpec);int width = getMySize(100, widthMeasureSpec);int height = getMySize(100, heightMeasureSpec);if (width < height) {height = width;} else {width = height;}setMeasuredDimension(width, height);
}

我们设置一下布局

  <com.hc.studyview.MyViewandroid:layout_width="match_parent"android:layout_height="100dp"android:background="#ff0000" />

看看使用了我们自己定义的onMeasure函数后的效果:

自定义View

而如果我们不重写onMeasure,效果则是如下:

默认size

1.3.重写onDraw

上面我们学会了自定义尺寸大小,那么尺寸我们会设定了,接下来就是把我们想要的效果画出来吧~绘制我们想要的效果很简单,直接在画板Canvas对象上绘制就好啦,过于简单,我们以一个简单的例子去学习:假设我们需要实现的是,我们的View显示一个圆形,我们在上面已经实现了宽高尺寸相等的基础上,继续往下做:

  @Overrideprotected void onDraw(Canvas canvas) {//调用父View的onDraw函数,因为View这个类帮我们实现了一些// 基本的而绘制功能,比如绘制背景颜色、背景图片等super.onDraw(canvas);int r = getMeasuredWidth() / 2;//也可以是getMeasuredHeight()/2,本例中我们已经将宽高设置相等了//圆心的横坐标为当前的View的左边起始位置+半径int centerX = getLeft() + r;//圆心的纵坐标为当前的View的顶部起始位置+半径int centerY = getTop() + r;Paint paint = new Paint();paint.setColor(Color.GREEN);//开始绘制canvas.drawCircle(centerX, centerY, r, paint);}

显示效果

1.4.自定义布局属性

如果有些属性我们希望由用户指定,只有当用户不指定的时候才用我们硬编码的值,比如上面的默认尺寸,我们想要由用户自己在布局文件里面指定该怎么做呢?那当然是通我们自定属性,让用户用我们定义的属性啦~

首先我们需要在res/values/styles.xml文件(如果没有请自己新建)里面声明一个我们自定义的属性:

<resources><!--name为声明的"属性集合"名,可以随便取,但是最好是设置为跟我们的View一样的名称--><declare-styleable name="MyView"><!--声明我们的属性,名称为default_size,取值类型为尺寸类型(dp,px等)--><attr name="default_size" format="dimension" /></declare-styleable>
</resources>

接下来就是在布局文件用上我们的自定义的属性啦~

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"xmlns:hc="http://schemas.android.com/apk/res-auto"android:layout_width="match_parent"android:layout_height="match_parent"><com.hc.studyview.MyViewandroid:layout_width="match_parent"android:layout_height="100dp"hc:default_size="100dp" /></LinearLayout>

注意:需要在根标签(LinearLayout)里面设定命名空间,命名空间名称可以随便取,比如hc,命名空间后面取得值是固定的:"http://schemas.android.com/apk/res-auto"

最后就是在我们的自定义的View里面把我们自定义的属性的值取出来,在构造函数中,还记得有个AttributeSet属性吗?就是靠它帮我们把布局里面的属性取出来:

 private int defalutSize;public MyView(Context context, AttributeSet attrs) {super(context, attrs);//第二个参数就是我们在styles.xml文件中的<declare-styleable>标签//即属性集合的标签,在R文件中名称为R.styleable+nameTypedArray a = context.obtainStyledAttributes(attrs, R.styleable.MyView);//第一个参数为属性集合里面的属性,R文件名称:R.styleable+属性集合名称+下划线+属性名称//第二个参数为,如果没有设置这个属性,则设置的默认的值defalutSize = a.getDimensionPixelSize(R.styleable.MyView_default_size, 100);//最后记得将TypedArray对象回收a.recycle();}

最后,把MyView的完整代码附上:

package com.hc.studyview;import android.content.Context;
import android.content.res.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.util.AttributeSet;
import android.util.Log;
import android.view.MotionEvent;
import android.view.View;/*** Package com.hc.studyview* Created by HuaChao on 2016/6/3.*/
public class MyView extends View {private int defalutSize;public MyView(Context context) {super(context);}public MyView(Context context, AttributeSet attrs) {super(context, attrs);//第二个参数就是我们在styles.xml文件中的<declare-styleable>标签//即属性集合的标签,在R文件中名称为R.styleable+nameTypedArray a = context.obtainStyledAttributes(attrs, R.styleable.MyView);//第一个参数为属性集合里面的属性,R文件名称:R.styleable+属性集合名称+下划线+属性名称//第二个参数为,如果没有设置这个属性,则设置的默认的值defalutSize = a.getDimensionPixelSize(R.styleable.MyView_default_size, 100);//最后记得将TypedArray对象回收a.recycle();}private int getMySize(int defaultSize, int measureSpec) {int mySize = defaultSize;int mode = MeasureSpec.getMode(measureSpec);int size = MeasureSpec.getSize(measureSpec);switch (mode) {case MeasureSpec.UNSPECIFIED: {//如果没有指定大小,就设置为默认大小mySize = defaultSize;break;}case MeasureSpec.AT_MOST: {//如果测量模式是最大取值为size//我们将大小取最大值,你也可以取其他值mySize = size;break;}case MeasureSpec.EXACTLY: {//如果是固定的大小,那就不要去改变它mySize = size;break;}}return mySize;}@Overrideprotected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {super.onMeasure(widthMeasureSpec, heightMeasureSpec);int width = getMySize(defalutSize, widthMeasureSpec);int height = getMySize(defalutSize, heightMeasureSpec);if (width < height) {height = width;} else {width = height;}setMeasuredDimension(width, height);}@Overrideprotected void onDraw(Canvas canvas) {//调用父View的onDraw函数,因为View这个类帮我们实现了一些// 基本的而绘制功能,比如绘制背景颜色、背景图片等super.onDraw(canvas);int r = getMeasuredWidth() / 2;//也可以是getMeasuredHeight()/2,本例中我们已经将宽高设置相等了//圆心的横坐标为当前的View的左边起始位置+半径int centerX = getLeft() + r;//圆心的纵坐标为当前的View的顶部起始位置+半径int centerY = getTop() + r;Paint paint = new Paint();paint.setColor(Color.GREEN);//开始绘制canvas.drawCircle(centerX, centerY, r, paint);}}

2 自定义ViewGroup

自定义View的过程很简单,就那几步,可自定义ViewGroup可就没那么简单啦~,因为它不仅要管好自己的,还要兼顾它的子View。我们都知道ViewGroup是个View容器,它装纳child View并且负责把child View放入指定的位置。我们假象一下,如果是让你负责设计ViewGroup,你会怎么去设计呢?

1.首先,我们得知道各个子View的大小吧,只有先知道子View的大小,我们才知道当前的ViewGroup该设置为多大去容纳它们。

2.根据子View的大小,以及我们的ViewGroup要实现的功能,决定出ViewGroup的大小

3.ViewGroup和子View的大小算出来了之后,接下来就是去摆放了吧,具体怎么去摆放呢?这得根据你定制的需求去摆放了,比如,你想让子View按照垂直顺序一个挨着一个放,或者是按照先后顺序一个叠一个去放,这是你自己决定的。

4.已经知道怎么去摆放还不行啊,决定了怎么摆放就是相当于把已有的空间"分割"成大大小小的空间,每个空间对应一个子View,我们接下来就是把子View对号入座了,把它们放进它们该放的地方去。

现在就完成了ViewGroup的设计了,我们来个具体的案例:将子View按从上到下垂直顺序一个挨着一个摆放,即模仿实现LinearLayout的垂直布局。

首先重写onMeasure,实现测量子View大小以及设定ViewGroup的大小:

    @Overrideprotected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {super.onMeasure(widthMeasureSpec, heightMeasureSpec);//将所有的子View进行测量,这会触发每个子View的onMeasure函数//注意要与measureChild区分,measureChild是对单个view进行测量measureChildren(widthMeasureSpec, heightMeasureSpec);int widthMode = MeasureSpec.getMode(widthMeasureSpec);int widthSize = MeasureSpec.getSize(widthMeasureSpec);int heightMode = MeasureSpec.getMode(heightMeasureSpec);int heightSize = MeasureSpec.getSize(heightMeasureSpec);int childCount = getChildCount();if (childCount == 0) {//如果没有子View,当前ViewGroup没有存在的意义,不用占用空间setMeasuredDimension(0, 0);} else {//如果宽高都是包裹内容if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) {//我们将高度设置为所有子View的高度相加,宽度设为子View中最大的宽度int height = getTotleHeight();int width = getMaxChildWidth();setMeasuredDimension(width, height);} else if (heightMode == MeasureSpec.AT_MOST) {//如果只有高度是包裹内容//宽度设置为ViewGroup自己的测量宽度,高度设置为所有子View的高度总和setMeasuredDimension(widthSize, getTotleHeight());} else if (widthMode == MeasureSpec.AT_MOST) {//如果只有宽度是包裹内容//宽度设置为子View中宽度最大的值,高度设置为ViewGroup自己的测量值setMeasuredDimension(getMaxChildWidth(), heightSize);}}}/**** 获取子View中宽度最大的值*/private int getMaxChildWidth() {int childCount = getChildCount();int maxWidth = 0;for (int i = 0; i < childCount; i++) {View childView = getChildAt(i);if (childView.getMeasuredWidth() > maxWidth)maxWidth = childView.getMeasuredWidth();}return maxWidth;}/**** 将所有子View的高度相加**/private int getTotleHeight() {int childCount = getChildCount();int height = 0;for (int i = 0; i < childCount; i++) {View childView = getChildAt(i);height += childView.getMeasuredHeight();}return height;}

代码中的注释我已经写得很详细,不再对每一行代码进行讲解。上面的onMeasure将子View测量好了,以及把自己的尺寸也设置好了,接下来我们去摆放子View吧~

 @Overrideprotected void onLayout(boolean changed, int l, int t, int r, int b) {int count = getChildCount();//记录当前的高度位置int curHeight = t;//将子View逐个摆放for (int i = 0; i < count; i++) {View child = getChildAt(i);int height = child.getMeasuredHeight();int width = child.getMeasuredWidth();//摆放子View,参数分别是子View矩形区域的左、上、右、下边child.layout(l, curHeight, l + width, curHeight + height);curHeight += height;}}

我们测试一下,将我们自定义的ViewGroup里面放3个Button ,将这3个Button的宽度设置不一样,把我们的ViewGroup的宽高都设置为包裹内容wrap_content,为了看的效果明显,我们给ViewGroup加个背景:

<?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"><com.hc.studyview.MyViewGroupandroid:layout_width="wrap_content"android:layout_height="wrap_content"android:background="#ff9900"><Buttonandroid:layout_width="100dp"android:layout_height="wrap_content"android:text="btn" /><Buttonandroid:layout_width="200dp"android:layout_height="wrap_content"android:text="btn" /><Buttonandroid:layout_width="50dp"android:layout_height="wrap_content"android:text="btn" /></com.hc.studyview.MyViewGroup></LinearLayout>

看看最后的效果吧~

自定义ViewGroup

是不是很激动~我们自己也可以实现LinearLayout的效果啦~~~~

最后附上MyViewGroup的完整源码:

package com.hc.studyview;import android.content.Context;
import android.util.AttributeSet;
import android.view.MotionEvent;
import android.view.View;
import android.view.ViewGroup;/*** Package com.hc.studyview* Created by HuaChao on 2016/6/3.*/
public class MyViewGroup extends ViewGroup {public MyViewGroup(Context context) {super(context);}public MyViewGroup(Context context, AttributeSet attrs) {super(context, attrs);}/**** 获取子View中宽度最大的值*/private int getMaxChildWidth() {int childCount = getChildCount();int maxWidth = 0;for (int i = 0; i < childCount; i++) {View childView = getChildAt(i);if (childView.getMeasuredWidth() > maxWidth)maxWidth = childView.getMeasuredWidth();}return maxWidth;}/**** 将所有子View的高度相加**/private int getTotleHeight() {int childCount = getChildCount();int height = 0;for (int i = 0; i < childCount; i++) {View childView = getChildAt(i);height += childView.getMeasuredHeight();}return height;}@Overrideprotected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {super.onMeasure(widthMeasureSpec, heightMeasureSpec);//将所有的子View进行测量,这会触发每个子View的onMeasure函数//注意要与measureChild区分,measureChild是对单个view进行测量measureChildren(widthMeasureSpec, heightMeasureSpec);int widthMode = MeasureSpec.getMode(widthMeasureSpec);int widthSize = MeasureSpec.getSize(widthMeasureSpec);int heightMode = MeasureSpec.getMode(heightMeasureSpec);int heightSize = MeasureSpec.getSize(heightMeasureSpec);int childCount = getChildCount();if (childCount == 0) {//如果没有子View,当前ViewGroup没有存在的意义,不用占用空间setMeasuredDimension(0, 0);} else {//如果宽高都是包裹内容if (widthMode == MeasureSpec.AT_MOST && heightMode == MeasureSpec.AT_MOST) {//我们将高度设置为所有子View的高度相加,宽度设为子View中最大的宽度int height = getTotleHeight();int width = getMaxChildWidth();setMeasuredDimension(width, height);} else if (heightMode == MeasureSpec.AT_MOST) {//如果只有高度是包裹内容//宽度设置为ViewGroup自己的测量宽度,高度设置为所有子View的高度总和setMeasuredDimension(widthSize, getTotleHeight());} else if (widthMode == MeasureSpec.AT_MOST) {//如果只有宽度是包裹内容//宽度设置为子View中宽度最大的值,高度设置为ViewGroup自己的测量值setMeasuredDimension(getMaxChildWidth(), heightSize);}}}@Overrideprotected void onLayout(boolean changed, int l, int t, int r, int b) {int count = getChildCount();//记录当前的高度位置int curHeight = t;for (int i = 0; i < count; i++) {View child = getChildAt(i);int height = child.getMeasuredHeight();int width = child.getMeasuredWidth();child.layout(l, curHeight, l + width, curHeight + height);curHeight += height;}}}

好啦~自定义View的学习到此结束,是不是发现自定义View如此简单呢?

Android 自定义view完全解析--带你通透了解自定义view相关推荐

  1. Android LayoutInflater原理分析,带你一步步深入了解View

    Android视图绘制流程完全解析,带你一步步深入了解View(一) 转载请注明出处:http://blog.csdn.net/guolin_blog/article/details/12921889 ...

  2. Android LayoutInflater原理分析,带你一步步深入了解View(一)

    转载请注明出处:http://blog.csdn.net/guolin_blog/article/details/12921889 有段时间没写博客了,感觉都有些生疏了呢.最近繁忙的工作终于告一段落, ...

  3. 【JFreeChart】自定义蜘蛛网图生成带刻度三角雷达图 自定义文字风格 背景色

    工作中需要生成PDF 且包含图表.iText 或其他 可以访问网页地址转PDF.但是效果不是特别理想.故用iText代码方式实现生成PDF.奈何图表又是一个问题(还是个三角形的雷达图).Java端生成 ...

  4. Android LayoutInflater原理分析,带你一步步深入了解View(一) 郭霖学习摘要

    2019独角兽企业重金招聘Python工程师标准>>> public class MainActivity extends Activity {//----------------- ...

  5. Android自定义View的实现方法,带你一步步深入了解View(四)

    不知不觉中,带你一步步深入了解View系列的文章已经写到第四篇了,回顾一下,我们一共学习了LayoutInflater的原理分析.视图的绘制流程.视图的状态及重绘等知识,算是把View中很多重要的知识 ...

  6. Android自定义View的实现方法,带你一步步深入了解View

    如果说要按类型来划分的话,自定义View的实现方式大概可以分为三种,自绘控件.组合控件.以及继承控件.那么下面我们就来依次学习一下,每种方式分别是如何自定义View的. 一.自绘控件 自绘控件的意思就 ...

  7. Android视图绘制流程完全解析,带你一步步深入了解View(二)

    在上一篇文章中,我带着大家一起剖析了一下LayoutInflater的工作原理,可以算是对View进行深入了解的第一步吧.那么本篇文章中,我们将继续对View进行深入探究,看一看它的绘制流程到底是什么 ...

  8. Android LayoutInflater 源码解析

    在上篇文章中我们学习了setContentView的源码,还记得其中的LayoutInflater吗?本篇文章就来学习下LayoutInflater. @Overridepublic void set ...

  9. Carson带你学Android:源码解析自定义View Draw过程

    前言 自定义View是Android开发者必须了解的基础 网上有大量关于自定义View原理的文章,但存在一些问题:内容不全.思路不清晰.无源码分析.简单问题复杂化 等 今天,我将全面总结自定义View ...

最新文章

  1. Clipboard类summary
  2. python 爬虫 selenium
  3. 【英文写作日知录 第1期】句式汇总 Sun, 05 July 2020
  4. 项目部署—移除Spring Boot内置Tomcat,部署到云服务器Tomcat
  5. MySQL8的8大新SQL特性
  6. [WPF Bug清单]之(4)——点击RadioButton的空白没有反应
  7. 3D视频的质量评价报告 (MSU出品)
  8. opencv图像直方图的计算及绘制
  9. 汇编语言程序设计-钱晓捷(第五版)第三章-汇编语言程序格式
  10. Hadoop面试题及参考答案
  11. 常用工具方法(7S,28,SWOT,PDCA,SMART,6W2H,时间管理四矩阵,WBS,碎石分析,ORID)
  12. 『深度实战』天池小目标检测大赛·宫颈癌风险智能诊断推荐
  13. python flask web框架_Python_WEB框架之Flask
  14. 借助工业网关如何实现罗克韦尔PLC的远程监控和维护?
  15. 春招大厂上岸学长带你有效春招找工作
  16. JavaScriptES5新方法
  17. 计算机win10分区软件,如何利用Win10系统DiskPart工具进行GPT硬盘分区
  18. HTML期末学生大作业 基于HTML+CSS+JavaScript通用的后台管理系统ui框架模板
  19. yaf 修改php.ini,Yaf的配置
  20. EPSON LS3-401S机器人学习笔记 5 - 坐标系

热门文章

  1. vue2 typescript 项目 如何引入antd -ui组件
  2. 这所211怒将自己学生告成老赖:90后校友承诺捐母校1100万未兑现
  3. python中range函数是什么意思_python中range什么意思
  4. python读取千万级数据库数据类型_Python 基于Python从mysql表读取千万数据实践
  5. 中国石油大学《工程概预算与招投标》第一阶段在线作业
  6. 受汉城改名鼓舞,世界各地掀起改名热潮,尤其是那个小日本(笑话)
  7. iOS开发基础之第三方调起自己的App
  8. 五一影视圈的神仙打架,揭开了内容市场暗自角力的真相
  9. 我的言论05-04-06
  10. Android面试总结(持续更新修改)