Android触摸机制与自定义 View 实战

1、深入解析 Android 摇杆控件 JoystickView

在 Android 游戏或无人机遥控等应用中,摇杆(Joystick)是一种常见的人机交互控件。它模拟了游戏手柄的摇杆操作,通过触摸滑动来控制方向与力度。virtual-joystick-android 是一个轻量级、高度可定制的开源摇杆库,代码简洁且功能完善。本文将以该库的 JoystickView 源码为核心,深入剖析 Android 触摸事件分发机制、自定义 View 的测量绘制流程,并详解如何实现一个高性能、可伸缩的摇杆控件。全文将结合源码片段,帮助读者掌握自定义 View 的高级技巧。

2、Android 触摸机制基础

在分析 JoystickView 之前,必须先理解 Android 触摸事件的核心概念。

2.1 MotionEvent 与事件类型

触摸事件的每一次交互都由 MotionEvent 对象封装,包含触摸位置(X/Y)、触摸压力、事件类型等信息。常见事件类型有:

  • ACTION_DOWN:第一个手指按下。

  • ACTION_MOVE:手指在屏幕上移动。

  • ACTION_UP:最后一个手指抬起。

  • ACTION_POINTER_DOWN:已有手指按下时,另一手指按下。

  • ACTION_POINTER_UP:多指情况下某一手指抬起。

  • ACTION_CANCEL:事件被父视图拦截或系统取消。

2.2 事件分发机制

Android 事件分发由三个关键方法协作完成:

  • dispatchTouchEvent(MotionEvent ev):负责事件分发,返回 true 表示事件被消费,否则继续传递。

  • onInterceptTouchEvent(MotionEvent ev)(仅 ViewGroup 有效):决定是否拦截事件,拦截后事件不再向下传递,直接交给自己的 onTouchEvent

  • onTouchEvent(MotionEvent ev):实际处理事件,返回 true 表示消费事件。

对于自定义 View(非 ViewGroup),通常只需要重写 onTouchEventJoystickView 继承自 View,因此直接在其 onTouchEvent 中处理所有触摸逻辑。

2.3 多点触控与指针索引

MotionEvent 支持多点触摸,通过 getPointerCount() 获取当前触控点数,getActionIndex() 获取触发当前事件的手指索引,getX(int pointerIndex) / getY(int pointerIndex) 获取特定手指的坐标。JoystickView 利用多点触控实现了双指长按回调(OnMultipleLongPressListener)。

2.4 事件坐标体系

触摸坐标分为绝对坐标(相对于屏幕)和相对坐标(相对于当前 View)。getX() 返回的是相对于当前 View 左上角的坐标。JoystickView 正是利用相对坐标来计算摇杆按钮的位置。

3、自定义 View 的基本步骤

继承 View 并实现一个完整功能的自定义控件通常需要以下步骤:

  1. 定义自定义属性 :在 res/values/attrs.xml 中声明属性,构造函数中通过 TypedArray 读取。

  2. 重写 onMeasure:测量 View 的宽高,决定尺寸。

  3. 重写 onSizeChanged:在尺寸确定后计算子元素或绘制参数(如半径)。

  4. 重写 onDraw :进行绘制,使用 CanvasPaint 等。

  5. 处理触摸事件 :重写 onTouchEvent,实现交互逻辑。

  6. 提供回调接口:将状态变化通过监听器暴露给外部。

JoystickView 严格遵循了这些步骤,下面逐一剖析。

4、JoystickView 源码深度解析

4.1 自定义属性定义与解析

JoystickViewattrs.xml 中定义了大量可配置属性(源码中未给出该文件,但从 TypedArray 读取的属性名可推断)。主要包括:

  • JV_buttonColor:按钮颜色,默认黑色。

  • JV_borderColor:边框颜色,默认透明。

  • JV_borderAlpha:边框透明度,0-255。

  • JV_backgroundColor:背景圆颜色。

  • JV_borderWidth:边框宽度。

  • JV_buttonImage:按钮图片,使用 Drawable

  • JV_buttonSizeRatio:按钮半径占控件宽高的比例,默认 0.25。

  • JV_backgroundSizeRatio:背景圆半径占控件宽高的比例,默认 0.75。

  • JV_fixedCenter:摇杆中心是否固定,默认 true。

  • JV_autoReCenterButton:松手后是否自动回中,默认 true。

  • JV_buttonStickToBorder:按钮是否始终吸附在边界上,默认 false。

  • JV_enabled:是否启用,默认 true。

  • JV_buttonDirection:限制移动方向(both/horizontal/vertical)。

构造函数中通过 styledAttributes 获取这些值并初始化对应的 Paint 对象或成员变量。例如:

ini 复制代码
mButtonSizeRatio = styledAttributes.getFraction(R.styleable.JoystickView_JV_buttonSizeRatio, 1, 1, 0.25f);

