Android自定义View|从0到1落地业务,避坑+实战+面试全覆盖(附完整案例)

16 阅读13分钟

本文适配Android新手→进阶,基于真实业务场景编写,Kotlin+Java双版本代码,覆盖自定义View全流程(属性定义→测量→布局→绘制→交互),补充高频面试考点+调试技巧,看完直接搞定80%业务自定义控件需求 🚀

一、前言:为什么自定义View是Android开发的“分水岭”?

做Android开发1-2年,你一定会遇到这样的困境:

  • 产品要“独特的圆形头像+渐变边框”,原生ImageView做不到;
  • UI给的“分段进度条+动画效果”,原生ProgressBar无法适配;
  • 需求要“流式标签布局+点击删除”,自带控件没有对应效果;

原生控件的局限性,决定了自定义View是进阶必备技能——不仅能搞定复杂UI,更是面试中区分“初级”和“中级”开发的核心考点。

本文不搞“纸上谈兵”,全程围绕「业务落地」展开,从基础原理到复杂实战,再到面试避坑,新手也能跟着敲、跟着用。

二、先搞懂:自定义View的3种实现方式(优先级+适用场景)

很多新手一上来就直接继承View,写得又复杂又容易出问题。记住:优先选最简单的方案,降低开发成本和维护难度,具体对应场景如下:

1. 扩展现有控件(优先级最高,最常用)

继承AppCompatTextView、AppCompatImageView、CardView等原生控件,只扩展功能、修改样式,无需重写测量和布局,开发效率最高。

适用场景:给现有控件加边框、加点击效果、修改文字样式、适配特殊需求(如带删除图标的TextView)。

核心优势:复用原生控件的测量、布局逻辑,避免重复造轮子,减少bug。

2. 完全自定义View(优先级中等)

直接继承View,从零开始绘制控件,必须重写onMeasure(测量)+ onDraw(绘制) ,灵活度最高。

适用场景:原生控件无法实现的简单UI,如圆形、椭圆、折线图、自定义指示器等。

注意点:新手容易忽略wrap_content适配和padding适配,这是高频bug点(后面重点讲)。

3. 自定义ViewGroup(优先级最低,最复杂)

继承ViewGroup,自定义子View的布局规则,必须重写onMeasure(测量子View)+ onLayout(定位子View) ,难度最大。

适用场景:需要自定义子View排列方式,如流式布局、网格布局(原生GridLayout不满足需求时)、自定义导航栏等。

三、核心原理:自定义View的4大核心步骤

自定义View的绘制流程由系统自动调度,核心是「测量→布局→绘制」,再加上「自定义属性」,四步缺一不可。很多新手只关注绘制,忽略前三步,导致控件适配差、bug多。

1. 自定义属性(XML可配置,提升复用性)

原生控件的android:text、android:background都是系统定义的属性,我们可以自定义属性,让控件在XML中直接配置,无需在代码中硬编码,提升复用性。

