一、自定义 View 是什么?

Android 中所有界面元素本质上都是 ViewViewGroup

常见控件:

类型

示例

作用

View

TextView、ImageView、Button

显示内容、处理点击、绘制 UI

ViewGroup

LinearLayout、FrameLayout、RecyclerView

管理和摆放子 View

所谓 自定义 View,就是开发者根据业务需求,自己定义一个新的 UI 控件。

它可以是:

1. 在系统控件基础上增强
2. 完全自己绘制一个控件
3. 自己定义一个布局容器
4. 把多个已有控件组合成一个新控件

所以,自定义 View 不等于只写 onDraw()

onDraw() 自绘只是其中一种方式。


二、自定义 View 的四种常见方式

Android 自定义 View 常见可以分为四类:

类型

继承对象

特点

适合场景

继承已有 View

TextViewImageViewButton

在原控件基础上增强

带角标 TextView、圆形 ImageView

继承 View 自绘

View

完全自己绘制 UI

圆形进度条、仪表盘、折线图

继承 ViewGroup

ViewGroup

自己测量和摆放子 View

流式布局、九宫格、自定义容器

组合控件

FrameLayoutLinearLayoutConstraintLayout

把多个已有控件封装成一个控件

标题栏、搜索栏、设置项

可以简单记忆:

能复用系统控件,就不要急着 Canvas 自绘。
能组合出来的 UI,优先使用组合控件。
需要复杂图形,才考虑继承 View 自绘。
需要特殊布局规则,才考虑继承 ViewGroup。

三、方式一:继承已有控件

1. 什么是继承已有控件?

这种方式是在系统控件的基础上做增强。

例如:

TextView + 红点
ImageView + 圆角
Button + 防重复点击
EditText + 清除按钮

它不是从零开始写一个控件,而是复用系统控件已有能力。

比如继承 TextView

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

    private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        color = Color.RED
        style = Paint.Style.FILL
    }

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

        val radius = 8f
        canvas.drawCircle(width - radius - 4f, radius + 4f, radius, paint)
    }
}

这个例子是在 TextView 的右上角额外画一个红点。


2. 适合场景

继承已有控件适合对系统控件做轻量增强。

需求

可以继承

带红点的 TextView

TextView

圆形头像

ImageView

防连点按钮

Button

带清除按钮的输入框

EditText

自动适配文字大小

TextView

这种方式的优点是:

简单
开发成本低
复用系统控件能力
不需要自己处理太多细节

缺点是:

自由度有限
只适合在已有控件基础上增强
不适合复杂自定义图形

四、方式二:继承 View 自己绘制

1. 什么是继承 View 自绘?

这种方式是直接继承 View,然后在 onDraw() 中通过 CanvasPaint 自己绘制内容。

例如:

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

    private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        color = Color.RED
        style = Paint.Style.FILL
    }

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

        canvas.drawCircle(width / 2f, height / 2f, 100f, paint)
    }
}

这个控件不依赖系统已有控件,而是自己画一个圆。


2. 自绘 View 的核心对象

自绘 View 常用对象:

对象

作用

Canvas

画布,负责画内容

Paint

画笔,负责颜色、线宽、样式

Path

路径,适合复杂图形

Rect / RectF

矩形区域

Bitmap

图片

Shader

渐变、纹理

Matrix

图形变换

常见绘制方法:

方法

作用

drawCircle()

画圆

drawRect()

画矩形

drawRoundRect()

画圆角矩形

drawLine()

画线

drawText()

画文字

drawPath()

画路径

drawBitmap()

画图片

drawArc()

画圆弧


3. Paint 常用属性

private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
    color = Color.BLUE
    strokeWidth = 8f
    style = Paint.Style.STROKE
    textSize = 48f
}

属性

含义

color

颜色

strokeWidth

线宽

style

填充或描边

textSize

文字大小

isAntiAlias

是否抗锯齿

strokeCap

线条端点样式

Paint.Style 常见有三种:

样式

含义

FILL

填充

STROKE

描边

FILL_AND_STROKE

填充加描边


