android 自定义view 详解

Android 自定义 View 详解

自定义 View 是 Android 开发的核心技能之一,本文从原理到实践,系统梳理自定义 View 的完整知识体系。


一、自定义 View 的三种方式

方式 适用场景 核心工作
继承现有控件 (如 TextViewImageView 在已有控件基础上扩展功能 复用绘制逻辑,重写关键方法
继承 View 完全自定义绘制内容(如饼图、进度条) 重写 onMeasure + onDraw
继承 ViewGroup 自定义布局管理器(如流式布局、瀑布流) 重写 onMeasure + onLayout

二、核心生命周期方法

1. 构造方法(Constructor)

kotlin

复制代码
class CustomView @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : View(context, attrs, defStyleAttr) {
    init {
        // 读取自定义属性
        val typedArray = context.obtainStyledAttributes(attrs, R.styleable.CustomView)
        val color = typedArray.getColor(R.styleable.CustomView_circleColor, Color.RED)
        typedArray.recycle()
    }
}

三个构造方法的区别:

  • View(Context):代码中直接 new

  • View(Context, AttributeSet):XML 中使用

  • View(Context, AttributeSet, Int):XML 中使用 + 指定 style

2. onMeasure ------ 测量尺寸

kotlin

复制代码
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec)
    
    val widthMode = MeasureSpec.getMode(widthMeasureSpec)
    val widthSize = MeasureSpec.getSize(widthMeasureSpec)
    
    // 三种测量模式
    when (widthMode) {
        MeasureSpec.EXACTLY -> { /* match_parent 或具体数值 */ }
        MeasureSpec.AT_MOST -> { /* wrap_content */ }
        MeasureSpec.UNSPECIFIED -> { /* 父布局不限制,如 ScrollView 内 */ }
    }
    
    // 设置最终测量结果
    setMeasuredDimension(resolveSize(desiredWidth, widthMeasureSpec),
                         resolveSize(desiredHeight, heightMeasureSpec))
}

测量模式速查:

Mode 触发条件 处理方式
EXACTLY match_parent / 具体 dp 直接使用给定尺寸
AT_MOST wrap_content 计算内容所需尺寸,不超过上限
UNSPECIFIED 父布局不限制 按内容实际需要设置

3. onSizeChanged ------ 尺寸变化

kotlin

复制代码
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
    super.onSizeChanged(w, h, oldw, oldh)
    // 初始化与尺寸相关的对象(如 Rect、Path、Shader)
    centerX = w / 2f
    centerY = h / 2f
    radius = min(w, h) / 2f - padding
}

4. onDraw ------ 绘制内容(核心)

kotlin

复制代码
override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)
    
    // 1. 绘制背景(系统已处理,通常不需要手动调用)
    
    // 2. 绘制内容
    canvas.drawCircle(centerX, centerY, radius, paint)
    canvas.drawRect(rect, paint)
    canvas.drawText("Hello", x, y, textPaint)
    
    // 3. 使用 Path 绘制复杂图形
    val path = Path().apply {
        moveTo(100f, 100f)
        lineTo(200f, 200f)
        quadTo(300f, 100f, 400f, 200f) // 二次贝塞尔曲线
        close()
    }
    canvas.drawPath(path, paint)
}

Canvas 常用绘制 API:

复制代码
drawCircle()      drawRect()      drawOval()
drawArc()         drawLine()      drawPoint()
drawText()        drawBitmap()    drawPath()

5. onLayout(仅 ViewGroup)

kotlin

复制代码
override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
    for (i in 0 until childCount) {
        val child = getChildAt(i)
        // 计算子 View 的位置
        child.layout(left, top, right, bottom)
    }
}

三、Paint 详解 ------ 画笔配置

kotlin

