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 提供了 ScaleGestureDetectorGestureDetector 这两个工具类,帮我们做手势识别。

我在项目中遇到过这样一个场景:视频播放器需要支持单指拖动进度、双指缩放画面。如果用原始 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 触摸事件处理的完整链路:

SurfaceView 触摸事件处理链路 用户触摸屏幕 onTouchEvent() 主线程接收 手势识别层 GestureDetector | ScaleGestureDetector | 自定义手势 更新 RenderState 线程安全同步 渲染线程读取 最终:Canvas 应用变换矩阵 → 绘制画面

从这张图可以清楚看到:触摸事件从用户手指开始,经过主线程接收、手势识别、状态更新,最后被渲染线程读取并应用到画面上。每一步都有它的职责,缺一不可。

避坑指南

最后,我把自己踩过的坑总结一下,希望能帮你少走弯路:

常见问题 原因 解决方案
触摸事件不响应 没有设置 setOnTouchListener()onTouchEvent() 返回 false 确保返回 true,并且 SurfaceView 可点击
画面抖动/跳跃 触摸事件和渲染线程同时修改了同一个变量 使用 volatile 或 synchronized 保护共享状态
缩放时画面闪烁 缩放因子突变,没有做平滑处理 使用插值器或者限制每帧缩放变化量
多点触控混乱 没有正确处理 pointer index 和 action mask 使用 getActionMasked()getActionIndex()

我曾经在一个直播项目里,因为忘记处理 ACTION_POINTER_UP 事件,导致双指缩放后画面一直卡在缩放状态。用户反馈说「画面回不去了」,排查了半天才发现是事件状态没重置。从那以后,我每次写触摸代码都会仔细检查所有的事件分支。

好了,关于 SurfaceView 的触摸事件处理,核心内容就是这些。记住一个原则:触摸是主线程的事,渲染是子线程的事,两者通过线程安全的状态对象来沟通。把这个模型刻在脑子里,你就能写出流畅又稳定的交互体验。


公众号:蓝海资料掘金营,微信deep3321