4. Canvas 坐标系

Android View 的坐标系是:

左上角是原点
向右是 X 轴正方向
向下是 Y 轴正方向

示意:

(0, 0) -----------------> X
   |
   |
   |
   v
   Y

例如:

canvas.drawCircle(100f, 100f, 50f, paint)

意思是:

圆心在 View 左上角往右 100px、往下 100px 的位置
半径是 50px

5. save 和 restore

绘制时经常需要临时改变画布状态。

例如:

canvas.save()

canvas.translate(width / 2f, height / 2f)
canvas.rotate(45f)
canvas.drawRect(-50f, -50f, 50f, 50f, paint)

canvas.restore()

含义:

save():保存当前画布状态
restore():恢复之前保存的画布状态

可以理解为:

先保存现场
中间随便平移、旋转、缩放
画完之后恢复现场

这样不会影响后续绘制。


五、方式三:继承 ViewGroup 自定义布局

1. 什么是自定义 ViewGroup?

如果你想定义一种新的布局规则,就需要继承 ViewGroup

例如:

流式布局
标签自动换行布局
九宫格布局
拖拽布局
瀑布流布局

这种情况下,重点不是自己画内容,而是:

测量子 View
摆放子 View

2. ViewGroup 的核心方法

自定义 ViewGroup 通常需要重写两个方法:

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    // 测量自己和子 View
}

override fun onLayout(
    changed: Boolean,
    l: Int,
    t: Int,
    r: Int,
    b: Int
) {
    // 摆放子 View
}

简单理解:

onMeasure:问每个孩子需要多大
onLayout:告诉每个孩子放在哪里

3. 简单竖向布局示例

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

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        var totalHeight = paddingTop + paddingBottom
        var maxWidth = 0

        for (i in 0 until childCount) {
            val child = getChildAt(i)

            if (child.visibility != GONE) {
                measureChild(child, widthMeasureSpec, heightMeasureSpec)

                totalHeight += child.measuredHeight
                maxWidth = maxOf(maxWidth, child.measuredWidth)
            }
        }

        maxWidth += paddingLeft + paddingRight

        val width = resolveSize(maxWidth, widthMeasureSpec)
        val height = resolveSize(totalHeight, heightMeasureSpec)

        setMeasuredDimension(width, height)
    }

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

        for (i in 0 until childCount) {
            val child = getChildAt(i)

            if (child.visibility != GONE) {
                val left = paddingLeft
                val top = currentTop
                val right = left + child.measuredWidth
                val bottom = top + child.measuredHeight

                child.layout(left, top, right, bottom)

                currentTop = bottom
            }
        }
    }
}

这个例子实现了一个简单的竖向布局。

它的逻辑类似简化版 LinearLayout


4. 适合场景

自定义 ViewGroup 适合处理系统布局无法满足的摆放规则。

需求

推荐方式

标签自动换行

自定义 ViewGroup

子 View 按圆形排列

自定义 ViewGroup

拖拽交换位置

自定义 ViewGroup

特殊吸附布局

自定义 ViewGroup

自定义瀑布流

自定义 ViewGroup


六、方式四:组合控件

1. 什么是组合控件?

组合控件是把多个已有控件组合起来,封装成一个新的控件。

它也叫:

组合式自定义 View
Compound View
复合控件

比如一个标题栏:

返回按钮 + 标题文字 + 右侧按钮

如果每个页面都写一遍,就会很重复。

可以封装成:

<com.example.CommonTitleBar
    android:layout_width="match_parent"
    android:layout_height="48dp"
    app:titleText="设置"
    app:rightText="保存" />

这样使用起来就像系统控件一样。


2. 组合控件通常继承什么?

组合控件一般继承已有布局:

FrameLayout
LinearLayout
ConstraintLayout
RelativeLayout

比如:

