[Android 从零到一] Custom View 自定义绘制:从 onDraw 到完整交互

为什么需要 Custom View?

Android 提供了丰富的内置控件(TextView、Button、ImageView 等),但实际开发中经常会遇到「现有控件不够用」的场景:

  • 需要一个环形进度条
  • 需要一个自定义的图表
  • 需要一个特殊形状的按钮
  • 需要一个手势识别的画板

这时候就需要 Custom View ------ 自己绘制、自己处理交互。

View 的绘制流程

每个 View 从创建到显示在屏幕上,会经历三个核心阶段:

复制代码
measure(测量)→ layout(布局)→ draw(绘制)
阶段 方法 作用
测量 onMeasure() 确定 View 的宽高
布局 onLayout() 确定子 View 的位置(ViewGroup 用)
绘制 onDraw() 在 Canvas 上绘制内容

💡 对于自定义单个 View ,主要关注 onMeasure() 和 onDraw();onLayout() 在自定义 ViewGroup 时才需要重写。

第一步:最简单的 Custom View

创建一个继承自 View 的类,重写 onDraw():

kotlin 复制代码
class SimpleView(context: Context, attrs: AttributeSet?) : View(context, attrs) {

    private val paint = Paint().apply {
        color = Color.BLUE
        style = Paint.Style.FILL
        isAntiAlias = true
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        // 画一个蓝色圆形
        canvas.drawCircle(width / 2f, height / 2f, 100f, paint)
    }
}

这就完成了一个最简单的自定义 View ------ 在屏幕中央画一个蓝色圆。

第二步:理解 onMeasure

View 的尺寸不是你想多大就多大,需要和父布局"协商"。这就是 onMeasure() 的作用。

MeasureSpec 三种模式

模式 含义 场景
EXACTLY 精确值 设置了具体 dp 值或 match_parent
AT_MOST 最大值 wrap_content
UNSPECIFIED 不限制 ScrollView 内嵌 View

重写 onMeasure

kotlin 复制代码
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    val widthMode = MeasureSpec.getMode(widthMeasureSpec)
    val widthSize = MeasureSpec.getSize(widthMeasureSpec)
    val heightMode = MeasureSpec.getMode(heightMeasureSpec)
    val heightSize = MeasureSpec.getSize(heightMeasureSpec)

    // 默认尺寸
    val desiredWidth = 200
    val desiredHeight = 200

    val width = when (widthMode) {
        MeasureSpec.EXACTLY -> widthSize
        MeasureSpec.AT_MOST -> minOf(desiredWidth, widthSize)
        else -> desiredWidth
    }

    val height = when (heightMode) {
        MeasureSpec.EXACTLY -> heightSize
        MeasureSpec.AT_MOST -> minOf(desiredHeight, heightSize)
        else -> desiredHeight
    }

    setMeasuredDimension(width, height)
}

⚠️ 如果不重写 onMeasure(),使用 wrap_content 时会占满父布局,表现得和 match_parent 一样。

第三步:Canvas 与 Paint

自定义绘制的核心就是两个类:Canvas (画布)和 Paint(画笔)。

Canvas 常用绘制方法

kotlin 复制代码
// 画矩形
canvas.drawRect(left, top, right, bottom, paint)

// 画圆角矩形
canvas.drawRoundRect(rect, rx, ry, paint)

// 画文字
canvas.drawText("Hello", x, y, paint)

// 画路径(最灵活)
val path = Path().apply {
    moveTo(100f, 100f)
    lineTo(200f, 200f)
    lineTo(100f, 200f)
    close()
}
canvas.drawPath(path, paint)

// 画 Bitmap
canvas.drawBitmap(bitmap, left, top, paint)

Paint 常用属性

kotlin 复制代码
val paint = Paint().apply {
    color = Color.RED              // 颜色
    style = Paint.Style.STROKE     // 描边 / FILL 填充 / FILL_AND_STROKE
    strokeWidth = 4f               // 描边宽度
    isAntiAlias = true             // 抗锯齿(必开)
    textSize = 36f                 // 文字大小
    textAlign = Paint.Align.CENTER // 文字对齐
    setShadowLayer(8f, 0f, 0f, Color.GRAY) // 阴影
}