完整步骤

  1. 定义属性文件:在res/values下新建attrs.xml,指定属性名称、类型(color、dimension、integer等),支持枚举、引用等复杂类型。
  2. 在XML中使用:添加自定义命名空间(xmlns:app="schemas.android.com/apk/res-aut…
  3. 在代码中获取:通过TypedArray获取属性值,必须调用recycle()回收,避免内存泄漏(新手高频遗忘点)。

完整代码示例

// attrs.xml
<?xml version="1.0" encoding="utf-8"?>
<resources>
    <!-- 圆形View自定义属性(补充渐变、边框属性) -->
    <declare-styleable name="CircleView">
        &lt;attr name="circle_color" format="color"/&gt; <!-- 圆形填充色 -->
        &lt;attr name="circle_radius" format="dimension"/&gt; <!-- 半径 -->
        &lt;attr name="circle_border_color" format="color"/&gt; <!-- 边框颜色 -->
        <attr name="circle_border_width" format="dimension"/&gt; <!-- 边框宽度 -->
        <attr name="circle_gradient" format="boolean"/&gt; <!-- 是否渐变 -->
    </declare-styleable>
</resources>

// XML布局使用
<com.example.myview.CircleView
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:padding="10dp"
    app:circle_color="#FF4081"
    app:circle_radius="60dp"
    app:circle_border_color="#000000"
    app:circle_border_width="2dp"
    app:circle_gradient="true"/>

2. onMeasure:测量宽高(最容易踩坑的一步)

核心作用:测量控件自身宽高,以及子View(ViewGroup)的宽高,决定控件在屏幕上的大小。

新手必踩坑:View默认wrap_content和match_parent效果一致,不手动处理,控件会铺满父布局。

核心逻辑:通过MeasureSpec(自定义View测量环节的核心,至关重要)获取父布局的约束,它是父布局与子View之间传递尺寸约束的关键载体,直接决定子View的宽高计算逻辑,也是避免测量bug的核心。其包含三种模式(EXACTLY:固定尺寸、AT_MOST:最大尺寸、UNSPECIFIED:无约束),结合自身需求,计算出实际宽高,调用setMeasuredDimension()设置。从底层本质来说,MeasureSpec是一个32位int值,高2位存储测量模式(mode),低30位存储测量尺寸(size),正是这种设计让尺寸约束的传递高效且简洁。

实战代码(处理wrap_content+padding)

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec)
    // 1. 获取父布局约束
    val widthMode = MeasureSpec.getMode(widthMeasureSpec)
    val widthSize = MeasureSpec.getSize(widthMeasureSpec)
    val heightMode = MeasureSpec.getMode(heightMeasureSpec)
    val heightSize = MeasureSpec.getSize(heightMeasureSpec)
    
    // 2. 计算实际宽高(处理wrap_content)
    val defaultSize = (mCircleRadius * 2 + paddingLeft + paddingRight).toInt()
    val actualWidth = when (widthMode) {
        MeasureSpec.EXACTLY -> widthSize // match_parent或固定尺寸
        MeasureSpec.AT_MOST -> minOf(defaultSize, widthSize) // 不超过父布局最大尺寸
        else -> defaultSize // 无约束(如ScrollView中)
    }
    val actualHeight = when (heightMode) {
        MeasureSpec.EXACTLY -> heightSize
        MeasureSpec.AT_MOST -> minOf(defaultSize, heightSize)
        else -> defaultSize
    }
    
    // 3. 设置最终宽高
    setMeasuredDimension(actualWidth, actualHeight)
}

3. onLayout:定位子View(仅ViewGroup需要)

核心作用:确定子View在父容器中的位置(left、top、right、bottom),只有自定义ViewGroup需要重写,View无需重写。

关键细节:遍历所有子View,调用child.layout()方法定位,注意处理子View的margin和父容器的padding。

4. onDraw:绘制内容(自定义View的核心)

核心作用:通过Canvas(画布)绘制控件的具体内容,如圆形、文字、图片、路径等,搭配Paint(画笔)控制样式。

新手必避坑:禁止在onDraw中创建对象(如Paint、Rect),onDraw会高频调用(如屏幕刷新、滑动时),频繁创建对象会导致内存抖动、卡顿。

Canvas常用API

  • drawCircle(cx, cy, radius, paint):绘制圆形
  • drawRect(left, top, right, bottom, paint):绘制矩形
  • drawText(text, x, y, paint):绘制文字(注意y是基线位置,不是文字顶部)
  • drawPath(path, paint):绘制自定义路径(如三角形、多边形)
  • drawBitmap(bitmap, x, y, paint):绘制图片

四、实战案例(3个业务级案例)

案例均来自真实业务需求,包含Kotlin+Java双版本、交互处理、动画效果、性能优化,复制就能直接集成到项目中。

案例1:完全自定义View → 渐变圆形View(带边框+点击交互)

需求:可配置填充色/渐变、边框、半径,支持点击事件,适配padding、wrap_content,满足头像、指示器等场景。

