28、SurfaceView与触摸事件:触摸事件分发、手势识别(缩放/拖动/点击)、触摸与渲染交互、自定义手势处理
各位同学,今天我们来聊聊 SurfaceView 里一个特别容易踩坑的话题——触摸事件。
说实话,我见过不少开发者,SurfaceView 渲染玩得飞起,但一到触摸交互就翻车。为什么?因为 SurfaceView 的事件机制和普通 View 不太一样。你想想看,SurfaceView 本身是一个独立的窗口,它的事件分发路径、线程模型都和常规 View 体系有差异。今天我就把这块掰开揉碎了讲清楚。
SurfaceView 的触摸事件分发机制
先说说最基础的问题:SurfaceView 怎么接收触摸事件?
其实 SurfaceView 本身就是一个 View,它继承自 View 类。所以它天然支持 onTouchEvent() 方法。但这里有个关键点——SurfaceView 的渲染是在子线程里做的,而触摸事件是在主线程(UI 线程)分发的。这就带来了一个经典的「跨线程通信」问题。
我个人习惯的做法是:在 SurfaceView 的 onTouchEvent() 里,把触摸事件封装成一个消息,通过 Handler 或者共享变量传递给渲染线程。千万别在触摸回调里直接操作渲染数据,否则你会遇到各种诡异的并发问题。
核心要点:触摸事件在主线程,渲染在子线程。两者之间需要线程安全的通信机制。
来看一段基础代码:
public class TouchSurfaceView extends SurfaceView implements View.OnTouchListener {
private RenderThread mRenderThread;
// 使用 volatile 保证可见性
private volatile TouchEvent mPendingEvent;
public TouchSurfaceView(Context context) {
super(context);
setOnTouchListener(this);
}
@Override
public boolean onTouch(View v, MotionEvent event) {
// 在主线程接收触摸事件
// 封装后交给渲染线程处理
mPendingEvent = new TouchEvent(event);
return true; // 消费事件
}
// 渲染线程中调用
public TouchEvent pollTouchEvent() {
TouchEvent event = mPendingEvent;
mPendingEvent = null;
return event;
}
}
嗯,这里要注意:return true 表示消费了事件,否则事件会继续向上传递。我刚开始做的时候,经常忘记返回 true,结果触摸事件被父容器拦截了,折腾了半天才发现。
手势识别:缩放、拖动、点击
光拿到原始的 MotionEvent 还不够,我们需要把它解析成有意义的操作。Android 提供了 ScaleGestureDetector 和 GestureDetector 这两个工具类,帮我们做手势识别。
我在项目中遇到过这样一个场景:视频播放器需要支持单指拖动进度、双指缩放画面。如果用原始 MotionEvent 去算,代码会变得非常复杂。用系统提供的检测器就省事多了。
点击和拖动
使用 GestureDetector 可以轻松识别单击、双击、长按、拖动等手势。
public class GestureSurfaceView extends SurfaceView {
private GestureDetector mGestureDetector;
private ScaleGestureDetector mScaleDetector;
public GestureSurfaceView(Context context) {
super(context);
mGestureDetector = new GestureDetector(context, new GestureListener());
mScaleDetector = new ScaleGestureDetector(context, new ScaleListener());
}
@Override
public boolean onTouchEvent(MotionEvent event) {
// 同时交给两个检测器处理
mGestureDetector.onTouchEvent(event);
mScaleDetector.onTouchEvent(event);
return true;
}
private class GestureListener extends GestureDetector.SimpleOnGestureListener {
@Override
public boolean onSingleTapConfirmed(MotionEvent e) {
// 单击确认——播放/暂停
return true;
}
@Override
public boolean onScroll(MotionEvent e1, MotionEvent e2,
float distanceX, float distanceY) {
// 拖动——调整进度或亮度
return true;
}
}
private class ScaleListener extends ScaleGestureDetector.SimpleOnScaleGestureListener {
@Override
public boolean onScale(ScaleGestureDetector detector) {
float scaleFactor = detector.getScaleFactor();
// 缩放——调整画面大小
return true;
}
}
}
小技巧:GestureDetector 的 onSingleTapConfirmed() 和 onSingleTapUp() 有区别。前者会等待双击判定后才触发,适合做「单击播放/暂停」这种操作。后者会立即触发,适合做「点击高亮」这种即时反馈。
缩放手势的细节
缩放手势有个坑:ScaleGestureDetector 默认只处理双指缩放。但有时候用户会单指操作,这时候检测器不会触发。我曾经遇到一个 bug,用户单指滑动时画面莫名其妙缩放了——后来发现是没处理好手指数量判断。
我的建议是:在 onScale() 回调里,先检查手指数量,再做缩放逻辑。
@Override
public boolean onScale(ScaleGestureDetector detector) {
// 只处理双指缩放
if (detector.getCurrentSpan() < 50f) {
return false; // 手指太近,忽略
}
float scale = detector.getScaleFactor();
// 限制缩放范围
scale = Math.max(0.5f, Math.min(3.0f, scale));
// 更新渲染参数
return true;
}
触摸与渲染的交互
这是整个章节最核心的部分。触摸事件怎么影响渲染结果?
说白了,就是触摸事件改变了一些「状态变量」,渲染线程读取这些变量来绘制画面。常见的状态变量有:
- 平移偏移量:dx, dy,用于拖动画面
- 缩放比例:scaleFactor,用于缩放画面
- 旋转角度:rotation,用于旋转画面
- 点击位置:tapX, tapY,用于显示点击特效
我习惯把这些状态封装成一个 RenderState 对象,用 synchronized 或者 ReentrantReadWriteLock 保护起来。
public class RenderState {
private float offsetX, offsetY;
private float scale = 1.0f;
private float rotation = 0f;
public synchronized void applyScroll(float dx, float dy) {
this.offsetX += dx;
this.offsetY += dy;
}
public synchronized void applyScale(float factor) {
this.scale *= factor;
this.scale = Math.max(0.5f, Math.min(3.0f, this.scale));
}
public synchronized Matrix getTransformMatrix() {
Matrix matrix = new Matrix();
matrix.postTranslate(offsetX, offsetY);
matrix.postScale(scale, scale, centerX, centerY);
matrix.postRotate(rotation, centerX, centerY);
return matrix;
}
}
渲染线程每帧调用 getTransformMatrix() 获取变换矩阵,然后应用到 Canvas 上。这样触摸和渲染就解耦了。
警告:千万不要在渲染线程里直接调用 onTouchEvent() 或者操作 View 的 UI 状态。SurfaceView 的渲染线程和 UI 线程是并行的,直接操作会导致死锁或者界面卡死。我曾经有一次在渲染线程里调了 postInvalidate(),结果 UI 线程和渲染线程互相等待,画面直接冻住了。
自定义手势处理
有时候系统提供的手势检测器不够用。比如,我需要识别「三指同时下滑」这种自定义手势。这时候就得自己写手势识别逻辑。
我的做法是:在 onTouchEvent() 里记录手指的轨迹,然后根据轨迹特征判断手势。
public class CustomGestureHandler {
private static final int GESTURE_THREE_FINGER_SWIPE_DOWN = 1;
private float[] mStartY = new float[3];
private boolean mTracking = false;
public int handleTouchEvent(MotionEvent event) {
int action = event.getActionMasked();
int pointerCount = event.getPointerCount();
switch (action) {
case MotionEvent.ACTION_DOWN:
case MotionEvent.ACTION_POINTER_DOWN:
if (pointerCount == 3) {
// 记录三根手指的起始位置
for (int i = 0; i < 3; i++) {
mStartY[i] = event.getY(i);
}
mTracking = true;
}
break;
case MotionEvent.ACTION_MOVE:
if (mTracking && pointerCount == 3) {
float totalDelta = 0;
for (int i = 0; i < 3; i++) {
totalDelta += event.getY(i) - mStartY[i];
}
float avgDelta = totalDelta / 3;
// 判断是否下滑超过阈值
if (avgDelta > 200f) {
mTracking = false;
return GESTURE_THREE_FINGER_SWIPE_DOWN;
}
}
break;
case MotionEvent.ACTION_UP:
case MotionEvent.ACTION_POINTER_UP:
mTracking = false;
break;
}
return 0; // 未识别
}
}
你想想看,为什么我要用 ACTION_MASKED 而不是 ACTION_MASK?因为多点触控时,getAction() 返回的是低位 action 和高位 pointer index 的混合值。用 getActionMasked() 才能拿到纯粹的 action 类型。这个细节我当年调试了整整一个下午才搞明白。
知识体系总览
下面这张图总结了 SurfaceView 触摸事件处理的完整链路:
从这张图可以清楚看到:触摸事件从用户手指开始,经过主线程接收、手势识别、状态更新,最后被渲染线程读取并应用到画面上。每一步都有它的职责,缺一不可。
避坑指南
最后,我把自己踩过的坑总结一下,希望能帮你少走弯路:
| 常见问题 | 原因 | 解决方案 |
|---|---|---|
| 触摸事件不响应 | 没有设置 setOnTouchListener() 或 onTouchEvent() 返回 false |
确保返回 true,并且 SurfaceView 可点击 |
| 画面抖动/跳跃 | 触摸事件和渲染线程同时修改了同一个变量 | 使用 volatile 或 synchronized 保护共享状态 |
| 缩放时画面闪烁 | 缩放因子突变,没有做平滑处理 | 使用插值器或者限制每帧缩放变化量 |
| 多点触控混乱 | 没有正确处理 pointer index 和 action mask | 使用 getActionMasked() 和 getActionIndex() |
我曾经在一个直播项目里,因为忘记处理 ACTION_POINTER_UP 事件,导致双指缩放后画面一直卡在缩放状态。用户反馈说「画面回不去了」,排查了半天才发现是事件状态没重置。从那以后,我每次写触摸代码都会仔细检查所有的事件分支。
好了,关于 SurfaceView 的触摸事件处理,核心内容就是这些。记住一个原则:触摸是主线程的事,渲染是子线程的事,两者通过线程安全的状态对象来沟通。把这个模型刻在脑子里,你就能写出流畅又稳定的交互体验。
公众号:蓝海资料掘金营,微信deep3321