Canvas 变换操作

Canvas 还支持平移、旋转、缩放、裁剪,用来实现复杂效果:

kotlin 复制代码
canvas.save()                     // 保存当前状态
canvas.translate(cx, cy)          // 平移原点到中心
canvas.rotate(angle)              // 旋转
canvas.drawArc(rectF, startAngle, sweepAngle, true, paint)
canvas.restore()                  // 恢复

💡 save() 和 restore() 要配对使用,避免影响后续绘制。

实战:环形进度条

把上面的知识综合起来,画一个环形进度条:

kotlin 复制代码
class CircleProgressBar(context: Context, attrs: AttributeSet?) : View(context, attrs) {

    private var progress: Float = 0f  // 0~100

    private val bgPaint = Paint().apply {
        color = Color.LTGRAY
        style = Paint.Style.STROKE
        strokeWidth = 20f
        isAntiAlias = true
        strokeCap = Paint.Cap.ROUND
    }

    private val progressPaint = Paint().apply {
        color = Color.parseColor("#4CAF50")
        style = Paint.Style.STROKE
        strokeWidth = 20f
        isAntiAlias = true
        strokeCap = Paint.Cap.ROUND
    }

    private val textPaint = Paint().apply {
        color = Color.DKGRAY
        textSize = 48f
        textAlign = Paint.Align.CENTER
        isAntiAlias = true
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        val size = 200
        val w = resolveSize(size, widthMeasureSpec)
        val h = resolveSize(size, heightMeasureSpec)
        val min = minOf(w, h)
        setMeasuredDimension(min, min)
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)

        val cx = width / 2f
        val cy = height / 2f
        val radius = minOf(cx, cy) - 30f
        val rectF = RectF(cx - radius, cy - radius, cx + radius, cy + radius)

        // 背景圆环
        canvas.drawArc(rectF, 0f, 360f, false, bgPaint)

        // 进度弧
        val sweepAngle = 360f * (progress / 100f)
        canvas.drawArc(rectF, -90f, sweepAngle, false, progressPaint)

        // 文字
        val text = "${progress.toInt()}%"
        val textY = cy - (textPaint.descent() + textPaint.ascent()) / 2f
        canvas.drawText(text, cx, textY, textPaint)
    }

    fun setProgress(value: Float) {
        progress = value.coerceIn(0f, 100f)
        invalidate() // 触发重绘
    }
}

使用方法:

kotlin 复制代码
val progressBar = findViewById<CircleProgressBar>(R.id.circle_progress)
progressBar.setProgress(75f)

第四步:处理触摸事件

Custom View 不仅可以画,还可以交互。重写 onTouchEvent():

kotlin 复制代码
override fun onTouchEvent(event: MotionEvent): Boolean {
    when (event.action) {
        MotionEvent.ACTION_DOWN -> {
            // 手指按下
            return true  // 返回 true 表示消费此事件
        }
        MotionEvent.ACTION_MOVE -> {
            // 手指移动
            invalidate() // 根据触摸位置重绘
        }
        MotionEvent.ACTION_UP -> {
            // 手指抬起
        }
    }
    return super.onTouchEvent(event)
}

实战:简单画板

kotlin 复制代码
class DrawingBoard(context: Context, attrs: AttributeSet?) : View(context, attrs) {

    private val path = Path()
    private val paint = Paint().apply {
        color = Color.BLACK
        style = Paint.Style.STROKE
        strokeWidth = 5f
        isAntiAlias = true
        strokeJoin = Paint.Join.ROUND
    }

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        canvas.drawPath(path, paint)
    }

    override fun onTouchEvent(event: MotionEvent): Boolean {
        when (event.action) {
            MotionEvent.ACTION_DOWN -> {
                path.moveTo(event.x, event.y)
                return true
            }
            MotionEvent.ACTION_MOVE -> {
                path.lineTo(event.x, event.y)
                invalidate()
            }
        }
        return true
    }
}

性能优化