import android.content.Context
import android.graphics.*
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import com.example.myview.R

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

    // 画笔提前初始化,避免onDraw中创建
    private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG) // 填充画笔
    private val borderPaint = Paint(Paint.ANTI_ALIAS_FLAG) // 边框画笔
    private var circleRadius = 50f // 默认半径
    private var borderWidth = 2f // 默认边框宽度
    private var isGradient = false // 是否渐变
    private var fillColor = Color.RED // 默认填充色
    private var borderColor = Color.BLACK // 默认边框色
    private var gradient: LinearGradient? = null // 渐变对象

    init {
        // 1. 获取自定义属性(渐变、边框属性)
        val typedArray = context.obtainStyledAttributes(attrs, R.styleable.CircleView)
        fillColor = typedArray.getColor(R.styleable.CircleView_circle_color, fillColor)
        circleRadius = typedArray.getDimension(R.styleable.CircleView_circle_radius, circleRadius)
        borderColor = typedArray.getColor(R.styleable.CircleView_circle_border_color, borderColor)
        borderWidth = typedArray.getDimension(R.styleable.CircleView_circle_border_width, borderWidth)
        isGradient = typedArray.getBoolean(R.styleable.CircleView_circle_gradient, isGradient)
        typedArray.recycle() // 必须回收

        // 2. 初始化画笔
        initPaint()
    }

    private fun initPaint() {
        // 填充画笔
        fillPaint.style = Paint.Style.FILL
        if (isGradient) {
            // 渐变配置
            gradient = LinearGradient(
                0f, 0f, circleRadius * 2, circleRadius * 2,
                fillColor, Color.parseColor("#FFFFFF"),
                Shader.TileMode.CLAMP
            )
            fillPaint.shader = gradient
        } else {
            fillPaint.color = fillColor
        }

        // 边框画笔
        borderPaint.style = Paint.Style.STROKE
        borderPaint.color = borderColor
        borderPaint.strokeWidth = borderWidth
    }

    // 3. 重写onMeasure,处理wrap_content和padding
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        val defaultSize = (circleRadius * 2 + paddingLeft + paddingRight + borderWidth * 2).toInt()
        val actualWidth = getActualSize(widthMeasureSpec, defaultSize)
        val actualHeight = getActualSize(heightMeasureSpec, defaultSize)
        setMeasuredDimension(actualWidth, actualHeight)
    }

    // 辅助方法:计算实际宽高
    private fun getActualSize(measureSpec: Int, defaultSize: Int): Int {
        val mode = MeasureSpec.getMode(measureSpec)
        val size = MeasureSpec.getSize(measureSpec)
        return when (mode) {
            MeasureSpec.EXACTLY -> size
            MeasureSpec.AT_MOST -> minOf(defaultSize, size)
            else -> defaultSize
        }
    }

    // 4. 重写onDraw,绘制圆形+边框
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        // 适配padding和边框,计算圆心坐标
        val cx = paddingLeft + borderWidth + circleRadius
        val cy = paddingTop + borderWidth + circleRadius
        // 绘制填充圆形
        canvas.drawCircle(cx, cy, circleRadius, fillPaint)
        // 绘制边框
        canvas.drawCircle(cx, cy, circleRadius + borderWidth / 2, borderPaint)
    }

    // 5.点击交互
    override fun onTouchEvent(event: MotionEvent): Boolean {
        when (event.action) {
            MotionEvent.ACTION_DOWN -> {
                // 点击时改变颜色,反馈交互
                fillPaint.color = Color.parseColor("#FF8800")
                invalidate() // 主线程刷新视图
                return true
            }
            MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
                // 松开时恢复颜色
                if (isGradient) {
                    fillPaint.shader = gradient
                } else {
                    fillPaint.color = fillColor
                }
                invalidate()
                // 触发点击回调(可对外暴露)
                performClick()
            }
        }
        return super.onTouchEvent(event)
    }

    // Java版本代码
    // public class CircleView extends View {
    //     private Paint fillPaint;
    //     private Paint borderPaint;
    //     private float circleRadius = 50f;
    //     private float borderWidth = 2f;
    //     private boolean isGradient = false;
    //     private int fillColor = Color.RED;
    //     private int borderColor = Color.BLACK;
    //     private LinearGradient gradient;
    //
    //     public CircleView(Context context) {
    //         this(context, null);
    //     }
    //
    //     public CircleView(Context context, AttributeSet attrs) {
    //         this(context, attrs, 0);
    //     }
    //
    //     public CircleView(Context context, AttributeSet attrs, int defStyleAttr) {
    //         super(context, attrs, defStyleAttr);
    //         initAttrs(attrs);
    //         initPaint();
    //     }
    //
    //     private void initAttrs(AttributeSet attrs) {
    //         TypedArray typedArray = getContext().obtainStyledAttributes(attrs, R.styleable.CircleView);
    //         fillColor = typedArray.getColor(R.styleable.CircleView_circle_color, fillColor);
    //         circleRadius = typedArray.getDimension(R.styleable.CircleView_circle_radius, circleRadius);
    //         borderColor = typedArray.getColor(R.styleable.CircleView_circle_border_color, borderColor);
    //         borderWidth = typedArray.getDimension(R.styleable.CircleView_circle_border_width, borderWidth);
    //         isGradient = typedArray.getBoolean(R.styleable.CircleView_circle_gradient, isGradient);
    //         typedArray.recycle();
    //     }
    //
    //     private void initPaint() {
    //         fillPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
    //         fillPaint.setStyle(Paint.Style.FILL);
    //         if (isGradient) {
    //             gradient = new LinearGradient(0f, 0f, circleRadius * 2, circleRadius * 2,
    //                     fillColor, Color.parseColor("#FFFFFF"), Shader.TileMode.CLAMP);
    //             fillPaint.setShader(gradient);
    //         } else {
    //             fillPaint.setColor(fillColor);
    //         }
    //
    //         borderPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
    //         borderPaint.setStyle(Paint.Style.STROKE);
    //         borderPaint.setColor(borderColor);
    //         borderPaint.setStrokeWidth(borderWidth);
    //     }
    //
    //     @Override
    //     protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
    //         super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    //         int defaultSize = (int) (circleRadius * 2 + getPaddingLeft() + getPaddingRight() + borderWidth * 2);
    //         int actualWidth = getActualSize(widthMeasureSpec, defaultSize);
    //         int actualHeight = getActualSize(heightMeasureSpec, defaultSize);
    //         setMeasuredDimension(actualWidth, actualHeight);
    //     }
    //
    //     private int getActualSize(int measureSpec, int defaultSize) {
    //         int mode = MeasureSpec.getMode(measureSpec);
    //         int size = MeasureSpec.getSize(measureSpec);
    //         if (mode == MeasureSpec.EXACTLY) {
    //             return size;
    //         } else if (mode == MeasureSpec.AT_MOST) {
    //             return Math.min(defaultSize, size);
    //         } else {
    //             return defaultSize;
    //         }
    //     }
    //
    //     @Override
    //     protected void onDraw(Canvas canvas) {
    //         super.onDraw(canvas);
    //         float cx = getPaddingLeft() + borderWidth + circleRadius;
    //         float cy = getPaddingTop() + borderWidth + circleRadius;
    //         canvas.drawCircle(cx, cy, circleRadius, fillPaint);
    //         canvas.drawCircle(cx, cy, circleRadius + borderWidth / 2, borderPaint);
    //     }
    //
    //     @Override
    //     public boolean onTouchEvent(MotionEvent event) {
    //         switch (event.getAction()) {
    //             case MotionEvent.ACTION_DOWN:
    //                 fillPaint.setColor(Color.parseColor("#FF8800"));
    //                 invalidate();
    //                 return true;
    //             case MotionEvent.ACTION_UP:
    //             case MotionEvent.ACTION_CANCEL:
    //                 if (isGradient) {
    //                     fillPaint.setShader(gradient);
    //                 } else {
    //                     fillPaint.setColor(fillColor);
    //                 }
    //                 invalidate();
    //                 performClick();
    //                 break;
    //         }
    //         return super.onTouchEvent(event);
    //     }
    // }
}