class CommonTitleBar @JvmOverloads constructor(
    context: Context,
    attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : FrameLayout(context, attrs, defStyleAttr) {
}

它本质上还是一个 ViewGroup

但是和纯自定义 ViewGroup 不同的是:

组合控件通常不需要自己写复杂的 onMeasure() 和 onLayout()

因为 FrameLayoutLinearLayoutConstraintLayout 已经帮我们处理了测量和布局。


3. 组合控件适合场景

场景

示例

标题栏

返回按钮 + 标题 + 右侧按钮

搜索栏

搜索图标 + 输入框 + 清除按钮

设置项

左图标 + 标题 + 副标题 + 右箭头

用户信息卡片

头像 + 昵称 + 简介

验证码输入框

输入框 + 获取验证码按钮

空页面

图片 + 提示文案 + 按钮


4. 组合控件示例:标题栏

第一步:内部布局

res/layout/view_common_title_bar.xml

<merge xmlns:android="http://schemas.android.com/apk/res/android">

    <TextView
        android:id="@+id/tvBack"
        android:layout_width="wrap_content"
        android:layout_height="match_parent"
        android:gravity="center"
        android:paddingStart="16dp"
        android:paddingEnd="16dp"
        android:text="返回" />

    <TextView
        android:id="@+id/tvTitle"
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:gravity="center"
        android:text="标题"
        android:textSize="18sp" />

    <TextView
        android:id="@+id/tvRight"
        android:layout_width="wrap_content"
        android:layout_height="match_parent"
        android:layout_gravity="end"
        android:gravity="center"
        android:paddingStart="16dp"
        android:paddingEnd="16dp"
        android:text="更多" />

</merge>

这里使用 <merge> 是为了减少一层无意义的布局嵌套。


第二步:自定义属性

res/values/attrs.xml

<resources>
    <declare-styleable name="CommonTitleBar">
        <attr name="titleText" format="string" />
        <attr name="rightText" format="string" />
    </declare-styleable>
</resources>

第三步:自定义控件代码

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

    private val tvBack: TextView
    private val tvTitle: TextView
    private val tvRight: TextView

    init {
        LayoutInflater.from(context).inflate(
            R.layout.view_common_title_bar,
            this,
            true
        )

        tvBack = findViewById(R.id.tvBack)
        tvTitle = findViewById(R.id.tvTitle)
        tvRight = findViewById(R.id.tvRight)

        val typedArray = context.obtainStyledAttributes(
            attrs,
            R.styleable.CommonTitleBar
        )

        val titleText = typedArray.getString(
            R.styleable.CommonTitleBar_titleText
        )

        val rightText = typedArray.getString(
            R.styleable.CommonTitleBar_rightText
        )

        typedArray.recycle()

        tvTitle.text = titleText ?: "标题"
        tvRight.text = rightText ?: ""
    }

    fun setTitle(text: String) {
        tvTitle.text = text
    }

    fun setRightText(text: String) {
        tvRight.text = text
    }

    fun setOnBackClickListener(listener: OnClickListener) {
        tvBack.setOnClickListener(listener)
    }

    fun setOnRightClickListener(listener: OnClickListener) {
        tvRight.setOnClickListener(listener)
    }
}

第四步:XML 中使用

<com.example.CommonTitleBar
    android:id="@+id/titleBar"
    android:layout_width="match_parent"
    android:layout_height="48dp"
    app:titleText="个人中心"
    app:rightText="编辑" />

Activity 或 Fragment 中:

binding.titleBar.setOnBackClickListener {
    finish()
}

binding.titleBar.setOnRightClickListener {
    // 点击右侧按钮
}

七、自定义 View 的核心流程

Android View 的工作流程可以概括为三步:

measure -> layout -> draw
测量       布局       绘制

对应方法:

阶段

方法

作用

测量

onMeasure()

决定 View 有多大

布局

onLayout()

决定 View 放在哪里

绘制

onDraw()

决定 View 长什么样

普通自定义 View 重点关注:

onMeasure()
onDraw()
onTouchEvent()

自定义 ViewGroup 重点关注:

onMeasure()
onLayout()

组合控件重点关注:

加载布局
读取属性
封装方法
处理事件

八、自定义 View 的生命周期

一个 View 大致会经历:

构造方法
   ↓
