Android自定义View三关:触摸分发、绘制流程与坐标错位实战
1. 三关不是三个知识点,而是一条完整的因果链
很多人第一次接触 Android 自定义 View,都会把它拆成三块来学:触摸分发是一块,绘制流程是一块,坐标计算又是一块。分开看每块都能看懂,但一放到真实项目里就出问题——点击没反应、滑动跟手但位置偏了、明明画在中心却跑到了左上角。这些现象看起来是三个独立的问题,实际上它们是一条因果链上的三个环节。
我先把这条链说清楚。用户手指按下屏幕,触摸分发决定这个事件最终交给哪个 View 处理;View 拿到事件后,如果内容需要变化,会触发重绘,绘制流程决定像素怎么落到屏幕上;而绘制时用的坐标,和触摸事件里拿到的坐标,往往不在同一个参照系里,这就是坐标错位的根源。三关之间是串联关系,任何一关理解不到位,后面两关都会跟着出问题。
这篇内容适合两类人:一类是已经能写简单自定义 View,但遇到"点击区域不对""滑动有偏移""多指操作乱跳"这类问题不知道怎么下手的开发者;另一类是准备系统梳理自定义 View 知识体系,想把零散经验串成一条线的人。我会按照真实排查问题的顺序来讲,先讲触摸分发怎么走、再讲绘制怎么触发、最后讲坐标怎么对齐,每一关都配上我实际踩过的坑和验证过的处理方式。
需要提前说明的是,下面涉及的具体数值和阈值,比如触摸 slop 的像素值、重绘的触发条件,都是基于常见实践给出的参考值,不同设备密度和系统版本会有差异,实际使用时建议以真机实测为准。
2. 触摸分发:事件到底交给了谁
2.1 从 Activity 到 View 的三层传递
触摸事件的分发,本质是一个"从上往下问、从下往上答"的过程。起点是 Activity 的 dispatchTouchEvent,它把事件交给根 ViewGroup(比如 DecorView 里的内容容器),然后一层层往下传。每一层 ViewGroup 在 dispatchTouchEvent 里做三件事:先判断自己要不要拦截(onInterceptTouchEvent),再决定分给哪个子 View,最后如果没人要就自己处理(onTouchEvent)。
这里最容易搞混的是"拦截"和"消费"的区别。拦截是父容器说"这个事件我不往下传了,我自己处理";消费是某个 View 说"这个事件我接了,后续同序列的事件都归我"。一个事件序列从 ACTION_DOWN 开始,到 ACTION_UP 或 ACTION_CANCEL 结束,中间的所有 ACTION_MOVE 都会沿着 DOWN 时确定的路径走,不会重新分发。这一点非常关键,很多人以为每次 MOVE 都会重新找目标,其实不是。
我见过一个典型场景:一个横向滑动的 ViewPager 里嵌了一个纵向滑动的列表。手指按下时,DOWN 事件先到 ViewPager,ViewPager 的 onInterceptTouchEvent 在 DOWN 时通常返回 false,让列表先拿到事件。等手指移动超过一定距离,ViewPager 判断这是横向滑动,于是在某个 MOVE 事件里把 onInterceptTouchEvent 改成返回 true,同时给列表发一个 ACTION_CANCEL,告诉它"这个序列不归你了"。列表收到 CANCEL 后要把自己的滑动状态复位,否则会出现"滑到一半卡住"的现象。
2.2 onInterceptTouchEvent 的返回值陷阱
onInterceptTouchEvent 只在 ViewGroup 里有,View 没有这个方法。它的返回值含义是"我是否要拦截后续事件"。注意是"后续",不是"当前"。当它在某个 MOVE 返回 true 时,当前这个 MOVE 会先被转成 CANCEL 发给子 View,然后父容器从下一个事件开始自己处理。
这里有个坑我踩过:在 onInterceptTouchEvent 里根据滑动方向判断是否拦截时,如果只在 MOVE 里判断,DOWN 时返回 false 是没问题的,但要注意判断的阈值。如果阈值设得太小,轻微抖动就会触发拦截,导致子 View 的点击事件被吞掉。常见的做法是用 ViewConfiguration.get(context).getScaledTouchSlop() 拿到系统推荐的滑动阈值,这个值在不同密度设备上是不同的,比自己写死一个像素值靠谱得多。
注意:DOWN 事件返回 true 会导致子 View 完全收不到事件,点击、长按全部失效。除非你确定这个容器要独占所有触摸,否则 DOWN 一律返回 false。
2.3 requestDisallowInterceptTouchEvent 的正确用法
有时候子 View 希望父容器在某个时间段内不要拦截,比如一个可拖拽的悬浮按钮,按下后希望整个拖动过程都由自己处理。这时候可以在子 View 的 onTouchEvent 里调用 getParent().requestDisallowInterceptTouchEvent(true),告诉父容器"这段时间别拦我"。
但这个调用有个细节:它只对当前事件序列有效,一旦序列结束(UP 或 CANCEL),标志位会自动复位。而且如果父容器在 DOWN 时就已经拦截了,子 View 根本没机会调用这个方法。所以它适合用在"父容器平时不拦、但子 View 主动要求独占"的场景,不适合用来对抗一个铁了心要拦截的父容器。
我在做一个可缩放图片控件时用过这个机制:图片在双指缩放时,需要禁止外层 ViewPager 拦截,否则缩放到一半会被翻页打断。做法是在检测到第二个手指按下(ACTION_POINTER_DOWN)时调用 requestDisallowInterceptTouchEvent(true),在 ACTION_POINTER_UP 且只剩一个手指时再调回 false。实测下来这个方案在大多数机型上稳定,但要注意某些定制系统对多指事件的处理有差异,最好在真机上多测几轮。
3. 绘制:什么时候画、画在哪一层
3.1 invalidate 和 requestLayout 的分工
绘制流程的起点是 invalidate() 或 requestLayout(),但这两个方法做的事完全不同。invalidate() 只触发重绘,不重新测量和布局,适合内容变了但大小位置没变的场景,比如进度条数值更新、文字颜色变化。requestLayout() 会触发测量和布局,如果尺寸变了还要重新绘制,开销更大,适合 View 大小或位置需要重新计算的场景。
很多人分不清什么时候用哪个,我的经验是:只要 View 的宽高或相对父容器的位置没变,就用 invalidate()。比如一个自定义的圆形进度条,进度值从 30% 变成 60%,圆的半径和圆心都没变,只是弧的角度变了,这时候用 invalidate() 就够了。如果是要根据内容动态调整 View 的高度,那就得 requestLayout()。
还有一个容易忽略的点:invalidate() 必须在主线程调用。如果在子线程更新数据后直接调 invalidate(),会抛 CalledFromWrongThreadException。正确的做法是用 postInvalidate(),它内部会切到主线程。我在做下载进度显示时就犯过这个错,子线程回调里直接调了 invalidate(),结果一跑就崩,换成 postInvalidate() 后正常。
3.2 onDraw 里不该做的事
onDraw 会被频繁调用,任何在这里创建对象的行为都会加重 GC 负担。我见过有人在 onDraw 里 new Paint()、new RectF(),甚至 new 一个 Bitmap,这在低端机上会明显卡顿。正确的做法是把这些对象作为成员变量,在构造函数或 onSizeChanged 里初始化。
另一个坑是在 onDraw 里调用 requestLayout()。这会导致布局和绘制互相触发,形成死循环,界面直接卡死。如果确实需要在绘制后调整布局,应该用 post 把 requestLayout 抛到下一个消息循环里,而不是在 onDraw 里直接调。
3.3 硬件加速下的绘制差异
从 Android 3.0 开始默认开启硬件加速,绘制指令会被转成 GPU 操作。大部分情况下这是好事,但有些绘制方式在硬件加速下不支持或表现不同。比如 Canvas.drawTextOnPath 在某些版本上硬件加速支持不完整,setShadowLayer 对文本以外的内容可能不生效。
判断是否支持有个简单办法:看 View.isHardwareAccelerated() 的返回值。如果某个绘制效果在硬件加速下不对,可以在 View 级别关闭硬件加速,在构造函数里调 setLayerType(LAYER_TYPE_SOFTWARE, null)。但这会牺牲性能,只建议对个别 View 使用,不要整个界面都关。
我在做一个带阴影的自定义卡片时遇到过这个问题:setShadowLayer 在硬件加速下阴影不显示,查了半天才发现是加速模式的问题。后来改成用 Paint.setShadowLayer 配合软件层,或者干脆用 elevation 属性来实现阴影,效果更稳定。
4. 坐标错位:为什么画的和点的对不上
4.1 getX 和 getRawX 的区别
坐标错位最常见的原因是搞混了 getX() 和 getRawX()。getRawX() 返回的是相对于屏幕左上角的坐标,getX() 返回的是相对于当前 View 左上角的坐标。在 onTouchEvent 里,如果 View 本身有偏移(比如在 ScrollView 里滚动了一段距离),这两个值会差很多。
我举个具体例子。一个 View 在屏幕上的位置是 (100, 200),用户点在 View 内部 (50, 30) 的位置。这时候 getRawX() 返回 150,getRawY() 返回 230,而 getX() 返回 50,getY() 返回 30。如果你在绘制时用的是相对 View 的坐标,但触摸判断时用了 getRawX(),就会出现"点左边却触发了右边"的错位。
处理原则很简单:View 内部的绘制和触摸判断,统一用相对坐标(getX/getY);需要跨 View 或跨窗口计算时,才用绝对坐标(getRawX/getRawY)。比如做拖拽时,如果要把 View 移动到手指位置,需要把绝对坐标转换成父容器坐标系下的位置,这时候会用到 getRawX() 减去父容器在屏幕上的偏移。
4.2 滚动容器里的坐标换算
在 ScrollView 或 RecyclerView 里,View 的 getX() 是相对于自身左上角的,但 getLeft()、getTop() 是相对于父容器的。如果父容器滚动了,getTop() 不会变,但 View 在屏幕上的实际位置变了。这时候如果要做"点击位置在屏幕上的绝对坐标"计算,需要把滚动偏移加回去。
这段代码看起来简单,但在滚动容器里 getLocationOnScreen 返回的值会随滚动变化,所以每次判断都要重新获取,不能缓存。我在做一个列表项侧滑删除时就吃过这个亏:缓存了初始位置,结果列表一滚动,判断全乱了。
4.3 多指操作时的坐标基准
多指操作是坐标错位的重灾区。event.getX() 默认返回第一个手指的坐标,event.getX(index) 返回指定手指的坐标。在双指缩放时,如果只用 getX() 和 getY(),拿到的永远是第一根手指的位置,第二根手指的移动完全被忽略,缩放中心就会漂移。
正确的做法是用 getActionMasked() 区分 ACTION_POINTER_DOWN 和 ACTION_POINTER_UP,用 getActionIndex() 拿到变化的手指索引,然后分别计算两根手指的距离和中心点。
注意:
ACTION_POINTER_DOWN和ACTION_POINTER_UP必须用getActionMasked()获取,直接用getAction()会带上手指索引,判断会出错。这是很多人第一次写多指逻辑时必踩的坑。
5. 三关联动:一个可拖拽缩放控件的完整实现
5.1 需求拆解与设计取舍
把三关串起来的最好方式,是做一个同时涉及触摸、绘制、坐标的控件。我选一个"可拖拽、可双指缩放的图片查看器"作为例子,因为它同时踩到了三关的所有要点。
需求是这样的:单指拖动图片移动,双指捏合缩放,缩放以两指中心为基准,拖动和缩放都要限制在合理范围内,不能拖出边界。设计上有几个取舍:拖动用 getX() 还是 getRawX()?缩放用矩阵变换还是直接改绘制参数?边界限制在触摸时算还是绘制时算?
我的选择是:拖动和缩放都用相对坐标,通过一个 Matrix 统一管理变换,边界限制在触摸事件里实时计算。用 Matrix 的好处是绘制时直接 canvas.drawBitmap(bitmap, matrix, paint),不用手动算每个点的位置;边界限制放在触摸里是因为要在用户操作时给出即时反馈,不能等到绘制时才发现越界。
5.2 触摸分发层的处理
这个控件本身是叶子节点,不需要拦截子 View,所以 onInterceptTouchEvent 不用重写。但要注意如果它被放在 ScrollView 里,需要在双指按下时调用 requestDisallowInterceptTouchEvent(true),防止 ScrollView 把事件抢走。
5.3 绘制层的矩阵应用
绘制层要做的事很简单:把 Matrix 应用到 Canvas 上,然后画 Bitmap。但有个细节要注意,canvas.concat(matrix) 会改变后续所有绘制操作的坐标系,如果后面还要画别的元素(比如边框、水印),需要先 canvas.save() 再 canvas.restore()。
5.4 坐标错位的边界处理
边界限制是这个控件里最容易出问题的地方。因为 Matrix 变换后,图片的实际显示区域和 View 的边界不一定对齐。我的做法是先把 Matrix 应用到图片的四个角上,算出变换后的包围盒,再和 View 的边界比较,如果超出就平移回来。
这段代码里 mapRect 是关键,它把 Matrix 的变换应用到矩形上,得到变换后的实际位置。如果不做这一步,直接用原始图片尺寸判断,缩放后就会错位。我在第一次实现时就是漏了 mapRect,结果图片放大后拖到边缘还能继续拖,露出大片空白。
6. 排查坐标错位的通用思路
6.1 先确认坐标系,再确认变换顺序
遇到坐标对不上的问题,我的排查顺序是固定的:第一步,确认当前用的坐标是相对坐标还是绝对坐标,打印出来对比;第二步,确认 Matrix 的变换顺序,postScale 和 preScale 的结果完全不同;第三步,确认是否有多个变换叠加,比如父容器有滚动、自身有缩放。
打印坐标是最直接的手段。在 onTouchEvent 里把 getX()、getRawX()、getLeft()、getTop() 都打出来,和预期值对比,很快就能定位是哪一层出了问题。我一般会在关键位置加临时日志,问题解决后再删掉。
6.2 变换顺序为什么这么重要
Matrix 的 pre 和 post 系列方法,名字容易让人误解。简单说,postScale 是"在当前变换之后追加缩放",preScale 是"在当前变换之前插入缩放"。如果先平移再缩放,和先缩放再平移,结果完全不同。
举个例子:一个图片先平移 100 像素,再以原点为中心放大 2 倍。如果先 postTranslate(100, 0) 再 postScale(2, 2),实际效果是平移量也被放大了,图片会移动 200 像素。如果先 postScale(2, 2) 再 postTranslate(100, 0),平移量不受缩放影响,图片移动 100 像素。
我在做缩放时习惯用 set 重置矩阵,然后按"缩放、平移"的顺序 post 上去,这样每次都是从初始状态重新计算,不会累积误差。如果连续 post 而不重置,多次操作后矩阵会漂移,这是很多人做拖拽缩放时"越用越偏"的原因。
6.3 常见错位现象对照表
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 点击位置整体偏移 | 用了 getRawX 但绘制用相对坐标 | 统一坐标系 |
| 滚动后点击错位 | 缓存了 getLocationOnScreen 结果 | 每次重新获取 |
| 缩放后拖动偏移 | Matrix 变换顺序错误 | 检查 pre/post 顺序 |
| 双指缩放中心漂移 | 只用了 getX() 没区分手指 | 用 getX(index) |
| 多指操作跳变 | 没处理 ACTION_POINTER_UP | 补全手指增减逻辑 |
| 边界限制失效 | 没做 mapRect 变换 | 先映射再判断 |
这张表是我从实际项目中总结的,基本上覆盖了八成以上的坐标问题。遇到错位时先对照这张表,能省不少排查时间。
7. 几个我踩过的坑和验证过的处理方式
第一个坑是关于 ACTION_CANCEL 的。很多人只处理 DOWN、MOVE、UP,忘了 CANCEL。当父容器拦截事件时,子 View 会收到 CANCEL,如果不处理,滑动状态不会复位,下次触摸时会出现"从上次的位置继续滑"的诡异现象。我的做法是在 CANCEL 里做和 UP 一样的清理工作,把速度、偏移量都归零。
第二个坑是关于 getPointerCount() 的。在 ACTION_POINTER_UP 时,getPointerCount() 返回的还是抬起前的数量,但抬起的那根手指的坐标已经无效了。如果这时候用 getX(index) 去取,可能拿到的是脏数据。正确做法是用 getActionIndex() 拿到抬起的手指索引,在计算时跳过它。
第三个坑是关于性能的。在 onDraw 里做复杂计算,比如遍历大量数据点画折线图,会导致每帧耗时过长。我的处理方式是把计算结果缓存起来,只在数据变化时重新计算,onDraw 里只做绘制。如果数据量特别大,还可以考虑用 Path 预先构建好路径,绘制时直接 drawPath。
第四个坑是关于 invalidate 的调用频率。在 ACTION_MOVE 里每次都调 invalidate() 是没问题的,但如果 MOVE 事件触发频率很高(有些设备能到 200Hz),可以考虑用 postInvalidateOnAnimation(),它会把重绘对齐到屏幕刷新率,避免不必要的绘制。
最后分享一个调试技巧:如果怀疑是触摸分发的问题,可以在每个关键方法里加日志,用 Log.d 打印方法名和事件类型,跑一遍操作看日志顺序。事件分发的日志会呈现一个清晰的树状结构,哪一层拦截了、哪一层消费了,一目了然。这个笨办法在排查复杂嵌套布局的触摸问题时特别有效,比盯着代码空想快得多。
相关推荐
基于Python+Open CV的手势识别算法设计
伴随着人工智能时代的到来,人机交互的领域也逐渐成为研究的一大方向;其中,手势识别是人机交互领域的一项关键技术,自此手势识别也迎来了一波高潮,近几年无论是在消费领域、电子领域、数码领域、家电领域甚至汽车领域上,都能或多或少的见到手势识别的身影。 手势识别在设计智能高效的人机界面方面具有至关重要的作用, 目前手势识别已应用到手语识别、智能监控、到虚拟现实等各个领域,手势识别的原理都是利用各种传感器(例如红外、摄像头等)对手部的形态进行捕捉并进行建模,形成一个模型信息的序列帧,而后将这些信息序列转换为机器能够识别的相对应指令(例如打开、切换菜单、移动等)来完成控制。 采用Python的集成开发环境Pycharm进行本次课程设计,在Pycharm中进行需要库(模块)的下载,调取电脑摄像头,按帧读取摄像头采集到的头像,形态学处理,图像旋转(由于摄像头采集到的图像是镜像,需要用cv2.flip将摄像头采集到的图像进行处理),选取图片中固定位置作为手势输入,用红线画出手势识别框,基于hsv的肤色检测,进行高斯滤波,找出轮廓,求出图像中手势的凹凸点,手指间角度求取。
ROS-CV-ImageConverter:将ROS图像转换为OpenCV(cv :: Mat)类型的图像
ROS-CV图像转换器 依存关系 cv_bridge(ROS软件包)
计算机视觉图像处理-CV2与图像插值算法
用最近邻插值与双线性插值原理完成图像缩放 最近邻插值,是指将目标图像中的点,对应到源图像中后,找到最相邻的整数点,作为插值后的输。缺点:使用最近邻插值算法处理图像放大时,在图象中可能出现明显的块状效应 双线性插值,在两个方向上做三次线性插值。使用双线性插值算法处理图像放大时,在图象中可能出现马赛克 代码 import cv2 img = cv2.imread(’./xiabang.jpg’, cv2.IMREAD_UNCHANGED) #缩放比例 scale_percent = 0.3 width = int(img.shape[1] * scale_percent) height = int
基于水平集CV模型的图像分割(Matlab代码)
基于水平集CV模型的图像分割(Matlab代码),全套代码
导向滤波(guide_filter)的python实现
导向滤波(guide_filter)的python实现,可以结合暗通道;理论实现比较好的图像去噪效果。

