自定义View是一个综合的技术体系,他涉及View的层次结构,事件分发机制和View的工作原理等技术细节.
自定义View总体可以分为四类
- 继承View重写onDraw方法
- 继承ViewGroup派生特殊的Layout
- 继承特定的View(比如TextView)
- 继承特定的ViewGroup(比如LinearLayout)
注意事项
- **让View支持wrap_content: **直接继承View或ViewGroup的控件要
在onMeasure中对wrap_content做特殊处理. - 如果有必要,让你的View支持padding: 继承View的控件在draw方法中处理
padding,padding属性才能起作用.继承ViewGroup的控件要在onMeasure和onLayout中考虑padding和子元素的margin对其造成的影响(Padding是 View 内部的事情,而Margin是 View 外部由其父布局决定). - **尽量不要在View中使用
Handler: **View内部提供了post系列方法. - **View中如果有线程或者动画,需要及时停止: **参考
View#onDetachedFromWindow. - View带有滑动嵌套情形时,需要处理好滑动冲突: 重写
onInterceptTouchEvent(外部拦截法) 或dispatchTouchEvent(内部拦截法)
measure过程
onMeasure 的作用是什么?
在 Android 的视图体系中,每个 View 和 ViewGroup 都需要知道自己应该占据多大的空间。这个确定大小的过程就发生在 Measure (测量) 阶段。
onMeasure 方法就是这个阶段的核心。它的职责是:
- 接收来自父容器的尺寸约束 (Constraints)。
- 根据这些约束和自身的特性 (内容、背景等),计算出自己期望的宽度和高度。
- 通过调用
setMeasuredDimension(width, height)方法,将最终计算出的尺寸保存起来。
整个视图树的绘制流程分为三个主要阶段:
- Measure (测量): 从根视图开始,递归地调用所有子视图的
onMeasure,确定每个视图的大小。 - Layout (布局): 从根视图开始,递归地调用所有子视图的
onLayout,确定每个视图在屏幕上的具体位置(左、上、右、下坐标)。 - Draw (绘制): 从根视图开始,递归地调用所有子视图的
onDraw,将它们的内容绘制到屏幕上。
onMeasure 是这三大流程的第一个,它为后续的布局和绘制奠定了基础。
理解核心概念:MeasureSpec
在深入 onMeasure 之前,必须先理解 MeasureSpec。onMeasure 方法接收两个参数:
1 |
|
这两个 int 类型的参数并不是直接的像素值,而是 MeasureSpec。它是一个 32 位的整数,其中高 2 位代表 模式 (Mode),低 30 位代表 尺寸 (Size)。
MeasureSpec 是父容器传递给子视图的“尺寸要求”或“约束条件”。它告诉子视图:“你应该在这个规则下测量自己”。
MeasureSpec 的三种模式 (Mode)
MeasureSpec.EXACTLY(精确模式)- 含义: 父容器已经为子视图确定了切确的尺寸。子视图的最终尺寸就是
MeasureSpec中指定的size值。 - 对应场景:
- 布局文件中设置了具体的尺寸,如
android:layout_width="100dp"。 - 布局文件中设置了
android:layout_width="match_parent",并且父容器的尺寸是确定的。
- 布局文件中设置了具体的尺寸,如
- 含义: 父容器已经为子视图确定了切确的尺寸。子视图的最终尺寸就是
MeasureSpec.AT_MOST(最大模式)- 含义: 父容器为子视图指定了一个最大可用尺寸。子视图的尺寸不能超过这个
size值,但可以根据自身内容调整为比它更小的值。 - 对应场景:
- 布局文件中设置了
android:layout_width="wrap_content"。此时size就是父容器剩余的可用空间。
- 布局文件中设置了
- 含义: 父容器为子视图指定了一个最大可用尺寸。子视图的尺寸不能超过这个
MeasureSpec.UNSPECIFIED(不指定模式)- 含义: 父容器对子视图的尺寸没有任何限制,子视图可以根据需要设置为任意大小。
- 对应场景:
- 通常用在可滚动的容器中,如
ScrollView、RecyclerView。因为这些容器可以在一个维度上无限延伸,所以它们在测量子视图时不会限制其高度(或宽度)。
- 通常用在可滚动的容器中,如
我们可以使用 MeasureSpec 类的静态方法来解析这两个值:
1 | int specMode = MeasureSpec.getMode(measureSpec); |
自定义 View的 onMeasure
对于一个独立的、没有子视图的自定义 View(例如,继承自 View、TextView、ImageView 等),onMeasure 的实现相对简单。它的目标是根据父容器的 MeasureSpec 计算出自己的尺寸。
基本步骤:
- 重写
onMeasure(int widthMeasureSpec, int heightMeasureSpec)方法。 - 在方法内部,分别解析宽度和高度的
mode和size。 - 针对不同的
mode,计算出最终的宽度和高度。EXACTLY: 直接使用specSize作为最终尺寸。AT_MOST: 计算出自己内容所需的尺寸(比如文本的宽度、图片的高度等),然后与specSize比较,取较小值。Math.min(contentSize, specSize)。UNSPECIFIED: 直接使用自己内容所需的尺寸。
- 【最重要的一步】 调用
setMeasuredDimension(measuredWidth, measuredHeight)保存计算结果。如果不调用这个方法,系统会抛出异常。
代码示例:一个简单的正方形 View
假设我们要创建一个自定义 View,它总是尝试将自己渲染成一个正方形。
1 | public class SquareView extends View { |
小技巧: Android 提供了一个辅助方法 resolveSize(size, measureSpec),它可以帮你简化 AT_MOST 和 EXACTLY 模式的处理。它等价于:
1 | public static int resolveSize(int size, int measureSpec) { |
自定义 ViewGroup 的 onMeasure
自定义 ViewGroup 的 onMeasure 要复杂得多,因为它有双重职责:
- 测量所有子视图 (Children): 它需要遍历自己的每一个子视图,调用它们的
measure()方法,并为其生成合适的MeasureSpec。 - 确定自身尺寸: 在测量完所有子视图后,它需要根据子视图的尺寸和自身的布局逻辑(例如,是线性排列还是层叠排列),计算出自己的总尺寸。
基本步骤:
- 重写
onMeasure(int widthMeasureSpec, int heightMeasureSpec)方法。 - 遍历所有子视图:使用
for循环和getChildCount()。 - 为每个子视图生成
MeasureSpec: 这是最复杂的一步。你需要结合父容器传给ViewGroup的MeasureSpec和子视图自身的LayoutParams(例如layout_width="match_parent"或layout_width="wrap_content") 来为子视图创建一个新的MeasureSpec。通常使用getChildMeasureSpec()这个静态辅助方法来完成。 - 测量子视图: 调用
child.measure(childWidthMeasureSpec, childHeightMeasureSpec)。这个调用会触发子视图的onMeasure方法。 - 计算自身尺寸: 测量完所有子视图后,通过
child.getMeasuredWidth()和child.getMeasuredHeight()获取它们的尺寸。然后根据你的布局逻辑(例如,将所有子视图高度相加)计算出ViewGroup自身需要的总宽度和总高度。 - 合并尺寸和约束: 将计算出的自身所需尺寸与父容器传递的
MeasureSpec进行协调(处理EXACTLY和AT_MOST模式)。 - 保存结果: 调用
setMeasuredDimension()保存最终尺寸。
代码示例:一个简单的垂直线性布局
1 | public class CustomVerticalLayout extends ViewGroup { |
注意: 在 ViewGroup 中,通常会使用 measureChild() 或 measureChildWithMargins() 这两个辅助方法,它们内部封装了 getChildMeasureSpec() 和 child.measure() 的调用,能简化代码,特别是处理 padding 和 margin 时。
Wrap_content解决方案
定义默认尺寸
在你的 View 类中,最好将默认尺寸定义为成员变量,方便管理和转换。
1 | public class CircleView extends View { |
- 最佳实践: 将
100这个硬编码的数值定义在res/values/dimens.xml中会是更好的做法,这样更容易维护。
重写 onMeasure() 方法
这是最关键的一步。你需要检查测量规格中的模式,并据此计算最终尺寸。
在很多情况下,你不需要 Math.min 那么复杂的逻辑,可以直接在 AT_MOST 模式下设置默认值。很多人也会使用 resolveSize() 这个辅助方法来简化代码,它内部已经包含了类似的逻辑。
1 |
|
注意:
resolveSize()在处理UNSPECIFIED模式时,会直接返回你提供的默认尺寸。这在大多数情况下是符合预期的。
总结与关键点
-
onMeasure的目的是计算并设置 View 的尺寸。 -
MeasureSpec是核心,它封装了父容器对子视图的尺寸约束(模式 + 尺寸)。 -
自定义
View:根据MeasureSpec和自身内容计算尺寸,然后调用setMeasuredDimension()。 -
自定义
ViewGroup:- 先遍历并测量所有子视图 (
child.measure())。 - 再根据子视图的尺寸和布局逻辑计算自身尺寸。
- 最后调用
setMeasuredDimension()。
- 先遍历并测量所有子视图 (
-
必须调用
setMeasuredDimension(),否则程序会崩溃。 -
onMeasure可能会被调用多次,所以其中的计算逻辑应该尽可能高效,避免重复或耗时的操作。 -
当你需要重新触发布局流程时(例如,View 的内容变化导致尺寸可能改变),应该调用
requestLayout()方法,它会使视图树重新执行onMeasure和onLayout。
layout过程
onLayout 是 View 绘制三大流程中的第二步(Measure -> Layout -> Draw)。在 onMeasure 确定了所有 View 的尺寸之后,onLayout 的职责就是确定这些 View 的 具体位置。
onLayout的作用和参数
onLayout 方法的签名如下:
1 |
|
它的作用是:父容器调用这个方法来确定其所有子视图(Children)在屏幕上的最终位置。
参数解释
-
changed(boolean): 表示 View 的尺寸或位置是否相较于上一次布局发生了变化。如果为true,意味着需要重新布局。 -
left,top,right,bottom(int): 这四个参数定义了 当前 View 本身 在其父容器坐标系中的位置和区域。它们是由当前 View 的父容器在调用child.layout()时传递过来的。left: View 左边缘相对于父容器左边缘的距离。top: View 上边缘相对于父容器上边缘的距离。right: View 右边缘相对于父容器左边缘的距离。bottom: View 下边缘相对于父容器上边缘的距离。
你可以通过
getWidth()(right - left) 和getHeight()(bottom - top) 获取到 View 在onMeasure后并经过布局确定的最终尺寸。
自定义 View的 onLayout
对于一个不包含任何子视图的自定义 View (继承自 View 或 TextView 等),通常你不需要重写 onLayout 方法。
为什么?
因为 onLayout 的核心职责是 布局它的子视图。一个普通的 View 没有子视图,所以它没有东西需要布局。它的位置完全由它的父 ViewGroup 来决定。
父 ViewGroup 在自己的 onLayout 方法中,会计算出这个 View 应该放在哪里,然后调用这个 View 的 layout(l, t, r, b) 方法。View 类中 final 的 layout() 方法会负责将这四个坐标值保存到 View 的内部变量中(mLeft, mTop, mRight, mBottom),然后它会调用 onLayout()。
View.java 中 onLayout() 的默认实现是空的:
1 | // in View.java |
所以,对于一个简单的自定义 View,由于没有子 View 需要排列,我们让这个空方法被调用就可以了,无需重写。
自定义 ViewGroup的 onLayout
onLayout 对于自定义 ViewGroup 来说是 必须实现的核心方法。这正是 ViewGroup 与普通 View 的本质区别所在——它需要管理和排列它的孩子们。
核心职责:
遍历所有的子视图,并为每一个子视图调用其 layout(l, t, r, b) 方法,从而将它们放置在 ViewGroup 内部的正确位置。
基本步骤:
- 重写
onLayout(boolean changed, int left, int top, int right, int bottom)方法。 - 在方法内部,通常需要先考虑自身的
padding。子视图的布局区域应该在ViewGroup的padding内部。 - 遍历所有子视图:使用
for循环和getChildCount()。 - 获取子视图的测量尺寸:在
onMeasure阶段结束后,可以通过child.getMeasuredWidth()和child.getMeasuredHeight()来获取每个子视图希望的尺寸。 - 计算每个子视图的位置:根据你的布局逻辑(例如垂直线性排列、水平排列、或者像
FrameLayout一样层叠),计算出每个子视图的left,top,right,bottom坐标。注意:这些坐标是相对于当前ViewGroup的,而不是相对于屏幕的。 - 【最重要的一步】 为每个子视图调用
child.layout(childLeft, childTop, childRight, childBottom),将计算出的位置应用到子视图上。这个调用会递归地触发子视图的布局流程。
代码示例:继续完善 CustomVerticalLayout
我们接着 onMeasure 的例子,为 CustomVerticalLayout 实现 onLayout 方法。
1 | public class CustomVerticalLayout extends ViewGroup { |
在这个例子中:
- 我们维护一个
currentTop变量,用于记录当前应当放置子视图的纵向位置。 - 对于每个子视图,我们从
currentTop开始,加上它的topMargin,确定它的top位置。 - 它的
bottom位置就是top + childHeight。 - 它的
left和right位置则由ViewGroup的paddingLeft、子视图的leftMargin和子视图的宽度共同决定。 - 放置好一个子视图后,我们将
currentTop增加这个子视图的总高度(测量高度 + 上下 margin),为下一个子视图的放置做准备。
onMeasurevsonLayout 核心区别对比
为了更清晰地理解,我们将这两个方法进行对比:
| 特性 | onMeasure |
onLayout |
|---|---|---|
| 核心职责 | 计算视图的 尺寸 (Size) | 确定视图的 位置 (Position) |
| 工作对象 | View 和 ViewGroup 都需要。ViewGroup 测量子视图,并基于此计算自身尺寸。 |
主要是 ViewGroup 的职责。View 通常不需要实现它。 |
| 关键调用 | 计算完尺寸后,必须调用 setMeasuredDimension() 来保存结果。 |
遍历子视图,为每个子视图调用 child.layout() 来应用位置。 |
| 输入参数 | widthMeasureSpec, heightMeasureSpec (父容器的尺寸约束) |
changed, left, top, right, bottom (自身在父容器中的位置) |
| 数据来源 | 依赖父容器的约束和自身内容。 | 依赖 onMeasure 的结果 (getMeasuredWidth()) 和自身的布局逻辑。 |
| 触发时机 | 在 layout 之前。 |
在 measure 之后,draw 之前。 |
总结与关键点
onLayout的核心使命是“放位置”。它在onMeasure算出“有多大”之后,决定“放哪里”。- 对于自定义
View,因为没有子视图,所以基本不用管onLayout。 - 对于自定义
ViewGroup,onLayout是你实现自定义布局逻辑的地方,必须重写。 - 在
onLayout中,你需要遍历子视图,获取它们测量好的尺寸,然后调用child.layout()将它们一一安放。 onLayout中计算的坐标是 相对于父容器(也就是当前这个ViewGroup)的。requestLayout()会触发布局的重新计算,它会依次调用onMeasure和onLayout。而invalidate()只会触发重绘,调用onDraw。如果你的 View 只是内容变了但尺寸和位置不变(比如改变画笔颜色),调用invalidate();如果尺寸可能发生变化,调用requestLayout()。
draw过程
onDraw 是 View 绘制三大流程中的最后一步(Measure -> Layout -> Draw)。在 onMeasure 确定了尺寸、onLayout 确定了位置之后,onDraw 的职责就是 将视图的内容实际绘制到屏幕上。
onDraw 的作用和核心工具
onDraw 方法的签名如下:
1 |
|
它的作用是:提供一个 Canvas (画布) 对象,让你可以在上面进行任意的 2D 绘制操作。
核心工具
要进行绘制,通常需要两个核心类的配合:
Canvas(画布)onDraw方法会传给你一个Canvas对象。你可以把它想象成一块指定了尺寸和位置的画布。- 它提供了一系列
draw...()方法,比如:drawRect(float left, float top, float right, float bottom, Paint paint): 绘制矩形。drawCircle(float cx, float cy, float radius, Paint paint): 绘制圆形。drawLine(float startX, float startY, float stopX, float stopY, Paint paint): 绘制线条。drawText(String text, float x, float y, Paint paint): 绘制文本。drawBitmap(Bitmap bitmap, float left, float top, Paint paint): 绘制位图 (Bitmap)。drawPath(Path path, Paint paint): 绘制自定义路径。
Canvas定义了 “画什么” 和 “在哪里画”。
Paint(画笔)Paint对象定义了绘制的样式和效果。你可以把它想象成一支画笔。- 它控制着各种属性,比如:
setColor(int color): 设置颜色。setStyle(Paint.Style style): 设置样式(FILL填充、STROKE描边、FILL_AND_STROKE填充并描边)。setStrokeWidth(float width): 设置线条或描边的宽度。setTextSize(float textSize): 设置文本大小。setAntiAlias(boolean aa): 设置是否开启抗锯齿,能让图形边缘更平滑。
Paint定义了 “怎么画” (颜色、粗细、样式等)。
坐标系:在 onDraw 方法中,画布 Canvas 的坐标系原点 (0, 0) 位于当前 View 的 左上角。
自定义 View的 onDraw
对于一个不包含子视图的自定义 View,onDraw 是它的灵魂。这是你将所有视觉元素呈现给用户的地方。
核心职责: 使用 Canvas 和 Paint 对象,在 View 的边界内绘制出你想要的任何内容。
基本步骤:
- 初始化
Paint对象:这是一个至关重要的性能优化点。onDraw方法会被频繁调用(例如,在动画期间每秒调用 60 次)。如果在onDraw内部创建Paint等对象,会引发大量的内存分配和垃圾回收(GC),导致界面卡顿。因此,务必在构造函数或其他一次性初始化方法中创建Paint对象。 - 重写
onDraw(Canvas canvas)方法。 - 在
onDraw内部,你可以通过getWidth()和getHeight()获取当前View的最终宽高。 - 使用
canvas.draw...()方法和预先初始化好的Paint对象进行绘制。
代码示例:一个绘制了背景和圆形的 SquareView
我们继续之前的 SquareView 例子,让它不再是透明的,而是在内部绘制内容。
1 | public class SquareView extends View { |
自定义 ViewGroup 的 onDraw
默认情况下,ViewGroup 是不会调用 onDraw 方法的。
为什么?
因为 ViewGroup 的主要职责是管理和布局子 View,它本身通常被认为是透明的、不可见的容器。为了进行性能优化,系统假设它不需要绘制任何内容,从而跳过它的 onDraw 流程。子 View 的绘制是由系统在 dispatchDraw 方法中管理的。
如何让 ViewGroup 调用 onDraw?
如果你希望 ViewGroup 也绘制一些内容(例如,一个特殊的背景、子 View 之间的分割线),你必须明确告诉系统。有两种常用方法:
-
在构造函数中调用
setWillNotDraw(false)ViewGroup内部有一个标志位,默认为true,表示“我不会绘制任何东西”。将其设置为false后,系统在绘制流程中就会调用该ViewGroup的onDraw方法。1
2
3
4
5public CustomVerticalLayout(Context context, AttributeSet attrs) {
super(context, attrs);
// 告诉系统,这个 ViewGroup 需要执行 onDraw
setWillNotDraw(false);
} -
在 XML 中设置
android:background属性 只要你为ViewGroup设置了任何背景(无论是颜色还是Drawable),系统会自动为你调用setWillNotDraw(false),因为绘制背景本身就需要onDraw流程的参与。
ViewGroup 的绘制顺序
当一个 ViewGroup 需要绘制时,其绘制层级如下:
- 绘制背景: 如果设置了
background,先绘制它。 - 绘制自身内容 (
onDraw): 调用onDraw方法,绘制你在其中指定的任何内容(例如分割线)。 - 绘制子视图 (
dispatchDraw):onDraw执行完毕后,系统会调用dispatchDraw(Canvas canvas)方法。这个方法会遍历所有子View,并调用每个子View的draw()方法,从而让子View绘制自己。 - 绘制前景和滚动条: 最后,绘制滑动边缘效果、滚动条和前景(
Foreground)。
这意味着,在 onDraw 中绘制的内容会被 **“压在”**所有子 View 的 下面。如果你想在子 View 的上面绘制内容,你需要重写 dispatchDraw 方法,并在调用 super.dispatchDraw(canvas) 之后进行绘制。
代码示例:继续完善 CustomVerticalLayout
1 | public class CustomVerticalLayout extends ViewGroup { |
在这个例子中:
Paint mDividerPaint: 我们首先定义了一个Paint对象作为成员变量。这个“画笔”将专门用来绘制我们的分割线。init()方法: 我们在构造函数中调用init()来进行初始化。将初始化逻辑集中在一起是个好习惯。在这里,我们创建了Paint对象并设置了它的颜色、样式(描边)和粗细。setWillNotDraw(false): 这是让ViewGroup能够绘制的关键一步。默认情况下,为了优化性能,ViewGroup不会调用onDraw方法。我们通过这个调用来告诉系统:“这个ViewGroup有自己的内容需要绘制,请调用我的onDraw方法”。onDraw(Canvas canvas): 这是实际进行绘制的地方。系统会传入一个Canvas(画布)对象,我们所有的绘制操作都在这个画布上进行。- 遍历子视图: 我们使用一个
for循环来遍历子视图。请注意循环条件是i < getChildCount() - 1,因为我们只需要在子视图“之间”画线,所以最后一个子视图下面不需要再画。 - 计算Y坐标: 我们需要确定水平分割线应该画在哪里。一个比较好的位置是在当前子视图的底部(
child.getBottom())与其下外边距(lp.bottomMargin)所构成的空间内。这里我们取其中点,使得视觉上更均衡。 - 计算X坐标: 我们希望分割线能横跨整个
ViewGroup的内容区域,所以线的起点是ViewGroup的左内边距(getPaddingLeft()),终点是ViewGroup的宽度减去右内边距(getWidth() - getPaddingRight())。 canvas.drawLine(...): 最后,我们调用canvas的drawLine方法,传入计算好的起点和终点坐标,并指定使用我们预设好的mDividerPaint画笔,从而在屏幕上绘制出红色的分割线。
如何触发重绘
当你的 View 的外观需要更新时(例如,用户点击后改变颜色),你需要通知系统重新绘制它。这通过调用 invalidate() 方法来完成。
invalidate(): 请求重绘当前View。它会使View的onDraw方法在未来的某个时间点(通常是下一帧)被UI线程调用。它只会触发绘制流程,不会触发布局和测量。postInvalidate():invalidate()的线程安全版本。如果你在非 UI 线程想刷新View,应调用此方法。
再次对比 requestLayout() 和 invalidate()
requestLayout(): 当View的 尺寸或位置 可能发生变化时调用。它会触发onMeasure,onLayout,onDraw的完整流程。这是一个重量级操作。invalidate(): 当View的 外观 发生变化,但尺寸和位置不变时调用。它只触发onDraw。这是一个相对轻量级的操作。
总结与关键点
onDraw是绘制流程的最后一步,负责将视图渲染到屏幕上。- 自定义
View的核心就是实现onDraw,使用Canvas(画什么,在哪画) 和Paint(怎么画) 来创造视觉内容。 - 自定义
ViewGroup默认不执行onDraw。若要绘制,需调用setWillNotDraw(false)或设置背景。ViewGroup的onDraw内容会显示在所有子View的下方。 - 性能至上: 绝对不要 在
onDraw方法中创建新对象(new Paint(),new Path()等),应在构造函数中预先创建并复用。 - 触发重绘: 使用
invalidate()或postInvalidate()来请求系统重新调用onDraw。
说些什么吧!