自定义 View 绘制与触摸冲突:从滑动卡顿到事件分发闭环

自定义 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 滚动方向常用
kotlin 复制代码
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 会被频繁调用(滚动、动画、焦点变化),所以它必须快,并且不应该有副作用。

kotlin 复制代码
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 触发重绘

更新进度后需要重新绘制:

kotlin 复制代码
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 收不到事件。

事件分发流程

  1. dispatchTouchEvent:事件入口,决定是自己处理还是传给子 View
  2. onInterceptTouchEvent:ViewGroup 专用,决定是否拦截事件
  3. onTouchEvent:实际处理事件

场景 1:水平滑动进度条嵌套在垂直 RecyclerView

用户水平滑动进度条时,RecyclerView 可能误判为垂直滚动并拦截事件。解决方式:

kotlin 复制代码
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 的滑动

kotlin 复制代码
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。

状态保存与恢复:屏幕旋转不丢数据

kotlin 复制代码
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+ 默认开启):

kotlin 复制代码
init {
    setLayerType(LAYER_TYPE_HARDWARE, null)
}

但硬件加速不支持某些绘制操作(如 drawPicture),此时需要降级:

kotlin 复制代码
setLayerType(LAYER_TYPE_SOFTWARE, null)

减少过度绘制

  • 去掉不必要的背景(默认透明时不要设 android:background)
  • 用 clipRect 限制绘制区域
  • 用开发者选项的"调试 GPU 过度绘制"检查

避免在 onDraw 里做重计算

  • 复杂路径放在 onSizeChanged 里预计算
  • 位图缩放、颜色转换等放在后台线程

可测试的自定义 View

至少覆盖:

  • wrap_content、match_parent、固定尺寸的测量结果
  • 嵌套滚动容器时的事件分发
  • 屏幕旋转后状态是否保留
  • 快速连续 invalidate 不会崩溃或 ANR

用 Robolectric 可以测量、布局和部分绘制逻辑,但触摸事件和硬件加速相关的行为需要真机或模拟器验证。

小结

自定义 View 的稳定性不是单纯的绘制技巧,而是"测量模式、事件分发、状态保存、性能优化和可测试性"的组合工程。先保证测量逻辑能适配各种父容器,再保证触摸事件不被误拦截或误消费,最后用状态保存和硬件加速让 View 在配置变更和高频刷新下仍然稳定,问题才能从偶发卡顿变成可验证的工程行为。

相关推荐
vancece1 小时前
JS实现堆 & 215.数组中的第K个最大元素
java·前端·javascript
weixin_403810131 小时前
安卓自动化脚本 HID 硬件选购指南:蓝牙HID与OTG HID区别、成本与搭建教程
android·运维·自动化·hid·安卓自动化脚本·安卓中控
lichenyang4531 小时前
NestJS + Socket.IO 鉴权、用户房间与幂等邀请
前端·后端
paopaokaka_luck1 小时前
基于springboot3+vue3+uniapp的剧本杀预约小程序(AI角色对话、协同过滤算法、地图Api、Echarts图形化分析)
java·前端·spring boot·学习·echarts
lichenyang4531 小时前
React 实时通知与异步竞态处理
前端·后端
三十而立洋1 小时前
搭建现代化 Git 提交规范:commitlint + czg + husky + lint‑staged 完整实战
前端
半个落月1 小时前
React + Vite Todo 实践:用 Axios 与 Mock 解开前后端等待
前端
qq_316837751 小时前
uniapp 安卓/H5 使用 contentEditable 属性 富文本编辑器
android·javascript·uni-app
yuqifang1 小时前
var deferred = jQuery.Deferred(); 使用示例
前端·javascript·面试