☰
Unity InputSystem移动端触摸路由与多指手势稳定实践
2026/11/4 3:22:52 网站建设 项目流程

1. 触摸输入在 InputSystem 里的三层结构

去年下半年接手一个移动端的休闲项目,玩法本身不复杂,但上线前的真机测试里问题集中爆发:点击按钮偶尔没反应、双指缩放会把画面甩飞、拖拽角色时偶尔直接判定为"点了一下"、部分机型上手指刚按下就报 Ended。当时项目已经从旧输入管理器整体迁到 InputSystem,代码层面看着没问题,问题全出在触摸这条链路上——InputSystem 对触摸的抽象比旧系统细得多,用错一层就会出各种玄学现象。

这篇东西就是那段时间的完整复盘。我会从 InputSystem 的触摸数据模型讲起,把兼容性问题按"设备层、事件层、交互层"拆开,然后给出一套可以直接抄的触摸路由层实现,包括多指手势配对、UI 命中断言、坐标换算、延迟调优。适合手上正在做 Unity 移动端交互、被 InputSystem 的触摸折磨过的同学,也适合正准备从旧输入系统迁移的团队。代码基于 InputSystem 1.5 以上版本,2021.3 和 2022.3 都验证过。

1.1 设备层:Touchscreen 并不是永远存在的

很多人写触摸逻辑的习惯是在Awake里缓存一下Touchscreen.current,之后到处用这个引用。这在不同平台上表现差异极大。Touchscreen是一个 InputDevice,只有在系统报告存在触摸硬件时才会被 InputSystem 创建。PC 上没有触摸屏就是 null,跑在 Editor 里如果没开鼠标模拟触摸同样是 null,一些安卓设备上设备的注册时机还会比第一个场景的Awake更晚。

我自己遇到的典型场景是:某个中端机型冷启动时,Awake阶段Touchscreen.current为 null,我的初始化逻辑直接走了"无触摸设备"分支,把整个触摸路由层跳过了,结果就是整局游戏完全不能操作。后来改成每帧取Touchscreen.current,或者监听InputSystem.onDeviceChange动态维护,问题才消失。