mBackgroundSizeRatio = styledAttributes.getFraction(R.styleable.JoystickView_JV_backgroundSizeRatio, 1, 1, 0.75f);

注意 getFraction 可以处理百分数(如 25%)和浮点数。

4.2 测量与尺寸计算

JoystickView 希望自身是一个正方形,因此重写 onMeasure

java 复制代码
@Override

protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {

    int d = Math.min(measure(widthMeasureSpec), measure(heightMeasureSpec));

    setMeasuredDimension(d, d);

}

measure() 方法处理 MeasureSpec 模式:

  • 如果是 UNSPECIFIED(未指定尺寸),返回默认大小 DEFAULT_SIZE = 200(像素)。

  • 否则返回 MeasureSpec.getSize(measureSpec)

这种实现保证控件始终为正方形,边长取父容器给出的宽度和高度的最小值。

4.3 尺寸变化时的参数重算

onSizeChanged 中,根据最终的宽高计算按钮半径、边框半径和背景圆半径:

ini 复制代码
int d = Math.min(w, h); // 实际边长

mButtonRadius = (int) (d / 2 * mButtonSizeRatio);

mBorderRadius = (int) (d / 2 * mBackgroundSizeRatio);

mBackgroundRadius = mBorderRadius - (mPaintCircleBorder.getStrokeWidth() / 2);
  • mButtonRadius:摇杆按钮的半径。

  • mBorderRadius:摇杆活动区域的边界半径(也是背景圆的半径)。

  • mBackgroundRadius:仅为绘制背景填充圆时减去描边宽度的一半,避免边框被覆盖。

如果设置了按钮图片,还会将 Bitmap 缩放到 mButtonRadius * 2 的大小。

4.4 绘制实现

onDraw 方法执行三层绘制:

scss 复制代码
// 1. 绘制背景圆(填充)

canvas.drawCircle(mFixedCenterX, mFixedCenterY, mBackgroundRadius, mPaintBackground);

// 2. 绘制边框圆(描边)

canvas.drawCircle(mFixedCenterX, mFixedCenterY, mBorderRadius, mPaintCircleBorder);

// 3. 绘制摇杆按钮(图片或圆形)

if (mButtonBitmap != null) {

    canvas.drawBitmap(mButtonBitmap, ...);

} else {

    canvas.drawCircle(mPosX + mFixedCenterX - mCenterX, mPosY + mFixedCenterY - mCenterY, mButtonRadius, mPaintCircleButton);

}

注意 mFixedCenterXmFixedCenterY 是 View 的中心坐标,而 mCenterX/mCenterY 是摇杆的有效中心(在固定中心模式下两者相等;在动态中心模式下,mCenterX/mCenterY 会在每次 ACTION_DOWN 时被设置到触摸点,但不会超出边界)。按钮的圆心坐标由 mPosXmPosY 确定,它们记录了相对于有效中心的偏移量。

绘制时实际按钮圆心位置为:

ini 复制代码
buttonCenterX = mPosX + mFixedCenterX - mCenterX

buttonCenterY = mPosY + mFixedCenterY - mCenterY

这个变换稍显复杂,但本质是:无论有效中心在何处(动态模式),按钮显示始终在 View 的固定中心区域平移。实际上,当 mFixedCenter = true 时,mCenterX = mFixedCenterX,公式简化为 mPosXmPosY 直接作为按钮圆心。

4.5 触摸事件处理 ------ 核心交互

onTouchEvent 是整个控件的灵魂,处理了手指滑动、边界限制、方向限制、自动回中以及多点长按检测。我们逐步解析。

4.5.1 基本禁用逻辑

mEnabled == false,直接返回 true 但不做任何移动,避免消耗。

4.5.2 根据方向限制更新坐标

ini 复制代码
mPosY = mButtonDirection < 0 ? mCenterY : (int) event.getY();

mPosX = mButtonDirection > 0 ? mCenterX : (int) event.getX();
  • 当方向限制为水平(mButtonDirection < 0),Y 坐标固定为中心,只允许 X 变化。

  • 当方向限制为垂直(mButtonDirection > 0),X 坐标固定为中心。

  • BUTTON_DIRECTION_BOTH(0),同时允许 X/Y 变化。

4.5.3 ACTION_UP 时的处理

当手指抬起时:

  • 停止线程循环(mThread.interrupt())。

  • 如果启用自动回中,调用 resetButtonPosition()mPosX/mPosY 设回 mCenterX/mCenterY,并立即通过回调通知 angle=0, strength=0。

  • 如果不自动回中,则不清零,但之后用户再次触摸时会从当前位置开始。

4.5.4 ACTION_DOWN 时的处理