案例2:扩展现有控件 → 带删除图标的TextView(业务常用)

需求:继承AppCompatTextView,右侧添加删除图标,点击图标删除文字,适配不同文字长度、图标大小,用于搜索框、标签等场景。

import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.drawable.Drawable
import android.util.AttributeSet
import android.view.MotionEvent
import androidx.appcompat.widget.AppCompatTextView

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

    private var deleteIcon: Drawable? = null // 删除图标
    private var iconSize = 24f // 图标大小
    private var iconMargin = 8f // 图标与文字间距
    private val iconPaint = Paint(Paint.ANTI_ALIAS_FLAG)

    init {
        // 获取自定义属性(图标、大小、间距)
        val typedArray = context.obtainStyledAttributes(attrs, R.styleable.DeleteTextView)
        deleteIcon = typedArray.getDrawable(R.styleable.DeleteTextView_delete_icon)
        iconSize = typedArray.getDimension(R.styleable.DeleteTextView_icon_size, iconSize)
        iconMargin = typedArray.getDimension(R.styleable.DeleteTextView_icon_margin, iconMargin)
        typedArray.recycle()

        // 初始化图标(缩放至指定大小)
        deleteIcon?.let {
            it.setBounds(0, 0, iconSize.toInt(), iconSize.toInt())
        }

        // 设置文字间距,给图标留出空间
        compoundDrawablePadding = iconMargin.toInt()
    }

    // 重写onDraw,绘制删除图标(右侧)
    override fun onDraw(canvas: Canvas) {
        super.onDraw(canvas)
        deleteIcon?.let {
            // 计算图标位置(右侧居中)
            val iconLeft = width - paddingRight - iconSize - iconMargin
            val iconTop = (height - iconSize) / 2
            // 保存画布状态,避免影响文字绘制
            canvas.save()
            canvas.translate(iconLeft, iconTop)
            it.draw(canvas)
            canvas.restore()
        }
    }

    // 点击图标删除文字(交互逻辑)
    override fun onTouchEvent(event: MotionEvent): Boolean {
        deleteIcon?.let {
            val iconLeft = width - paddingRight - iconSize - iconMargin
            val iconRight = iconLeft + iconSize
            val iconTop = (height - iconSize) / 2
            val iconBottom = iconTop + iconSize

            // 判断点击位置是否在图标上
            if (event.x in iconLeft..iconRight && event.y in iconTop..iconBottom) {
                when (event.action) {
                    MotionEvent.ACTION_UP -> {
                        text = "" // 删除文字
                        invalidate()
                        return true
                    }
                }
            }
        }
        return super.onTouchEvent(event)
    }

    // 对外暴露方法,设置删除图标
    fun setDeleteIcon(drawable: Drawable) {
        deleteIcon = drawable
        deleteIcon?.setBounds(0, 0, iconSize.toInt(), iconSize.toInt())
        invalidate()
    }
}

