Android 自定义 View 详解
自定义 View 是 Android 开发的核心技能之一,本文从原理到实践,系统梳理自定义 View 的完整知识体系。
一、自定义 View 的三种方式
| 方式 | 适用场景 | 核心工作 |
|---|---|---|
继承现有控件 (如 TextView、ImageView) |
在已有控件基础上扩展功能 | 复用绘制逻辑,重写关键方法 |
继承 View |
完全自定义绘制内容(如饼图、进度条) | 重写 onMeasure + onDraw |
继承 ViewGroup |
自定义布局管理器(如流式布局、瀑布流) | 重写 onMeasure + onLayout |
二、核心生命周期方法
1. 构造方法(Constructor)
kotlin
class CustomView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
init {
// 读取自定义属性
val typedArray = context.obtainStyledAttributes(attrs, R.styleable.CustomView)
val color = typedArray.getColor(R.styleable.CustomView_circleColor, Color.RED)
typedArray.recycle()
}
}
三个构造方法的区别:
-
View(Context):代码中直接 new -
View(Context, AttributeSet):XML 中使用 -
View(Context, AttributeSet, Int):XML 中使用 + 指定 style
2. onMeasure ------ 测量尺寸
kotlin
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
// 三种测量模式
when (widthMode) {
MeasureSpec.EXACTLY -> { /* match_parent 或具体数值 */ }
MeasureSpec.AT_MOST -> { /* wrap_content */ }
MeasureSpec.UNSPECIFIED -> { /* 父布局不限制,如 ScrollView 内 */ }
}
// 设置最终测量结果
setMeasuredDimension(resolveSize(desiredWidth, widthMeasureSpec),
resolveSize(desiredHeight, heightMeasureSpec))
}
测量模式速查:
| Mode | 触发条件 | 处理方式 |
|---|---|---|
EXACTLY |
match_parent / 具体 dp |
直接使用给定尺寸 |
AT_MOST |
wrap_content |
计算内容所需尺寸,不超过上限 |
UNSPECIFIED |
父布局不限制 | 按内容实际需要设置 |
3. onSizeChanged ------ 尺寸变化
kotlin
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
// 初始化与尺寸相关的对象(如 Rect、Path、Shader)
centerX = w / 2f
centerY = h / 2f
radius = min(w, h) / 2f - padding
}
4. onDraw ------ 绘制内容(核心)
kotlin
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 1. 绘制背景(系统已处理,通常不需要手动调用)
// 2. 绘制内容
canvas.drawCircle(centerX, centerY, radius, paint)
canvas.drawRect(rect, paint)
canvas.drawText("Hello", x, y, textPaint)
// 3. 使用 Path 绘制复杂图形
val path = Path().apply {
moveTo(100f, 100f)
lineTo(200f, 200f)
quadTo(300f, 100f, 400f, 200f) // 二次贝塞尔曲线
close()
}
canvas.drawPath(path, paint)
}
Canvas 常用绘制 API:
drawCircle() drawRect() drawOval()
drawArc() drawLine() drawPoint()
drawText() drawBitmap() drawPath()
5. onLayout(仅 ViewGroup)
kotlin
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
for (i in 0 until childCount) {
val child = getChildAt(i)
// 计算子 View 的位置
child.layout(left, top, right, bottom)
}
}
三、Paint 详解 ------ 画笔配置
kotlin
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = Color.RED // 颜色
strokeWidth = 8f // 描边宽度
style = Paint.Style.STROKE // FILL / STROKE / FILL_AND_STROKE
strokeCap = Paint.Cap.ROUND // 线帽:BUTT / ROUND / SQUARE
strokeJoin = Paint.Join.ROUND // 连接处:MITER / ROUND / BEVEL
isAntiAlias = true // 抗锯齿
isDither = true // 防抖动(颜色过渡更平滑)
// 高级效果
shader = LinearGradient(...) // 渐变
maskFilter = BlurMaskFilter(...) // 模糊效果
pathEffect = DashPathEffect(...) // 虚线效果
}
四、完整示例:圆形进度条
1. 自定义属性(res/values/attrs.xml)
xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
<declare-styleable name="CircleProgressView">
<attr name="progressColor" format="color"/>
<attr name="bgColor" format="color"/>
<attr name="strokeWidth" format="dimension"/>
<attr name="maxProgress" format="integer"/>
<attr name="currentProgress" format="integer"/>
</declare-styleable>
</resources>
2. 完整 View 代码
kotlin
class CircleProgressView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private var progressColor = Color.parseColor("#2196F3")
private var bgColor = Color.parseColor("#E0E0E0")
private var strokeWidth = 20f
private var maxProgress = 100
private var currentProgress = 0
private val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val progressPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val rectF = RectF()
init {
context.obtainStyledAttributes(attrs, R.styleable.CircleProgressView).apply {
progressColor = getColor(R.styleable.CircleProgressView_progressColor, progressColor)
bgColor = getColor(R.styleable.CircleProgressView_bgColor, bgColor)
strokeWidth = getDimension(R.styleable.CircleProgressView_strokeWidth, strokeWidth)
maxProgress = getInt(R.styleable.CircleProgressView_maxProgress, maxProgress)
currentProgress = getInt(R.styleable.CircleProgressView_currentProgress, currentProgress)
recycle()
}
bgPaint.apply {
color = bgColor
this.strokeWidth = this@CircleProgressView.strokeWidth
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
}
progressPaint.apply {
color = progressColor
this.strokeWidth = this@CircleProgressView.strokeWidth
style = Paint.Style.STROKE
strokeCap = Paint.Cap.ROUND
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val size = resolveSize(200.dpToPx(), widthMeasureSpec)
setMeasuredDimension(size, size)
}
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
val padding = strokeWidth / 2
rectF.set(padding, padding, w - padding, h - padding)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 绘制背景圆环
canvas.drawArc(rectF, 0f, 360f, false, bgPaint)
// 绘制进度圆弧
val sweepAngle = 360f * currentProgress / maxProgress
canvas.drawArc(rectF, -90f, sweepAngle, false, progressPaint)
// 绘制进度文字
val text = "$currentProgress%"
val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = progressColor
textSize = width / 4f
textAlign = Paint.Align.CENTER
}
val baseline = height / 2f - (textPaint.descent() + textPaint.ascent()) / 2
canvas.drawText(text, width / 2f, baseline, textPaint)
}
fun setProgress(progress: Int) {
currentProgress = progress.coerceIn(0, maxProgress)
invalidate() // 请求重绘
}
private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).toInt()
}
3. XML 中使用
xml
<com.example.CircleProgressView
android:layout_width="120dp"
android:layout_height="120dp"
app:progressColor="@color/purple_500"
app:bgColor="#F5F5F5"
app:strokeWidth="12dp"
app:maxProgress="100"
app:currentProgress="75"/>
五、触摸事件处理
kotlin
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
parent.requestDisallowInterceptTouchEvent(true)
// 处理按下逻辑
return true // 消费事件
}
MotionEvent.ACTION_MOVE -> {
// 处理滑动逻辑
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
parent.requestDisallowInterceptTouchEvent(false)
// 处理抬起逻辑
}
}
return super.onTouchEvent(event)
}
事件分发要点:
-
dispatchTouchEvent→ 事件入口 -
onInterceptTouchEvent(仅 ViewGroup)→ 是否拦截 -
onTouchEvent→ 实际处理 -
返回
true表示消费事件,不再向上传递
六、动画与属性更新
kotlin
// 方式1:属性动画
ValueAnimator.ofInt(0, 100).apply {
duration = 2000
interpolator = DecelerateInterpolator()
addUpdateListener { animator ->
setProgress(animator.animatedValue as Int)
}
start()
}
// 方式2:配合 ObjectAnimator
var progress: Int = 0
set(value) {
field = value
invalidate()
}
ObjectAnimator.ofInt(this, "progress", 0, 100).apply {
duration = 1500
start()
}
七、性能优化建议
| 优化项 | 说明 |
|---|---|
避免在 onDraw 中创建对象 |
所有 Paint、Path、Rect 应在 init 或 onSizeChanged 中初始化 |
使用 invalidate(Rect) 局部重绘 |
只重绘变化区域,减少绘制工作量 |
| 开启硬件加速 | android:hardwareAccelerated="true",但注意部分 API 不支持 |
| 减少过度绘制 | 避免多层重叠绘制,使用 clipRect 裁剪 |
| 复杂图形使用 Bitmap 缓存 | 静态内容先绘制到 Bitmap,后续直接 drawBitmap |
使用 requestLayout() 谨慎 |
会触发完整测量-布局-绘制流程,开销大 |
八、常见问题排查
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 自定义 View 不显示 | 未重写 onMeasure 且父布局为 wrap_content |
重写 onMeasure 设置默认尺寸 |
wrap_content 无效 |
未处理 AT_MOST 模式 |
在 onMeasure 中处理 |
| 文字绘制位置偏移 | 未计算 baseline | 使用 Paint.FontMetrics 计算 |
| 触摸事件不响应 | onTouchEvent 返回 false |
返回 true 消费事件 |
| 动画卡顿 | 主线程阻塞或过度绘制 | 使用 Choreographer,减少绘制层级 |
九、进阶方向
-
自定义 Drawable :实现
Drawable接口,可复用于多个 View -
RenderThread / RenderNode:Android 10+ 的硬件渲染优化
-
Compose 自定义 :使用
CanvasModifier 或LayoutComposable -
SVG Path 动画 :
AnimatedVectorDrawable实现复杂路径动画