自定义View基础:从View到Canvas
自定义View是Android进阶开发的核心技能之一。通过自定义View,开发者可以完全控制UI的绘制过程,实现系统控件无法提供的视觉效果和交互体验。理解自定义View的绘制原理,需要深入掌握View的测量、布局和绘制三大流程,以及Canvas、Paint、Path等核心绘图类。
自定义View三大核心流程
- 测量(Measure):确定View的宽高尺寸,通过onMeasure方法实现
- 布局(Layout):确定View在父容器中的位置,通过onLayout方法实现
- 绘制(Draw):将View内容渲染到屏幕上,通过onDraw方法实现
自定义View的三种方式
根据需求复杂度,自定义View有三种实现方式:继承现有控件、组合多个控件、完全自定义绘制。每种方式适用于不同的场景。
// 方式一:继承现有控件(扩展功能)
class CustomTextView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : AppCompatTextView(context, attrs, defStyleAttr) {
init {
// 扩展TextView功能,如添加描边效果
paint.apply {
style = Paint.Style.STROKE
strokeWidth = 3f
color = Color.BLACK
}
}
}
// 方式二:组合控件(复用现有View)
class UserCardView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : LinearLayout(context, attrs, defStyleAttr) {
private val avatarView: ImageView
private val nameView: TextView
private val descView: TextView
init {
orientation = VERTICAL
LayoutInflater.from(context).inflate(R.layout.view_user_card, this, true)
avatarView = findViewById(R.id.avatar)
nameView = findViewById(R.id.name)
descView = findViewById(R.id.description)
}
fun setUser(user: User) {
avatarView.setImageResource(user.avatarRes)
nameView.text = user.name
descView.text = user.description
}
}
// 方式三:完全自定义绘制(继承View)
class CircleProgressView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private var progress = 0f
set(value) {
field = value.coerceIn(0f, 100f)
invalidate() // 请求重绘
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 自定义绘制逻辑
drawProgressCircle(canvas)
}
}
测量与布局:onMeasure与onLayout
测量是自定义View的第一步,决定了View的宽高。理解MeasureSpec和测量模式对于正确处理各种布局场景至关重要。
MeasureSpec详解
| 测量模式 | 值 | 说明 | 典型场景 |
|---|---|---|---|
| EXACTLY | 0x40000000 | 精确值,View必须使用指定尺寸 | match_parent、具体数值 |
| AT_MOST | 0x80000000 | 最大值,View尺寸不能超过指定值 | wrap_content |
| UNSPECIFIED | 0x00000000 | 无限制,View可以使用任意尺寸 | ScrollView内部 |
class CustomView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private var defaultWidth = 200.dpToPx()
private var defaultHeight = 200.dpToPx()
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
// 计算最终宽高
val finalWidth = when (widthMode) {
MeasureSpec.EXACTLY -> widthSize
MeasureSpec.AT_MOST -> min(defaultWidth, widthSize)
else -> defaultWidth
}
val finalHeight = when (heightMode) {
MeasureSpec.EXACTLY -> heightSize
MeasureSpec.AT_MOST -> min(defaultHeight, heightSize)
else -> defaultHeight
}
setMeasuredDimension(finalWidth, finalHeight)
}
// ViewGroup需要实现onLayout
class CustomLayout @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : ViewGroup(context, attrs, defStyleAttr) {
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
var left = paddingLeft
val top = paddingTop
for (i in 0 until childCount) {
val child = getChildAt(i)
if (child.visibility != GONE) {
val childWidth = child.measuredWidth
val childHeight = child.measuredHeight
child.layout(left, top, left + childWidth, top + childHeight)
left += childWidth + 20.dpToPx() // 间距
}
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
// 测量所有子View
measureChildren(widthMeasureSpec, heightMeasureSpec)
var totalWidth = paddingLeft + paddingRight
var maxHeight = 0
for (i in 0 until childCount) {
val child = getChildAt(i)
if (child.visibility != GONE) {
totalWidth += child.measuredWidth + 20.dpToPx()
maxHeight = max(maxHeight, child.measuredHeight)
}
}
totalWidth -= 20.dpToPx() // 减去最后一个间距
setMeasuredDimension(
resolveSize(totalWidth, widthMeasureSpec),
resolveSize(maxHeight + paddingTop + paddingBottom, heightMeasureSpec)
)
}
}
}
Canvas绘制:核心API详解
Canvas是Android 2D绘制的核心类,提供了丰富的绘制API。掌握Canvas的坐标系、变换矩阵和绘制方法,是实现复杂自定义View的基础。
Canvas核心绘制方法
- drawColor/drawARGB:绘制背景色
- drawPoint/drawPoints:绘制点
- drawLine/drawLines:绘制线
- drawRect/drawRoundRect:绘制矩形
- drawCircle/drawOval:绘制圆形/椭圆
- drawArc:绘制圆弧
- drawPath:绘制路径
- drawText:绘制文本
- drawBitmap:绘制图片
class DrawingDemoView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val path = Path()
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val width = width.toFloat()
val height = height.toFloat()
val centerX = width / 2
val centerY = height / 2
// 1. 绘制背景
canvas.drawColor(Color.parseColor("#F5F5F5"))
// 2. 绘制圆形(填充+描边)
paint.apply {
color = Color.parseColor("#2196F3")
style = Paint.Style.FILL
}
canvas.drawCircle(centerX, centerY, 100f, paint)
paint.apply {
color = Color.parseColor("#1976D2")
style = Paint.Style.STROKE
strokeWidth = 8f
}
canvas.drawCircle(centerX, centerY, 100f, paint)
// 3. 绘制圆角矩形
paint.apply {
color = Color.parseColor("#4CAF50")
style = Paint.Style.FILL
}
val rect = RectF(50f, 50f, 250f, 150f)
canvas.drawRoundRect(rect, 20f, 20f, paint)
// 4. 绘制圆弧(进度条效果)
paint.apply {
color = Color.parseColor("#FF9800")
style = Paint.Style.STROKE
strokeWidth = 20f
strokeCap = Paint.Cap.ROUND
}
val arcRect = RectF(centerX - 150f, centerY - 150f, centerX + 150f, centerY + 150f)
canvas.drawArc(arcRect, -90f, 270f, false, paint)
// 5. 绘制Path(贝塞尔曲线)
path.reset()
path.moveTo(100f, 400f)
path.quadTo(200f, 300f, 300f, 400f) // 二次贝塞尔曲线
path.cubicTo(350f, 450f, 400f, 350f, 500f, 400f) // 三次贝塞尔曲线
paint.apply {
color = Color.parseColor("#9C27B0")
style = Paint.Style.STROKE
strokeWidth = 5f
}
canvas.drawPath(path, paint)
// 6. 绘制文字
paint.apply {
color = Color.BLACK
style = Paint.Style.FILL
textSize = 48f
textAlign = Paint.Align.CENTER
}
canvas.drawText("Hello Canvas!", centerX, height - 100f, paint)
}
}
Paint常用属性
- color:设置颜色
- style:FILL填充、STROKE描边、FILL_AND_STROKE两者都应用
- strokeWidth:描边宽度
- strokeCap:线帽样式(BUTT、ROUND、SQUARE)
- strokeJoin:线段连接样式(MITER、ROUND、BEVEL)
- shader:着色器,实现渐变效果
- maskFilter:遮罩滤镜,实现模糊效果
- pathEffect:路径效果,实现虚线等
实战:圆形进度条
下面实现一个功能完整的圆形进度条控件,包含进度动画、渐变效果、文字显示等功能。
class CircleProgressBar @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
// 进度值 0-100
var progress: Float = 0f
set(value) {
field = value.coerceIn(0f, 100f)
invalidate()
}
// 最大进度
var maxProgress: Float = 100f
// 圆环宽度
private var ringWidth: Float = 20f.dpToPx()
// 圆环颜色
private var ringColor: Int = Color.parseColor("#E0E0E0")
private var progressColor: Int = Color.parseColor("#2196F3")
// 文字大小和颜色
private var textSize: Float = 40f.dpToPx()
private var textColor: Int = Color.parseColor("#333333")
// 是否显示文字
private var showText: Boolean = true
// 动画时长
private var animationDuration: Long = 1000
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val rectF = RectF()
private var progressAnimator: ValueAnimator? = null
init {
// 读取自定义属性
context.obtainStyledAttributes(attrs, R.styleable.CircleProgressBar).apply {
progress = getFloat(R.styleable.CircleProgressBar_progress, 0f)
maxProgress = getFloat(R.styleable.CircleProgressBar_maxProgress, 100f)
ringWidth = getDimension(R.styleable.CircleProgressBar_ringWidth, 20f.dpToPx())
ringColor = getColor(R.styleable.CircleProgressBar_ringColor, Color.parseColor("#E0E0E0"))
progressColor = getColor(R.styleable.CircleProgressBar_progressColor, Color.parseColor("#2196F3"))
textSize = getDimension(R.styleable.CircleProgressBar_android_textSize, 40f.dpToPx())
textColor = getColor(R.styleable.CircleProgressBar_android_textColor, Color.parseColor("#333333"))
showText = getBoolean(R.styleable.CircleProgressBar_showText, true)
animationDuration = getInt(R.styleable.CircleProgressBar_animationDuration, 1000).toLong()
recycle()
}
}
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val defaultSize = 150f.dpToPx().toInt()
val width = resolveSize(defaultSize, widthMeasureSpec)
val height = resolveSize(defaultSize, heightMeasureSpec)
setMeasuredDimension(width, height)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val centerX = width / 2f
val centerY = height / 2f
val radius = (min(width, height) - ringWidth) / 2f - 10f.dpToPx()
// 绘制背景圆环
paint.apply {
color = ringColor
style = Paint.Style.STROKE
strokeWidth = ringWidth
strokeCap = Paint.Cap.ROUND
}
canvas.drawCircle(centerX, centerY, radius, paint)
// 绘制进度圆环
val sweepAngle = (progress / maxProgress) * 360f
rectF.set(
centerX - radius,
centerY - radius,
centerX + radius,
centerY + radius
)
// 创建渐变色
val shader = SweepGradient(
centerX, centerY,
intArrayOf(
Color.parseColor("#2196F3"),
Color.parseColor("#03A9F4"),
Color.parseColor("#00BCD4"),
Color.parseColor("#2196F3")
),
null
)
paint.shader = shader
canvas.drawArc(rectF, -90f, sweepAngle, false, paint)
paint.shader = null // 清除着色器
// 绘制进度文字
if (showText) {
paint.apply {
color = textColor
style = Paint.Style.FILL
this.textSize = this@CircleProgressBar.textSize
textAlign = Paint.Align.CENTER
}
val text = "${progress.toInt()}%"
val textY = centerY - (paint.descent() + paint.ascent()) / 2
canvas.drawText(text, centerX, textY, paint)
}
}
/**
* 设置进度并播放动画
*/
fun setProgressWithAnimation(targetProgress: Float) {
progressAnimator?.cancel()
progressAnimator = ValueAnimator.ofFloat(progress, targetProgress).apply {
duration = animationDuration
interpolator = DecelerateInterpolator()
addUpdateListener {
progress = it.animatedValue as Float
}
start()
}
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
progressAnimator?.cancel()
}
}
实战:折线图控件
下面实现一个支持多点数据、动画效果和交互的折线图控件,展示更复杂的自定义View开发技巧。
data class PointData(
val x: Float,
val y: Float,
val label: String = ""
)
class LineChartView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
private val dataPoints = mutableListOf<PointData>()
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val path = Path()
// 图表边距
private val paddingLeft = 60f.dpToPx()
private val paddingTop = 40f.dpToPx()
private val paddingRight = 40f.dpToPx()
private val paddingBottom = 60f.dpToPx()
// 线条样式
private var lineColor: Int = Color.parseColor("#2196F3")
private var lineWidth: Float = 4f.dpToPx()
private var pointRadius: Float = 6f.dpToPx()
private var pointColor: Int = Color.parseColor("#1976D2")
// 动画进度 0-1
private var animationProgress: Float = 1f
private var animator: ValueAnimator? = null
// 手势检测
private val gestureDetector = GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() {
override fun onSingleTapUp(e: MotionEvent): Boolean {
val touchedPoint = findNearestPoint(e.x, e.y)
touchedPoint?.let { onPointClickListener?.invoke(it) }
return true
}
})
var onPointClickListener: ((PointData) -> Unit)? = null
fun setData(points: List<PointData>) {
dataPoints.clear()
dataPoints.addAll(points)
startAnimation()
invalidate()
}
private fun startAnimation() {
animator?.cancel()
animationProgress = 0f
animator = ValueAnimator.ofFloat(0f, 1f).apply {
duration = 1500
interpolator = OvershootInterpolator()
addUpdateListener {
animationProgress = it.animatedValue as Float
invalidate()
}
start()
}
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (dataPoints.isEmpty()) return
val chartWidth = width - paddingLeft - paddingRight
val chartHeight = height - paddingTop - paddingBottom
// 计算数据范围
val minX = dataPoints.minOf { it.x }
val maxX = dataPoints.maxOf { it.x }
val minY = dataPoints.minOf { it.y }
val maxY = dataPoints.maxOf { it.y }
val xRange = maxX - minX
val yRange = max(maxY - minY, 0.1f) // 避免除零
// 绘制坐标轴
drawAxes(canvas, minY, maxY, chartWidth, chartHeight)
// 绘制折线
path.reset()
val visiblePoints = (dataPoints.size * animationProgress).toInt()
.coerceIn(0, dataPoints.size)
for (i in 0 until visiblePoints) {
val point = dataPoints[i]
val x = paddingLeft + (point.x - minX) / xRange * chartWidth
val y = paddingTop + chartHeight - (point.y - minY) / yRange * chartHeight
if (i == 0) {
path.moveTo(x, y)
} else {
// 使用贝塞尔曲线使折线更平滑
val prevPoint = dataPoints[i - 1]
val prevX = paddingLeft + (prevPoint.x - minX) / xRange * chartWidth
val prevY = paddingTop + chartHeight - (prevPoint.y - minY) / yRange * chartHeight
val cp1x = prevX + (x - prevX) / 2
val cp1y = prevY
val cp2x = prevX + (x - prevX) / 2
val cp2y = y
path.cubicTo(cp1x, cp1y, cp2x, cp2y, x, y)
}
}
// 绘制线条
paint.apply {
color = lineColor
style = Paint.Style.STROKE
strokeWidth = lineWidth
}
canvas.drawPath(path, paint)
// 绘制数据点
paint.style = Paint.Style.FILL
for (i in 0 until visiblePoints) {
val point = dataPoints[i]
val x = paddingLeft + (point.x - minX) / xRange * chartWidth
val y = paddingTop + chartHeight - (point.y - minY) / yRange * chartHeight
paint.color = Color.WHITE
canvas.drawCircle(x, y, pointRadius + 2f, paint)
paint.color = pointColor
canvas.drawCircle(x, y, pointRadius, paint)
}
}
private fun drawAxes(
canvas: Canvas,
minY: Float,
maxY: Float,
chartWidth: Float,
chartHeight: Float
) {
paint.apply {
color = Color.parseColor("#E0E0E0")
style = Paint.Style.STROKE
strokeWidth = 2f.dpToPx()
}
// Y轴
canvas.drawLine(
paddingLeft, paddingTop,
paddingLeft, height - paddingBottom,
paint
)
// X轴
canvas.drawLine(
paddingLeft, height - paddingBottom,
width - paddingRight, height - paddingBottom,
paint
)
// 绘制Y轴刻度
paint.apply {
color = Color.parseColor("#666666")
style = Paint.Style.FILL
textSize = 24f.dpToPx()
textAlign = Paint.Align.RIGHT
}
val ySteps = 5
for (i in 0..ySteps) {
val value = minY + (maxY - minY) * i / ySteps
val y = height - paddingBottom - chartHeight * i / ySteps
canvas.drawText(String.format("%.1f", value), paddingLeft - 10f, y + 8f, paint)
// 网格线
if (i > 0) {
paint.color = Color.parseColor("#F0F0F0")
canvas.drawLine(paddingLeft, y, width - paddingRight, y, paint)
paint.color = Color.parseColor("#666666")
}
}
}
private fun findNearestPoint(touchX: Float, touchY: Float): PointData? {
// 实现点击检测逻辑
return dataPoints.minByOrNull { point ->
val chartWidth = width - paddingLeft - paddingRight
val chartHeight = height - paddingTop - paddingBottom
val minX = dataPoints.minOf { it.x }
val maxX = dataPoints.maxOf { it.x }
val minY = dataPoints.minOf { it.y }
val maxY = dataPoints.maxOf { it.y }
val x = paddingLeft + (point.x - minX) / (maxX - minX) * chartWidth
val y = paddingTop + chartHeight - (point.y - minY) / (maxY - minY) * chartHeight
val dx = touchX - x
val dy = touchY - y
dx * dx + dy * dy
}?.takeIf {
val chartWidth = width - paddingLeft - paddingRight
val chartHeight = height - paddingTop - paddingBottom
val minX = dataPoints.minOf { it.x }
val maxX = dataPoints.maxOf { it.x }
val minY = dataPoints.minOf { it.y }
val maxY = dataPoints.maxOf { it.y }
val x = paddingLeft + (it.x - minX) / (maxX - minX) * chartWidth
val y = paddingTop + chartHeight - (it.y - minY) / (maxY - minY) * chartHeight
val distance = kotlin.math.hypot(touchX - x, touchY - y)
distance < 50f.dpToPx()
}
}
override fun onTouchEvent(event: MotionEvent): Boolean {
return gestureDetector.onTouchEvent(event) || super.onTouchEvent(event)
}
override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
animator?.cancel()
}
}
性能优化与最佳实践
自定义View的性能直接影响应用的流畅度。以下是关键的优化策略和最佳实践。
避免过度绘制
- 使用clipRect限制绘制区域
- 避免在onDraw中创建对象
- 使用Canvas的save/restore管理状态
- 复杂背景考虑使用View的background属性
class OptimizedView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
// 在初始化时创建对象,避免在onDraw中创建
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val path = Path()
private val rectF = RectF()
private val textBounds = Rect()
// 缓存计算结果
private var cachedWidth = 0
private var cachedHeight = 0
private var cachedPath: Path? = null
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
// 尺寸变化时重新计算路径
if (w != cachedWidth || h != cachedHeight) {
cachedWidth = w
cachedHeight = h
cachedPath = calculatePath(w, h)
}
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// 使用缓存的路径
cachedPath?.let { path ->
canvas.drawPath(path, paint)
}
// 局部刷新优化
// 只重绘变化的部分,而不是整个View
}
/**
* 局部刷新示例
*/
fun updatePartialRegion(region: Rect) {
// 只重绘指定区域
invalidate(region)
}
/**
* 硬件加速注意事项
*/
private fun checkHardwareAcceleration() {
if (isHardwareAccelerated) {
// 硬件加速开启时的优化
// 避免使用复杂的路径效果
// 避免频繁创建Bitmap
} else {
// 软件渲染时的处理
}
}
/**
* 使用离屏缓存
*/
private var cacheBitmap: Bitmap? = null
private var cacheCanvas: Canvas? = null
private fun drawWithCache(canvas: Canvas) {
if (cacheBitmap == null) {
cacheBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
cacheCanvas = Canvas(cacheBitmap!!)
// 绘制复杂内容到缓存
drawComplexContent(cacheCanvas!!)
}
// 直接绘制缓存
canvas.drawBitmap(cacheBitmap!!, 0f, 0f, null)
}
fun invalidateCache() {
cacheBitmap?.recycle()
cacheBitmap = null
invalidate()
}
}
自定义View最佳实践
- 支持自定义属性:在res/values/attrs.xml中定义属性
- 处理padding:确保View正确响应内边距设置
- 支持wrap_content:正确处理AT_MOST测量模式
- 动画优化:使用属性动画而非定时重绘
- 内存管理:及时释放Bitmap等资源
- 无障碍支持:实现accessibility功能