View的工作流程

3.7 View的工作流程
View的工作流程,指的就是measure、layout和draw。其中,measure用来测量View的宽和高,layout用来确定View的位置,draw则用来绘制View。
3.7.1 View的工作流程入口
在3.6.1节中我们讲到了Activity的构成,最后讲到了DecorView的创建以及它加载的资源。这个时候DecorView的内容还无法显示,因为它还没有被加载到Window中。接下来我们来看看DecorView如何被加载到Window中。
1.DecorView被加载到Window中
当DecorView创建完毕,要加载到Window中时,我们需要先了解一下Activity的创建过程。当我们调用Activity的startActivity方法时,最终是调用ActivityThread的handleLaunchActivity方法来创建Activity的,代码如下所示:

上面代码注释 1 处调用 performLaunchActivity 方法来创建 Activity,在这里面会调用到Activity的onCreate方法,从而完成DecorView的创建,这个内容在3.6.1节讲到过。接着在上面代码注释2处调用handleResumeActivity方法,代码如下所示:
final void handleResumeActivity(IBinder token,

在上面代码注释1处的performResumeActivity方法中会调用Activity的onResume方法。接着往下看,注释2处得到了DecorView。注释3处得到了WindowManager,WindowManager是一个接口并且继承了接口ViewManager。在注释4处调用WindowManager的addView方法,WindowManager 的实现类是 WindowManagerImpl,所以实际调用的是 WindowManagerImpl 的addView方法。具体代码如下所示:

在 WindowManagerImpl 的 addView 方法中,又调用了 WindowManagerGlobal 的 addView方法,代码如下所示:

在上面代码注释1处创建了ViewRootImpl实例,在注释2处调用了ViewRootImpl的setView方法并将DecorView作为参数传进去,这样就把DecorView加载到了Window中。当然界面仍不会显示出什么来,因为View的工作流程还没有执行完,还需要经过measure、layout以及draw才会把View绘制出来。
2.ViewRootlmpl的PerformTraveals方法
前面讲到了将 DecorView 加载到 Window 中,是通过 ViewRootImpl 的 setView 方法。ViewRootImpl还有一个方法PerformTraveals,这个方法使得ViewTree开始View的工作流程,代码如下所示:

这里面主要执行了3个方法,分别是performMeasure、performLayout和performDraw,在其方法的内部又会分别调用View的measure、layout和draw方法。需要注意的是,performMeasure方法中需要传入两个参数,分别是 childWidthMeasureSpec 和 childHeightMeasureSpec。要了解这两个参数,需要了解MeasureSpec。
3.7.2 理解MeasureSpec
MeasureSpec是View的内部类,其封装了一个View的规格尺寸,包括View的宽和高的信息,它的作用是在Measure流程中,系统会将View的LayoutParams根据父容器所施加的规则转换成对应的MeasureSpec,然后在onMeasure方法中根据这个MeasureSpec来确定View的宽和高。MeasureSpec的代码如下所示:

从MeasureSpec的常量可以看出,它代表了32位的int值,其中高2位代表了SpecMode,低30位则代表SpecSize。SpecMode指的是测量模式,SpecSize指的是测量大小。SpecMode有3种模式,如下所示。
• UNSPECIFIED:未指定模式,View想多大就多大,父容器不做限制,一般用于系统内部的测量。
• AT_MOST:最大模式,对应于wrap_comtent属性,子View的最终大小是父View指定的SpecSize值,并且子View的大小不能大于这个值。
• EXACTLY:精确模式,对应于 match_parent 属性和具体的数值,父容器测量出 View所需要的大小,也就是SpecSize的值。
对于每一个View,都持有一个MeasureSpec,而该MeasureSpec则保存了该View的尺寸规格。在View的测量流程中,通过makeMeasureSpec来保存宽和高的信息。通过getMode或getSize得到模式和宽、高。MeasureSpec是受自身LayoutParams和父容器的MeasureSpec共同影响的。作为顶层View的DecorView来说,其并没有父容器,那么它的MeasureSpec是如何得来的呢?为了解决这个疑问,我们再回到ViewRootImpl的PerformTraveals方法,如下所示:

上面代码注释 1 处调用了 getRootMeasureSpec(mWidth,lp.width)方法。下面来查看getRootMeasureSpec(mWidth,lp.width)方法做了什么:

getRootMeasureSpec方法的第一个参数windowSize指的是窗口的尺寸,所以对于DecorView来说,它的MeasureSpec由自身的LayoutParams和窗口的尺寸决定,这一点和普通View是不同的。接着往下看,就会看到根据自身的LayoutParams来得到不同的MeasureSpec。讲到这里,3.7.1节最后遗留的问题:performMeasure方法中需要传入两个参数,即childWidthMeasureSpec和childHeightMeasureSpec,这代表什么我们也应该明白了。接着回到PerformTraveals方法,查看在注释2处的performMeasure方法内部做了什么,代码如下所示:

其实,就算不看我们也应该知道里面调用了什么—是View的measure方法。3.7.3节我们就来学习一下View的measure流程。
3.7.3 View的measure流程
measure 用来测量 View 的宽和高,它的流程分为 View 的 measure 流程和 ViewGroup 的measure流程,只不过ViewGroup的measure流程除了要完成自己的测量,还要遍历地调用子元素的measure()方法。
1.View的measure流程
首先来看一下View的onMeasure方法:

接着查看setMeasuredDimension方法,代码如下所示:

这很显然是用来设置View的宽、高的,再回头看看getDefaultSize()方法处理了什么:

SpecMode是View的测量模式,而SpecSize是View的测量大小,在3.7.2节中我们也了解了MeasureSpec。这里很显然根据不同的SpecMode值来返回不同的result值,也就是SpecSize。在AT_MOST和EXACTLY模式下,都返回SpecSize这个值,即View在这两种模式下的测量宽和高直接取决于SpecSize。也就是说,对于一个直接继承自View的自定义View来说,它的wrap_content 和 match_parent 属性的效果是一样的。因此如果要实现自定义 View 的wrap_content,则要重写onMeasure方法,并对自定义View的wrap_content属性进行处理。而在 UNSPECIFIED 模式下返回的是 getDefaultSize 方法的第一个参数 size 的值,size 的值从onMeasure方法来看是getSuggestedMinimumWidth方法或者getSuggestedMinimumHeight方法得到的。我们来查看getSuggestedMinimumWidth方法做了什么。只需要弄懂getSuggestedMinimum-Width方法就可以了,因为这两个方法的原理是一样的。

如果 View 没有设置背景,则取值为 mMinWidth,mMinWidth 是可以设置的,它对应于Android:minWidth这个属性设置的值或者View的setMinimumWidth的值;如果不指定的话,则默认为0。setMinimumWidth方法如下所示:

如果View设置了背景,则取值为max(mMinWidth,mBackground.getMinimumWidth()),也就是取mMinWidth和mBackground.getMinimumWidth()之间的最大值。此前讲了mMinWidth,下面看看mBackground.getMinimumWidth()。这个mBackground是Drawable类型的,Drawable类的getMinimumWidth方法如下所示:
public int getMinimumWidth() {

intrinsicWidth得到的是这个Drawable的固有宽度,如果固有宽度大于0则返回固有宽度,否则返回0。总结一下,getSuggestedMinimumWidth方法就是:如果View没有设置背景,则返回mMinWidth;如果设置了背景,就返回mMinWidth和Drawable的最小宽度之间的最大值。
2.ViewGroup的measure流程
讲完了View的measure流程,接下来看看ViewGroup的measure流程。对于ViewGroup,它不只要测量自身,还要遍历地调用子元素的measure()方法。ViewGroup中没有定义onMeasure()方法,但却定义了measureChildren()方法:

遍历子元素并调用measureChild方法,measureChild方法如下所示:

调用 child.getLayoutParams()方法来获得子元素的 LayoutParams 属性,获取子元素的MeasureSpec 并调用子元素的 measure()方法进行测量。getChildMeasureSpec()方法里写了什么呢?其代码如下:

很显然,这是根据父容器的MeasureSpec模式再结合子元素的LayoutParams属性来得出的子元素的 MeasureSpec 属性。有一点需要注意的是,如果父容器的 MeasureSpec 属性为AT_MOST,子元素的LayoutParams属性为WRAP_CONTENT,那根据上面代码注释1处的代码,我们会发现子元素的MeasureSpec属性也为AT_MOST,它的SpecSize值为父容器的SpecSize减去padding的值。换句话说,这和子元素设置LayoutParams属性为MATCH_PARENT效果是一样的。为了解决这个问题,需要在LayoutParams属性为WRAP_CONTENT时指定一下默认的宽和高。ViewGroup并没有提供onMeasure 方法,而是让其子类来各自实现测量的方法,究其原因就是ViewGroup有不同布局的需要,很难统一。接下来我们简单分析一下ViewGroup的子类LinearLayout的measure流程。现在先来看看它的onMeasure方法,代码如下所示:

这个方法的逻辑很简单,如果是垂直方向则调用 measureVertical 方法,否则就调用measureHorizontal方法。接着分析垂直measureVertical()方法的部分源码:

这里定义了mTotalLength用来存储LinearLayout在垂直方向的高度,然后遍历子元素,根据子元素的MeasureSpec模式分别计算每个子元素的高度。如果是WRAP_CONTENT,则将每个子元素的高度和margin垂直高度等值相加并赋值给mTotalLength。当然,最后还要加上垂直方向padding的值。如果布局高度设置为MATCH_PARENT 或者具体数值,则和View的测量方法是一样的。measure流程就讲到这里了,接下来讲解View的layout和draw流程。
3.7.4 View的layout流程
layout方法的作用是确定元素的位置。ViewGroup中的layout方法用来确定子元素的位置,View中的layout方法则用来确定自身的位置。首先我们看看View的layout方法:

layout方法的4个参数l、t、r、b分别是View从左、上、右、下相对于其父容器的距离。接着来查看setFrame方法里做了什么,代码如下所示:

setFrame方法用传进来的l、t、r、b这4个参数分别初始化mLeft、mTop、mRight、mBottom这4个值,这样就确定了该View在父容器中的位置。在调用setFrame方法后,会调用onLayout方法:

onLayout方法是一个空方法,这和onMeasure方法类似。确定位置时根据不同的控件有不同的实现,所以在View和ViewGroup中均没有实现onLayout方法。既然这样,我们下面就来查看LinearLayout的onLayout方法:

与 onMeasure 方法类似,根据方向来调用不同的方法。这里仍旧查看垂直方向的layoutVertical方法,如下所示:

这个方法会遍历子元素并调用setChildFrame方法。其中childTop值是不断累加的,这样子元素才会依次按照垂直方向一个接一个排列下去而不会是重叠的。接着看setChildFrame方法:

在setChildFrame方法中调用子元素的layout方法来确定自己的位置。
3.7.5 View的draw流程
View的draw流程很简单,下面先来看看View的draw方法。
官方注释清楚地说明了每一步的做法,它们分别是:
(1)如果需要,则绘制背景。
(2)保存当前canvas层。
(3)绘制View的内容。
(4)绘制子View。
(5)如果需要,则绘制View的褪色边缘,这类似于阴影效果。
(6)绘制装饰,比如滚动条。
其中第2步和第5步可以跳过,所以这里不做分析,重点分析其他步骤。
步骤1:绘制背景
绘制背景调用了View的drawBackground方法,如下所示:

从上面代码注释1 处可看出绘制背景考虑了偏移参数 scrollX 和scrollY。如果有偏移值不为0,则会在偏移后的canvas绘制背景。
步骤3:绘制View的内容
步骤3调用了View的onDraw方法。这个方法是一个空实现,因为不同的View有着不同的内容,这需要我们自己去实现,即在自定义View中重写该方法来实现:

步骤4:绘制子View
步骤4调用了dispatchDraw方法,这个方法也是一个空实现,如下所示:

ViewGroup重写了这个方法,紧接着看看ViewGroup的dispatchDraw方法:

源码很长,这里截取了关键的部分,在 dispatchDraw 方法中对子类 View 进行遍历,并调用drawChild方法:

这里主要调用了View的draw方法,代码如下所示:

源码很长,我们挑重点的看。在上面代码注释1处判断是否有缓存,如果没有则正常绘制,如果有则利用缓存显示。
步骤6:绘制装饰
绘制装饰的方法为View的onDrawForeground方法:

很明显这个方法用于绘制ScrollBar以及其他的装饰,并将它们绘制在视图内容的上层。
3.8 自定义View
经过前面的铺垫,接下来讲讲自定义View。自定义View一直被认为是高手掌握的技能,因为情况太多,想实现的效果又变化多端。但它也要遵循一定的规则,我们要讲的就是这个规则,至于那些变化多端的酷炫效果就由各位读者来慢慢发挥了。但是需要注意的是,凡事都要有度,自定义View毕竟不是规范的控件,如果设计不好、不考虑性能反而会适得其反,另外适配起来可能也会产生问题。笔者的建议是,如果能用系统控件的情况还是应尽量用系统控件。在阅读本节之前,希望读者能把本章前面的内容都读完,因为学习自定义View需要了解View的层次、View的事件分发机制和View的工作流程。自定义View按照笔者的划分,分为三大类,第一种是自定义 View,第二种是自定义 ViewGroup,第三种是自定义组合控件。其中自定义View又分为继承系统控件(比如TextView)和继承View两种。自定义ViewGroup也分为继承ViewGroup 和继承系统特定的 ViewGroup(比如 RelativeLayout)。接下来就分别介绍自定义View的使用方法。
3.8.1 继承系统控件的自定义View
这种自定义 View 在系统控件的基础上进行拓展,一般是添加新的功能或者修改显示的效果,一般情况下在onDraw()方法中进行处理。这里举一个简单的例子,写一个自定义View,继承自TextView:

这个自定义View继承了TextView,并且在onDraw()方法中画了一条红色的横线。接下来在布局中引用这个InvalidTextView,代码如下所示:

运行程序,效果如图3-9所示。

图3-9 继承系统控件的自定义View
3.8.2 继承View的自定义View
与上面的继承系统控件的自定义View不同,继承View的自定义View实现起来要稍微复杂一些。其不只是要实现onDraw()方法,而且在实现过程中还要考虑到wrap_content属性以及padding 属性的设置;为了方便配置自己的自定义 View,还会对外提供自定义的属性。另外,如果要改变触控的逻辑,还要重写 onTouchEvent()等触控事件的方法。按照上面的例子我们再写一个RectView类继承View来画一个正方形,代码如下所示。
1.简单实现继承View的自定义View

最后在布局中引用RectView,如下所示:

运行程序查看效果,如图3-10所示。
在这里插入图片描述
在这里插入图片描述

图3-10 简单实现继承View的自定义View
2.对padding属性进行处理
修改布局文件,设置padding属性,如下所示:

运行程序,发现没有任何作用。看来还得对padding属性进行处理。只需要在onDraw()方法中稍加修改,在绘制正方形的时候考虑padding属性即可,代码如下所示:

运行程序,效果如图3-11所示。与图3-10对比一下,可以发现设置的padding属性确实生效了。

图3-11 对padding属性进行处理
3.对wrap_content属性进行处理
修改布局文件,让RectView的宽度分别为wrap_content和match_parent时的效果都是一样的,如图3-12所示。

图3-12 未对wrap_content属性进行处理的情况
至于为何会产生这种情况,这在3.7.3节中已经说得很清楚了,这里就不赘述了。对于这种情况需要我们在onMeasure方法中指定一个默认的宽和高,在设置wrap_content属性时设置此默认的宽和高就可以了:

需要注意的是 setMeasuredDimension()方法接收的参数单位是 px,来看看效果,如图 3-13所示。

图3-13 对wrap_content属性进行处理的情况
4.自定义属性
Android系统的控件以android开头的(比如android:layout_width)都是系统自带的属性。为了方便配置RectView的属性,我们也可以自定义属性。首先在values目录下创建 attrs.xml:

这个配置文件定义了名为RectView的自定义属性组合。我们定义了rect_color属性,它的格式为color。接下来在RectView的构造方法中解析自定义属性的值,如下所示:
在这里插入图片描述

用 TypedArray 来获取自定义的属性集 R.styleable.RectView,这个 RectView 就是我们在XML中定义的name的值,然后通过TypedArray的getColor方法来获取自定义的属性值。最后修改布局文件,如下所示:

使用自定义属性需要添加schemas:xmlns:app=“http://schemas.android.com/apk/res-auto”,其中 app 是我们自定义的名字。最后我们配置新定义的 app:rect_color 属性为 android:color/holo_blue_light。运行程序发现RectView的颜色变成了蓝色。RectView的完整代码,如下所示:
在这里插入图片描述
在这里插入图片描述

3.8.3 自定义组合控件
前面讲到了自定义View,下面接着讲常用的自定义组合控件。自定义组合控件就是多个控件组合起来成为一个新的控件,其主要用于解决多次重复地使用同一类型的布局。比如我们应用的顶部标题栏及弹出的固定样式的 Dialog,这些都是常用的,所以把它们所需要的控件组合起来重新定义成一个新的控件。本节就来自定义一个顶部的标题栏。当然,实现标题栏有很多方法,下面来看看用自定义组合控件如何去实现。首先,我们定义组合控件的布局:
在这里插入图片描述

这是很简单的布局,左右边各一个图标,中间是标题文字。接下来编写Java代码。因为我们的组合控件整体布局是RelativeLayout,所以组合控件要继承RelativeLayout,代码如下所示:

在这里插入图片描述
在这里插入图片描述

这里重写了3个构造方法并在构造方法中加载布局文件,对外提供了3个方法,分别用来设置标题的名字,以及左右按钮的点击事件。前面讲到了自定义属性,这里同样使用自定义属性,在values目录下创建 attrs.xml,代码如下所示:
在这里插入图片描述

我们定义了3个属性,分别用来设置顶部标题栏的背景颜色、标题文字颜色和标题文字。为了引入自定义属性,需要在TitleBar的构造方法中解析自定义属性的值,代码如下所示:
在这里插入图片描述

接下来引用组合控件的布局。使用自定义属性需要添加 schemas:xmlns:app=“http://schemas.android.com/apk/res-auto”,其中,app是我们自定义的名字,当然也可以取其他的名字:
在这里插入图片描述
在这里插入图片描述

最后,在主界面调用自定义的TitleBar,并设置了左右两边按钮的点击事件:
在这里插入图片描述

运行程序,效果如图3-14所示。

图3-14 自定义标题栏
3.8.4 自定义ViewGroup
前面介绍过自定义ViewGroup又分为继承ViewGroup和继承系统特定的ViewGroup(比如RelativeLayout),其中继承系统特定的ViewGroup比较简单,在这里就不做介绍了,主要介绍继承 ViewGroup。本节的例子是一个自定义的 ViewGroup,左右滑动切换不同的页面,类似一个特别简化的ViewPager。其会涉及本章很多节的内容,比如View的工作流程、View的滑动等,所以,对View体系不太了解的读者先从头阅读本章,再来看本节会更好些。需要注意的是,要实现一个自定义的ViewGroup是很复杂的,这一点看LineraLayout等的源码就会知道,在此只实现主要的功能就好了。
1.继承ViewGroup
要实现自定义的ViewGroup,首先要继承ViewGroup并调用父类的构造方法,实现抽象方法等:
在这里插入图片描述

这里我们定义了名为HorizontalView的类并继承 ViewGroup,onLayout这个抽象方法是必须要实现的,我们暂且什么都不做。
2.对wrap_content属性进行处理
至于为什么要对wrap_content属性进行处理,这在3.7.3节中已经说得很清楚了,这里就不赘述了。接下来的代码对wrap_content属性进行处理,其中省略了此前的构造方法代码:
在这里插入图片描述

这里如果没有子元素,则采用简化的写法,将宽和高直接设置为 0。正常的话,我们应该根据LayoutParams中的宽和高来做相应的处理。接着根据widthMode 和heightMode来分别设置HorizontalView的宽和高。另外,我们在测量时没有考虑HorizontalView的padding和子元素的margin。
3.实现onLayout
接下来实现onLayout来布局子元素。因为对每一种布局方式,子View的布局都是不同的,所以这是ViewGroup唯一一个抽象方法,需要我们自己去实现,代码如下所示:
在这里插入图片描述

遍历所有的子元素。如果子元素不是GONE,则调用子元素的layout方法将其放置到合适的位置上。这相当于默认第一个子元素占满了屏幕,后面的子元素就是在第一个屏幕后面紧挨着和屏幕一样大小的后续元素。所以left是一直累加的,top保持为0,bottom保持为第一个元素的高度,right就是left+元素的宽度。同样,这里没有处理HorizontalView的padding以及子元素的margin。
4.处理滑动冲突
这个自定义ViewGroup为水平滑动,如果里面是ListView,ListView则为垂直滑动,这样会导致滑动的冲突。解决的方法就是,如果我们检测到的滑动方向是水平的话,就让父View进行拦截,确保父View用来进行View的滑动切换。

在这里插入图片描述
在这里插入图片描述

在上面代码注释1处,在刚进入onInterceptTouchEvent方法时就得到了点击事件的坐标,在MotionEvent.ACTION_MOVE中计算每次手指移动的距离,并在注释2处判断用户是水平滑动还是垂直滑动,如果是水平滑动则设置 intercept=true 来进行拦截,这样事件则由HorizontalView的onTouchEvent方法来处理。
5.弹性滑动到其他页面
接着处理 onTouchEvent 事件,在 onTouchEvent 方法里需要进行滑动切换页面,这里就需要用到Scroller。在本章的3.3.6节中介绍了如何使用Scroller,在本章的3.5节中我们通过源码解析了Scroller为何能够进行滑动,不了解的读者可以去查看相关内容。

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述

同样,在刚进入 onTouchEvent 方法时就得到点击事件的坐标,在 MotionEvent.ACTION_MOVE中用scrollBy方法来处理HorizontalView控件随手指滑动的效果。在上面代码注释1处判断滑动的距离是否大于宽度的1/2,如果大于则切换到其他页面,然后调用Scroller来进行弹性滑动。
6.快速滑动到其他页面
通常情况下,只在滑动超过一半时才切换到上/下一个页面是不够的。如果滑动速度很快的话,我们也可以判定为用户想要滑动到其他页面,这样的体验也是好的。需要在onTouchEvent方法的 ACTION_UP 中对快速滑动进行处理。在这里又需要用到 VelocityTracker,它是用来测试滑动速度的。使用方法也很简单,首先在构造方法中进行初始化,也就是在前面的 init 方法中增加一条语句,代码如下所示:
在这里插入图片描述

在init方法中对VelocityTracker进行初始化。接着开始改写onTouchEvent部分,如下所示:
在这里插入图片描述
在这里插入图片描述

在上面代码注释 1 处获取水平方向的速度;接着在注释 2 处,如果速度的绝对值大于 50的话,就被认为是“快速滑动”,执行切换页面。不要忘了在注释3处重置速度计算器。
7.再次触摸屏幕阻止页面继续滑动
如果有如下的场景:当我们快速向左滑动切换到下一个页面时,在手指释放以后,页面会弹性滑动到下一个页面,这可能需要1秒才完成滑动,在这个时间内,我们再次触摸屏幕,希望能拦截这次滑动,然后再次去操作页面。要实现在弹性滑动过程中再次触摸拦截,肯定要在onInterceptTouchEvent的ACTION_DOWN中去判断。如果在ACTION_DOWN的时候,Scroller还没有执行完毕,说明上一次的滑动还正在进行中,则直接中断Scroller,代码如下所示:
在这里插入图片描述
在这里插入图片描述

主要逻辑在上面代码注释1处,如果Scroller没有执行完成,则调用Scroller的abortAnimation方法来打断 Scroller。因为 onInterceptTouchEvent 方法的 ACTION_DOWN 返回 false,所以在onTouchEvent方法中无法获取DOWN事件,故而需要在注释2处设置lastX和lastY这两个参数。
8.应用HorizontalView
首先,我们在主布局中引用HorizontalView,它作为父容器,里面有两个ListView。布局文件如下所示:
在这里插入图片描述

接着,在代码中为ListView填加数据:
在这里插入图片描述

运行程序,效果如图3-15所示。当我们向右滑动的时候,界面会滑动到第二个ListView,如图3-16所示。

图3-15 初始的第一页

图3-16 滑动到第二页
最后贴上HorizontalView的整个源码:

在这里插入图片描述

在这里插入图片描述
在这里插入图片描述
在这里插入图片描述

3.9 本章小结
到这里自定义View就讲完了。读到这里,很多读者会发现前面各节做的铺垫十分有必要:从最基础的View与ViewGroup,到View的滑动以及View的事件分发机制和View的工作流程,这些都是为了最终讲解自定义View。当然,自定义View作为一个技术难点,它有着十分多变的处理方式。但是不管它如何多变,都遵循着一定的规则,本章就讲解了这一规则。其他的变化需要我们自己去处理,但前提是要掌握本章的全部内容,打好基本功。这样才能以不变应万变,开发出更实用、更绚丽的自定义View。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

android framework

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值