void OnEnable() { InputSystem.onDeviceChange += HandleDeviceChange; } void OnDisable() { InputSystem.onDeviceChange -= HandleDeviceChange; } void HandleDeviceChange(InputDevice device, InputDeviceChange change) { if (device is Touchscreen) { // 设备被添加、移除、重新启用,都需要重置触摸状态 ResetAllFingers(); } }

InputDeviceChange里有Added、Removed、Disconnected、Reconnected、Enabled、Disabled、ConfigurationChanged等值。触摸状态机的重置要覆盖Removed和Disabled两种情况,否则会出现"手指数一直被占着"的假象——后面所有新按下的手指都拿不到资源。

注意:不要在Awake里缓存任何Touchscreen的引用,也不要缓存Touchscreen.current.touches数组里的 Control。设备重连之后这些对象可能失效,读出来的值全是零。

1.2 控制层:TouchControl 的字段哪些能信

Touchscreen.current.touches返回的是ReadOnlyArray<TouchControl>,数组长度是固定的(默认 10 个槽位,对应 10 根手指)。关键点在于:槽位是固定的,但不等于手指编号。touches[0]是"第一根手指的槽",不是"第一根按下的手指"。每次设备状态刷新,InputSystem 会把当前活跃的触摸按顺序填进这些槽位,中间可能出现空槽。

所以绝对不能拿数组下标当手指 ID 用。我见过最典型的错误写法是这样:

// 错误示范:用数组下标当手指身份 var t0 = Touchscreen.current.touches[0]; var t1 = Touchscreen.current.touches[1]; if (t1.press.isPressed) { /* 判定双指 */ }

这段代码在单指操作时就可能因为touches[1]恰好有残留状态而误判为双指。正确做法是遍历数组,用isInProgress或者press.isPressed过滤出真正活跃的触摸,再顺序收集。

TouchControl上有几个字段需要分清楚:

字段含义使用建议
touchId平台层触摸 ID,可复用只做日志排查,不能做长期身份
position当前屏幕坐标(像素)直接可用于射线
delta相对上一帧的位移拖动逻辑用这个,避免绝对坐标跳变
phaseNone/Began/Moved/Stationary/Ended/Canceled状态机的核心依据
startPosition本次按下的起点点击位移判定必备
startTime按下时刻(InputSystem 时间基准)判定长按需要换算
pressure/radius压感与接触半径多数机型不保证有效,别依赖
tap/tapCount内置的点击识别阈值不可控,业务层自己算更稳

pressure和radius这两个字段我要专门说一句。它们在 iOS 上相对可靠,在安卓上很多机型返回固定值 0 或者 1,radius干脆就是常量。任何基于压感做力度反馈的设计,必须在拿到首帧数据时做一次有效性检测,无效就降级到"按时间轴做效果"。

1.3 增强触摸层:EnhancedTouch 到底解决了什么

EnhancedTouch是 InputSystem 提供的补充 API,命名空间是UnityEngine.InputSystem.EnhancedTouch。它不是一个新的设备,而是在Touchscreen之上套了一层"手指身份追踪"的逻辑,把散落在固定槽位里的触摸重新按手指组织起来。

它带来的三个核心能力:第一是Touch.activeTouches,直接给出当前帧所有活跃触摸,不用自己过滤空槽;第二是Finger概念,一个Finger从按下到抬起期间身份稳定,Touch.finger.index可以作为稳定的 key 塞进字典;第三是Touch.history,能回溯这个手指在当前帧之前的若干位置采样,对低帧率下的轨迹平滑特别有用。

代价也有:必须显式调用EnhancedTouchSupport.Enable()才生效,不调用的话Touch.activeTouches永远是空的,而且不会报错,非常容易让人怀疑人生。这个开关是引用计数的,Enable和Disable必须成对,否则后续场景里再没人能打开它。

void OnEnable() { EnhancedTouchSupport.Enable(); Touch.onFingerDown += OnFingerDown; Touch.onFingerUp += OnFingerUp; } void OnDisable() { Touch.onFingerDown -= OnFingerDown; Touch.onFingerUp -= OnFingerUp; EnhancedTouchSupport.Disable(); }

还有个命名冲突的坑:UnityEngine.Touch和UnityEngine.InputSystem.EnhancedTouch.Touch同名,直接using两个命名空间会报歧义编译错误。标准做法是加一行别名:

using Touch = UnityEngine.InputSystem.EnhancedTouch.Touch;

这行别名最好放在所有触摸相关脚本的顶部,团队里统一约定,避免有人写出两套不同风格的代码。

2. 兼容性问题的分层拆解

真机上的触摸问题看起来五花八门,但把每一条往链路上归位之后就会发现,绝大多数集中在三个层面:设备是否存在、事件序列是否完整、交互归属是否明确。分层之后排查效率会高很多,因为每一层的验证手段和修复手段完全不同,混在一起查只会越查越乱。

2.1 设备层:设备缺失、热插拔与 Editor 模拟

设备层的症状很直接:要么完全收不到触摸,要么收到的坐标全为零。前者通常是Touchscreen.current == null,后者往往是读到了一个已经失效的 Control。

Editor 里调试是个高频误区。默认情况下 Editor 不模拟触摸,Touchscreen.current为 null,很多人为了在 Editor 里跑通,会在代码里加一句InputSystem.AddDevice<Touchscreen>()手动造一个。这样确实能跑,但这个假设备的输入源是鼠标,行为和真机差异不小——它没有真正的多指、没有 Cancelled 相位、touchId永远固定。更好的做法是打开 Input System Package 设置里的触摸模拟选项(不同版本位置略有差异,一般在 Project Settings 的 Input System Package 面板中),让 Editor 用鼠标和键盘组合模拟单指与双指,至少相位序列是完整的。

热插拔这一层要注意的不只是触摸屏本身。有些安卓平板支持外接键盘和鼠标,插拔鼠标时 InputSystem 可能会重新枚举设备集合,触摸设备短暂地触发Disabled再Enabled。如果你的状态机在Disabled时没做清理,就会出现"上一次的手指永远按着"的现象。处理方式统一:任何设备层变化,一律清空整个手指状态字典,让所有业务方收到一次取消回调。

提示:设备层的验证最省事的办法是打开 Input Debugger,看 Devices 列表里有没有 Touchscreen,展开看touches下每个控件的实时值。真机可以用 Input System 的 Remote 连接,桌面端能实时看到设备上的原始数据。

2.2 事件层:相位丢失、ID 复用与切后台

事件层的坑最多,也最隐蔽。比较典型的三类:

第一类是相位丢失。理想情况下一次触摸的相位序列是 Began → Moved → Ended,但真机上Stationary会大量出现(手指没动但设备仍然上报),Moved也可能是多帧连续。更麻烦的是Canceled——来电、通知下拉、系统手势从屏幕边缘划过,都会让当前触摸直接变成 Canceled 而不是 Ended。业务代码如果只处理Ended,这次触摸就永远收不了尾,状态机会卡住。

第二类是 ID 复用。平台层的touchId是有复用池的,尤其在手指快速抬起再按下时,新触摸可能拿到刚释放的 ID。如果业务层拿touchId做长期身份,就会出现"新按下的手指继承了上一个手势的进度"这种诡异现象。用Finger.index做 key 会好很多,但Finger本身也是有限池(默认 10 个),极高频的操作下依然存在复用。真正稳妥的 key 是finger.index加上一次"代次"计数,抬起时自增,按下时把当前代次写进状态。

第三类是切后台。Application.focusChanged或者OnApplicationPause触发时,正在按着的手指不会收到正常的 Ended。回到前台后这一帧的Touch.activeTouches可能是空的,但你的字典里还留着状态。

void OnApplicationFocus(bool hasFocus) { if (!hasFocus) ResetAllFingers(); } void OnApplicationPause(bool paused) { if (paused) ResetAllFingers(); }

ResetAllFingers里必须给每个还活着的手指派发一次Canceled类型的收尾事件,让上层逻辑有机会回滚。业务侧收到的取消和正常的抬起要区别对待——比如拖拽中的物件应该弹回原位而不是停在那里。

2.3 交互层:UI 与场景抢指针

交互层的本质问题是"这次触摸到底归谁"。Unity 里有两套命权判定:一套是EventSystem的 UI 射线,一套是业务代码自己打的世界射线。两边都在同一帧读取同一批触摸,如果没有任何仲裁,就会出现点按钮的同时角色也往前走了一步。

旧输入系统时代,大家习惯用EventSystem.current.IsPointerOverGameObject()一刀切。切到 InputSystem 之后这个 API 的行为变了,它依赖PointerEventData的缓存,而 InputSystem UI 模块维护的指针集合和触摸手指之间不是一对一关系。多指同时操作时,这个 API 经常返回错误的布尔值,尤其是第二根手指按下的那一帧。

我最终放弃了这个 API,改成自己打 UI 射线。核心思路很简单:把触摸位置和 finger 索引构造成一个PointerEventData,调EventSystem.RaycastAll,看有没有命中任何Graphic或者PhysicsRaycaster的目标。这样结果是确定的,也不依赖 UI 模块内部的指针缓存。

3. 触摸输入路由层的落地实现

真机上跑得稳的触摸方案,基本都不是在每个业务脚本里直接读Touch.activeTouches,而是在中间加一层路由。这层的职责是:把原始的、混乱的触摸数据整理成带身份的、有优先级的事件流,再分发给各个业务模块。好处是业务代码只关心"我的区域被碰到了没有",不用管设备差异和相位边界。

3.1 初始化与生命周期管理

路由层做成场景内唯一的单例,生命周期跟随场景。设备的 Enable 和 Disable 集中在这里处理,避免多个脚本各自调用EnhancedTouchSupport.Enable()导致引用计数错乱。

using System; using System.Collections.Generic; using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.InputSystem; using UnityEngine.InputSystem.EnhancedTouch; using Touch = UnityEngine.InputSystem.EnhancedTouch.Touch; public class TouchRouter : MonoBehaviour { public static TouchRouter Instance { get; private set; } public struct FingerInfo { public int index; public int touchId; public Vector2 startPos; public Vector2 prevPos; public Vector2 currentPos; public float startTime; public bool overUI; public int owner; // 已被哪个系统消费,0 表示未消费 } readonly Dictionary<int, FingerInfo> _fingers = new Dictionary<int, FingerInfo>(11); readonly List<int> _expired = new List<int>(4); void Awake() { if (Instance != null && Instance != this) { Destroy(gameObject); return; } Instance = this; EnhancedTouchSupport.Enable(); } void OnDestroy() { EnhancedTouchSupport.Disable(); if (Instance == this) Instance = null; } }

owner字段是手势仲裁的关键。多个模块同时想处理同一根手指时,谁的优先级高谁写入owner,后来者看见owner != 0就直接退出。用一个 int 做标识,比用枚举或字符串比对开销小,也方便扩展。

3.2 手指状态机与每帧刷新

每帧在Update里扫一遍Touch.activeTouches,把相位翻译成状态机的迁移。这里有个顺序问题需要特别注意:对Ended和Canceled的处理必须放在派发事件之后,而不是在处理循环里直接删字典——否则上层回调里再想读取这根手指的信息就会拿到默认值。

void Update() { var actives = Touch.activeTouches; _expired.Clear(); for (int i = 0; i < actives.Count; i++) { var t = actives[i]; int key = t.finger.index; if (!_fingers.TryGetValue(key, out var info)) { info = new FingerInfo { index = key, touchId = t.touchId, startPos = t.screenPosition, prevPos = t.screenPosition, currentPos = t.screenPosition, startTime = Time.unscaledTime, overUI = TouchUtility.IsOverUI(t.screenPosition), owner = 0 }; _fingers[key] = info; continue; } info.prevPos = info.currentPos; info.currentPos = t.screenPosition; _fingers[key] = info; if (t.phase == UnityEngine.InputSystem.TouchPhase.Ended || t.phase == UnityEngine.InputSystem.TouchPhase.Canceled) { _expired.Add(key); } } }

这段代码里有两个刻意的设计。第一,overUI只在按下那一帧算一次,后续移动过程中不重算。这是经过真机验证的取舍:手指按下后再滑进 UI 区域,不应该突然被 UI 吃掉,否则拖拽操作会在划过按钮时被打断。第二,用Time.unscaledTime记录按压时长,而不是t.startTime。原因是后者基于 InputSystem 内部的时间基准,和Time系列不完全同步,跨系统比较容易出偏差。

3.3 点击、长按、拖动的阈值判定

InputSystem 内置了tap判定,但它的阈值是引擎内部常量,不同版本可能有微调,而且不接受配置。业务上需要"240 毫秒内、位移不超过屏幕短边的 3%"这种精确条件,就必须自己算。

public const float TapMaxDuration = 0.24f; public const float LongPressMinDuration = 0.5f; public const float TapMaxMoveRatio = 0.03f; public static bool IsTap(in FingerInfo info, float now, float moveTolerance) { if (now - info.startTime > TapMaxDuration) return false; if ((info.currentPos - info.startPos).sqrMagnitude > moveTolerance * moveTolerance) return false; return true; }

moveTolerance不用写死像素值,而是按屏幕短边比例算出来:Mathf.Min(Screen.width, Screen.height) * TapMaxMoveRatio。固定像素在不同 DPI 的设备上手感差异巨大,在低分辨率机上 30 像素是"抖动",在 2K 屏上 30 像素是"明确移动"。用比例算出来的值,在高分屏手机上大概是 30 到 40 像素,在平板上可能是 60 像素以上,和物理尺寸基本成正比。

判定顺序也有讲究。我采用的优先级是:双指手势 > 单指拖动 > 长按 > 点击。手指按下时先不判定任何东西,等位移或者时间超过阈值再定性,这样能保证"用户以为是点击但其实手抖了一下"不会被误判成拖动。具体来说,位移超过容差就锁定为拖动,时间超过 0.5 秒且位移没超容差就锁定为长按,两样都没触发、手指抬起时才判定为点击。

心得:千万不要在Began帧就立即派发"按下"事件给业务。安卓上有些机型会先报一次 Began 然后立刻 Canceled,业务收到按下又收不到抬起,状态就野了。等一个Update周期再做定性判断,稳定性会高一个档次。

4. 多点触控手势的稳定实现

多指是移动端最容易翻车的地方。单指逻辑随便写写都能跑,一到双指就开始出现画面跳动、缩放比例突变、手指交换导致方向反转。根因基本都在"配对策略"上——两帧之间如果没有稳定的配对方式,第二根手指的索引变化就会让距离计算产生巨大的跳变。

4.1 双指缩放的配对与状态保持

配对的原则是:一旦确定参与缩放的两根手指,在本次手势结束前就不再改变。哪怕中间有第三根手指按下,也不重新配对,避免缩放基准被重置。

int _pinchA = -1; int _pinchB = -1; float _pinchStartDistance; float _pinchStartValue; bool TryBeginPinch(float currentValue, out float result) { result = currentValue; if (_pinchA >= 0 || _fingers.Count < 2) return false; // 按 index 升序取前两个,保证配对稳定 int min = int.MaxValue, second = int.MaxValue; foreach (var kv in _fingers) { if (kv.Key < min) { second = min; min = kv.Key; } else if (kv.Key < second) { second = kv.Key; } } if (second == int.MaxValue) return false; _pinchA = min; _pinchB = second; _pinchStartDistance = Vector2.Distance( _fingers[_pinchA].currentPos, _fingers[_pinchB].currentPos); _pinchStartValue = currentValue; return _pinchStartDistance > 10f; }

起始距离的 10 像素下限看着不起眼,但作用很大。两根手指靠得极近时,分母接近零,缩放比例会瞬间放大到荒谬的数值,画面直接炸开。加了这个下限,两根手指一开始并拢的情况会被直接判定为"本次缩放不成立",等手指分开到合理距离再重新开始。

缩放比例用距离比计算,但要加一个死区。距离变化小于 5% 时不更新,避免手指轻微抖动导致画面持续微颤。

float ratio = currentDistance / _pinchStartDistance; if (Mathf.Abs(ratio - 1f) < 0.05f) return _lastValue; _lastValue = Mathf.Clamp(_pinchStartValue * ratio, minValue, maxValue);

4.2 区域化输入与摇杆的隔离

移动端常见的布局是:左半屏虚拟摇杆、右半屏滑动视角、UI 按钮散布在顶部。这种布局下,触摸的归属必须在按下那一帧就确定,不能等到手指移动了再判断。

思路是把屏幕划分成若干矩形区域,每块区域注册一个 handler。手指按下时遍历区域,命中谁就由谁接管。区域之间可以有重叠,重叠部分按注册顺序或者显式优先级决定。

区域类型判定方式典型用途
屏幕矩形归一化坐标落在 Rect 内左摇杆、右滑动区
世界空间圆形射线命中后判断距离场景中的可拖拽物件
动态跟随按下点周围半径内浮动摇杆(按下即出现)

浮动摇杆的实现要点:按下时以触点为中心生成摇杆底座,手指移动时只有相对位移进入摇杆计算,底座本身不跟着动。如果用底座跟随手指的写法,玩家会觉得摇杆"粘手",而且中心点不断变化会让方向计算失效。

Vector2 delta = currentPos - stickOrigin; if (delta.magnitude > stickRadius) { delta = delta.normalized * stickRadius; stickOrigin = currentPos - delta; // 底座补偿,避免拖出边界后回中不准 } Vector2 dir = delta / stickRadius;

底座补偿这一步很多教程里没写,但很重要。手指拖出摇杆最大半径后,如果指针的原始值被钳制但基准点不变,当手指往回拉时,输入会有一段时间仍然是满值(死区内),手感就是"回中迟钝"。补偿之后,回中路径立刻响应。

4.3 手势冲突的仲裁规则

三根手指同时按下的场景必须显式定义规则,否则行为完全取决于遍历顺序,在真机上表现为随机。

我的规则是这样:第一根手指按下,先挂起,不立即定性;第二根手指按下,如果两指距离足够,立即升级为双指手势,第一根手指从待定池移出;第三根及以上手指按下,直接忽略,不参与任何手势。这样做的理由是:三指以上的操作在休闲游戏里极少需要,支持它带来的复杂度远大于收益。

实现上用owner字段。区域 handler 申请接管时检查info.owner,非零就拒绝:

bool TryClaim(int fingerIndex, int ownerId) { if (!_fingers.TryGetValue(fingerIndex, out var info)) return false; if (info.owner != 0 && info.owner != ownerId) return false; info.owner = ownerId; _fingers[fingerIndex] = info; return true; }

owner在手指抬起或者被取消时自动清零。这套机制跑起来之后,"拖拽时误触按钮"和"双指时角色还在跑"这类问题基本绝迹。

5. 坐标换算与屏幕适配

触摸坐标是屏幕像素空间,业务需要的是世界坐标或者 UI 局部坐标。中间这一层换算在固定分辨率下非常直观,一旦碰上刘海屏、折叠屏、平板横竖屏切换,各种偏移就冒出来了。这部分必须一次性做对,后期补丁会非常痛苦。

5.1 屏幕点到世界点的正确姿势

Camera.ScreenToWorldPoint需要的 z 参数是"距离相机的深度",而不是世界坐标的 z。对正交相机来说,z 参数写什么都行,结果一样。对透视相机,z 决定换算出的世界点在多远的平面上。

public static Vector3 ScreenToWorldOnPlane(Vector2 screenPos, Camera cam, float distance) { Vector3 sp = new Vector3(screenPos.x, screenPos.y, distance); return cam.ScreenToWorldPoint(sp); }

判定一个物件是否被点到,更可靠的做法是打射线而不是算投影:

Ray ray = cam.ScreenPointToRay(screenPos); if (Physics.Raycast(ray, out var hit, maxDistance, layerMask)) { var target = hit.collider.GetComponent<ITouchable>(); }

射线方案的好处是自动处理了遮挡关系,也避免了"投影点在物件背后但视觉上重叠"的错误。缺点是开销稍大,每帧每根手指一次射线。实际上活跃手指很少超过三根,这个开销完全可以接受。

5.2 安全区与刘海屏的坐标修正

Screen.safeArea返回的是像素单位的矩形,表示不被刘海、圆角、底部指示条遮挡的区域。UI 层的适配通常在 Canvas 上加一个SafeArea组件就解决了,但触摸判定区域如果直接用Screen.width和Screen.height划分,就会出现"按在刘海位置上也能触发"的问题。

Rect safe = Screen.safeArea; Rect normalized = new Rect( safe.x / Screen.width, safe.y / Screen.height, safe.width / Screen.width, safe.height / Screen.height);

把安全区转成 0 到 1 的归一化坐标存起来,区域划分全部基于归一化坐标做,最后再乘回实际像素。这样竖屏切横屏时不需要重新计算任何区域定义,只要Screen.width变了重新归一化一次即可。

注意:Screen.safeArea在部分安卓机上返回的数值会包含状态栏高度,在另一些机型上又不包含。做适配时要留出至少 8 像素的容错边距,UI 可点区域的边界往内缩一点,宁可少几个像素也不要让按钮贴在边缘。

5.3 旋转、分辨率变化与折叠屏

屏幕旋转时Screen.width和Screen.height会互换,如果不监听变化,所有缓存下来的像素坐标全部失效。监听方式最简单的是每帧比较宽高,变了就重算。

int _lastW, _lastH; void Update() { if (_lastW != Screen.width || _lastH != Screen.height) { _lastW = Screen.width; _lastH = Screen.height; RebuildCachedData(); ResetAllFingers(); // 旋转时正在按的手指一律取消 } }

旋转时重置手指状态是必须的。坐标系都变了,还拿着旧坐标的手指信息去算位移,结果必然是乱跳。折叠屏更极端,从折叠到展开可能连续触发多次分辨率变化,用上面的方式每帧检测、每次变化就重置,虽然粗暴但稳定。

6. UI 抢占问题的排查手册

UI 和场景输入打架,是所有输入方案里最难统一描述的问题,因为它涉及EventSystem、GraphicRaycaster、InputSystemUIInputModule三者的协作,任何一环配置不当都会出问题。我把它拆成三步:先看 UI 模块的指针策略,再看自己的命中断言,最后看射线层级配置。

6.1 InputSystemUIInputModule 的指针策略

InputSystemUIInputModule上有一个Pointer Behavior选项,三档取值行为差异很大:

取值行为适用场景
SingleMouseOrPenButMultiTouchAndTrack鼠标笔单指针,触摸多指独立默认值,多数项目适用
SingleUnifiedPointer所有指针合并成一个只需要主指针的老逻辑
AllPointersAsIs各指针完全独立,不做合并多指同时按多个按钮

默认档位在单指操作上与后两者没差别,多指时才会体现差异。如果你的游戏需要两根手指同时按两个 UI 按钮(比如赛车游戏的油门加转向),必须切到AllPointersAsIs,否则第二个按下会被合并到第一个指针上,表现为按钮不响应。

这个选项在代码里对应module.pointerBehavior,可以在初始化阶段根据游戏类型设置:

var module = EventSystem.current.currentInputModule as UnityEngine.InputSystem.UI.InputSystemUIInputModule; if (module != null) { module.pointerBehavior = UIPointerBehavior.AllPointersAsIs; }

6.2 自己写一套可靠的 UI 命中断言

前面说过IsPointerOverGameObject在 InputSystem 下不可靠,下面这套是替代方案。核心是复用PointerEventData,避免每次调用都产生垃圾。

public static class TouchUtility { static readonly List<RaycastResult> _hits = new List<RaycastResult>(8); static PointerEventData _cached; public static bool IsOverUI(Vector2 screenPos) { var es = EventSystem.current; if (es == null) return false; if (_cached == null || _cached.pressPosition != screenPos) { _cached ??= new PointerEventData(es); _cached.position = screenPos; _cached.pressPosition = screenPos; _cached.button = PointerEventData.InputButton.Left; } _hits.Clear(); es.RaycastAll(_cached, _hits); for (int i = 0; i < _hits.Count; i++) { var go = _hits[i].gameObject; if (go == null) continue; // 只认真正参与交互的 UI,纯展示的 Image 不算 if (go.GetComponent<ICanvasRaycastFilter>() != null) return true; if (go.GetComponent<Selectable>() != null) return true; } return false; } }

这段代码里加了一层过滤:只有挂了Selectable或者自定义ICanvasRaycastFilter的对象才算"有效 UI 命中"。因为很多项目里满屏都是raycastTarget = true的装饰性 Image,全部算作 UI 的话,场景里几乎处处都点不动。真正该关的是这些装饰物的raycastTarget,但历史项目改不动的时候,用这层过滤兜底。

6.3 一套走通的排查流程

遇到"点了没反应"或者"点一下触发两次",按这个顺序走一遍基本都能定位:

先看射线是否被别的 Canvas 挡住。多个 Canvas 叠加时,sortingOrder高的会先被射线命中,即使它是全屏透明遮罩。检查一下有没有忘记关掉的 Loading 层。

再看GraphicRaycaster的Blocking Objects设置。设成All的时候,3D 碰撞体会阻挡 UI 射线,导致明明点在按钮上却判定为被场景物件挡住。

然后确认EventSystem里只有一个输入模块。从旧系统迁移过来时,StandaloneInputModule和InputSystemUIInputModule经常同时存在在场景里,结果就是一次点击被处理两遍,按钮触发两次。

最后检查EventSystem.sendNavigationEvents和firstSelectedGameObject。某些情况下导航事件会干扰点击判定,尤其是手柄和触摸混用的项目。

7. 真机高频问题速查

以下这些都是在实际项目里真实遇到过、并且花了不少时间才定位的问题。做成表格方便对照,症状和原因的对应关系基本是稳定的。

7.1 症状与根因对照表

症状可能原因处理方式
完全收不到触摸EnhancedTouchSupport未 Enable,或Touchscreen.current为 null检查 Enable 配对,每帧取设备
点击偶尔丢失只处理Ended,忽略了Canceled两个相位都要收尾
双指缩放画面跳变配对不稳定,手指索引互换按 index 排序固定配对
切换后台回来后卡住未响应OnApplicationPause暂停时清空所有状态
按钮要点两次才响应两套输入模块同时存在场景里只保留一个
触摸坐标整体偏移安全区计算错误,或旋转后未重建缓存每帧检查宽高变化
拖拽时角色瞬移用了绝对坐标而不是 delta位移累加改用 delta
部分机型压感恒为 1硬件不上报做有效性检测并降级
多指同时按按钮只有一个响应指针策略是默认档切到AllPointersAsIs

7.2 真机调试的实用手段

真机日志是最基础的,但只看Debug.Log效率太低。我的做法是在屏幕上叠一个半透明的调试面板,实时显示当前活跃手指数、每根手指的坐标、相位、按压时长、是否 over UI。这个面板用一个OnGUI实现就够,不需要 UI 系统参与,反而不会干扰被测逻辑。

void OnGUI() { if (!debugVisible) return; GUILayout.BeginArea(new Rect(10, 10, 420, 600)); GUILayout.Label($"W:{Screen.width} H:{Screen.height}"); GUILayout.Label($"Active:{Touch.activeTouches.Count}"); foreach (var kv in _fingers) { var f = kv.Value; GUILayout.Label($"#{f.index} id:{f.touchId} " + $"pos:{(int)f.currentPos.x},{(int)f.currentPos.y} " + $"ui:{f.overUI} owner:{f.owner}"); } GUILayout.EndArea(); }

OnGUI在移动端有一定开销,但只在调试构建里开启就无所谓。这个面板在上线前的压力测试里帮我定位了好几个偶现问题,尤其是"某一帧手指数突然变成 0"这类难以复现的情况。

另一个手段是利用 Input Debugger 的 Remote 模式。桌面编辑器连上真机后,可以直接看到设备上报的原始触摸数据,包括touchId、phase、pressure这些业务层可能已经处理过的字段。当业务层的表现和原始数据对不上时,说明问题在中间处理逻辑,反之则是设备层的问题。

心得:每次遇到触摸相关的玄学问题,第一件事是打开调试面板确认"这一帧到底有没有触摸"。很多所谓的输入问题,实际上是业务状态机自己的问题,输入数据本身完全正常。先确认数据源,再查逻辑,能省一半时间。

8. 延迟与性能的实际调优

触摸延迟是玩家能直接感知到的东西。从手指接触屏幕到画面响应,整个链路上有多个环节可以优化,但收益差别很大。按投入产出比排序,最值得改的是帧率和事件处理模式,其次是手势判定的时机,最后才是插值和预测。

8.1 帧率与渲染设置

响应延迟的下限是"一个渲染帧"。60 帧下最坏情况是 16.7 毫秒加上设备本身的触摸上报延迟,实际体感在 50 到 80 毫秒之间。如果游戏跑在 30 帧,这个数字直接翻倍,玩家就会明显感觉到"粘手"。

移动端的Application.targetFrameRate默认不一定是 60,有些平台默认跟随系统设置,有些直接锁 30。必须显式设置:

void Awake() { Application.targetFrameRate = 60; QualitySettings.vSyncCount = 0; }

vSyncCount在移动端一般设为 0,让targetFrameRate生效。设成 1 的话会被垂直同步锁到屏幕刷新率,在 90Hz 或 120Hz 的屏幕上反而不受targetFrameRate控制,功耗会上去。

再就是Application.runInBackground。有些项目为了让后台也跑逻辑开了这个选项,代价是切后台后仍然在处理触摸事件,回来时状态容易错乱。我的建议是关掉它,配合前面说的暂停时清空状态,行为最可预期。

8.2 事件处理时序与 Update Mode

InputSystem 有一个全局设置Update Mode,控制输入事件在玩家循环的哪个阶段被处理。两个取值:

Process Events In Dynamic Update是默认值,事件在EarlyUpdate阶段处理,比Update早,所以Update里读到的永远是最新数据,响应最快。代价是如果游戏帧率波动大,输入事件的批处理频率也跟着波动。

Process Events In Fixed Update把事件处理挪到固定步长阶段,好处是输入和物理在同一节拍上,FixedUpdate里的读取更稳。坏处是触摸响应会被固定步长拖慢,50Hz 的固定步长意味着最多 20 毫秒的额外延迟。

移动端触摸操作我认为应该用默认的 Dynamic 模式。物理相关的输入如果用不上触摸,完全不冲突;如果确实需要,也应该在Update里缓存输入值,在FixedUpdate里消费缓存,而不是整个切到 Fixed 模式。

8.3 触摸历史与轨迹平滑

低端机上有一个现象:手指快速划一道弧线,画出来的轨迹是折线,采样点明显不够。原因是触摸上报频率高于渲染帧率,一帧内可能有两次甚至三次位置更新,但Touch.activeTouches只给出最后一帧的位置。

Touch.history可以补上这部分信息。它保存了当前手指在最近若干时刻的位置记录,每帧可以取多个采样点做插值:

var t = Touch.activeTouches[0]; int count = t.h

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询