案例3:自定义ViewGroup → 完整流式布局(带间距+换行+点击删除)

需求:子View横向排列,超出父容器宽度自动换行,支持子View间距、点击删除子View,用于标签、筛选条件等场景。

import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import android.widget.TextView

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

    private var horizontalSpacing = 10f // 子View横向间距
    private var verticalSpacing = 10f // 子View纵向间距

    init {
        // 获取自定义间距属性
        val typedArray = context.obtainStyledAttributes(attrs, R.styleable.FlowLayout)
        horizontalSpacing = typedArray.getDimension(R.styleable.FlowLayout_horizontal_spacing, horizontalSpacing)
        verticalSpacing = typedArray.getDimension(R.styleable.FlowLayout_vertical_spacing, verticalSpacing)
        typedArray.recycle()
    }

    // 1. 重写onMeasure,测量子View和自身宽高
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        val widthMode = MeasureSpec.getMode(widthMeasureSpec)
        val widthSize = MeasureSpec.getSize(widthMeasureSpec)
        var totalHeight = paddingTop + paddingBottom // 总高度(初始为上下内边距)
        var currentLineWidth = paddingLeft // 当前行宽度(初始为左内边距)
        var currentLineHeight = 0 // 当前行高度

        // 遍历所有子View,测量并计算行宽、行高
        for (i in 0 until childCount) {
            val child = getChildAt(i)
            // 测量子View(必须调用,否则子View宽高为0)
            measureChild(child, widthMeasureSpec, heightMeasureSpec)
            val childWidth = child.measuredWidth
            val childHeight = child.measuredHeight

            // 判断当前子View是否需要换行
            if (currentLineWidth + childWidth + horizontalSpacing > widthSize - paddingRight) {
                // 换行:累加当前行高度,重置当前行宽和行高
                totalHeight += currentLineHeight + verticalSpacing
                currentLineWidth = paddingLeft + childWidth
                currentLineHeight = childHeight
            } else {
                // 不换行:累加当前行宽,更新当前行最高高度
                currentLineWidth += childWidth + horizontalSpacing
                currentLineHeight = maxOf(currentLineHeight, childHeight)
            }
        }

        // 加上最后一行的高度
        totalHeight += currentLineHeight

        // 设置自身宽高
        val actualWidth = if (widthMode == MeasureSpec.EXACTLY) widthSize else currentLineWidth + paddingRight
        setMeasuredDimension(actualWidth, totalHeight)
    }

    // 2. 重写onLayout,定位子View
    override fun onLayout(changed: Boolean, l: Int, t: Int, r: Int, b: Int) {
        var currentLeft = paddingLeft // 当前子View的左坐标
        var currentTop = paddingTop // 当前子View的上坐标
        var currentLineHeight = 0 // 当前行高度

        for (i in 0 until childCount) {
            val child = getChildAt(i)
            val childWidth = child.measuredWidth
            val childHeight = child.measuredHeight

            // 判断是否需要换行
            if (currentLeft + childWidth + horizontalSpacing > width - paddingRight) {
                // 换行:重置左坐标,累加行高
                currentLeft = paddingLeft
                currentTop += currentLineHeight + verticalSpacing
                currentLineHeight = childHeight
            } else {
                // 不换行:更新当前行最高高度
                currentLineHeight = maxOf(currentLineHeight, childHeight)
            }

            // 定位子View(left, top, right, bottom)
            val childLeft = currentLeft
            val childTop = currentTop + (currentLineHeight - childHeight) / 2 // 垂直居中
            val childRight = childLeft + childWidth
            val childBottom = childTop + childHeight
            child.layout(childLeft, childTop, childRight, childBottom)

            // 更新下一个子View的左坐标
            currentLeft += childWidth + horizontalSpacing
        }
    }

    // 3. 添加标签+点击删除功能
    fun addTag(text: String) {
        val tagView = TextView(context).apply {
            this.text = text
            setPadding(12.dp, 6.dp, 12.dp, 6.dp)
            setBackgroundResource(R.drawable.tag_bg) // 自定义标签背景
            textSize = 14f
            // 点击删除当前标签
            setOnClickListener { removeView(this) }
        }
        addView(tagView)
    }

    // dp转px(辅助方法)
    private val Int.dp: Int
        get() = (this * resources.displayMetrics.density + 0.5f).toInt()
}

