自定义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功能