自定义 View 绘制与触摸冲突:从滑动卡顿到事件分发闭环
自定义 View 的绘制逻辑和触摸事件处理,通常在 Demo 里运行正常,但嵌入复杂列表、ViewPager 或嵌套滚动容器后,就会出现滑动冲突、绘制卡顿、状态丢失等线上问题。本文从一个可复现的自定义进度条场景出发,拆解测量、布局、绘制、事件分发和状态保存的完整链路,给出可验证的实现策略。
先把自定义 View 的职责边界划清楚
自定义 View 要回答三个问题:
- 我需要多大空间?(onMeasure)
- 我的子 View 放在哪里?(onLayout,ViewGroup 专用)
- 我怎么画自己?(onDraw)
还有两个隐藏问题:
- 用户触摸我时应该怎么反应?(onTouchEvent、事件分发)
- 屏幕旋转或配置变更时,我的状态怎么保留?(onSaveInstanceState / onRestoreInstanceState)
很多卡顿和冲突,源于把"绘制逻辑"和"状态更新"混在一起,或者在 onDraw 里做了超出绘制范围的事情。
onMeasure 要处理三种测量模式
父容器通过 MeasureSpec 告诉你可用空间和约束:
- EXACTLY:精确尺寸,比如 layout_width="100dp" 或 match_parent
- AT_MOST:最大尺寸,比如 wrap_content
- UNSPECIFIED:不限制,ScrollView 滚动方向常用
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
val widthMode = MeasureSpec.getMode(widthMeasureSpec)
val widthSize = MeasureSpec.getSize(widthMeasureSpec)
val heightMode = MeasureSpec.getMode(heightMeasureSpec)
val heightSize = MeasureSpec.getSize(heightMeasureSpec)
val desiredWidth = (defaultWidth * resources.displayMetrics.density).toInt()
val desiredHeight = (defaultHeight * resources.displayMetrics.density).toInt()
val finalWidth = when (widthMode) {
MeasureSpec.EXACTLY -> widthSize
MeasureSpec.AT_MOST -> minOf(desiredWidth, widthSize)
else -> desiredWidth
}
val finalHeight = when (heightMode) {
MeasureSpec.EXACTLY -> heightSize
MeasureSpec.AT_MOST -> minOf(desiredHeight, heightSize)
else -> desiredHeight
}
setMeasuredDimension(finalWidth, finalHeight)
}
不调用 setMeasuredDimension() 会崩溃。wrap_content 时如果直接返回父容器给的最大尺寸,View 会填满整个可用空间,看起来和 match_parent 一样。
onDraw 只负责绘制,不要更新数据
onDraw 会被频繁调用(滚动、动画、焦点变化),所以它必须快,并且不应该有副作用。
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
// ❌ 错误:在 onDraw 里创建对象
// val paint = Paint()
// ✅ 正确:Paint 在构造函数或 init 块里创建
paint.color = progressColor
paint.strokeWidth = barHeight
val progressWidth = width * (progress / 100f)
canvas.drawLine(0f, height / 2f, progressWidth, height / 2f, paint)
}
常见错误:
- 在 onDraw 里 new 对象,导致 GC 压力和卡顿
- 在 onDraw 里读取网络数据或访问数据库
- 在 onDraw 里修改 View 的状态,导致无限递归 invalidate
Paint、Path、Rect 等绘制工具应该在初始化时创建并复用。如果需要根据尺寸调整,放在 onSizeChanged 里。
用 invalidate 和 postInvalidate 触发重绘
更新进度后需要重新绘制:
var progress: Float = 0f
set(value) {
field = value.coerceIn(0f, 100f)
invalidate() // 主线程调用
}
// 后台线程更新时用 postInvalidate()
fun updateFromBackground(newProgress: Float) {
progress = newProgress
postInvalidate()
}
invalidate 会触发整个 View 重绘。如果只有局部区域变化,可以用 invalidate(left, top, right, bottom) 减少绘制范围,但要确保坐标计算正确。
事件分发:触摸冲突的根源
当自定义 View 嵌套在 ScrollView、ViewPager 或 RecyclerView 里,用户滑动时可能被父容器拦截,导致自定义 View 收不到事件。
事件分发流程:
- dispatchTouchEvent:事件入口,决定是自己处理还是传给子 View
- onInterceptTouchEvent:ViewGroup 专用,决定是否拦截事件
- onTouchEvent:实际处理事件
场景 1:水平滑动进度条嵌套在垂直 RecyclerView
用户水平滑动进度条时,RecyclerView 可能误判为垂直滚动并拦截事件。解决方式:
override fun onTouchEvent(event: MotionEvent): Boolean {
when (event.action) {
MotionEvent.ACTION_DOWN -> {
parent.requestDisallowInterceptTouchEvent(true)
lastX = event.x
return true
}
MotionEvent.ACTION_MOVE -> {
val delta = event.x - lastX
progress += (delta / width) * 100
lastX = event.x
return true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
parent.requestDisallowInterceptTouchEvent(false)
return true
}
}
return super.onTouchEvent(event)
}
关键是 requestDisallowInterceptTouchEvent(true),告诉父容器"这个事件序列我要独占,你别拦截"。
场景 2:自定义 ViewGroup 需要拦截子 View 的滑动
override fun onInterceptTouchEvent(event: MotionEvent): Boolean {
return when (event.action) {
MotionEvent.ACTION_MOVE -> {
val dx = abs(event.x - downX)
val dy = abs(event.y - downY)
// 水平滑动距离大于垂直,拦截
dx > dy && dx > touchSlop
}
else -> false
}
}
一旦 onInterceptTouchEvent 返回 true,后续事件会直接到达自己的 onTouchEvent,子 View 会收到 ACTION_CANCEL。
状态保存与恢复:屏幕旋转不丢数据
override fun onSaveInstanceState(): Parcelable {
val superState = super.onSaveInstanceState()
val savedState = SavedState(superState)
savedState.progress = progress
return savedState
}
override fun onRestoreInstanceState(state: Parcelable?) {
if (state is SavedState) {
super.onRestoreInstanceState(state.superState)
progress = state.progress
} else {
super.onRestoreInstanceState(state)
}
}
private class SavedState : BaseSavedState {
var progress: Float = 0f
constructor(superState: Parcelable?) : super(superState)
constructor(source: Parcel) : super(source) {
progress = source.readFloat()
}
override fun writeToParcel(out: Parcel, flags: Int) {
super.writeToParcel(out, flags)
out.writeFloat(progress)
}
companion object {
@JvmField
val CREATOR = object : Parcelable.Creator<SavedState> {
override fun createFromParcel(source: Parcel) = SavedState(source)
override fun newArray(size: Int) = arrayOfNulls<SavedState>(size)
}
}
}
如果不实现状态保存,屏幕旋转后进度会重置为初始值。
性能陷阱:过度绘制与硬件加速
开启硬件加速(API 14+ 默认开启):
init {
setLayerType(LAYER_TYPE_HARDWARE, null)
}
但硬件加速不支持某些绘制操作(如 drawPicture),此时需要降级:
setLayerType(LAYER_TYPE_SOFTWARE, null)
减少过度绘制:
- 去掉不必要的背景(默认透明时不要设 android:background)
- 用 clipRect 限制绘制区域
- 用开发者选项的"调试 GPU 过度绘制"检查
避免在 onDraw 里做重计算:
- 复杂路径放在 onSizeChanged 里预计算
- 位图缩放、颜色转换等放在后台线程
可测试的自定义 View
至少覆盖:
- wrap_content、match_parent、固定尺寸的测量结果
- 嵌套滚动容器时的事件分发
- 屏幕旋转后状态是否保留
- 快速连续 invalidate 不会崩溃或 ANR
用 Robolectric 可以测量、布局和部分绘制逻辑,但触摸事件和硬件加速相关的行为需要真机或模拟器验证。
小结
自定义 View 的稳定性不是单纯的绘制技巧,而是"测量模式、事件分发、状态保存、性能优化和可测试性"的组合工程。先保证测量逻辑能适配各种父容器,再保证触摸事件不被误拦截或误消费,最后用状态保存和硬件加速让 View 在配置变更和高频刷新下仍然稳定,问题才能从偶发卡顿变成可验证的工程行为。