五、高频避坑+面试考点

这部分是咱们掘金用户最关心的内容,既有开发中常踩的坑,也有面试高频提问,帮你既搞定业务,也能应对面试。

1. 开发必避坑(新手高频错误)

  • 坑1:onMeasure未处理wrap_content → 解决方案:手动计算wrap_content时的默认尺寸,结合MeasureSpec处理。
  • 坑2:onDraw中创建对象 → 解决方案:在init或onSizeChanged中初始化画笔、Rect等对象,避免内存抖动。
  • 坑3:未适配padding/margin → 解决方案:绘制和布局时,必须加上paddingLeft/Top/Right/Bottom,ViewGroup还要处理子View的margin。
  • 坑4:TypedArray未回收 → 解决方案:获取属性后,必须调用typedArray.recycle(),避免内存泄漏。
  • 坑5:子线程刷新视图 → 解决方案:主线程用invalidate(),子线程用postInvalidate()或Handler.post()。
  • 坑6:Canvas绘制顺序错误 → 解决方案:先绘制底层内容(如背景),再绘制上层内容(如文字、图标),避免被覆盖。

2. 面试高频考点(自定义View核心提问)

  • Q:自定义View的绘制流程是什么? → A:measure(测量)→ layout(布局)→ draw(绘制),补充各步骤的核心作用。
  • Q:onMeasure中MeasureSpec的三种模式是什么?分别对应什么场景? → A:作为自定义View测量的核心,MeasureSpec的三种模式直接决定子View的宽高表现,是面试高频必问考点:EXACTLY(固定尺寸、match_parent,父布局明确指定子View宽高)、AT_MOST(wrap_content,子View宽高不超过父布局最大尺寸)、UNSPECIFIED(无约束,如ScrollView中的子View,可自由设置尺寸)。
  • Q:如何优化自定义View的性能? → A:① 避免onDraw中创建对象;② 减少onDraw调用次数;③ 用硬件加速(setLayerType);④ 避免过度绘制(减少重叠绘制)。
  • Q:自定义ViewGroup和自定义View的区别是什么? → A:ViewGroup需要重写onMeasure(测量子View)和onLayout(定位子View),View无需重写onLayout。
  • Q:invalidate()和postInvalidate()的区别? → A:两者都是刷新视图,invalidate()只能在主线程调用,postInvalidate()可在子线程调用。

六、实战调试技巧(掘金独家干货)

新手写自定义View,最头疼的是“看不到绘制过程,不知道bug在哪”,分享3个实用调试技巧,快速定位问题:

  1. 开启布局边界:设置→系统→开发者选项→开启“显示布局边界”,可直观看到控件的宽高、位置,快速排查布局错位问题。
  2. 绘制辅助线:在onDraw中用Canvas.drawLine()绘制辅助线,标记圆心、边界等,查看绘制位置是否正确。
  3. 打印日志:在onMeasure、onLayout、onDraw中打印宽高、坐标等信息,排查测量和布局逻辑错误。

七、总结

自定义View没有想象中难,核心就是“选对实现方式→处理自定义属性→重写三大核心方法→避坑+优化”。

本文所有案例均来自真实业务,复制就能用。

掌握这些内容,你不仅能搞定80%的业务自定义控件需求,还能在面试中脱颖而出。后续可根据实际需求,扩展更复杂的控件(如自定义进度条、折线图)。