先把结论放在前面:自定义 View 的进阶能力集中在三件事——Paint 的三大能力(画笔属性、Xfermode 合成、Shader 填充)、Canvas 的变换矩阵与图层、以及属性动画的驱动。 这三件事能组合出遮罩、渐变、橡皮擦、裁剪、保存恢复等效果。答不出"效果是怎么算出来的",就只是背 API。
自定义 View 进阶是高频考点,但答好的人不多。这篇带你从零理清它的原理、用法和坑。
Paint 的三个层次
// 1) 画笔属性:决定"画成什么样"
val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
style = Paint.Style.STROKE // FILL / STROKE / FILL_AND_STROKE
strokeWidth = 6f // dp 换算:dp * density
strokeCap = Paint.Cap.ROUND
strokeJoin = Paint.Join.ROUND
color = 0xFF2196F3.toInt()
textSize = 14f * resources.displayMetrics.scaledDensity
typeface = Typeface.create("sans-serif-medium", Typeface.NORMAL)
letterSpacing = 0.02f // 字间距
isSubpixelText = true // 允许亚像素,牺牲一点性能换清晰度
setShadowLayer(8f, 0f, 2f, 0x30000000)
}
// 2) Shader:填充来源(渐变 / 位图 / 着色器)
paint.shader = LinearGradient(0f, 0f, width.toFloat(), 0f,
intArrayOf(0xFF6200EE.toInt(), 0xFF03DAC5.toInt()), null, Shader.TileMode.CLAMP)
paint.shader = RadialGradient(cx, cy, radius,
0xFFFFFFFF.toInt(), 0xFF000000.toInt(), Shader.TileMode.CLAMP)
paint.shader = BitmapShader(bitmap, Shader.TileMode.REPEAT, Shader.TileMode.REPEAT)
// 3) Xfermode:像素如何合成(进设备 API 26+ 需开启硬件加速的层或用 offscreen)
Xfermode 的规则要讲清——它作用于"新画的内容"与"已画内容"的组合,用一张两圆叠加的图就能解释 SRC_IN(保留新画的、遮罩掉旧的)、DST_OUT(擦除已画区域)、PORTER_DUFF 那几个模式。Xfermode 在硬件加速层上需要通过 saveLayer 隔离,否则会影响该层之后的所有绘制内容——这是"用完 Xfermode 后后面的绘制都变了"这类诡异 bug 的根因。
// 正确的 Xfermode 用法:先开离屏层
val layer = saveLayer(0f, 0f, width.toFloat(), height.toFloat(), null)
paint.xfermode = PorterDuffXfermode(PorterDuff.Mode.SRC_IN)
// ... 绘制遮罩
restoreToCount(layer) // 恢复,避免污染后续绘制
最常见的坑是
onDraw 里 new 对象与创建路径,GC 抖动引发掉帧。
onDraw 每帧调用一次,60Hz 下每秒 600 次。里面 new Paint()、new RectF()、new Path()、甚至 String.format() 都会分配对象,堆增长触发 GC,表现为"滑动时偶发掉帧,且帧率图上是一根一根的尖刺"。更隐蔽的是在 onDraw 里做 new Path() 后反复 path.reset() 复用是安全的,而每次重建 Path 对象则不是——Path 的内部缓存(PathVerb 优化)会在重建时丢失。
规范做法是把对象提到 onSizeChanged 或构造期缓存,并在缩放时统一更新:
class RingProgressView @JvmOverloads constructor(
context: Context, attrs: AttributeSet? = null, defStyle: Int = 0
) : View(context, attrs, defStyle) {
// 复用的对象,只在尺寸变化时更新
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val progressPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val arcBounds = RectF()
private val arcPath = Path()
private var startAngle = -90f
private var sweepAngle = 0f
private var animated = 0f
private var animator: ValueAnimator? = null
init {
trackPaint.style = Paint.Style.STROKE
trackPaint.strokeCap = Paint.Cap.ROUND
trackPaint.color = 0xFFE0E0E0.toInt()
progressPaint.style = Paint.Style.STROKE
progressPaint.strokeCap = Paint.Cap.ROUND
progressPaint.color = 0xFF2196F3.toInt()
}
override fun onSizeChanged(w: Int, h: Int, ow: Int, oh: Int) {
super.onSizeChanged(w, h, ow, oh)
val d = min(w, h).toFloat() - strokeWidth * 2
val left = (w - d) / 2
val top = (h - d) / 2
// 只改数值,不新建对象
arcBounds.set(left, top, left + d, top + d)
trackPaint.strokeWidth = strokeWidth
progressPaint.strokeWidth = strokeWidth
}
override fun onDraw(canvas: Canvas) {
// 每帧只做绘制,不分配任何对象
canvas.drawArc(arcBounds, startAngle, 360f, false, trackPaint)
canvas.drawArc(arcBounds, startAngle, sweepAngle, false, progressPaint)
}
}
还有一个更隐蔽的坑:自定义 View 进阶的边界要进注释、进清单、进单测,三处都落到才算到位。具体清单:① 进注释——onMeasure 里写清支持哪几种 mode、为什么这么处理(覆盖"嵌套滚动容器"这类前提);② 进清单——这个 View 依赖什么(硬件加速?saveLayer?特定 API 级别?),以及哪些属性必须外部设置(setProgress(0f..1f)),写进 KDoc 与 README;③ 进单测——三模式测量用 makeMeasureSpec 参数化测试、动画终值断言、以及"不同尺寸下不崩溃"的健壮性测试。这三条能挡掉自绘 View 交付后最常见的三类问题:测量异常、误用、边界尺寸崩溃。
再补两个"工程现场"话题,它们比 API 细节更能体现做过。
话题一:文字测量与基线对齐。 Paint.measureText 给出的是"可见宽度"(不含尾部空格与部分字形溢出),getTextBounds 给出的是"墨迹范围"(上下含字体上下延伸)。做精确排版时要用 getTextBounds 或 FontMetrics,否则 descender(下伸部)会被裁掉。还有中英文混排的基线问题:Paint.FontMetrics 的 ascent/descent 描述相对基线,top/bottom 是字体实际墨迹边界——用 top 而不是 ascent 计算容器高度,才能保证不裁字。
val fm = paint.fontMetrics
val textHeight = fm.descent - fm.ascent
val textTop = fm.ascent // 相对基线向上为负
canvas.drawText(text, x, baselineY - textTop, paint) // 避免顶部被裁
话题二:硬件加速下的图层与 Paint 的等价路径。 硬件加速绘制时,Canvas 记录的是"显示列表"由 GPU 逐条执行,因此"绘制顺序 = 记录顺序"仍然成立,但某些操作(clipPath 复杂路径、drawBitmapMesh、部分 Xfermode)会触发离屏渲染,代价明显上升。所以在自定义 View 里要避开复杂 clipPath,用 canvas.clipRect 或 canvas.saveLayer 明确裁剪区域,性能差异很明显。判断某操作是否离屏,可以用 adb shell dumpsys gfxinfo <package> 看渲染耗时分布——这是排查绘制性能最有用的命令之一。
还有一个高频实战问题:自定义 View 怎么支持 RTL(从右到左)布局? 做法有两条——① 布局层面用 start/end 代替 left/right(View 在 onSizeChanged 里通过 layoutDirection 判断方向后镜像计算坐标);② 绘制层面用 canvas.scale(-1, 1, pivotX, pivotY) 做整体镜像,动画类控件(进度、仪表)通常这样做。更省事的方案是直接用 android:supportsRtl="true" + 系统提供的 getLayoutDirection(),不要自己判断屏幕坐标。
三条高频追问
问:Shader 和 ColorFilter 有什么区别?
Shader 决定"像素从哪来"(渐变、位图、图案),ColorFilter 决定"已有像素怎么变"(LightingColorFilter 调色、PorterDuffColorFilter 叠加、ColorMatrixColorFilter 做色彩矩阵)。用 ColorMatrix 做灰度/反色是最省事的方式:
val matrix = ColorMatrix().apply {
setSaturation(0f) } // 灰度
view.setColorFilter(ColorMatrixColorFilter(matrix))
问:saveLayer 什么时候必须用?
三类场景:① 用 Xfermode 时(否则污染整个图层);② BitmapShader 配合 PorterDuff 做遮罩时;③ 需要离屏渲染后再整体合成(比如截图、加噪、圆角裁剪)。代价是它会分配一张离屏位图,在 onDraw 里频繁 saveLayer 是卡顿的常见来源——能预生成 bitmap 缓存就别在每帧开图层。
问:怎么让自绘 View 支持点击?
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
downX = event.x; downY = event.y
isDragging = true
parent.requestDisallowInterceptTouchEvent(true) // 关键:防止父容器抢走
}
MotionEvent.ACTION_MOVE -> {
/* 更新状态 + invalidate() */ }
MotionEvent.ACTION_UP, ACTION_CANCEL -> isDragging = false
}
return true
}
要点:parent.requestDisallowInterceptTouchEvent(true) 放在 ACTION_DOWN 里,否则在 ScrollView 里拖动会被父容器截走;另外在移动超过 touchSlop 后主动 invalidate() 之外,还要注意"只在状态变化时刷新"——把每个 MOVE 都转成 invalidate 会在快速滑动时放大绘制成本。
问:ValueAnimator 驱动的自绘 View 如何避免内存泄漏?
ValueAnimator 持有回调,回调持有 View,View 持有 Context——链路完整就会泄漏。规范做法是在 onDetachedFromWindow 里 animator?.cancel(),并在 onAttachedToWindow 里按需重启;同时把 animator 的 updateListener 用 WeakReference 或在 detach 时置空。动画驱动的自绘 View 是泄漏高发区,这条要主动讲。
落到项目里怎么做
一条能直接落地的规则:自绘 View 的性能红线是"onDraw 里零分配"。用 AllocationTracker 或 StrictMode 在 debug 包里跑一遍,越界就当 bug 修。
配套实践三条:① 建 BaseCustomView 统一处理 Paint 初始化、dp 换算、动画生命周期、内存泄漏防护;② 复杂效果(阴影、多层叠加、模糊)优先预生成 bitmap 并缓存,只在尺寸或数据变化时重绘;③ 所有属性动画走 ValueAnimator + invalidate(),不自己起线程刷新。
给正在准备面试的你
这题的答法要落到"效果是怎么算出来的"。推荐这条线:先讲 Paint 三层次(属性 / Shader 填充来源 / Xfermode 合成规则)并强调 Xfermode 需要 saveLayer 隔离 → 讲 onDraw 零分配与对象复用(onSizeChanged 里只改数值)→ 补动画与内存泄漏(detach 时 cancel)→ 落到"边界进注释进清单进单测"。能被追问到"为什么 Xfermode 会影响后续绘制",说明真跑过;能讲出泄漏链路,说明交付过。
如果这篇文章对你有帮助,欢迎点赞、在看、转发三连。你的支持就是这个系列持续更新的动力。
「Android软件开发面试·从入门到精通」连载系列
上一篇:MeasureSpec-与测量模式:三种模式的取舍
下一篇预告:事件分发机制:从-Activity-到-View-的完整链路
有任何问题欢迎在评论区留言交流。