做移动端项目绕不开的一个坎就是手势交互,尤其双指缩放、双指旋转,在图片浏览器、地图、AR 场景里几乎成了标配。我在这类需求上踩过不少坑,也沉淀了一套比较稳的写法,今天把 Unity 双指触控以及多指触摸的代码原理一次性拆清楚。文章不会只贴代码,还会解释每段代码背后的判断逻辑,比如为什么用增量而不是绝对距离、为什么处理旋转角度要归一化、为什么一定要跟踪 fingerId,这些才是真机上稳定运行的关键。
适合刚接触触摸输入的 Unity 开发者,也适合那些已经写完单指功能、想扩展成完整手势库的朋友。你会看到一套可以直接抄到项目里的手势基类,以及我在真机调试时碰到的各种问题和解法。看完之后,你不仅能复制粘贴,还能在面试或需求评审时把每条技术的取舍原因讲明白,这才是做手势功能最有价值的部分。
1. 双指触控的基础:先搞懂 Input.touches 怎么工作
1.1 屏幕上的一根手指,Unity 怎么读到
当手指碰到屏幕,Unity 底层会把触摸信息打包成一个 Touch 结构体,存放在每帧更新的 Input.touches 数组里。每个 Touch 包含 position(屏幕像素坐标)、deltaPosition(这一帧相对上一帧的位移)、fingerId(手指唯一编号)以及 phase(触摸阶段)等字段。很多新手直接用一个布尔值 Input.GetKey,或者拿鼠标事件去糊弄触摸,到了真机上发现要么没反应、要么一卡一顿,就是因为没有理解 Touch 的生命周期。
整个触摸过程被 Unity 拆成了五个阶段:Began、Moved、Stationary、Ended、Canceled。手指落下时是 Began,按住不动每帧保持 Stationary,滑动时是 Moved,抬起时是 Ended,被系统打断时则触发 Canceled。这五个阶段构成了从手指落下到抬起全过程的骨架。我在实际项目里会把自定义手势类也按这五个阶段来组织,而不是在 Update 里无脑判断 Input.touchCount,原因很简单:手指按住的节奏不同,比如用户先放一根手指再放第二根,两指之间有时间差,如果只判断 touchCount >= 2,你无法知道变化发生在哪根手指上。
用阶段控制的好处是,你可以很自然地区分“第二根手指刚落下”和“两根手指同时在移动”这两种状态。前者往往需要重新初始化手势参数,后者才是真正执行缩放旋转的时机。很多手势库的 bug 就出在这个切换点:第二根手指落下时,上一帧的距离还是默认值,如果你没做初始化直接拿它算 delta,就会多出来一个毫无道理的跳变。所以我在代码里凡是看到“上一帧”这样的状态,都会写一个显式的初始化逻辑,你后面会反复看到这个模式。
1.2 别用数组下标记住手指
这是双指触控最容易翻车的地方。Input.touches 数组的长度会变化,顺序也不是固定的,尤其是用户手指离开时,剩下那根手指可能从下标 0 变成下标 1。如果你在代码里写 Touch t1 = Input.GetTouch(0); Touch t2 = Input.GetTouch(1); 并且把 t1 当成“第一根手指”,那么当用户抬起第二根手指时,你的 t1 就会突然变成原来那根,位移数据和手指身份对不上,缩放、旋转都会出现莫名其妙的抽动。
正确做法是始终用 fingerId 来跟踪,而不是用索引。我在实际项目里用一个 Dictionary<int, Vector2> 保存每根手指上一次的位置,Began 时加入,Ended 或 Canceled 时移除。这样即使数组顺序变了,我也能根据 fingerId 找到同一根手指的历史数据,双指手势就不会因为某根手指抬起而错乱。这个小细节,真机上一旦出问题,表现就是“缩放过程中突然抽动一下”,而且很难复现,所以我一开始就重点提醒。
private Dictionary<int, Vector2> lastPositions = new Dictionary<int, Vector2>(); void Update() { foreach (Touch touch in Input.touches) { switch (touch.phase) { case TouchPhase.Began: lastPositions[touch.fingerId] = touch.position; break; case TouchPhase.Moved: case TouchPhase.Stationary: if (lastPositions.ContainsKey(touch.fingerId)) { Vector2 delta = touch.position - lastPositions[touch.fingerId]; lastPositions[touch.fingerId] = touch.position; // 这里就能拿到任意手指这一帧的真实位移 delta // 可以根据 delta 实现单指拖拽、三指拼合等逻辑 } break; case TouchPhase.Ended: case TouchPhase.Canceled: lastPositions.Remove(touch.fingerId); break; } } }有人可能会问,直接用 touch.deltaPosition 不就行了吗?确实可以,deltaPosition 是引擎帮你算好的两个点之间的位移。但我习惯自己维护,因为当你需要在同一帧里同时处理多根手指之间的相对关系时,自己那份位置记录往往比引擎暴露出来的字段更可控。比如双指缩放的“上一帧距离”,引擎不会给你算,你只能自己存。
1.3 为什么不能只用鼠标模拟触摸
在 Unity 编辑器里测试,很多人会顺手用 Input.GetMouseButton 模拟点击。这套代码到了 Android 真机上就是空的,因为鼠标事件和触摸事件是完全不同的两条路径,触摸支持多点,鼠标天然只有一个点。Unity 提供了一个 Input.simulateMouseWithTouches 选项,用来让触摸事件模拟鼠标事件,方便 Unity 编辑器调试 UI,但它不是帮你把鼠标逆变成触摸,只是把触摸转换成鼠标事件,方向反了。
如果你确实需要在编辑器里调双指手势,建议用 Unity 自带的 Device Simulator 窗口,在 Window > General > Device Simulator 可以打开,它能模拟带触摸屏的手机模型,支持两根手指同时触碰。我个人的习惯是,在编辑器里先把逻辑跑通,发布到真机前再完整做一个手势回归测试。因为模拟环境下的输入延迟、触摸精度和真机有本质差别,特别是双指缩放这类对 delta 敏感的功能,编辑器里看起来平滑,真机上可能抖成一团。
如果你在一个需要支持快捷键的编辑器工具里,也可以按住 Ctrl 然后用鼠标左键模拟第一根手指、按住 Alt 模拟第二根手指,但那只适合临时调试,不适合写到正式代码里。正式发布包必须走 Input.touches 的路径,不要给测试机和编辑器做两套分支,否则很容易出现“编辑器里没问题、真机上全崩”的尴尬。
2. 双指缩放与旋转:一段可以抄的完整实现
2.1 双指缩放:增量法为什么比绝对距离稳
双指缩放的核心思路不复杂:记录两根手指之间的距离,当距离变大时放大,距离变小时缩小。但这里有一个关键选择:是直接把当前距离当成缩放值,还是把距离的变化量累加到已有缩放值上?我明确推荐后者。
如果直接把距离当成 scale,比如 scale = distance * 0.01f,那么用户手指放上去的一瞬间,distance 可能是几百像素,你会突然得到一个很大的缩放值,画面会明显跳一下。而且比例系数很难调,同样一个手势在一台低分辨率手机上可能放大 2 倍,换到高分辨率手机上可能只有 1.2 倍,因为像素密度不同。增量法则没有这个问题,它只关心“这一帧比上一帧远了多少”,这个值和屏幕分辨率的关系相对稳定,逻辑上也更自然:用户捏开多少,物体就长大多少。
具体实现时要注意一个细节:只有在双指都处于已跟踪状态时才能计算 delta。也就是第一次检测到第二根手指落下时,只记录初始距离,不应用缩放;从第二帧开始才有上一帧的距离可比较。我在代码里用一个 lastDistance 变量,初始化为 -1 表示无效,手指数量不足 2 时也重置为 -1,这个习惯能避免很多状态错乱。
using UnityEngine; public class PinchZoom : MonoBehaviour { public float zoomSpeed = 0.01f; public float minScale = 0.5f; public float maxScale = 5f; private float lastDistance = -1f; void Update() { if (Input.touchCount == 2) { Touch t1 = Input.GetTouch(0); Touch t2 = Input.GetTouch(1); float dist = Vector2.Distance(t1.position, t2.position); if (lastDistance > 0f) { float delta = dist - lastDistance; float newScale = transform.localScale.x + delta * zoomSpeed; newScale = Mathf.Clamp(newScale, minScale, maxScale); transform.localScale = Vector3.one * newScale; } lastDistance = dist; } else { lastDistance = -1f; } } }zoomSpeed 是我在实际项目里调出来的经验值,单位是“像素变化量对应的缩放变化量”。不同项目差异很大,因为屏幕像素密度、物体初始大小、相机远近都会影响手感。我建议把它设成 public 字段,让策划和 UI 在真机上自己调。数值一般落在 0.005 到 0.02 之间,如果你发现捏半天都没感觉,先检查是不是这个系数给太小了。
2.2 双指旋转:角度连续性和 ±180° 边界处理
双指旋转的数学其实也很简单:计算两根手指所在位置连成的线与屏幕水平方向的夹角,用 Mathf.Atan2 得到弧度再转角度即可。关键是这个角度会跨过 ±180° 边界。比如上一帧两指连线是 179°,这一帧由于手指稍微挪动变成了 -179°,如果直接相减,delta 是 -358°,你会看到物体突然疯狂旋转一大圈。
任何旋转计算都会遇到这个角度回绕问题,不止触摸,角色朝向、航海图、小地图指针都会遇到。解决方案是先把待处理的差值通过归一化函数夹到 [-180°, 180°] 范围内。这个动作等同于“取最短路径”,保证物体永远朝最近的旋转方向转。比如从 179° 到 -179°,最短的路径是逆时针转 2°,而不是顺时针转 358°,归一化函数返回的就是 -2°。
float GetFingerAngle(Touch t1, Touch t2) { Vector2 dir = t2.position - t1.position; float angle = Mathf.Atan2(dir.y, dir.x) * Mathf.Rad2Deg; return angle; } float NormalizeAngleDelta(float delta) { while (delta > 180f) delta -= 360f; while (delta < -180f) delta += 360f; return delta; }有了这个归一化函数,旋转逻辑就非常简单:记录 lastAngle,每帧计算当前角度 currentAngle,delta = NormalizeAngleDelta(currentAngle - lastAngle),然后更新 transform.localEulerAngles。我见过很多人在连续旋转测试时发现物体转着转着突然画风一变,原因几乎都出在边界上。你在做角度计算时,一定要把“角度的表示”和“角度的变化量”分开理解,变化量必须归一化。
2.3 双指中心点:让缩放旋转围绕视觉焦点
双指缩放旋转时,很多初学者会直接对 transform 的 localScale 和 localRotation 操作,结果物体总是在屏幕中央缩放旋转,而用户的焦点明明在两根手指中间。正确的做法是把双指中心点作为锚点,去调整物体的位置,保证用户捏住的那块内容留在视野中心。
中心点的计算就是简单的两点中点:(t1.position + t2.position) * 0.5f,但这里要区分屏幕坐标和世界坐标。如果你操作的是 UI,直接将中心点换算成屏幕坐标使用即可;如果你操作的是 3D 物体,需要把屏幕中心点通过 Camera.ScreenToWorldPoint 转换到世界坐标,再计算物体与锚点之间的偏移,在缩放旋转后补偿这个偏移。代码层面大致是这个思路。
Vector2 screenCenter = (t1.position + t2.position) * 0.5f; // 缩放前记录物体到锚点的偏移 Vector3 anchor = Camera.main.ScreenToWorldPoint( new Vector3(screenCenter.x, screenCenter.y, depth)); Vector3 offset = transform.position - anchor; // 缩放旋转后,重新把偏移加回来,保证锚点不漂移 transform.position = anchor + offset;注意这里的 depth 是物体到相机的距离,不能直接用 0 代替,否则 ScreenToWorldPoint 算出来的是相机近裁剪面上的点,物体会被瞬间拉飞。更稳妥的做法是在初始化时用射线碰撞得到深度,或者用一个固定的 z 值传给相机。网格地图项目通常直接把 z 固定在地图平面上,这样反而简单。
2.4 把缩放旋转平移统一成一个手势基类
接需求接到后面你会发现,单测一个 PinchZoom 组件太单薄了,真实项目往往要求“双指缩放、双指旋转、三指平移、双击复位”同时工作。我建议直接写一个手势基类,把触摸识别和业务逻辑分离,基类负责计算手势参数,业务层只用这些参数去驱动物体。
我的做法是这样的:在 Update 里根据 Input.touchCount 进入不同分支,维护一个手势状态枚举,状态只在手指数量变化时切换。状态切换的瞬间要重置所有临时变量,比如 lastDistance、lastAngle、手势开始时的中心点。这个结构初看有点绕,但好处是状态清晰,每一种手势的进入和退出都有迹可循,后续排查问题非常方便。
3. 多指触摸的原理分析与扩展手势
3.1 三指手势:平均位置比单指更抗抖
双指之外最常见的多指需求是三指滑动,用来做页面切换、视角平移或者隐藏 UI。三指和双指的最大区别是,你不应该只取其中两根手指的数据,而是应该算三根手指的平均位置。
平均位置的好处是抗抖。单指操作时,手指的微小起伏都会造成位置抖动;三指平均后,单根手指的误差被另外两根手指补偿了,整体轨迹平滑很多。计算方式不复杂,把所有手指的位置加起来除以数量,得到中心点;这一帧中心点减去上一帧中心点,就是整个三指手势的位移 delta。
Vector2 GetAveragePosition() { Vector2 sum = Vector2.zero; for (int i = 0; i < Input.touchCount; i++) { sum += Input.GetTouch(i).position; } return sum / Input.touchCount; }用平均位置做平移,再配一个很小的位移阈值,比如超过 20 像素才算真正的手势开始,能有效避免用户本来打算双指缩放,结果第三根手指不小心碰到屏幕时产生的瞬间位移。阈值这个参数一定要暴露出来,因为不同机型、不同贴膜的手感差异非常大。
3.2 长按与双击的计时器方案
触摸事件本身只提供按下、移动、抬起的信息,并没有系统级的长按和双击识别,需要自己算。我的方案是维护一个 Dictionary<int, float> 记录每根手指按下的时间,同时在手指抬起时记录抬起位置。
长按的判断逻辑是:某根手指 Began 后,超过 0.5 秒没有移动超过阈值,且仍处于按着状态,就触发长按。双击的逻辑是:同一点附近,两次完整按下抬起的间隔小于 0.3 秒,就认为是一次双击。这里有一个容易踩的坑:第一次双击的按下动作本身可能被判定为长按,需要在长按触发时立刻取消同一手指的双击计数,否则用户明明是双击,却先收到一个长按事件。
我通常会用一套简单的事件系统来解决,手势识别层只负责上报,不负责裁决优先级。比如长按、单击、双击这些事件可能互相冲突,就允许上层注册多个监听器,并给每个手势配置一个优先级,这样代码就不会越写越乱。
3.3 处理 Canceled 防止触摸状态错乱
很多开发者的手势库里只处理 Began、Moved、Ended,把 Canceled 给漏了。但 Canceled 在多指场景里非常常见,系统来电、弹系统对话框、应用切后台、输入法弹出,都会让触摸被取消。
如果漏掉了 Canceled,后果是手指已经离开了屏幕,但你的字典里还存着它,touchCount 可能已经归零,可数据还在残留。下次用户重新触摸时,旧的坐标和新坐标混在一起,双指缩放会突然反向,三指平移会莫名其妙吞掉一大段位移。所有和 fingerId 相关的缓存,都必须在 Ended 和 Canceled 分支里同步清理。
case TouchPhase.Canceled: // 必须和 Ended 一样处理,不能只清理 Ended lastPositions.Remove(touch.fingerId); break;我在版本迭代中遇到过不止一次“偶发手势错乱”,最后定位都是 Canceled 分支缺失。排查这一类问题有个技巧:在 OnGUI 或者日志里输出当前 touchCount 和手指字典里的数量,如果屏幕已经没有任何手指,字典里还有条目,那必然是取消分支没处理。
3.4 扩展:多指捏合与自定义组合手势
多指触摸不止能用在平移和缩放上。我用过四指同时点击触发隐藏调试面板,用过三指同时向左滑返回上一页,也用过五指握拳的近似识别。这些组合手势本质上都是利用 fingerId 和阶段序列做状态匹配。
关键点是“阶段序列”,也就是一根手指在什么时间点落下、什么时间点抬起。比如四指同时点击,要求四根手指在 0.1 秒内都进入 Began 状态,且之后都没有移动超过阈值;三指左滑,则要求三根手指同时保持 Moved 状态,并且平均位移的 x 方向小于某个负值。把这些规则写成一个个识别函数,由手势基类统一调度,就能像搭积木一样扩展出各种新手势。我的经验是,不要试图在一段 if else 里处理所有手势,那会让代码在第四个手势出现时彻底失控。
4. 真机调试的常见问题与排查实录
4.1 手指刚放上去,物体突然跳变
现象:用户双手指还在慢慢靠拢,物体却瞬间放大了一截。
原因:绝大多数情况是用了“绝对距离映射缩放值”的方案,或者没有在双指刚接触时初始化 lastDistance。手指刚落下时 lastDistance 还是上一个状态的旧值,和当前距离差很大,delta 自然巨大。
解决思路:把缩放改成增量累加,并在手势状态切换时显式重置 lastDistance 为无效值。同时可以在双指落下后的前几帧加一个不响应窗口,过滤掉初始化抖动。这个不响应窗口不需要太长,2 到 3 帧就够了。
4.2 真机上多点触控失效
现象:单指操作完全正常,但第二根手指按上去没有反应,或两根手指只能识别其中一个。
原因:检查 Input.multiTouchEnabled 是不是被谁置为 false。某些广告 SDK、录屏插件或者自定义输入插件会修改这个设置,Unity 默认是 true,但项目起来后可能被第三方库改掉。
解决思路:在 Start 里强制设置一次 Input.multiTouchEnabled = true。如果还是不行,检查设备本身是否支持多点触控,比如一些低端机或特殊外接设备。注意:iOS 和 Android 都支持多点触控,但模拟器里经常只支持单点,所以排除法先从模拟器换到真机。
4.3 双指缩放时,UGUI 控件被误触发
现象:在 ScrollView 或者 Image 上做双指缩放,结果物品被拖动,或者按钮被误点击。
原因:EventSystem 默认把触摸事件映射成鼠标事件,StandaloneInputModule 在触摸时会执行拖拽、点击逻辑。双指缩放的第一次触摸会被当成鼠标按下,于是触发 UI 的点击判定。
解决思路:在缩放手势检测确认进入双指模式后,通知 UGUI 取消当前指针状态,可以用 EventSystem.current 的相关接口,也可以给 UI 根节点挂一个自定义 InputModule 拦截。我的通用做法是,在 UI 元素上做双指手势时,用 GraphicRaycaster 的阻断,或者使用 Unity 的新版 Input System,它在 UI 层和手势层的区分上更清晰。
4.4 帧率波动导致缩放不跟手
现象:低端机器上帧率忽高忽低,同一段手势操作,高帧率时缩放很顺滑,低帧率时一顿一顿,甚至感觉手指动了很多物体没反应。
原因:Update 的调用频率和帧率一致,帧率低时每一帧的时间间隔变大,手指在一帧内移动的像素距离也变大,于是 delta 变大,缩放幅度看起来忽大忽小。这本质上是“增量事件的采样频率不同”导致的。
解决思路:有两个方向。一是用 deltaTime 做归一化,把每帧的位移除以 deltaTime,再乘以固定时间步长,让缩放速度在帧率波动时保持稳定;二是移除增量数值突发,给 delta 做一个最大钳位,超过一定范围就当噪点忽略。我通常两个方案都上:钳位负责安全,归一化负责手感。
4.5 问题排查速查表
| 问题现象 | 常见原因 | 解决方向 |
|---|---|---|
| 双指落下瞬间物体跳变 | 使用了绝对距离或初始化缺失 | 改为增量累加,手势切换重置状态 |
| 真机无法识别第二根手指 | multiTouchEnabled 被第三方关闭 | 启动时强制开启多点触控 |
| 缩放同时触发 UI 点击或拖动 | 触摸被事件系统转成鼠标事件 | 双指模式时拦截 UI 输入 |
| 旋转到±180°附近疯狂反转 | 角度差未归一化 | 用标准化函数夹到 [-180,180] |
| 帧率低时缩放幅度过大 | delta 未按时间归一化 | 除以 deltaTime 或做 delta 钳位 |
| 手指全部抬起后状态残留 | 漏掉 Canceled 分支 | Ended 和 Canceled 都清理字典 |
5. 实操心得与手势模块的可扩展方向
多指触摸在 Unity 坑多,但核心原理就那么几条:理解 Touch 的生命周期,用 fingerId 跟踪同一根手指,用增量而不是绝对值,对角度差做归一化,严格清理状态。我这套方法在图片查看器、AR 涂鸦、棋牌对战界面里都稳定跑过,最大的收获就是“手势识别层”必须和“业务表现层”分离。
以后如果再接到类似需求,我建议你直接建一个独立的手势管理类,里面只输出语义手势,比如 OnPinchZoom(float scaleFactor)、OnRotate(float angleDelta)、OnThreeFingerPan(Vector2 delta),而业务层只关心这些语义事件。这样一来,换 UI 框架、换输入系统、换设备平台,都不用改动业务代码。Unity 的新版 Input System 已经提供了更完整的触摸事件封装,但底层原理和我们今天拆解的并没有本质区别,先掌握这套,再迁移到新系统会轻松很多。
最后再分享一个真机调试的经验:我习惯在手势组件里加一个调试开关,把当前所有手指的 fingerId、坐标、阶段输出到屏幕上。一旦策划报告手势异常,我可以不看任何日志直接从画面里看到哪根手指没被正确识别,比埋头看代码定位快十倍。这个习惯帮我省了太多时间,也推荐给你。