每次新的触摸按下时,会启动一个独立的线程 mThread 来循环调用回调方法。该线程每隔 mLoopInterval 毫秒(默认 50ms)在主线程中执行一次 onMove。这实现了即使手指不移动也能持续发送当前摇杆状态(对于需要连续控制的应用非常有用)。

4.5.5 多点长按检测

JoystickView 提供了一个独特的 OnMultipleLongPressListener,用于检测双指长按(通常用于穿戴设备中替代传统长按退出)。相关逻辑在 switch(event.getActionMasked()) 中:

  • ACTION_POINTER_DOWN:当第二根手指按下时,利用 Handler 延迟执行 mRunnableMultipleLongPress,延迟时间为 ViewConfiguration.getLongPressTimeout()*2

  • ACTION_MOVE:当移动发生时,递减 mMoveTolerance,一旦累计移动超过 10 像素(MOVE_TOLERANCE),就取消长按回调,防止误触发。

  • ACTION_POINTER_UP:当手指数量减少到 2 以下时,移除回调。

这种设计允许双指长按作为特殊操作(如打开菜单或退出),而单指滑动依然正常控制摇杆。

4.5.6 边界限制算法 ------ 关键点

摇杆按钮不能超出 mBorderRadius 定义的圆环范围。原代码使用欧几里得距离 abs

scss 复制代码
double abs = Math.sqrt((mPosX - mCenterX)*(mPosX - mCenterX) + (mPosY - mCenterY)*(mPosY - mCenterY));

if (abs > mBorderRadius || (mButtonStickToBorder && abs != 0)) {

    mPosX = (int) ((mPosX - mCenterX) * mBorderRadius / abs + mCenterX);

    mPosY = (int) ((mPosY - mCenterY) * mBorderRadius / abs + mCenterY);

}

逻辑解析:

  • 当距离大于半径时,将点投影到边界圆上(通过缩放因子 mBorderRadius / abs)。

  • 如果启用了 mButtonStickToBorder,即使距离小于半径,也会将按钮强制吸附到边界上(此时 abs != 0 排除中心点)。这种模式适合需要离散方向控制的场景。

重要缺陷 :上述算法忽略了按钮本身的半径。当按钮半径较大时,按钮会部分超出边界的视觉效果(即用户之前遇到的问题)。正确的做法应该是 maxRadius = mBorderRadius - mButtonRadius。稍后将给出修复方案。

4.5.7 动态中心模式

mFixedCenter = false 时,每次 ACTION_DOWN 会将有效中心 (mCenterX, mCenterY) 设置为手指按下的位置,但限制在 View 范围内(通过 Math.min/max 隐含,实际代码未显式限制,因为 mPosX/mPosY 初始值为 event.getX/Y(),而 mCenterX 被直接赋值为 mPosX,可能靠近边界导致背景圆绘制超出 View?实际上 onDraw 中的 mCenterX 只影响按钮偏移,背景圆始终以 mFixedCenterX 为中心绘制,这导致动态中心模式下背景与按钮不同心,视觉上奇怪。这是库的一个设计不足,通常动态中心模式应重新绘制背景圆的位置,但作者可能认为更简单的交互是合理的,因为背景圆不可见时(透明)影响不大。)。

4.6 角度与强度计算

  • getAngle():返回从中心指向按钮的方位角,数学上使用 atan2(deltaY, deltaX),Android 坐标系 Y 轴向下,因此需要 mCenterY - mPosY 来得到标准数学角度,并转换到 0~360 度范围。

  • getStrength():计算距离 dmBorderRadius 的比值,作为百分比。

强度百分比公式:

arduino 复制代码
return (int) (100 * d / mBorderRadius);

该公式同样忽略了按钮半径,当按钮刚好在边界圆上时,d = mBorderRadius,强度为 100。但由于按钮本身会超出边界,实际应该限制为 maxRadius

4.7 回调线程与性能

JoystickView 开启了一个 Thread 循环发送 onMove 事件。这种设计虽然简单,但存在一些问题:

  • 每次 ACTION_DOWN 都会 new Thread,频繁创建线程有开销。

  • 线程中使用 Thread.sleep 精度不高,且需要处理中断。

  • 更优雅的做法是使用 Handler + RunnableChoreographer 配合帧回调。但对于简单摇杆场景,此实现足以满足需求。

4.8 坐标标准化

getNormalizedX()getNormalizedY() 方法将按钮位置映射到 0~100 之间,方便对接其他协议。这两方法考虑了按钮半径,因此在边界内返回 0 到 100 的整数。

5、常见问题与解决方案

5.1 按钮超出控件边界

如上所述,JoystickView 原始的边界限制未减去按钮半径,导致按钮图片超出背景圆。修复方法:

ini 复制代码
int maxRadius = mBorderRadius - mButtonRadius;

if (maxRadius < 0) maxRadius = 0;

