首页 / Android 入门教程 / 自定义 View

Android 入门教程

自定义 View

本教程共 100 篇 · 第 94 篇 · 更新于 2026-07-28 · 约 9 分钟阅读

AndroidAndroid 入门教程自定义 ViewCanvasonDrawCompose

94. 自定义 View

本节目标:理解自定义 View 的三步(测量、布局、绘制),学会用 Canvas 画图形和文字、自定义属性、invalidate 触发重绘,知道 Compose 里怎么画自定义图形。

系统提供的 View 不够用时,就得自己画。进度条、饼图、签名板、复杂动画背景,都靠自定义 View。Compose 时代 View 体系用得少了,但理解绘制原理对 Compose 的 Canvas 也有帮助。

三步走

自定义 View(准确说是自定义 ViewGroup 或者 View)核心三个方法:

  • onMeasure:测量自己多大。
  • onLayout:摆放子 View 位置(ViewGroup 才需要)。
  • onDraw:画内容。

继承 View 重写 onDraw 就能画自定义内容。继承 ViewGroup 还要重写 onLayout 摆子 View。

打个比方,自定义 View 像自己画一幅画:先量画布多大(onMeasure),再决定画哪里(onLayout),最后动笔(onDraw)。

第一个自定义 View:圆形进度

class CircleProgressView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {

    private var progress = 0
    private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        color = Color.BLUE
        style = Paint.Style.STROKE
        strokeWidth = 20f
    }

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

    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        val cx = width / 2f
        val cy = height / 2f
        val radius = minOf(width, height) / 2f - paint.strokeWidth

        val sweepAngle = 360f * progress / 100f
        val rect = RectF(
            cx - radius, cy - radius,
            cx + radius, cy + radius
        )
        canvas.drawArc(rect, -90f, sweepAngle, false, paint)
    }
}

要点:

  • @JvmOverloads 让构造函数支持 XML 实例化。
  • Paint 配置画笔(颜色、风格、线宽)。
  • invalidate() 触发重绘,重绘会在主线程异步执行。
  • canvas.drawXxx() 画各种形状。

自定义属性

res/values/attrs.xml 声明:

<resources>
    <declare-styleable name="CircleProgressView">
        <attr name="progressColor" format="color" />
        <attr name="progressStrokeWidth" format="dimension" />
        <attr name="progress" format="integer" />
    </declare-styleable>
</resources>

XML 里用:

<com.example.CircleProgressView
    android:layout_width="200dp"
    android:layout_height="200dp"
    app:progressColor="#FF5722"
    app:progressStrokeWidth="16dp"
    app:progress="60" />

代码里读取:

init {
    context.obtainStyledAttributes(attrs, R.styleable.CircleProgressView).apply {
        paint.color = getColor(R.styleable.CircleProgressView_progressColor, Color.BLUE)
        paint.strokeWidth = getDimension(R.styleable.CircleProgressView_progressStrokeWidth, 20f)
        progress = getInt(R.styleable.CircleProgressView_progress, 0)
        recycle()  // 必须回收
    }
}
Note

obtainStyledAttributes 返回的 TypedArray 用完必须 recycle(),否则内存泄漏。

onMeasure 测量

默认 ViewonMeasure 不一定按你期望的尺寸。要自定义测量逻辑:

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    val defaultSize = 200.dpToPx().toInt()

    val width = resolveSize(defaultSize, widthMeasureSpec)
    val height = resolveSize(defaultSize, heightMeasureSpec)

    // 强制正方形
    val size = minOf(width, height)
    setMeasuredDimension(size, size)
}

MeasureSpec 有三种模式:

  • EXACTLY:match_parent 或者具体 dp。
  • AT_MOST:wrap_content。
  • UNSPECIFIED:没限制,少见。

resolveSize(default, spec) 帮你处理这三种情况,返回最终尺寸。

onLayout(ViewGroup)

自定义 ViewGroup 要在 onLayout 里摆放子 View:

class FlowLayout @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null
) : ViewGroup(context, attrs) {

    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        var x = paddingLeft
        var y = paddingTop

        for (i in 0 until childCount) {
            val child = getChildAt(i)
            if (x + child.measuredWidth > r - l - paddingRight) {
                x = paddingLeft
                y += child.measuredHeight
            }
            child.layout(x, y, x + child.measuredWidth, y + child.measuredHeight)
            x += child.measuredWidth
        }
    }
}

onMeasure 里还要测量子 View 尺寸,逻辑较复杂。新项目用 Compose 的 FlowRow 替代。

