本文适配Android新手→进阶,基于真实业务场景编写,Kotlin+Java双版本代码,覆盖自定义View全流程(属性定义→测量→布局→绘制→交互),补充高频面试考点+调试技巧,看完直接搞定80%业务自定义控件需求 🚀
一、前言:为什么自定义View是Android开发的“分水岭”?
做Android开发1-2年,你一定会遇到这样的困境:
- 产品要“独特的圆形头像+渐变边框”,原生ImageView做不到;
- UI给的“分段进度条+动画效果”,原生ProgressBar无法适配;
- 需求要“流式标签布局+点击删除”,自带控件没有对应效果;
原生控件的局限性,决定了自定义View是进阶必备技能——不仅能搞定复杂UI,更是面试中区分“初级”和“中级”开发的核心考点。
本文不搞“纸上谈兵”,全程围绕「业务落地」展开,从基础原理到复杂实战,再到面试避坑,新手也能跟着敲、跟着用。
二、先搞懂:自定义View的3种实现方式(优先级+适用场景)
很多新手一上来就直接继承View,写得又复杂又容易出问题。记住:优先选最简单的方案,降低开发成本和维护难度,具体对应场景如下:
1. 扩展现有控件(优先级最高,最常用)
继承AppCompatTextView、AppCompatImageView、CardView等原生控件,只扩展功能、修改样式,无需重写测量和布局,开发效率最高。
适用场景:给现有控件加边框、加点击效果、修改文字样式、适配特殊需求(如带删除图标的TextView)。
核心优势:复用原生控件的测量、布局逻辑,避免重复造轮子,减少bug。
2. 完全自定义View(优先级中等)
直接继承View,从零开始绘制控件,必须重写onMeasure(测量)+ onDraw(绘制) ,灵活度最高。
适用场景:原生控件无法实现的简单UI,如圆形、椭圆、折线图、自定义指示器等。
注意点:新手容易忽略wrap_content适配和padding适配,这是高频bug点(后面重点讲)。
3. 自定义ViewGroup(优先级最低,最复杂)
继承ViewGroup,自定义子View的布局规则,必须重写onMeasure(测量子View)+ onLayout(定位子View) ,难度最大。
适用场景:需要自定义子View排列方式,如流式布局、网格布局(原生GridLayout不满足需求时)、自定义导航栏等。
三、核心原理:自定义View的4大核心步骤
自定义View的绘制流程由系统自动调度,核心是「测量→布局→绘制」,再加上「自定义属性」,四步缺一不可。很多新手只关注绘制,忽略前三步,导致控件适配差、bug多。
1. 自定义属性(XML可配置,提升复用性)
原生控件的android:text、android:background都是系统定义的属性,我们可以自定义属性,让控件在XML中直接配置,无需在代码中硬编码,提升复用性。
完整步骤 :
- 定义属性文件:在res/values下新建attrs.xml,指定属性名称、类型(color、dimension、integer等),支持枚举、引用等复杂类型。
- 在XML中使用:添加自定义命名空间(xmlns:app="schemas.android.com/apk/res-aut…
- 在代码中获取:通过TypedArray获取属性值,必须调用recycle()回收,避免内存泄漏(新手高频遗忘点)。
完整代码示例:
// attrs.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 圆形View自定义属性(补充渐变、边框属性) -->
<declare-styleable name="CircleView">
<attr name="circle_color" format="color"/> <!-- 圆形填充色 -->
<attr name="circle_radius" format="dimension"/> <!-- 半径 -->
<attr name="circle_border_color" format="color"/> <!-- 边框颜色 -->
<attr name="circle_border_width" format="dimension"/> <!-- 边框宽度 -->
<attr name="circle_gradient" format="boolean"/> <!-- 是否渐变 -->
</declare-styleable>
</resources>
// XML布局使用
<com.example.myview.CircleView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:padding="10dp"
app:circle_color="#FF4081"
app:circle_radius="60dp"
app:circle_border_color="#000000"
app:circle_border_width="2dp"
app:circle_gradient="true"/>
2. onMeasure:测量宽高(最容易踩坑的一步)
核心作用:测量控件自身宽高,以及子View(ViewGroup)的宽高,决定控件在屏幕上的大小。
新手必踩坑:View默认wrap_content和match_parent效果一致,不手动处理,控件会铺满父布局。
核心逻辑:通过MeasureSpec(自定义View测量环节的核心,至关重要)获取父布局的约束,它是父布局与子View之间传递尺寸约束的关键载体,直接决定子View的宽高计算逻辑,也是避免测量bug的核心。其包含三种模式(EXACTLY:固定尺寸、AT_MOST:最大尺寸、UNSPECIFIED:无约束),结合自身需求,计算出实际宽高,调用setMeasuredDimension()设置。从底层本质来说,MeasureSpec是一个32位int值,高2位存储测量模式(mode),低30位存储测量尺寸(size),正是这种设计让尺寸约束的传递高效且简洁。
实战代码(处理wrap_content+padding) :
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
// 1. 获取父布局约束
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
// 2. 计算实际宽高(处理wrap_content)
val defaultSize = (mCircleRadius * 2 + paddingLeft + paddingRight).toInt()
val actualWidth = when (widthMode) {
MeasureSpec.EXACTLY -> widthSize // match_parent或固定尺寸
MeasureSpec.AT_MOST -> minOf(defaultSize, widthSize) // 不超过父布局最大尺寸
else -> defaultSize // 无约束(如ScrollView中)
}
val actualHeight = when (heightMode) {
MeasureSpec.EXACTLY -> heightSize
MeasureSpec.AT_MOST -> minOf(defaultSize, heightSize)
else -> defaultSize
}
// 3. 设置最终宽高
setMeasuredDimension(actualWidth, actualHeight)
}
3. onLayout:定位子View(仅ViewGroup需要)
核心作用:确定子View在父容器中的位置(left、top、right、bottom),只有自定义ViewGroup需要重写,View无需重写。
关键细节:遍历所有子View,调用child.layout()方法定位,注意处理子View的margin和父容器的padding。
4. onDraw:绘制内容(自定义View的核心)
核心作用:通过Canvas(画布)绘制控件的具体内容,如圆形、文字、图片、路径等,搭配Paint(画笔)控制样式。
新手必避坑:禁止在onDraw中创建对象(如Paint、Rect),onDraw会高频调用(如屏幕刷新、滑动时),频繁创建对象会导致内存抖动、卡顿。
Canvas常用API :
- drawCircle(cx, cy, radius, paint):绘制圆形
- drawRect(left, top, right, bottom, paint):绘制矩形
- drawText(text, x, y, paint):绘制文字(注意y是基线位置,不是文字顶部)
- drawPath(path, paint):绘制自定义路径(如三角形、多边形)
- drawBitmap(bitmap, x, y, paint):绘制图片
四、实战案例(3个业务级案例)
案例均来自真实业务需求,包含Kotlin+Java双版本、交互处理、动画效果、性能优化,复制就能直接集成到项目中。
案例1:完全自定义View → 渐变圆形View(带边框+点击交互)
需求:可配置填充色/渐变、边框、半径,支持点击事件,适配padding、wrap_content,满足头像、指示器等场景。
import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import com.example.myview.R
class CircleView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
// 画笔提前初始化,避免onDraw中创建
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG) // 填充画笔
private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG) // 边框画笔
private var circleRadius = 50f // 默认半径
private var borderWidth = 2f // 默认边框宽度
private var isGradient = false // 是否渐变
private var fillColor = Color.RED // 默认填充色
private var borderColor = Color.BLACK // 默认边框色
private var gradient: LinearGradient? = null // 渐变对象
init {
// 1. 获取自定义属性(渐变、边框属性)
val typedArray = context.obtainStyledAttributes(attrs, R.styleable.CircleView)
fillColor = typedArray.getColor(R.styleable.CircleView_circle_color, fillColor)
circleRadius = typedArray.getDimension(R.styleable.CircleView_circle_radius, circleRadius)
borderColor = typedArray.getColor(R.styleable.CircleView_circle_border_color, borderColor)
borderWidth = typedArray.getDimension(R.styleable.CircleView_circle_border_width, borderWidth)
isGradient = typedArray.getBoolean(R.styleable.CircleView_circle_gradient, isGradient)
typedArray.recycle() // 必须回收
// 2. 初始化画笔
initPaint()
}
private fun initPaint() {
// 填充画笔
fillPaint.style = Paint.Style.FILL
if (isGradient) {
// 渐变配置
gradient = LinearGradient(
0f, 0f, circleRadius * 2, circleRadius * 2,
fillColor, Color.parseColor("#FFFFFF"),
Shader.TileMode.CLAMP
)
fillPaint.shader = gradient
} else {
fillPaint.color = fillColor
}
// 边框画笔
borderPaint.style = Paint.Style.STROKE
borderPaint.color = borderColor
borderPaint.strokeWidth = borderWidth
}
// 3. 重写onMeasure,处理wrap_content和padding
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val defaultSize = (circleRadius * 2 + paddingLeft + paddingRight + borderWidth * 2).toInt()
val actualWidth = getActualSize(widthMeasureSpec, defaultSize)
val actualHeight = getActualSize(heightMeasureSpec, defaultSize)
setMeasuredDimension(actualWidth, actualHeight)
}
// 辅助方法:计算实际宽高
private fun getActualSize(measureSpec: Int, defaultSize: Int): Int {
val mode = MeasureSpec.getMode(measureSpec)
val size = MeasureSpec.getSize(measureSpec)
return when (mode) {
MeasureSpec.EXACTLY -> size
MeasureSpec.AT_MOST -> minOf(defaultSize, size)
else -> defaultSize
}
}
// 4. 重写onDraw,绘制圆形+边框
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 适配padding和边框,计算圆心坐标
val cx = paddingLeft + borderWidth + circleRadius
val cy = paddingTop + borderWidth + circleRadius
// 绘制填充圆形
canvas.drawCircle(cx, cy, circleRadius, fillPaint)
// 绘制边框
canvas.drawCircle(cx, cy, circleRadius + borderWidth / 2, borderPaint)
}
// 5.点击交互
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
// 点击时改变颜色,反馈交互
fillPaint.color = Color.parseColor("#FF8800")
invalidate() // 主线程刷新视图
return true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
// 松开时恢复颜色
if (isGradient) {
fillPaint.shader = gradient
} else {
fillPaint.color = fillColor
}
invalidate()
// 触发点击回调(可对外暴露)
performClick()
}
}
return super.onTouchEvent(event)
}
// Java版本代码
// public class CircleView extends View {
// private Paint fillPaint;
// private Paint borderPaint;
// private float circleRadius = 50f;
// private float borderWidth = 2f;
// private boolean isGradient = false;
// private int fillColor = Color.RED;
// private int borderColor = Color.BLACK;
// private LinearGradient gradient;
//
// public CircleView(Context context) {
// this(context, null);
// }
//
// public CircleView(Context context, AttributeSet attrs) {
// this(context, attrs, 0);
// }
//
// public CircleView(Context context, AttributeSet attrs, int defStyleAttr) {
// super(context, attrs, defStyleAttr);
// initAttrs(attrs);
// initPaint();
// }
//
// private void initAttrs(AttributeSet attrs) {
// TypedArray typedArray = getContext().obtainStyledAttributes(attrs, R.styleable.CircleView);
// fillColor = typedArray.getColor(R.styleable.CircleView_circle_color, fillColor);
// circleRadius = typedArray.getDimension(R.styleable.CircleView_circle_radius, circleRadius);
// borderColor = typedArray.getColor(R.styleable.CircleView_circle_border_color, borderColor);
// borderWidth = typedArray.getDimension(R.styleable.CircleView_circle_border_width, borderWidth);
// isGradient = typedArray.getBoolean(R.styleable.CircleView_circle_gradient, isGradient);
// typedArray.recycle();
// }
//
// private void initPaint() {
// fillPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
// fillPaint.setStyle(Paint.Style.FILL);
// if (isGradient) {
// gradient = new LinearGradient(0f, 0f, circleRadius * 2, circleRadius * 2,
// fillColor, Color.parseColor("#FFFFFF"), Shader.TileMode.CLAMP);
// fillPaint.setShader(gradient);
// } else {
// fillPaint.setColor(fillColor);
// }
//
// borderPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
// borderPaint.setStyle(Paint.Style.STROKE);
// borderPaint.setColor(borderColor);
// borderPaint.setStrokeWidth(borderWidth);
// }
//
// @Override
// protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
// super.onMeasure(widthMeasureSpec, heightMeasureSpec);
// int defaultSize = (int) (circleRadius * 2 + getPaddingLeft() + getPaddingRight() + borderWidth * 2);
// int actualWidth = getActualSize(widthMeasureSpec, defaultSize);
// int actualHeight = getActualSize(heightMeasureSpec, defaultSize);
// setMeasuredDimension(actualWidth, actualHeight);
// }
//
// private int getActualSize(int measureSpec, int defaultSize) {
// int mode = MeasureSpec.getMode(measureSpec);
// int size = MeasureSpec.getSize(measureSpec);
// if (mode == MeasureSpec.EXACTLY) {
// return size;
// } else if (mode == MeasureSpec.AT_MOST) {
// return Math.min(defaultSize, size);
// } else {
// return defaultSize;
// }
// }
//
// @Override
// protected void onDraw(Canvas canvas) {
// super.onDraw(canvas);
// float cx = getPaddingLeft() + borderWidth + circleRadius;
// float cy = getPaddingTop() + borderWidth + circleRadius;
// canvas.drawCircle(cx, cy, circleRadius, fillPaint);
// canvas.drawCircle(cx, cy, circleRadius + borderWidth / 2, borderPaint);
// }
//
// @Override
// public boolean onTouchEvent(MotionEvent event) {
// switch (event.getAction()) {
// case MotionEvent.ACTION_DOWN:
// fillPaint.setColor(Color.parseColor("#FF8800"));
// invalidate();
// return true;
// case MotionEvent.ACTION_UP:
// case MotionEvent.ACTION_CANCEL:
// if (isGradient) {
// fillPaint.setShader(gradient);
// } else {
// fillPaint.setColor(fillColor);
// }
// invalidate();
// performClick();
// break;
// }
// return super.onTouchEvent(event);
// }
// }
}
案例2:扩展现有控件 → 带删除图标的TextView(业务常用)
需求:继承AppCompatTextView,右侧添加删除图标,点击图标删除文字,适配不同文字长度、图标大小,用于搜索框、标签等场景。
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.MotionEvent
import androidx.appcompat.widget.AppCompatTextView
class DeleteTextView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AppCompatTextView(context, attrs, defStyleAttr) {
private var deleteIcon: Drawable? = null // 删除图标
private var iconSize = 24f // 图标大小
private var iconMargin = 8f // 图标与文字间距
private val iconPaint = Paint(Paint.ANTI_ALIAS_FLAG)
init {
// 获取自定义属性(图标、大小、间距)
val typedArray = context.obtainStyledAttributes(attrs, R.styleable.DeleteTextView)
deleteIcon = typedArray.getDrawable(R.styleable.DeleteTextView_delete_icon)
iconSize = typedArray.getDimension(R.styleable.DeleteTextView_icon_size, iconSize)
iconMargin = typedArray.getDimension(R.styleable.DeleteTextView_icon_margin, iconMargin)
typedArray.recycle()
// 初始化图标(缩放至指定大小)
deleteIcon?.let {
it.setBounds(0, 0, iconSize.toInt(), iconSize.toInt())
}
// 设置文字间距,给图标留出空间
compoundDrawablePadding = iconMargin.toInt()
}
// 重写onDraw,绘制删除图标(右侧)
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
deleteIcon?.let {
// 计算图标位置(右侧居中)
val iconLeft = width - paddingRight - iconSize - iconMargin
val iconTop = (height - iconSize) / 2
// 保存画布状态,避免影响文字绘制
canvas.save()
canvas.translate(iconLeft, iconTop)
it.draw(canvas)
canvas.restore()
}
}
// 点击图标删除文字(交互逻辑)
override fun onTouchEvent(event: MotionEvent): Boolean {
deleteIcon?.let {
val iconLeft = width - paddingRight - iconSize - iconMargin
val iconRight = iconLeft + iconSize
val iconTop = (height - iconSize) / 2
val iconBottom = iconTop + iconSize
// 判断点击位置是否在图标上
if (event.x in iconLeft..iconRight && event.y in iconTop..iconBottom) {
when (event.action) {
MotionEvent.ACTION_UP -> {
text = "" // 删除文字
invalidate()
return true
}
}
}
}
return super.onTouchEvent(event)
}
// 对外暴露方法,设置删除图标
fun setDeleteIcon(drawable: Drawable) {
deleteIcon = drawable
deleteIcon?.setBounds(0, 0, iconSize.toInt(), iconSize.toInt())
invalidate()
}
}
案例3:自定义ViewGroup → 完整流式布局(带间距+换行+点击删除)
需求:子View横向排列,超出父容器宽度自动换行,支持子View间距、点击删除子View,用于标签、筛选条件等场景。
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
class FlowLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {
private var horizontalSpacing = 10f // 子View横向间距
private var verticalSpacing = 10f // 子View纵向间距
init {
// 获取自定义间距属性
val typedArray = context.obtainStyledAttributes(attrs, R.styleable.FlowLayout)
horizontalSpacing = typedArray.getDimension(R.styleable.FlowLayout_horizontal_spacing, horizontalSpacing)
verticalSpacing = typedArray.getDimension(R.styleable.FlowLayout_vertical_spacing, verticalSpacing)
typedArray.recycle()
}
// 1. 重写onMeasure,测量子View和自身宽高
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
var totalHeight = paddingTop + paddingBottom // 总高度(初始为上下内边距)
var currentLineWidth = paddingLeft // 当前行宽度(初始为左内边距)
var currentLineHeight = 0 // 当前行高度
// 遍历所有子View,测量并计算行宽、行高
for (i in 0 until childCount) {
val child = getChildAt(i)
// 测量子View(必须调用,否则子View宽高为0)
measureChild(child, widthMeasureSpec, heightMeasureSpec)
val childWidth = child.measuredWidth
val childHeight = child.measuredHeight
// 判断当前子View是否需要换行
if (currentLineWidth + childWidth + horizontalSpacing > widthSize - paddingRight) {
// 换行:累加当前行高度,重置当前行宽和行高
totalHeight += currentLineHeight + verticalSpacing
currentLineWidth = paddingLeft + childWidth
currentLineHeight = childHeight
} else {
// 不换行:累加当前行宽,更新当前行最高高度
currentLineWidth += childWidth + horizontalSpacing
currentLineHeight = maxOf(currentLineHeight, childHeight)
}
}
// 加上最后一行的高度
totalHeight += currentLineHeight
// 设置自身宽高
val actualWidth = if (widthMode == MeasureSpec.EXACTLY) widthSize else currentLineWidth + paddingRight
setMeasuredDimension(actualWidth, totalHeight)
}
// 2. 重写onLayout,定位子View
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
var currentLeft = paddingLeft // 当前子View的左坐标
var currentTop = paddingTop // 当前子View的上坐标
var currentLineHeight = 0 // 当前行高度
for (i in 0 until childCount) {
val child = getChildAt(i)
val childWidth = child.measuredWidth
val childHeight = child.measuredHeight
// 判断是否需要换行
if (currentLeft + childWidth + horizontalSpacing > width - paddingRight) {
// 换行:重置左坐标,累加行高
currentLeft = paddingLeft
currentTop += currentLineHeight + verticalSpacing
currentLineHeight = childHeight
} else {
// 不换行:更新当前行最高高度
currentLineHeight = maxOf(currentLineHeight, childHeight)
}
// 定位子View(left, top, right, bottom)
val childLeft = currentLeft
val childTop = currentTop + (currentLineHeight - childHeight) / 2 // 垂直居中
val childRight = childLeft + childWidth
val childBottom = childTop + childHeight
child.layout(childLeft, childTop, childRight, childBottom)
// 更新下一个子View的左坐标
currentLeft += childWidth + horizontalSpacing
}
}
// 3. 添加标签+点击删除功能
fun addTag(text: String) {
val tagView = TextView(context).apply {
this.text = text
setPadding(12.dp, 6.dp, 12.dp, 6.dp)
setBackgroundResource(R.drawable.tag_bg) // 自定义标签背景
textSize = 14f
// 点击删除当前标签
setOnClickListener { removeView(this) }
}
addView(tagView)
}
// dp转px(辅助方法)
private val Int.dp: Int
get() = (this * resources.displayMetrics.density + 0.5f).toInt()
}
五、高频避坑+面试考点
这部分是咱们掘金用户最关心的内容,既有开发中常踩的坑,也有面试高频提问,帮你既搞定业务,也能应对面试。
1. 开发必避坑(新手高频错误)
- 坑1:onMeasure未处理wrap_content → 解决方案:手动计算wrap_content时的默认尺寸,结合MeasureSpec处理。
- 坑2:onDraw中创建对象 → 解决方案:在init或onSizeChanged中初始化画笔、Rect等对象,避免内存抖动。
- 坑3:未适配padding/margin → 解决方案:绘制和布局时,必须加上paddingLeft/Top/Right/Bottom,ViewGroup还要处理子View的margin。
- 坑4:TypedArray未回收 → 解决方案:获取属性后,必须调用typedArray.recycle(),避免内存泄漏。
- 坑5:子线程刷新视图 → 解决方案:主线程用invalidate(),子线程用postInvalidate()或Handler.post()。
- 坑6:Canvas绘制顺序错误 → 解决方案:先绘制底层内容(如背景),再绘制上层内容(如文字、图标),避免被覆盖。
2. 面试高频考点(自定义View核心提问)
- Q:自定义View的绘制流程是什么? → A:measure(测量)→ layout(布局)→ draw(绘制),补充各步骤的核心作用。
- Q:onMeasure中MeasureSpec的三种模式是什么?分别对应什么场景? → A:作为自定义View测量的核心,MeasureSpec的三种模式直接决定子View的宽高表现,是面试高频必问考点:EXACTLY(固定尺寸、match_parent,父布局明确指定子View宽高)、AT_MOST(wrap_content,子View宽高不超过父布局最大尺寸)、UNSPECIFIED(无约束,如ScrollView中的子View,可自由设置尺寸)。
- Q:如何优化自定义View的性能? → A:① 避免onDraw中创建对象;② 减少onDraw调用次数;③ 用硬件加速(setLayerType);④ 避免过度绘制(减少重叠绘制)。
- Q:自定义ViewGroup和自定义View的区别是什么? → A:ViewGroup需要重写onMeasure(测量子View)和onLayout(定位子View),View无需重写onLayout。
- Q:invalidate()和postInvalidate()的区别? → A:两者都是刷新视图,invalidate()只能在主线程调用,postInvalidate()可在子线程调用。
六、实战调试技巧(掘金独家干货)
新手写自定义View,最头疼的是“看不到绘制过程,不知道bug在哪”,分享3个实用调试技巧,快速定位问题:
- 开启布局边界:设置→系统→开发者选项→开启“显示布局边界”,可直观看到控件的宽高、位置,快速排查布局错位问题。
- 绘制辅助线:在onDraw中用Canvas.drawLine()绘制辅助线,标记圆心、边界等,查看绘制位置是否正确。
- 打印日志:在onMeasure、onLayout、onDraw中打印宽高、坐标等信息,排查测量和布局逻辑错误。
七、总结
自定义View没有想象中难,核心就是“选对实现方式→处理自定义属性→重写三大核心方法→避坑+优化”。
本文所有案例均来自真实业务,复制就能用。
掌握这些内容,你不仅能搞定80%的业务自定义控件需求,还能在面试中脱颖而出。后续可根据实际需求,扩展更复杂的控件(如自定义进度条、折线图)。