自定义绘制最容易成为性能瓶颈,注意以下几点:

1. 避免在 onDraw 中创建对象

kotlin 复制代码
// ❌ 错误:每次 onDraw 都会创建新对象
override fun onDraw(canvas: Canvas) {
    val paint = Paint() // 每帧创建,GC 压力大
    canvas.drawCircle(...)
}

// ✅ 正确:在 init 或成员变量中创建
private val paint = Paint().apply { ... }

2. 局部刷新

kotlin 复制代码
// ❌ 全量刷新
invalidate()

// ✅ 只刷新变化区域
invalidate(dirtyRect)

3. 使用硬件加速

Android 默认开启硬件加速,但有些 Canvas 操作不支持 GPU 渲染。遇到性能问题时检查:

kotlin 复制代码
// 关闭硬件加速(某些复杂绘制需要)
setLayerType(LAYER_TYPE_SOFTWARE, null)

4. 善用 Canvas.saveLayer

saveLayer() 会离屏渲染,开销较大。只在需要混合模式(PorterDuff)等效果时使用。

自定义属性

让 Custom View 支持 XML 配置:

xml 复制代码
<!-- res/values/attrs.xml -->
<declare-styleable name="CircleProgressBar">
    <attr name="cpb_progress" format="float" />
    <attr name="cpb_bg_color" format="color" />
    <attr name="cpb_progress_color" format="color" />
</declare-styleable>
kotlin 复制代码
// 在 init 中读取属性
init {
    context.obtainStyledAttributes(attrs, R.styleable.CircleProgressBar).apply {
        progress = getFloat(R.styleable.CircleProgressBar_cpb_progress, 0f)
        bgPaint.color = getColor(R.styleable.CircleProgressBar_cpb_bg_color, Color.LTGRAY)
        progressPaint.color = getColor(R.styleable.CircleProgressBar_cpb_progress_color, Color.GREEN)
        recycle()
    }
}

XML 中使用:

xml 复制代码
<com.example.CircleProgressBar
    android:layout_width="200dp"
    android:layout_height="200dp"
    app:cpb_progress="60"
    app:cpb_progress_color="#FF5722" />

总结

Custom View 的核心就三步:

步骤 要点
onMeasure 正确处理 wrap_content,调用 setMeasuredDimension()
onDraw 用 Canvas + Paint 绘制,避免创建对象
onTouchEvent 处理交互,返回 true 表示消费事件

掌握这三个方法,再加上 Canvas 的各种绘制 API,几乎可以实现任何 UI 效果。Custom View 是 Android 开发中从"会用控件"到"理解控件"的关键一步。


系列导航: 上一篇:Hilt 依赖注入 | 下一篇:ContentProvider 与跨进程通信

相关推荐
小码哥_常12 小时前
一套自己部署的 IM,客户端是同一份 Flutter
前端·后端
蜗牛攻城狮14 小时前
微前端四大方案选型对比与最佳实践:qiankun、micro-app、wujie、Module Federation
前端·微前端·qiankun·前端架构·micro-app·wujie
她说可以呀16 小时前
Vue插槽
前端·javascript·vue.js
qq_4480111616 小时前
Vue + Jinja2两套模版与法冲突造成undefinedError
前端·javascript·vue.js
aixingpan16 小时前
aixingpan.cn API开发文档:前端查询夏令时和时区接口指南
前端·php
2601_9583529017 小时前
23.5×19mm 换掉一整条音频链?|A-68 集成了什么:ADC / DAC / 24.576MHz 时钟 / AEC / 波束降噪,主控仅需 6 根线
前端·音视频·ai降噪·回声消除·语音模块
志尊宝17 小时前
AI原生前端开发(039):AI辅助的Nuxt 4全栈开发
前端·vue.js·ai-native
FRing0318 小时前
JavaScript入门到精通三十二(深拷贝、浅拷贝、递归函数)
开发语言·前端·javascript
excel18 小时前
双 Token 与前端使用 Crypto 公私钥鉴权换 Token,哪种方案更好?
前端·后端
外收内放18 小时前
Python基础语法练习题(73)
java·前端·python