onAttachedToWindow()
   ↓
onMeasure()
   ↓
onSizeChanged()
   ↓
onLayout()
   ↓
onDraw()
   ↓
onDetachedFromWindow()

常见方法说明:

方法

作用

构造方法

初始化对象、读取 XML 属性

onAttachedToWindow()

View 被添加到窗口

onMeasure()

测量 View 尺寸

onSizeChanged()

View 尺寸发生变化

onLayout()

摆放子 View

onDraw()

绘制内容

onTouchEvent()

处理触摸事件

onDetachedFromWindow()

View 从窗口移除,释放资源


九、构造方法

自定义 View 常见写法:

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

参数说明:

参数

说明

context

上下文

attrs

XML 中配置的属性

defStyleAttr

默认样式属性

如果自定义 View 需要在 XML 中使用,一般要支持 AttributeSet 参数。

Kotlin 中常用 @JvmOverloads,方便生成多个构造方法。


十、onMeasure:测量尺寸

1. MeasureSpec 是什么?

onMeasure() 方法中会收到两个参数:

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int)

这两个参数不是普通宽高,而是包含两部分信息:

MeasureSpec = 测量模式 + 测量大小

可以这样解析:

val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val widthSize = MeasureSpec.getSize(widthMeasureSpec)

2. 三种测量模式

模式

含义

对应 XML

EXACTLY

父容器已经确定具体大小

100dpmatch_parent

AT_MOST

最大不能超过某个值

wrap_content

UNSPECIFIED

不限制大小

ScrollView 等特殊场景

简单理解:

EXACTLY:父布局已经明确告诉你多大
AT_MOST:父布局给你一个最大值,你自己决定多大
UNSPECIFIED:父布局不限制你

3. 为什么要处理 wrap_content?

如果自定义 View 不重写 onMeasure(),那么 wrap_content 可能达不到预期效果。

因为系统不知道你的自定义 View 默认应该多大。

所以通常需要提供一个默认尺寸:

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

    private val defaultSize = 200

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        val width = resolveSize(defaultSize, widthMeasureSpec)
        val height = resolveSize(defaultSize, heightMeasureSpec)

        setMeasuredDimension(width, height)
    }
}

resolveSize() 会根据父布局传来的限制自动处理:

固定大小
match_parent
wrap_content

十一、onDraw:绘制内容

onDraw() 是自绘 View 的核心。

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

    canvas.drawCircle(width / 2f, height / 2f, 100f, paint)
}

一般来说:

普通自绘 View:主要写 onDraw()
组合控件:通常不需要写 onDraw()
自定义 ViewGroup:如果需要自己绘制背景或分割线,才写 onDraw()

需要注意:

如果继承的是 ViewGroup,默认可能不会执行 onDraw()

可以调用:

setWillNotDraw(false)

十二、自定义属性

自定义 View 经常需要支持 XML 属性。

例如:

<com.example.CircleProgressView
    android:layout_width="120dp"
    android:layout_height="120dp"
    app:progress="60"
    app:progressColor="@color/purple_500" />

1. 定义属性

res/values/attrs.xml

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

2. 读取属性

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

    private var progress = 0
    private var progressColor = Color.BLUE
    private var progressWidth = 12f

    init {
        val typedArray = context.obtainStyledAttributes(
            attrs,
            R.styleable.CircleProgressView
        )

        progress = typedArray.getInt(
            R.styleable.CircleProgressView_progress,
            0
        )

        progressColor = typedArray.getColor(
            R.styleable.CircleProgressView_progressColor,
            Color.BLUE
        )

        progressWidth = typedArray.getDimension(
            R.styleable.CircleProgressView_progressWidth,
            12f
        )

        typedArray.recycle()
    }
}

注意:

typedArray.recycle()

一定要调用。


十三、invalidate 和 requestLayout

这两个方法非常重要。

方法

作用

触发流程

invalidate()

重新绘制

触发 onDraw()

requestLayout()

重新测量和布局

触发 onMeasure()onLayout()onDraw()

简单记忆:

样子变了,用 invalidate()
大小变了,用 requestLayout()

