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

简介: 本文剖析自定义 View 开发中绘制与触摸冲突的根源,涵盖 onMeasure 三种模式适配、onDraw 纯绘制原则、invalidate 重绘机制、事件分发拦截策略(requestDisallowInterceptTouchEvent)、状态保存恢复及性能优化要点,助力构建稳定可测的自定义控件。

自定义 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 收不到事件。

事件分发流程:

  1. dispatchTouchEvent:事件入口,决定是自己处理还是传给子 View
  2. onInterceptTouchEvent:ViewGroup 专用,决定是否拦截事件
  3. 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 在配置变更和高频刷新下仍然稳定,问题才能从偶发卡顿变成可验证的工程行为。

相关文章
|
4月前
|
SQL 人工智能 关系型数据库
AI Agent 混合检索选型:阿里云 AnalyticDB MySQL 向量+全文一站式方案
阿里云AnalyticDB MySQL版是面向AI Agent/RAG场景的一站式混合检索数据库,原生支持向量检索+全文搜索+结构化查询,单SQL实现三合一。延迟<10ms,成本降60%+,开发提效3倍,显著优于Milvus+Elasticsearch多组件架构。
460 6
|
12天前
|
人工智能 安全 API
免费千万 Tokens 体验大模型:阿里云百炼 API-Key 申请、权限管理、多平台环境配置与代码实操指南
API‑Key是访问百炼大模型服务的身份凭证,整个接入流程分为开通服务、创建密钥、配置环境变量、业务调用四个主要步骤。创建密钥重点理解**归属账号、业务空间、自定义权限**三个配置项,业务空间决定密钥能够访问哪些模型,自定义IP白名单、模型白名单可以极大提升密钥的安全等级。开发调试阶段可以使用临时环境变量,线上生产环境配置永久环境变量,不要把密钥写死进代码。针对第三方AI工具,填入API‑Key、兼容模式Base‑URL、模型名称就可以快速完成对接。同时掌握curl、Python的基础调用代码,可以快速验证接口连通性,调试业务逻辑。开发者需要重点关注密钥安全,密钥一旦泄露会带来非预期的Toke
216 0
|
1月前
|
人工智能 IDE 开发工具
全新 Qoder 线上发布会,今晚 19:00 不见不散!
9月1日19:00,Qoder线上发布会直播!聚焦全新 Qoder,产品、研发、设计三位成员深度解读,助你厘清 Qoder IDE与新 Qoder 的适用场景。锁定视频号「Qoder.ai」
223 1
|
20天前
|
开发者
DSH 怎么查版本、看更新日志?dsh --version 与 DeepSeek Harness 更新日志阅读方法
查版本两步走:dsh --version 拿本机版本,官方 Releases 页与 npm 包页拿最新版;再读懂更新日志的结构——新特性、修复与破坏性变更,看到破坏性提示先备份、看升级路径再决定动不动。
219 0
|
2月前
|
Web App开发 安全 开发者
自动化发布流程验证 2026-08-09 01:28
这是一篇用于验证自动化发布流程的测试文章,覆盖标题、正文、摘要的自动填写与发布提交。
|
2月前
|
人工智能 算法 API
千问办公收编三大智能体,跨境电商协同架构的技术实践解析
千问办公整合QoderWork、悟空、MuleRun三大Agent,打通桌面操作、云端算力与内部系统,实现从“AI陪聊”到“AI干活”的跃迁。聚焦跨境电商协同断层,以状态机驱动物流节点、规则引擎保障合规、本地化处理守护数据安全,推动企业级AI真正落地提效。(239字)
|
2月前
|
人工智能 弹性计算 运维
让数字员工走进项目协作:基于 QoderWake 的“值班助手”落地实践
QoderWake是阿里云Qoder CN系列的数字员工平台,支持7×24小时本地或云端常驻运行。其“值班助手”是面向告警响应的专用数字员工,可自动接收告警、检索知识库、辅助根因分析、生成值班纪要并协同人工处置,实现事件秒级初诊与经验持续沉淀。(239字)
|
2月前
|
弹性计算 运维 安全
阿里云国际站(云老大):解决 VPC 对等连接网络不通,从配置校验到根因定位全套排查方法
云服务器之间明明配好了 VPC 对等连接,控制台也显示“已激活”,两台 ECS 之间的 ping 包却一去不回。这种“表面通了,实际不通”的情况在阿里云上并不罕见——往往不是服务故障,而是路由表、安全组或者网络 ACL 某个环节的配置出了遗漏。把排查路径梳理清楚,比频繁重建连接更治本。
150 0
|
存储 缓存 NoSQL
跟着源码学IM(十一):一套基于Netty的分布式高可用IM详细设计与实现(有源码)
本文将要分享的是如何从零实现一套基于Netty框架的分布式高可用IM系统,它将支持长连接网关管理、单聊、群聊、聊天记录查询、离线消息存储、消息推送、心跳、分布式唯一ID、红包、消息同步等功能,并且还支持集群部署。
14175 1
|
3月前
|
消息中间件 安全 测试技术
StateFlow 与 SharedFlow:复杂页面状态管理的边界与落地
本文深入解析StateFlow与SharedFlow在复杂页面状态管理中的分工:StateFlow承载可恢复、需立即获取的UI状态(如列表数据、加载态),SharedFlow处理一次性UI事件(如导航、Toast)。倡导单一不可变UiState建模、原子更新、冷流生命周期感知共享,并结合Compose最佳实践,提升稳定性与可测性。
116 0