Canvas 常用绘制

override fun onDraw(canvas: Canvas) {
    // 画圆
    canvas.drawCircle(cx, cy, radius, paint)

    // 画矩形
    canvas.drawRect(left, top, right, bottom, paint)

    // 画线
    canvas.drawLine(startX, startY, endX, endY, paint)

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

    // 画图片
    canvas.drawBitmap(bitmap, srcRect, dstRect, paint)

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

    // 旋转
    canvas.save()
    canvas.rotate(45f, cx, cy)
    canvas.drawRect(...)
    canvas.restore()
}

save() / restore() 保存恢复 Canvas 状态,做变换时配对用。

触摸事件

override fun onTouchEvent(event: MotionEvent): Boolean {
    when (event.action) {
        MotionEvent.ACTION_DOWN -> {
            // 按下
            return true
        }
        MotionEvent.ACTION_MOVE -> {
            val x = event.x
            val y = event.y
            // 移动
        }
        MotionEvent.ACTION_UP -> {
            // 抬起
        }
    }
    return super.onTouchEvent(event)
}

返回 true 表示消费事件,否则事件传给父 View。

invalidate 和 requestLayout

  • invalidate():触发 onDraw 重绘(不改变尺寸)。
  • requestLayout():触发 onMeasure + onLayout(尺寸变了)。

属性变化只影响外观用 invalidate,影响尺寸用 requestLayout

双缓冲优化

频繁重绘(如动画)会卡。用硬件加速:

// 在构造函数
setLayerType(LAYER_TYPE_HARDWARE, null)

或者用 RenderEffect(Android 12+)做高级效果。

Warning

onDraw 里别 new 对象(Paint、Path),GC 频繁触发卡顿。在初始化时创建,复用。

Compose 里的自定义绘制

Compose 用 Canvas Modifier 或者 drawBehind

@Composable
fun CircleProgress(progress: Float, modifier: Modifier = Modifier) {
    Canvas(modifier = modifier.size(200.dp)) {
        val strokeWidth = 20f
        val radius = (size.minDimension - strokeWidth) / 2
        val cx = size.width / 2
        val cy = size.height / 2

        // 背景圆
        drawCircle(
            color = Color.LightGray,
            radius = radius,
            center = Offset(cx, cy),
            style = Stroke(width = strokeWidth)
        )

        // 进度弧
        drawArc(
            color = Color.Blue,
            startAngle = -90f,
            sweepAngle = 360f * progress,
            useCenter = false,
            topLeft = Offset(cx - radius, cy - radius),
            size = Size(radius * 2, radius * 2),
            style = Stroke(width = strokeWidth, cap = StrokeCap.Round)
        )
    }
}

Compose 的 DrawScope 和 Canvas API 类似,但用声明式风格,状态一变自动重绘。

@Composable
fun AnimatedProgress() {
    var progress by remember { mutableStateOf(0f) }
    val animatedProgress by animateFloatAsState(progress, label = "progress")

    CircleProgress(animatedProgress)

    Button(onClick = { progress = if (progress < 1f) progress + 0.25f else 0f }) {
        Text("增加")
    }
}

animateFloatAsState 自动插值,配合 Canvas 重绘,流畅动画一行代码搞定。

自定义 Modifier

Compose 里还能封装成 Modifier 复用:

fun Modifier.circleBackground(color: Color) = this.drawBehind {
    drawCircle(color)
}

// 用法
Box(
    Modifier
        .size(100.dp)
        .circleBackground(Color.Red)
)

常见坑

  1. onDraw 里 new Paint:每帧 new,GC 频繁,卡顿。初始化时建。
  2. 没调 recycle():TypedArray 泄漏。
  3. invalidate 频繁:每秒 60 次以上没必要,节流。
  4. 触摸事件没消费:返回 false 后续 MOVE/UP 收不到。
  5. 自定义 View 不开硬件加速:性能差。Android 4.0+ 默认开,别关。
  6. onMeasure 不处理 wrap_content:wrap_content 表现像 match_parent,要自己测。
  7. width / height 在 onMeasure 用:还是 0,要等 onLayout 后才有值。

小结

自定义 View 三步:onMeasure 测尺寸、onLayout 摆位置(ViewGroup)、onDraw 画内容。Canvas 画图形文字,Paint 配画笔,自定义属性在 attrs.xml 声明 + obtainStyledAttributes 读取。invalidate 重绘、requestLayout 重测。Compose 用 Canvas Modifier + DrawScope,状态驱动自动重绘,更简洁。

下一章讲动画体系。