例如进度变化:

fun setProgress(value: Int) {
    progress = value.coerceIn(0, 100)
    invalidate()
}

如果是内容变化导致尺寸也可能变化:

fun setText(text: String) {
    this.text = text
    requestLayout()
}

子线程刷新

子线程中不要直接调用:

invalidate()

可以使用:

postInvalidate()

区别:

方法

使用线程

invalidate()

UI 线程

postInvalidate()

子线程也可以调用


十四、触摸事件处理

自定义 View 可以通过 onTouchEvent() 处理触摸事件。

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 -> {
            performClick()
        }

        MotionEvent.ACTION_CANCEL -> {
            // 事件被取消
        }
    }

    return true
}

override fun performClick(): Boolean {
    super.performClick()
    return true
}

常见事件:

事件

含义

ACTION_DOWN

手指按下

ACTION_MOVE

手指移动

ACTION_UP

手指抬起

ACTION_CANCEL

事件被取消

注意:

如果 ACTION_DOWN 不返回 true,后续 MOVE 和 UP 可能收不到。

所以如果当前 View 要处理一整套触摸事件,通常要在 ACTION_DOWN 返回 true


十五、完整示例:圆形进度条

1. attrs.xml

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

2. CircleProgressView.kt

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

    private var progress = 0
    private var progressColor = Color.BLUE
    private var backgroundCircleColor = Color.LTGRAY
    private var progressWidth = 12f

    private val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
        style = Paint.Style.STROKE
        strokeCap = Paint.Cap.ROUND
    }

    private val rectF = RectF()

    init {
        val typedArray = context.obtainStyledAttributes(
            attrs,
            R.styleable.CircleProgressView
        )

        progress = typedArray.getInt(
            R.styleable.CircleProgressView_progress,
            0
        )

        progressColor = typedArray.getColor(
            R.styleable.CircleProgressView_progressColor,
            Color.BLUE
        )

        backgroundCircleColor = typedArray.getColor(
            R.styleable.CircleProgressView_backgroundCircleColor,
            Color.LTGRAY
        )

        progressWidth = typedArray.getDimension(
            R.styleable.CircleProgressView_progressWidth,
            12f
        )

        typedArray.recycle()
    }

    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        val defaultSize = dpToPx(120)

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

        val size = minOf(width, height)

        setMeasuredDimension(size, size)
    }

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

        val halfStroke = progressWidth / 2f

        rectF.set(
            halfStroke,
            halfStroke,
            width - halfStroke,
            height - halfStroke
        )

        paint.strokeWidth = progressWidth

        paint.color = backgroundCircleColor
        canvas.drawArc(rectF, 0f, 360f, false, paint)

        paint.color = progressColor
        val sweepAngle = progress / 100f * 360f
        canvas.drawArc(rectF, -90f, sweepAngle, false, paint)
    }

    fun setProgress(value: Int) {
        progress = value.coerceIn(0, 100)
        invalidate()
    }

    private fun dpToPx(dp: Int): Int {
        return (dp * resources.displayMetrics.density + 0.5f).toInt()
    }
}

3. XML 使用

<com.example.CircleProgressView
    android:id="@+id/circleProgressView"
    android:layout_width="120dp"
    android:layout_height="120dp"
    app:progress="70"
    app:progressColor="@color/purple_500"
    app:backgroundCircleColor="#DDDDDD"
    app:progressWidth="10dp" />

十六、dp、sp、px 的区别

单位

说明

常用场景

px

像素,实际绘制单位

Canvas 绘制

dp

与屏幕密度无关的尺寸单位

控件宽高、间距

sp

字体单位,受系统字体大小影响

文字大小

自定义 View 中最终绘制使用的是 px。

所以经常需要转换:

private fun dpToPx(dp: Float): Float {
    return dp * resources.displayMetrics.density
}

private fun spToPx(sp: Float): Float {
    return sp * resources.displayMetrics.scaledDensity
}

十七、状态保存

屏幕旋转、配置变化时,自定义 View 的状态可能丢失。

