[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 与跨进程通信

相关推荐
李明卫杭州1 小时前
Vue3 v-memo 指令详解:让你的列表渲染性能翻倍 🚀
前端
梨子同志1 小时前
Monorepo
前端
lihaozecq1 小时前
继 Web Coding Agent 后,我做了一个本地优先的桌面 AI Agent
前端·agent
用户298698530142 小时前
在 React 中使用 JavaScript 将 Excel 转换为 SVG
前端·javascript·react.js
CodingSpace2 小时前
ESLint
前端
Csvn2 小时前
异步错误捕获的六大陷阱:await 裹着 try-catch 就一定稳了吗?
前端
用户059540174462 小时前
向量库静默丢数据踩坑实录:Playwright 端到端测试让我排查了72小时
前端·css
星栈2 小时前
SPA 写累了?试试 LiveView:服务端管状态,前端不写 JS
前端·前端框架·elixir
labixiong2 小时前
手写Promise--微任务、静态方法、async/await 全搞懂(三)
前端·javascript