ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Android手势识别实战:GestureDetector与ScaleGestureDetector详解

Android手势识别实战:GestureDetector与ScaleGestureDetector详解 做Android开发这些年我经常遇到一个现象很多人写点击事件用setOnClickListener很熟练但一碰到手势识别就犯怵。双击、长按、甩动、双指缩放、手写轨迹每个都恨不得用一堆自定义判断去硬算坐标差。其实Android在手势识别这块早就给了一套很完整的API从GestureDetector到ScaleGestureDetector再到GestureOverlayView覆盖了绝大多数交互场景。这篇文章我把自己在日常开发里积累的经验、踩过的坑、以及一套可以直接抄走的实现方案完整记录下来给正在跟手势识别较劲的朋友一个参考也让你少走几趟弯路。1. 项目概述与手势识别方案选型1.1 手势识别的核心需求解析先把概念拉齐。手势识别Gesture Recognition本质上解决的是一个问题把用户手指在屏幕上的一串触摸事件按下、移动、抬起翻译成有明确语义的操作指令。比如“快速滑动并抬起”翻译成“翻页”“两个手指向外撑开”翻译成“放大画面”“按住不动超过500毫秒”翻译成“呼出快捷菜单”。这套能力在App里的应用范围极广我给几个典型的场景图片浏览器双击放大、双指缩放、左右滑动切换图片这是最经典的手势组合。阅读类App长按选中文字、上下滑动翻页、双击唤醒目录。地图/绘图类应用双指缩放地图、单指平移、快速甩动切换视角。手势解锁/自定义指令用户在锁屏界面画一个特定轨迹来解锁或自定义某个图形来触发快捷操作。录音/相机类应用长按录音、上滑取消、左右滑动切换镜头。如果你只处理其中一两个交互靠OnTouchListener里硬编码坐标判断也能凑合但一旦手势场景变多事件判断逻辑会迅速膨胀成一座屎山。手势识别器的核心价值就是把这些重复且容易出错的“坐标差值判断”封装起来给你一个语义清晰的回调接口。1.2 三条技术路线的取舍我在项目里通常会根据交互复杂度选下面三条路线之一。第一条路线GestureDetector 配合 OnTouchEvent/OnTouchListener这是最基础也最常用的方案适合单击、双击、长按、滚动、甩动这类单指基础手势。Google把这套API归类在Android Framework层从API 1就存在稳定性和兼容性都经过了十几年的验证。绝大多数业务场景走到这一层就够了。第二条路线ScaleGestureDetector 单独处理缩放手势ScaleGestureDetector是GestureDetector之外独立的一套识别器专门用于双指也可以支持多指缩放。为什么单独列出来因为缩放手势的事件处理模式和单指手势差别很大它需要同时追踪多个触摸点计算两指之间的距离和焦点变化逻辑复杂得多。把它和基础手势检测器组合使用可以让各自的回调保持纯粹互不干扰。第三条路线GestureOverlayView 配合手势库识别这是Android原生提供的一套“手写轨迹识别”方案。用户可以在一个GestureOverlayView上画出任意形状系统通过GestureLibrary和预先录入的样本图形做比对返回匹配度评分。适合做手势解锁、自定义快捷指令这类场景。它的优点是无需自己编写图形匹配算法缺点是需要预先录入样本手势且识别准确率取决于样本质量和用户手绘的稳定性。选型时我的习惯是能用系统API解决的绝不用第三方库能用简单方案解决的绝不引入重型框架。很多团队一上来就上OpenCV或者自研深度学习模型做手势识别对于90%的业务场景来说都是过度设计。系统API在小样本、实时性、包体积、电池消耗这些方面都已经做了充分优化没必要重复造轮子。2. 触摸事件分发机制与手势识别原理2.1 View层级的事件分发流程要用好手势识别你必须先理解Android触摸事件的分发机制。我不打算把dispatchTouchEvent、onInterceptTouchEvent、onTouchEvent那套委托模型从头复述一遍但有几个关键点直接决定了手势识别器能不能正常工作需要单独拎出来说。事件分发是“从外到内”的问询过程Activity把MotionEvent交给根ViewGroupViewGroup通过onInterceptTouchEvent决定是自己处理还是继续往下传给子View。子View通过onTouchEvent决定是否消费这个事件。一旦某个View返回true后续的事件序列从ACTION_DOWN开始到ACTION_UP结束都会被优先派发给它中间其他View想抢都抢不走除非父View拦截。这跟手势识别有什么关系关系很大。手势识别器本身不会自动接收事件你必须把MotionEvent喂给它通常是在某个View的onTouchEvent里调gestureDetector.onTouchEvent(event)。这个View是谁决定了你能识别哪些手势。比如你希望整个Activity都支持双击那就在Activity.dispatchTouchEvent里喂事件你希望只有某个ImageView支持双指缩放那就在这个ImageView的onTouchEvent里喂事件。还有一个容易忽略的点事件序列的完整性。手势识别依赖从按下到抬起的完整事件序列。如果某个中间环节比如父View拦截把ACTION_CANCEL插进来手势识别器收到的就是一个不连续的序列很容易出现识别失败或回调异常。我后面在常见问题章节会专门讲这个冲突处理。2.2 从MotionEvent到手势语义的映射理解GestureDetector内部原理最直观的方式是看它如何把事件流映射成语义回调。我把MotionEvent的几个核心事件跟手势语义做一个对应表MotionEvent事件手势语义GestureDetector回调ACTION_DOWN手指按下手势开始onDownACTION_UP手指抬起手势结束onSingleTapUp / onSingleTapConfirmed / onFlingACTION_MOVE手指移动onScroll / onShowPress / onLongPress快速连续DOWN-UP两次快速点击抬起onDoubleTap / onDoubleTapEvent按下不动超阈值长按onLongPress快速滑动手势甩动onFlingGestureDetector内部维护了一个状态机通过分析相邻事件之间的时间差、坐标差、速度判断当前事件序列最接近哪种手势。以双击为例它实际做的是第一次DOWN-UP完成后开启一个短时间窗口ViewConfiguration.getDoubleTapTimeout()如果在这个窗口内再次收到DOWN事件就判定为双击。关于onSingleTapUp和onSingleTapConfirmed的区别很多初学者会搞混。简单说onSingleTapUp在手指抬起瞬间就会被调用不管后面还会不会出现第二次点击而onSingleTapConfirmed要等到“双击超时时限”过去之后确定没有第二次点击了才会被调用。如果你同时监听了单击和双击应该用onSingleTapConfirmed来做单击响应否则每次点击都会先触发一次“假单击”等双击超时后真正该触发单击的时候反而错过了时机。同样需要关注的是触摸判定阈值。Android在ViewConfiguration里定义了几个关键常量getScaledTouchSlop()判定为“移动”而不是“抖动”的像素距离、getScaledDoubleTapSlop()两次点击之间允许的最大位移、getLongPressTimeout()长按触发时间、getDoubleTapTimeout()双击触发间隔。理解这几个值你就能解释很多“为什么我觉得我已经长按了却没触发”之类的问题——不是代码写错了而是你的手指移动距离超过了TouchSlop系统认为这是一个滚动手势而不是长按手势。3. 核心API详解与实操要点3.1 GestureDetector单击、双击、长按、滚动、甩动一次搞定我先给出一份完整的GestureDetector实践模板。这里用Kotlin写代码可以直接跑到你的Activity或者自定义View里。class GestureActivity : AppCompatActivity(), GestureDetector.OnGestureListener, GestureDetector.OnDoubleTapListener { private lateinit var gestureDetector: GestureDetector override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_gesture) gestureDetector GestureDetector(this, this) // 关键同时监听单击和双击时必须设置OnDoubleTapListener gestureDetector.setOnDoubleTapListener(this) // 把事件喂给手势识别器如果希望整个页面都支持手势可以重写onTouchEvent // 也可以放在某个具体View的setOnTouchListener里 } override fun onTouchEvent(event: MotionEvent): Boolean { // 手势识别器消费事件后Activity默认不再处理这里按需取舍 return gestureDetector.onTouchEvent(event) || super.onTouchEvent(event) } // ---------- OnGestureListener ---------- override fun onDown(e: MotionEvent): Boolean { // onDown必须返回true表示我消费了这个按下事件后续事件序列才会继续传进来 return true } override fun onShowPress(e: MotionEvent) { // 手指按下未移动、未抬起时间已接近长按阈值可用于做按压视觉反馈 } override fun onSingleTapUp(e: MotionEvent): Boolean { // 手指抬起但还不能确认是单击还是双击的前奏 return true } override fun onScroll(e1: MotionEvent?, e2: MotionEvent?, distanceX: Float, distanceY: Float): Boolean { // 手指在屏幕上滑动e1是起始事件e2是当前事件 // distanceX / distanceY 是两次回调之间的位移量注意是“上一次”到“这一次”的差值 return true } override fun onLongPress(e: MotionEvent) { // 长按触发手指仍在屏幕上 } override fun onFling(e1: MotionEvent?, e2: MotionEvent?, velocityX: Float, velocityY: Float): Boolean { // 快速滑动并抬起velocityX/velocityY是甩动速度单位px/s return true } // ---------- OnDoubleTapListener ---------- override fun onSingleTapConfirmed(e: MotionEvent): Boolean { // 确认是单击双击超时窗口内没有第二次按下 return true } override fun onDoubleTap(e: MotionEvent): Boolean { // 检测到双击 return true } override fun onDoubleTapEvent(e: MotionEvent): Boolean { // 双击期间的DOWN/MOVE/UP都会回调到这里一般可忽略除非要监听双击拖拽 return true } }这段代码有三处细节要重点理解。第一onDown必须返回true。很多人第一次用手势识别器发现只回调了onDown后面的其他回调一个都不来原因就在这里。onDown返回false等于告诉系统“我不消费这个按下事件”那后续的ACTION_MOVE和ACTION_UP都不会再派发给这个View识别器自然就废了。第二onScroll里的distanceX/distanceY是“增量”而不是“总位移”。有些场景比如跟随手指拖拽View你直接拿e2.rawX - e1.rawX算总位移会得到正确结果但如果你在onScroll里累加distanceX一定要区分好“每次回调的增量”和“相对于起点的总位移”这两个概念很多列表拖拽卡顿、跳动的问题都是在这个地方算错了。第三同时监听单击和双击时务必设置setOnDoubleTapListener然后所有“单击”操作放到onSingleTapConfirmed里做。我见过不少同事图省事在onSingleTapUp里写了单击逻辑结果双击时也先触发了单击响应交互表现非常诡异。记住口诀要监听双击单击就别用onSingleTapUp用onSingleTapConfirmed。3.2 ScaleGestureDetector双指缩放的正确打开方式ScaleGestureDetector是我见过被用错次数最多的一个API。最常见的问题是拿它去和GestureDetector同时监听事件结果双指缩放时手指稍微动了一下GestureDetector就把事件消费走了缩放根本跑不起来。正确的组合方式是两个识别器依次接收事件缩放识别器优先基础手势识别器兜底。我给一段标准写法class ScaleableImageView(context: Context, attrs: AttributeSet?) : AppCompatImageView(context, attrs) { private val scaleDetector ScaleGestureDetector(context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() { override fun onScale(detector: ScaleGestureDetector): Boolean { val scaleFactor detector.scaleFactor val focusX detector.focusX val focusY detector.focusY // 以双指焦点为中心进行缩放平移量会自然跟随 scaleMatrix.postScale(scaleFactor, scaleFactor, focusX, focusY) imageMatrix scaleMatrix return true } }) private val gestureDetector GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() { override fun onScroll(e1: MotionEvent?, e2: MotionEvent?, distanceX: Float, distanceY: Float): Boolean { // 缩放手势过程中双指都放下时GestureDetector的onScroll也可能触发 // 这里用pointerCount做隔离只有单指时才做平移 if (e2 ! null e2.pointerCount 1) return false val dx e2?.x?.minus(e1?.x ?: 0f) ?: 0f val dy e2?.y?.minus(e1?.y ?: 0f) ?: 0f scaleMatrix.postTranslate(dx, dy) imageMatrix scaleMatrix return true } }) private val scaleMatrix Matrix() override fun onTouchEvent(event: MotionEvent): Boolean { // 先喂给缩放识别器 scaleDetector.onTouchEvent(event) // 再喂给基础手势识别器 gestureDetector.onTouchEvent(event) return true } }这段代码里有一个非常关键的细节在onScroll里做了一个pointerCount判断。因为双指缩放时两个手指都会产生移动事件GestureDetector的onScroll也会被回调。如果不做隔离缩放的同时会发生平移画面就会飘。这个坑我踩过排查了很久才发现是两个识别器互相干扰造成的。另外ScaleGestureDetector内部自己维护了有效的手指数判断如果只有一根手指放下它会认为缩放状态不成立不会回调onScale。所以你不用太担心单指滑动时它会误触发。但有一点要注意它默认实现的是“比例缩放”而不是“绝对缩放”。什么意思如果你在onScale里直接拿detector.scaleFactor去postScale多次累积之后缩放值会线性叠加。如果你想要一次性缩放到某个比例需要在每次回调时重置矩阵或者保存一个基准值按“当前矩阵×新比例”的方式计算。我还建议在onScaleBegin和onScaleEnd里面做一些UI相关的控制比如检测到开始缩放时隐藏操作栏、放大时显示缩略图等。这些回调的调用时机和onScale是配套的用起来非常顺手。3.3 GestureOverlayView手写轨迹与自定义手势库GestureOverlayView是很多开发者不太熟悉但在某些场景非常实用的一个组件。它的工作方式有两个阶段录入训练和识别预测。先说录入。你把GestureOverlayView放到界面上用户在上面自由绘图你监听OnGesturePerformedListener拿到用户画完的Gesture对象然后调用GestureLibrary.addGesture(name, gesture)和gestureLibrary.save()把它存到手势库中。再说识别。同样在OnGesturePerformedListener回调里调用gestureLibrary.recognize(gesture)会返回一个ArrayListPrediction每个Prediction包含一个手势名称和评分值。一般我以score 2.0作为识别成功的阈值这是一个经验值不是官方标准具体多少合适要看你的样本质量和用户操作习惯。这里放一个标准实现示例// 布局文件 // android.gesture.GestureOverlayView // android:idid/gesture_overlay // android:layout_widthmatch_parent // android:layout_height200dp // android:gestureStrokeTypemultiple // android:gestureColor#8800BFFF // android:eventsInterceptionEnabledtrue // android:gestureStrokeWidth6dp / class GestureOverlayActivity : AppCompatActivity() { private lateinit var gestureLib: GestureLibrary override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_gesture_overlay) // 从资源文件加载预置的手势库或者也可以从SD卡加载 gestureLib GestureLibraries.fromRawResource(this, R.raw.gestures) if (!gestureLib.load()) { Log.e(GestureOverlay, 手势库加载失败) } val gestureOverlay findViewByIdandroid.gesture.GestureOverlayView(R.id.gesture_overlay) gestureOverlay.addOnGesturePerformedListener { _, gesture - val predictions gestureLib.recognize(gesture) if (predictions.isNotEmpty() predictions[0].score 2.0) { when (predictions[0].name) { open_door - openTheDoor() cancel - cancelSomething() } } else { toast(未识别手势) } } } }几点使用心得gestureStrokeTypemultiple允许用户在一个GestureOverlayView上连续绘制多个笔画适合录入由多段线组成的手势。如果设置成single用户每次抬手就会清除之前的绘制。eventsInterceptionEnabled表示是否拦截发给底下子View的事件。如果你把GestureOverlayView放在ScrollView里必须仔细处理这个属性否则滑动冲突会很严重。我的习惯是设置成true让手势区域完全独立处理事件。手势识别对样本质量非常敏感。录入时要求用户尽量以一致的速度、大小、旋转方向绘制同时最好录制3个以上变体不同角度、不同大小。我在实际项目里发现用户坐着画和站着画出来的轨迹差异都很大所以只靠一两个样本识别率根本不够。4. 完整实操一个可复用的双指缩放滑动翻页案例4.1 场景设计与数据准备下面我做一个完整案例用一个自定义ViewGroup承载图片支持双指缩放、单指平移、双击放大缩小、滑动切换图片。这基本上是图片查看器、画廊类App的核心交互集合做完这个很多场景都能直接套用。先设计场景一个GestureImageView extends AppCompatImageView内部同时持有ScaleGestureDetector和GestureDetector重写onTouchEvent把事件分发给两个识别器。外部用一个ViewPager2承载多个GestureImageView模拟滑动切换图片。注意ViewPager2本身有自己的横滑手势手势识别器如果不做拦截图片放大的时候左右滑动会和翻页冲突。这个场景选型的关键在于双指缩放时需要禁用ViewPager2的翻页单指平移时也需要区分是“图片平移”还是“翻页滑动”。我的方案是在onScaleBegin时通知父容器禁止翻页在onScaleEnd时恢复在单指拖动时根据图片当前的缩放状态决定是否允许父容器拦截。4.2 识别器装配与事件分发设计核心代码我已经在3.2节给出了一部分这里把完整装配逻辑补齐包括双击切换缩放等级class GestureImageView JvmOverloads constructor( context: Context, attrs: AttributeSet? null, defStyleAttr: Int 0 ) : AppCompatImageView(context, attrs, defStyleAttr) { // 矩阵和缩放范围控制 private val drawingMatrix Matrix() private var minScale 1f private var maxScale 5f private var currentScale 1f // 缩放手势识别器 private val scaleDetector ScaleGestureDetector(context, object : ScaleGestureDetector.SimpleOnScaleGestureListener() { override fun onScaleBegin(detector: ScaleGestureDetector): Boolean { parent?.requestDisallowInterceptTouchEvent(true) return true } override fun onScale(detector: ScaleGestureDetector): Boolean { val factor detector.scaleFactor val newScale currentScale * factor // 限制缩放范围防止图片被缩到无限大或无限小 if (newScale minScale || newScale maxScale) return false drawingMatrix.postScale(factor, factor, detector.focusX, detector.focusY) currentScale newScale imageMatrix drawingMatrix return true } override fun onScaleEnd(detector: ScaleGestureDetector) { parent?.requestDisallowInterceptTouchEvent(false) } }) // 基础手势识别器双击 单指平移 private val gestureDetector GestureDetector(context, object : GestureDetector.SimpleOnGestureListener() { override fun onDown(e: MotionEvent): Boolean true override fun onDoubleTap(e: MotionEvent): Boolean { // 双击放大/缩小 val targetScale if (currentScale 2f) 1f else 3f drawingMatrix.postScale( targetScale / currentScale, targetScale / currentScale, e.x, e.y ) currentScale targetScale imageMatrix drawingMatrix return true } override fun onScroll(e1: MotionEvent?, e2: MotionEvent?, distanceX: Float, distanceY: Float): Boolean { if (e2?.pointerCount ! null e2.pointerCount 1) return false drawingMatrix.postTranslate(-distanceX, -distanceY) imageMatrix drawingMatrix return true } }) override fun onTouchEvent(event: MotionEvent): Boolean { scaleDetector.onTouchEvent(event) gestureDetector.onTouchEvent(event) return true } }这段代码可以跑通基础的双指缩放、双击缩放、单指平移。值得注意的地方有三个第一个是parent.requestDisallowInterceptTouchEvent(true)的使用。这个方法的作用是告诉父View“这一系列触摸事件我自己处理你不要拦截”。我在onScaleBegin里强制禁止父容器拦截保证缩放过程中不会被ViewPager2或其他父布局抢走事件。这里很容易犯的错误是只在onScale里调用但那时父布局可能已经拦截了事件为时已晚。第二个是onScale里的缩放范围控制。我通过newScale的越界判断在达到最大或最小缩放时直接返回false不再修改矩阵。有些实现是先postScale再检查当前比例如果越界就恢复上一帧的矩阵逻辑绕一圈还容易出bug不如在计算时直接拦截。第三个是onScroll中的pointerCount判断。我在3.2节提到过这一点实际项目里双指缩放的同时两个手指相对位置的变化会导致GestureDetector也认为这是一次滚动导致画面在缩放的同时发生错位的平移。加上这个判断以后只有单指移动才会走平移逻辑。4.3 完整集成与调试记录把GestureImageView放到ViewPager2里使用时还需要处理一个边界情况图片处于原始缩放比例scale1时单指平移操作应该交还给ViewPager2做翻页图片放大后单指平移应该优先移动图片。这个判断我一般这样实现override fun onScroll(e1: MotionEvent?, e2: MotionEvent?, distanceX: Float, distanceY: Float): Boolean { if (e2?.pointerCount ! null e2.pointerCount 1) return false // 图片未放大时不消费横向滑动事件交给ViewPager2处理翻页 if (currentScale 1f) return false drawingMatrix.postTranslate(-distanceX, -distanceY) imageMatrix drawingMatrix return true }这里把currentScale 1f时onScroll返回false事件交给父容器ViewPager2就能正常响应横滑翻页。图片放大之后返回true表示自己消费了滑动事件翻页操作自然失效。说到调试我强烈建议你在onTouchEvent里把每个MotionEvent的action和pointerCount打点出来观察事件流。尤其是双指缩放时你会看到ACTION_POINTER_DOWN和ACTION_POINTER_UP以及ACTION_MOVE里pointerCount2的状态。如果发现缩放识别不流畅先看日志里有没有ACTION_CANCEL八成是父容器拦截事件了。我在真机调试时发现荣耀、小米等不同机型的触摸采样率差异很大高端机每秒能上报300多个ACTION_MOVE事件低端机可能只有60个。同样是快速滑动不同设备上识别的velocity差异巨大。所以onFling的判定阈值不要写死建议直接用ViewConfiguration.get(context).scaledMinimumFlingVelocity来做基准或者简单点把阈值设置在800到1500之间再根据线上反馈微调。5. 常见问题与排查技巧实录5.1 事件冲突手势识别与列表滑动打架这是我在社区回答问题被问得最多的一类。“为什么我把GestureDetector放在RecyclerView的item里横滑手势总是触发不了”或者反过来“为什么列表竖向滑动时还会触发长按”先说长按和滑动冲突。Android的长按判定依赖手指的位移不超过TouchSlop并且时间超过LongPressTimeout。如果你长按的时候手指轻微移动了20像素在有些设备上系统会认为这是滚动而不是长按。解决这类问题没有银弹我的建议是对列表项的自定义监听尽量在OnTouchListener里自己计算按下坐标和时间不要裸用GestureDetector做长按。列表滚动事件会触发ACTION_CANCELGestureDetector的长按回调会被取消掉表现不一致。如果你确定要在列表项上用GestureDetector务必在RecyclerView.addOnItemTouchListener的层面去拦截而不是单纯在item的onTouchEvent里处理。RecyclerView默认拦截竖滑事件item自身很难拿到完整的移动事件序列。再说横滑和竖滑冲突。可以用requestDisallowInterceptTouchEvent解决我在4.2节已经展示过。这里补充一个临时方案在自定义View的onInterceptTouchEvent里按“横向位移大于纵向位移”来决定是否拦截。这个方案不需要手势识别器逻辑直白适合简单场景。以下是一个通用的“横竖滑判定”模板可以作为参考override fun onInterceptTouchEvent(ev: MotionEvent): Boolean { when (ev.actionMasked) { MotionEvent.ACTION_DOWN - { lastX ev.x lastY ev.y isIntercepted false } MotionEvent.ACTION_MOVE - { val dx abs(ev.x - lastX) val dy abs(ev.y - lastY) if (dx dy dx touchSlop) { isIntercepted true } } } return isIntercepted }5.2 灵敏度调节与回调时序的坑很多开发者抱怨手势识别“不灵敏”。我归纳出了三个最常见的导致“不灵敏”的根源。第一个根源是onDown返回了false。前面强调过这里再说一次onDown返回false后续事件全部中断你感觉到的“不灵敏”其实是指挥链断了。第二个根源是父View提前调用了requestDisallowInterceptTouchEvent(false)或者子View在ACTION_UP之前收到了ACTION_CANCEL。这种问题排查起来很隐蔽建议在onTouchEvent里把ACTION_CANCEL打日志看是不是父容器搞的鬼。第三个根源是采样率。GestureDetector在低采样率设备上计算出的滑动速度和加速度偏差会变大。如果你用手势做翻页或者惯性滚动建议对velocity做一次低通滤波不要直接拿原始值使用。简单做法是保存最近3次速度取平均这样即便某一帧采样出问题整体影响也不大。回调时序方面我再提一个坑GestureDetector的onFling触发时e1起始事件和e2当前事件的坐标差已经很可能不是最终的完整位移了。如果你要同时做“滑动跟随”和“甩动惯性”需要在onScroll里累加位移在onFling里只处理速度不要再次累加位移否则会出现“跳一下”的视觉bug。5.3 性能优化与内存泄漏预防手势识别本身的计算量很小真正的性能压力主要在响应手势时做的UI操作上。比如在onScroll里直接更新整个页面的位置、在onScale里重绘复杂的自定义View这些操作如果过于频繁就会导致掉帧。我的优化经验可以总结为三条手势回调里不要做对象分配。onScroll、onScale每秒可能回调几十甚至上百次每次回调都new一个对象GC就会频繁发生肉眼可见的卡顿。矩阵、Bitmap、数组这类对象尽量复用。需要频繁更新的View考虑用SurfaceView或者TextureView。手势识别加图片缩放这种场景ImageView的setImageMatrix在低端机上性能不够理想我自己在做一个大图预览功能时把渲染切到TextureView上流畅度提升非常明显。注意手势识别器的生命周期。GestureDetector本身不持有Activity引用但如果你在匿名内部类里访问了Activity的成员变量就会隐式持有外部类引用。在自定义View里问题不大但在Activity里创建GestureDetector并传入匿名监听器时如果Activity被销毁而手势识别器还挂在某个未被回收的全局对象上就会造成内存泄漏。建议在onDestroy里及时移除相关回调。最后补一个GestureOverlayView的性能建议它的绘制和识别都在主线程手势轨迹比较长的时候绘制开销明显上升。如果你的页面上还有多个动画在跑很容易出现“画完一笔卡一下”的情况。解决思路是把GestureOverlayView放到一个独立的、相对简单的层级里避免它和复杂布局同层绘制。6. 多指复杂手势的扩展思路6.1 自定义手势识别的方向从传感器到坐标序列有时候系统API覆盖不了业务需求。比如要识别“画出一个圆形”“画出一个对勾”GestureOverlayView可以解决但要识别“设备被翻转”“手机被拿起”这类基于传感器的手势就需要走SensorManager了。实际产品里比较常见的扩展方向有三个基于触摸坐标序列的模式匹配、基于加速度计/陀螺仪的姿态识别、基于摄像头光流的空中手势。在Android原生框架下性价比最高的扩展是第一种自己解析MotionEvent坐标序列用简单的特征匹配算法判断手势。比如要识别“画圆”可以维护一个点的队列计算这些点相对于起始点的极坐标如果角度从0变化到360度且半径波动不大就判定为画了一个圆。这种做法的难点在于起点和终点的验证以及旋转不变性。用户可能顺时针画也可能逆时针画可能从左上角开始也可能从右下角开始。一个稳妥的方案是先把手势坐标序列归一化平移、缩放再用动态时间规整DTW和模板做相似度对比。DTW在实时性要求不高的场景下足够用代码量也不大我建议有兴趣的读者把它当作升级GestureOverlayView识别率的备选方案。6.2 与Jetpack Compose手势API的对照如果你的新项目已经在用Jetpack Compose那手势识别的写法会有一个较大的变化。Compose提供了一组Modifier层面的手势扩展比如clickable、combinedClickable、draggable、transformable、detectTransformGestures等。这些API的底层仍然依赖MotionEvent、GestureDetector那套机制但使用方式变成了声明式。我简单列一下对应关系传统View手势Compose手势APIOnClickListenerModifier.clickableGestureDetector.onDoubleTapModifier.pointerInput detectTapGestures(onDoubleTap)GestureDetector.onLongPressModifier.pointerInput detectTapGestures(onLongPress)ScaleGestureDetectorModifier.transformableGestureDetector.onScrollModifier.draggable / detectDragGesturesGestureOverlayView无直接对应需要自定义PointerInput如果你从传统View迁移到Compose我的建议是不要急着把所有手势逻辑都重写。先把手势识别的“语义”抽出来比如onScaleChanged、onFlingCompleted这样的抽象接口底层用传统API还是Compose API实现都可以在不改动业务层的情况下替换。有一点要提醒Compose的detectTapGestures对双击和三击的监听内置了时间窗口管理但它对事件分发的处理和传统View完全不同。在LazyColumn里使用draggable你会发现纵向拖拽和列表滚动的冲突比传统View更隐蔽因为LazyColumn自己也是一个手势处理器。解决办法通常是使用Modifier.pointerInput配合awaitEachGesture自己管理事件序列而不是直接用draggable。个人心得与最后的一些叮嘱手势识别这块我做了四五年最大的体会是它不像业务代码那样“逻辑对了就行”它非常依赖真实的硬件表现和用户行为。同一个手势识别器在开发机模拟器上一切正常到了不同品牌的真机上表现可能完全不同。所以我的习惯是手势识别的核心参数不要写死全部做成配置项。TouchSlop、长按时间、双击间隔、甩动速度阈值、缩放比例范围这些尽量从ViewConfiguration取默认值再提供调试入口让测试同事可以灵活调整而不是每次改参数都要重新打包。另外一个很重要的经验是手势识别一定要有失败反馈。用户画了一个手势识别器给出了错误结果或者根本没有触发任何回调这种体验是最糟的。我后来做一个手势解锁功能时强制要求“识别失败时震动红框提示”上线之后用户投诉率一下子降低了六成。系统API识别置信度不那么高的时候一个诚实的“我没看懂”比一个错误的“我懂了”要友好得多。如果你正在用第三方手势库或者准备自研识别算法切记先回到系统API把基础事件流吃透。我见过太多人连ACTION_POINTER_DOWN和ACTION_DOWN都分不清就急着上机器学习模型最后识别率的瓶颈往往不是算法而是事件采集阶段就已经脏了。先把MotionEvent这套机制弄明白再去追高级玩法路会顺很多。
返回列表