可以重写:

override fun onSaveInstanceState(): Parcelable {
    val bundle = Bundle()
    bundle.putParcelable("super_state", super.onSaveInstanceState())
    bundle.putInt("progress", progress)
    return bundle
}

override fun onRestoreInstanceState(state: Parcelable?) {
    if (state is Bundle) {
        progress = state.getInt("progress")
        val superState = state.getParcelable<Parcelable>("super_state")
        super.onRestoreInstanceState(superState)
    } else {
        super.onRestoreInstanceState(state)
    }
}

适合保存:

进度
选中状态
展开状态
当前页码
用户输入内容

十八、性能优化

1. 不要在 onDraw 中频繁创建对象

不推荐:

override fun onDraw(canvas: Canvas) {
    val paint = Paint()
    val rectF = RectF()
}

推荐:

private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val rectF = RectF()

override fun onDraw(canvas: Canvas) {
    // 复用对象
}

因为 onDraw() 可能一秒执行很多次,频繁创建对象会导致 GC。


2. 复杂计算不要放在 onDraw 中

不推荐:

override fun onDraw(canvas: Canvas) {
    // 大量路径计算
    // Bitmap 解码
    // 数据转换
}

推荐放到:

init
onSizeChanged
setData

3. 控制刷新范围

如果只需要刷新局部区域,可以使用:

invalidate(left, top, right, bottom)

而不是刷新整个 View。


4. 注意资源释放

如果自定义 View 内部有动画、Handler、线程、Bitmap 等资源,需要在 View 移除时释放。

override fun onDetachedFromWindow() {
    super.onDetachedFromWindow()

    // 停止动画
    // 移除回调
    // 释放资源
}

十九、常见问题

1. 为什么自定义 View 不显示?

常见原因:

原因

解决方式

宽高为 0

检查 XML 宽高和 onMeasure

没有调用 setMeasuredDimension()

在 onMeasure 中设置尺寸

Paint 颜色透明

检查颜色

绘制坐标超出 View

检查坐标

数据变化后没有刷新

调用 invalidate

View 不可见

检查 visibility


2. 为什么 wrap_content 无效?

因为系统不知道你的自定义 View 默认应该多大。

解决方式是重写:

onMeasure()

并提供默认宽高。


3. 为什么 onDraw 不执行?

可能原因:

原因

说明

View 宽高为 0

没有测量成功

View 不可见

GONEINVISIBLE

继承 ViewGroup

默认可能不绘制

没有触发刷新

没有调用 invalidate

如果继承的是 ViewGroup,可以加:

setWillNotDraw(false)

4. invalidate 和 requestLayout 怎么选?

简单记忆:

样子变了,用 invalidate()
大小变了,用 requestLayout()

场景

方法

颜色变化

invalidate()

进度变化

invalidate()

文字变化但尺寸不变

invalidate()

文字变化导致尺寸变化

requestLayout()

宽高变化

requestLayout()

子 View 数量变化

requestLayout()


二十、学习路线

建议按这个顺序学习自定义 View:

1. View 坐标系
2. Canvas 和 Paint
3. onDraw 绘制
4. onMeasure 测量
5. 自定义属性
6. invalidate 和 requestLayout
7. 触摸事件
8. 组合控件
9. 自定义 ViewGroup
10. 动画结合自定义 View
11. 性能优化

二十一、总结

Android 自定义 View 不是只有 Canvas 绘制。

完整来看,自定义 View 主要有四种方式:

1. 继承已有 View
2. 继承 View 自己绘制
3. 继承 ViewGroup 自己布局
4. 组合多个已有控件

它们的选择原则是:

小增强:继承已有 View
复杂图形:继承 View 自绘
特殊布局:继承 ViewGroup
业务组件复用:组合控件

View 的核心流程是:

measure -> layout -> draw
测量       布局       绘制

一句话总结:

自定义 View 的重点不是只会画图,而是理解 Android 如何测量、布局、绘制和分发事件。掌握了这条主线,再结合不同的自定义方式,就能根据业务场景选择最合适的实现方案。