if (abs > maxRadius || (mButtonStickToBorder && abs != 0 && maxRadius > 0)) {

    mPosX = (int) ((mPosX - mCenterX) * maxRadius / abs + mCenterX);

    mPosY = (int) ((mPosY - mCenterY) * maxRadius / abs + mCenterY);

}

同时修正 getStrength() 分母为 maxRadius

5.2 动态中心模式下的绘制错误

由于背景圆始终以 View 几何中心绘制,而有效中心可能偏离,导致按钮在背景圆外。解决方案:要么禁用动态中心下的背景圆绘制,要么在动态中心时重新定位背景圆中心。

5.3 多线程回调导致的 ANR 风险

在线程中直接调用 post(new Runnable()) 是安全的,但频繁的 post 可能导致消息队列积压。可以改用 onDraw 的最后一帧回调或 ValueAnimator 驱动。更简单的改进:只在移动时(ACTION_MOVE)调用回调,而不是单独开线程。

5.4 与 ViewPager 等滑动控件冲突

由于 JoystickView 会消费触摸事件,当它嵌入在滑动容器中时,可能导致父容器无法滑动。解决方案:在 onTouchEvent 中调用 getParent().requestDisallowInterceptTouchEvent(true) 来阻止父容器拦截。

6、编写自定义 View 的最佳实践总结

通过分析 JoystickView,我们可以提炼出编写高质量自定义 View 的若干原则:

  1. 属性驱动:使用自定义属性让布局文件可配置,提高复用性。

  2. 遵循测量规范 :正确处理 MeasureSpec 的三种模式,明确 wrap_contentmatch_parent 的行为。

  3. 缓存计算结果 :在 onSizeChanged 中计算半径等常量,避免在 onDraw 中重复运算。

  4. 高效绘制 :使用 PaintsetAntiAlias 平滑边缘;避免在 onDraw 中创建对象。

  5. 触摸反馈 :合理处理 ACTION_DOWNACTION_MOVEACTION_UP,及时调用 invalidate() 刷新。

  6. 边界检查:对用户输入进行限制,避免绘制超出区域。

  7. 回调优化 :使用 Handler 或线程回调时要小心内存泄漏,注意取消。

  8. 多点触控支持 :根据需求决定是否支持多指,并正确处理 ACTION_POINTER_DOWN/UP

  9. 可访问性 :添加 contentDescription 等支持。

  10. 性能调优 :避免过度绘制,使用 clipRect 或不绘制不可见部分。

7、扩展与展望

virtual-joystick-android 作为一个轻量级库,已经覆盖了绝大多数摇杆需求。但它仍有改进空间:

  • 支持触控力度的映射(压力敏感)。

  • 支持自定义背景图并跟随动态中心。

  • 改用 GestureDetector 简化多点长按逻辑。

  • 增加惯性滑动(flick)效果。

作为开发者,理解其实现原理后,可以轻松地对其扩展或修复,例如上文提到的按钮边界问题,只需修改几行代码即可完美解决。

8、结语

Android 自定义 View 是 UI 开发中的高级技能,它要求开发者深刻理解触摸机制、绘图系统以及性能调优。本文以 JoystickView 为蓝本,逐行解读其实现,并指出了其中的设计亮点与不足。希望通过这篇文章,读者不仅能够掌握摇杆控件的开发,更能举一反三,实现各种复杂的交互控件。触摸是移动设备的核心交互方式,而高质量的自定义 View 则是优秀用户体验的基石。

相关推荐
千里马学框架2 天前
一起学 Android 14:ShellTransition 屏幕旋转过程深度剖析
android·智能手机·性能优化·framework·性能·屏幕旋转·rotation
美狐美颜SDK开放平台2 天前
开发直播APP时如何接入视频美颜SDK?开发流程与注意事项
android·人工智能·计算机视觉·音视频·直播美颜sdk
AFinalStone2 天前
Android7 SystemUI源码解析(七)Keyguard锁屏模块深度解析
android·systemui
致远ccc2 天前
Google Play 上架前如何测试 App?多国家 Android 环境测试
android·app测试·googleplay·多国家应用测试
ttyyttemo2 天前
Kotlin 协程中的 Job 结构化并发与取消
android
sun0077002 天前
tbox 4g/5g切换,导致wan ip 改变,导致车机旧网络不可用。需要重启车机才行
android
其实防守也摸鱼2 天前
内网穿透与反向代理:原理、工具与实战指南
android·大数据·运维·安全·网络安全·自动化·渗透
AFinalStone2 天前
Android7 SystemUI 源码解析(四)NavigationBar 导航栏与 SystemBars
android·systemui
JMchen2 天前
属性动画原理与高级动画实现
android·kotlin·canvas
AFinalStone2 天前
Android7 SystemUI 源码解析(二)启动流程深度解析
android·systemui