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

简介: Android内置控件有限,遇环形进度条、自定义图表、异形按钮等需求时,需通过Custom View自主绘制与交互。其核心流程为measure→layout→draw,重点掌握onMeasure、onDraw及onTouchEvent即可高效实现各类定制UI。

为什么需要 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():

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

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 常用绘制方法

// 画矩形
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 常用属性

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 还支持平移、旋转、缩放、裁剪,用来实现复杂效果:

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

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

实战:环形进度条

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

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() // 触发重绘
    }
}

使用方法:

val progressBar = findViewById<CircleProgressBar>(R.id.circle_progress)
progressBar.setProgress(75f)

第四步:处理触摸事件

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

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)
}

实战:简单画板

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 中创建对象

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

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

2. 局部刷新

// ❌ 全量刷新
invalidate()

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

3. 使用硬件加速

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

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

4. 善用 Canvas.saveLayer

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

自定义属性

让 Custom View 支持 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>
// 在 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 中使用:

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

相关文章
|
4月前
|
人工智能 自然语言处理 算法
GEO 优化深度解析:AI 偏好 FAQ 还是长文内容?
这篇文章将从多个维度深入探讨这个问题。我们会从 AI 的认知机制出发,分析 FAQ 和长文内容各自的优势与局限;我们会引入大量的实证数据和学术研究,用事实说话
356 1
|
4月前
|
API 数据安全/隐私保护
快递取件码-取件码-获取取件码API接口介绍
本API提供快递入驿站后的取件码实时通知服务,支持订阅、查询、推送三大功能。用户通过运单号+快递编码发起订阅(须在入站前完成),可选回调推送或主动查询取件码,适用于购物APP、公众号等需收货提醒的场景。
903 0
|
4月前
|
存储 人工智能 API
2026年内网RPA离线部署实战:从打包EXE缺依赖到7×24无人值守,8个踩坑记录与完整避坑方案
物理隔离、U盘过安检、服务器不让装Python——内网跑RPA的真实环境远比想象苛刻。本文从一个交付顾问三年二十余个内网项目的实战经验出发,完整梳理了内网离线部署RPA的八大核心难题:真离线与假离线的辨别方法、EXE打包依赖缺失的根治方案、多机脚本同步的管控机制、7×24无人值守的服务器配置、企业批量分发的统一管控、Python依赖嵌入打包的标准流程、离线环境下AI功能的三种替代路径、以及流程源码加密与授权保护的商业交付底线。文末附十项选型对照清单,覆盖API触发、指纹浏览器适配、Agent智能控制、本地OCR、元素智能抓取等差异化能力,帮助个人开发者、工作室及中小企业在零成本起步的前提下
892 0
|
5月前
|
数据采集 边缘计算 安全
边缘计算与云端协同:老旧注塑机如何通过VBOX实现全量数据上云?
本文介绍智象九维VBOX注塑机边缘网关,首创非侵入式旁路部署技术,无需停机破线,安全监听RS-485/CAN等总线;内置2000+协议解析引擎,支持海天、弘讯等多品牌老旧设备;结合云边协同架构,实现高频数据滤波、特征提取、断点续传,并无缝对接阿里云IoT平台与TSDB,构建高可靠工业数据底座。(239字)
622 8
|
4月前
|
JSON 自然语言处理 API
车辆尾号限行-出行限号查询-车辆尾号限行规则查询API接口介绍
【车辆限行查询API】提供全国重点城市实时、准确的机动车限行政策数据,覆盖限行时间、区域、尾号、节假日及临时管制等全要素。支持城市列表动态获取与单城规则查询,具备高并发、低延迟、易接入、强稳定等优势,广泛适用于导航、网约车、物流、车服及智慧交通等场景。(239字)
502 2
|
4月前
|
人工智能 自然语言处理 运维
基于阿里云云联络中心:智能云客服与普通在线客服架构深度对比,附 RAG/NLU 完整代码实战与落地踩坑优化方案
多数企业分不清传统在线客服规则引擎与阿里云云联络中心 LLM+RAG 智能架构,盲目上线智能客服后投诉上涨、服务效率反向下滑。本文从底层架构、代码实战、落地场景多维度拆解两类系统差异,附 4 段可直接运行的 Python 代码,复盘 8 大高频踩坑点,输出标准化灰度上线与知识库迭代方案,助力技术人员完成客服系统选型与二次开发。
430 1
|
4月前
|
人工智能 移动开发 安全
Night Plan 夜间算力特惠来袭!Qoder+Meoo 双产品 Qwen3.7 夜间低至 2 折
阿里云Night Plan上线!每晚22:00-次日8:00,Qwen3.7模型低至2折,算力成本直降80%。Qoder全系(含Desktop/CLI/Work)与Meoo秒悟均享优惠,试用/付费用户自动生效,无需开通。夜间挂机跑任务,白天专注调试,降本增效一步到位。
|
4月前
|
人工智能 JSON 自然语言处理
阿里云百炼工作流搭建中小学教材生成系统完整实操教程
依托阿里云百炼可视化工作流编排能力,无需深度代码开发,即可搭建标准化教学内容生成引擎。教师仅输入教材名称与适配学段,系统自动完成目录规划、章节正文撰写,产出贴合义务教育课程标准的完整教学材料,大幅压缩备课周期,缓解基层教师内容创作压力,助力区域教育资源均衡。整套方案采用低代码画布拖拽搭建,依托Qwen3.7-Max大模型承载内容生成能力,完整拆解节点设计、提示词配置、流程串联、测试发布全流程,同时拓展多类教育延伸应用场景
583 1
|
4月前
|
存储 人工智能 知识图谱
基于RAG架构的四标融合企业知识资产体系工程化建设:知识库、场景库、知识图谱与知识链接落地实践
本文提出“四标融合GEO工程方法论”,依托四项国标与ISO 42001,系统构建知识库、场景库、知识图谱、知识链接四大模块,适配RAG架构与大模型检索逻辑,解决企业知识“不可识别、不可检索、不可信赖”痛点,助力AI时代知识资产高效激活。(239字)
|
4月前
|
存储 缓存 数据中心
大模型多租户隔离:资源隔离、权限管控、性能互不干扰,SaaS落地场景实战.156
大模型多租户隔离是SaaS化核心,通过租户ID实现资源、权限与性能三重隔离:共享GPU/模型降低成本,逻辑隔离确保数据不交叉、请求不干扰、权限不越界,满足金融级安全与高并发稳定性要求。
522 1