复制代码
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
    color = Color.RED                    // 颜色
    strokeWidth = 8f                     // 描边宽度
    style = Paint.Style.STROKE           // FILL / STROKE / FILL_AND_STROKE
    strokeCap = Paint.Cap.ROUND          // 线帽:BUTT / ROUND / SQUARE
    strokeJoin = Paint.Join.ROUND        // 连接处:MITER / ROUND / BEVEL
    isAntiAlias = true                   // 抗锯齿
    isDither = true                      // 防抖动(颜色过渡更平滑)
    
    // 高级效果
    shader = LinearGradient(...)         // 渐变
    maskFilter = BlurMaskFilter(...)     // 模糊效果
    pathEffect = DashPathEffect(...)     // 虚线效果
}

四、完整示例:圆形进度条

1. 自定义属性(res/values/attrs.xml)

xml

复制代码
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <declare-styleable name="CircleProgressView">
        <attr name="progressColor" format="color"/>
        <attr name="bgColor" format="color"/>
        <attr name="strokeWidth" format="dimension"/>
        <attr name="maxProgress" format="integer"/>
        <attr name="currentProgress" format="integer"/>
    </declare-styleable>
</resources>

2. 完整 View 代码

kotlin

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

    private var progressColor = Color.parseColor("#2196F3")
    private var bgColor = Color.parseColor("#E0E0E0")
    private var strokeWidth = 20f
    private var maxProgress = 100
    private var currentProgress = 0
    
    private val bgPaint = Paint(Paint.ANTI_ALIAS_FLAG)
    private val progressPaint = Paint(Paint.ANTI_ALIAS_FLAG)
    private val rectF = RectF()
    
    init {
        context.obtainStyledAttributes(attrs, R.styleable.CircleProgressView).apply {
            progressColor = getColor(R.styleable.CircleProgressView_progressColor, progressColor)
            bgColor = getColor(R.styleable.CircleProgressView_bgColor, bgColor)
            strokeWidth = getDimension(R.styleable.CircleProgressView_strokeWidth, strokeWidth)
            maxProgress = getInt(R.styleable.CircleProgressView_maxProgress, maxProgress)
            currentProgress = getInt(R.styleable.CircleProgressView_currentProgress, currentProgress)
            recycle()
        }
        
        bgPaint.apply {
            color = bgColor
            this.strokeWidth = this@CircleProgressView.strokeWidth
            style = Paint.Style.STROKE
            strokeCap = Paint.Cap.ROUND
        }
        
        progressPaint.apply {
            color = progressColor
            this.strokeWidth = this@CircleProgressView.strokeWidth
            style = Paint.Style.STROKE
            strokeCap = Paint.Cap.ROUND
        }
    }
    
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        val size = resolveSize(200.dpToPx(), widthMeasureSpec)
        setMeasuredDimension(size, size)
    }
    
    override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
        super.onSizeChanged(w, h, oldw, oldh)
        val padding = strokeWidth / 2
        rectF.set(padding, padding, w - padding, h - padding)
    }
    
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        
        // 绘制背景圆环
        canvas.drawArc(rectF, 0f, 360f, false, bgPaint)
        
        // 绘制进度圆弧
        val sweepAngle = 360f * currentProgress / maxProgress
        canvas.drawArc(rectF, -90f, sweepAngle, false, progressPaint)
        
        // 绘制进度文字
        val text = "$currentProgress%"
        val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
            color = progressColor
            textSize = width / 4f
            textAlign = Paint.Align.CENTER
        }
        val baseline = height / 2f - (textPaint.descent() + textPaint.ascent()) / 2
        canvas.drawText(text, width / 2f, baseline, textPaint)
    }
    
    fun setProgress(progress: Int) {
        currentProgress = progress.coerceIn(0, maxProgress)
        invalidate() // 请求重绘
    }
    
    private fun Int.dpToPx(): Int = (this * resources.displayMetrics.density).toInt()
}

3. XML 中使用

xml

复制代码
<com.example.CircleProgressView
    android:layout_width="120dp"
    android:layout_height="120dp"
    app:progressColor="@color/purple_500"
    app:bgColor="#F5F5F5"
    app:strokeWidth="12dp"
    app:maxProgress="100"
    app:currentProgress="75"/>

五、触摸事件处理

kotlin

复制代码
override fun onTouchEvent(event: MotionEvent): Boolean {
    when (event.action) {
        MotionEvent.ACTION_DOWN -> {
            parent.requestDisallowInterceptTouchEvent(true)
            // 处理按下逻辑
            return true // 消费事件
        }
        MotionEvent.ACTION_MOVE -> {
            // 处理滑动逻辑
        }
        MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
            parent.requestDisallowInterceptTouchEvent(false)
            // 处理抬起逻辑
        }
    }
    return super.onTouchEvent(event)
}

事件分发要点:

  • dispatchTouchEvent → 事件入口

  • onInterceptTouchEvent(仅 ViewGroup)→ 是否拦截

  • onTouchEvent → 实际处理

  • 返回 true 表示消费事件,不再向上传递


六、动画与属性更新

kotlin

复制代码
// 方式1:属性动画
ValueAnimator.ofInt(0, 100).apply {
    duration = 2000
    interpolator = DecelerateInterpolator()
    addUpdateListener { animator ->
        setProgress(animator.animatedValue as Int)
    }
    start()
}

// 方式2:配合 ObjectAnimator
var progress: Int = 0
    set(value) {
        field = value
        invalidate()
    }

ObjectAnimator.ofInt(this, "progress", 0, 100).apply {
    duration = 1500
    start()
}

七、性能优化建议

优化项 说明
避免在 onDraw 中创建对象 所有 Paint、Path、Rect 应在 initonSizeChanged 中初始化
使用 invalidate(Rect) 局部重绘 只重绘变化区域,减少绘制工作量
开启硬件加速 android:hardwareAccelerated="true",但注意部分 API 不支持
减少过度绘制 避免多层重叠绘制,使用 clipRect 裁剪
复杂图形使用 Bitmap 缓存 静态内容先绘制到 Bitmap,后续直接 drawBitmap
使用 requestLayout() 谨慎 会触发完整测量-布局-绘制流程,开销大

八、常见问题排查

问题 原因 解决方案
自定义 View 不显示 未重写 onMeasure 且父布局为 wrap_content 重写 onMeasure 设置默认尺寸
wrap_content 无效 未处理 AT_MOST 模式 onMeasure 中处理
文字绘制位置偏移 未计算 baseline 使用 Paint.FontMetrics 计算
触摸事件不响应 onTouchEvent 返回 false 返回 true 消费事件
动画卡顿 主线程阻塞或过度绘制 使用 Choreographer,减少绘制层级

九、进阶方向

  1. 自定义 Drawable :实现 Drawable 接口,可复用于多个 View

  2. RenderThread / RenderNode:Android 10+ 的硬件渲染优化

  3. Compose 自定义 :使用 Canvas Modifier 或 Layout Composable

  4. SVG Path 动画AnimatedVectorDrawable 实现复杂路径动画

相关推荐
WAsbry1 小时前
协程任务的失败控制:取消、异常传播与Supervisor
android
Android打工仔2 小时前
从 finally 理解程序的控制流:它为什么不是 catch 后面的代码?
android·kotlin
随遇丿而安2 小时前
第15周:Service 全功能 + 后台优化
android
YXL1111YXL2 小时前
续体和状态机 —— suspend 函数的 CPS 变换
android·kotlin
WAsbry2 小时前
Flow数据模型:冷流、状态、事件与共享策略
android
WAsbry2 小时前
深入理解 Kotlin suspend:挂起语义、状态机与恢复调度
android
WAsbry2 小时前
Flow在Android中的完整落地:异常、重试与生命周期
android
WAsbry2 小时前
协程共享状态:Atomic、Mutex、Semaphore与状态封闭
android
WAsbry2 小时前
Flow执行模型:上下文保持与生产消费节奏
android