Unity移动端输入系统升级:从Input.touches迁移到EnhancedTouch API 1. 项目概述为什么Unity移动端输入系统需要一场“大修”如果你正在开发一款Unity移动端游戏并且还在使用那个经典的Input.touchesAPI那么这篇文章就是为你准备的。我最近接手了一个老项目的性能优化工作其中一个核心任务就是重构其输入系统。在深入代码后我发现项目里遍布着对Input.touches的调用这不仅导致了偶发的触摸事件丢失还在多指操作时产生了难以追踪的输入延迟。经过一番调研和实战我决定将整个输入系统从陈旧的Input.touches迁移到全新的EnhancedTouchAPI。这个过程远不止是简单的API替换它涉及到输入架构的重新设计、性能瓶颈的消除以及代码可维护性的全面提升。简单来说Input.touches是Unity旧输入系统UnityEngine.Input的一部分它简单直接但问题也显而易见它是一个每帧轮询的数组缺乏精细的生命周期管理在多指、手势识别和复杂UI交互的场景下显得力不从心并且其底层实现可能带来不必要的性能开销。而EnhancedTouch属于Unity的新输入系统Input System它提供了更结构化、更高效、更强大的触摸数据访问方式。这次改造的目标就是告别过去那种“能用但不好用”的状态构建一个稳定、高效且面向未来的移动端输入处理核心。2. 新旧API深度对比Input.touches的局限与EnhancedTouch的优势在动手之前我们必须彻底理解为什么要换以及新工具到底强在哪里。这不仅仅是技术选型更是对移动端输入处理理念的一次升级。2.1 Input.touches简单背后的陷阱Input.touchesAPI的使用非常直观在Update()中直接获取一个Touch[]数组然后遍历处理。这种模式对于初学者或快速原型开发非常友好。void Update() { foreach (Touch touch in Input.touches) { if (touch.phase TouchPhase.Began) { Debug.Log($Touch began at: {touch.position}); } } }然而它的局限性在复杂项目中会暴露无遗状态管理的缺失Input.touches数组每帧都是“全新”的。它只包含当前帧的触摸状态。如果你想跟踪一个从Began到Moved再到Ended的完整触摸生命周期必须在自己的代码中维护一个字典用fingerId作为键来关联不同帧的触摸。这增加了代码复杂度和出错几率。潜在的帧间状态丢失由于是轮询如果你的逻辑处理速度慢或者触摸事件发生在帧与帧之间一个非常微妙的时间点有可能错过某个短暂的Began或Ended阶段。这对于需要精准捕捉点击、快速滑动的游戏来说是致命的。性能与垃圾回收GC虽然Input.touches返回的是数组但其内部实现和频繁的调用在特定条件下仍可能引发微小的GC Alloc对于追求60FPS甚至120FPS的移动游戏任何不必要的内存分配都是需要警惕的。功能单一它只提供最基础的触摸位置、阶段和ID。对于压力感应、触摸区域半径、双指手势如缩放、旋转的原始数据处理需要开发者自己从多个触摸点中计算代码冗长且易错。2.2 EnhancedTouch为现代交互而生EnhancedTouchAPI是Unity新输入系统的一部分它从设计上就解决了上述问题。清晰的生命周期对象EnhancedTouch.Touch不再是一个瞬态的快照而是一个代表从按下到抬起整个“触摸事件”的对象。你可以通过Touch.activeTouches获取所有活跃的触摸事件每个事件对象都包含了完整的历史轨迹如果需要。基于“手指”和“触摸”的双维度访问Touch.activeFingers按“手指”维度。一个Finger对象代表一个物理手指或触控笔你可以追踪它的历史触摸记录。这对于判断同一个手指的连续操作如长按、拖动非常直观。Touch.activeTouches按“触摸”维度。这是我们最常用的它直接对应一个完整的触摸生命周期事件。零垃圾回收GC承诺这是EnhancedTouch最吸引人的特性之一。其官方文档明确说明该API的核心数据存储在非托管内存中通过结构体struct包装器进行访问。activeTouches和activeFingers返回的数组都是预分配的。这意味着在正常的触摸处理循环中不会产生任何额外的托管内存分配对性能极其友好。丰富的触摸数据除了位置和阶段EnhancedTouch.Touch还提供了screenPosition同position、startPosition起始位置、startTime、delta增量、radius触摸半径、pressure压力值如果设备支持等属性。这些数据为实现更丰富的交互如根据压力改变笔刷大小、根据触摸面积判断误触提供了可能。与Input System Action无缝集成EnhancedTouch底层与新的Input System共享数据。这意味着你可以同时使用基于事件的Action响应和基于轮询的EnhancedTouchAPI两者并不冲突。你可以用Action处理UI交互用EnhancedTouch处理复杂的游戏内手势识别架构更加灵活。注意启用EnhancedTouch需要显式调用EnhancedTouchSupport.Enable()。这是一个一次性设置通常放在Awake()或Start()方法中。不启用它Touch.activeTouches将始终返回空数组。3. 迁移实战一步步替换Input.touches代码理论说完了我们进入实战环节。迁移不是简单的“查找并替换”而是逻辑的重构。我将以一个典型的虚拟摇杆和技能按钮的多点触摸控制场景为例展示完整的迁移过程。3.1 环境准备与基础启用首先确保你的项目已经安装了Input System包。可以通过Unity Package Manager进行安装。然后在任何需要使用EnhancedTouch的脚本中添加命名空间引用并启用支持。using UnityEngine; using UnityEngine.InputSystem.EnhancedTouch; // 关键命名空间 using Touch UnityEngine.InputSystem.EnhancedTouch.Touch; // 为方便使用起别名 public class EnhancedTouchInputManager : MonoBehaviour { private void Awake() { // 启用Enhanced Touch支持。必须在访问Touch API前调用。 // 多次调用是安全的内部会做检查。 EnhancedTouchSupport.Enable(); Debug.Log(Enhanced Touch Support Enabled.); } private void OnDestroy() { // 良好的习惯在组件销毁时禁用虽然对于单例或游戏生命周期管理器不一定必要 EnhancedTouchSupport.Disable(); } }将这样一个管理器脚本挂载到场景中一个不会被销毁的GameObject上例如GameManager确保它在游戏开始时最早运行。3.2 核心逻辑迁移从遍历数组到处理活动触摸假设我们原有代码如下它遍历Input.touches来处理一个简单的点击// 旧代码 (OldTouchHandler.cs) void Update() { foreach (var oldTouch in Input.touches) { if (oldTouch.phase TouchPhase.Began) { Ray ray Camera.main.ScreenPointToRay(oldTouch.position); if (Physics.Raycast(ray, out RaycastHit hit)) { hit.collider.GetComponentIClickable()?.OnClick(); } } } }迁移后的EnhancedTouch版本// 新代码 (NewTouchHandler.cs) void Update() { // 遍历当前所有活跃的触摸事件 foreach (var touch in Touch.activeTouches) { // 注意这里的 touch.phase 是 UnityEngine.InputSystem.TouchPhase 枚举 // 它与 UnityEngine.TouchPhase 枚举值命名相同但属于不同命名空间。 if (touch.phase UnityEngine.InputSystem.TouchPhase.Began) { // 使用 touch.screenPosition 替代 oldTouch.position Ray ray Camera.main.ScreenPointToRay(touch.screenPosition); if (Physics.Raycast(ray, out RaycastHit hit)) { hit.collider.GetComponentIClickable()?.OnClick(); } } } }看起来变化不大这只是第一步。真正的优势在于处理需要跟踪的触摸比如一个可拖动的UI面板。旧方案我们需要自己存储fingerId和对应的拖拽目标。// 旧代码 - 拖拽示例 private Dictionaryint, DragTarget activeDrags new Dictionaryint, DragTarget(); void Update() { foreach (var touch in Input.touches) { int fingerId touch.fingerId; if (touch.phase TouchPhase.Began) { // 检测开始拖拽... if (TryGetDragTarget(touch.position, out DragTarget target)) { activeDrags[fingerId] target; target.OnDragStart(touch.position); } } else if (activeDrags.TryGetValue(fingerId, out DragTarget currentTarget)) { if (touch.phase TouchPhase.Moved || touch.phase TouchPhase.Stationary) { currentTarget.OnDragUpdate(touch.position, touch.deltaPosition); } else if (touch.phase TouchPhase.Ended || touch.phase TouchPhase.Canceled) { currentTarget.OnDragEnd(touch.position); activeDrags.Remove(fingerId); } } } // 还需要处理手指意外离开字典清理等边缘情况 }新方案EnhancedTouch.Touch对象本身就是一个完整的跟踪单元。我们可以利用touchId和touch.startTime等属性来管理但更优雅的方式是利用其“触摸事件”的特性。对于拖拽我们通常关心的是Moved阶段持续更新。// 新代码 - 拖拽示例 (更清晰的逻辑) void Update() { foreach (var touch in Touch.activeTouches) { switch (touch.phase) { case UnityEngine.InputSystem.TouchPhase.Began: HandleTouchBegan(touch); break; case UnityEngine.InputSystem.TouchPhase.Moved: HandleTouchMoved(touch); break; case UnityEngine.InputSystem.TouchPhase.Ended: case UnityEngine.InputSystem.TouchPhase.Canceled: HandleTouchEnded(touch); break; // Stationary 阶段通常可以忽略除非需要特殊处理 } } } private void HandleTouchBegan(Touch touch) { if (TryGetDragTarget(touch.screenPosition, out DragTarget target)) { // 可以将touch.touchId与目标关联存储 target.OnDragStart(touch.screenPosition); // 不需要自己维护一个复杂的字典来关联fingerId和状态 // 因为target对象内部可以存储它自己的触摸ID或者在Update中通过位置/碰撞检测来找到当前拖动的目标。 // 对于UI拖拽更推荐使用Input System的Action基于事件驱动这里展示轮询方式。 } } private void HandleTouchMoved(Touch touch) { // 找到当前被这个touchId拖动的目标。如何查找取决于你的架构。 // 例如你可以只有一个“当前被拖动的对象”引用。 DragTarget currentTarget GetCurrentDragTarget(); if (currentTarget ! null) { // 使用 touch.delta 获取平滑的增量比计算两帧位置差更可靠 currentTarget.OnDragUpdate(touch.screenPosition, touch.delta); } }实操心得对于复杂的多对象拖拽你仍然需要一个映射关系touchId-DragTarget。但EnhancedTouch的touch对象在整个生命周期内是稳定的你可以安全地将它的touchId或引用注意是struct存储在你的目标对象中逻辑会比旧API更清晰。3.3 实现复杂手势双指缩放与旋转这是EnhancedTouch真正大放异彩的地方。旧API需要手动记录两个手指的初始位置和当前位置计算距离和角度的变化代码繁琐且容易出错。新API虽然核心数学计算不变但数据获取更加稳定可靠。下面是一个利用Touch.activeTouches实现双指缩放的简化示例public class PinchZoomRotate : MonoBehaviour { private bool isPinching false; private float initialDistance; private Vector2 initialMidpoint; private float initialRotation; void Update() { // 我们需要恰好两个处于Began或Moved阶段的触摸 var touches Touch.activeTouches; if (touches.Count 2) { var touch1 touches[0]; var touch2 touches[1]; // 检查是否是两个手指都刚按下或者正在移动 if ((touch1.phase TouchPhase.Began || touch1.phase TouchPhase.Moved) (touch2.phase TouchPhase.Began || touch2.phase TouchPhase.Moved)) { Vector2 currentPos1 touch1.screenPosition; Vector2 currentPos2 touch2.screenPosition; if (!isPinching) { // 手势开始 isPinching true; initialDistance Vector2.Distance(currentPos1, currentPos2); initialMidpoint (currentPos1 currentPos2) / 2f; initialRotation Mathf.Atan2(currentPos2.y - currentPos1.y, currentPos2.x - currentPos1.x) * Mathf.Rad2Deg; OnPinchStart(initialMidpoint); } else { // 手势进行中 float currentDistance Vector2.Distance(currentPos1, currentPos2); Vector2 currentMidpoint (currentPos1 currentPos2) / 2f; float currentRotation Mathf.Atan2(currentPos2.y - currentPos1.y, currentPos2.x - currentPos1.x) * Mathf.Rad2Deg; float scaleFactor currentDistance / initialDistance; float rotationDelta currentRotation - initialRotation; OnPinchUpdate(scaleFactor, rotationDelta, currentMidpoint); } } } else { // 手指数量不是2结束手势 if (isPinching) { isPinching false; OnPinchEnd(); } } } private void OnPinchStart(Vector2 screenMidpoint) { /* 通知其他系统手势开始 */ } private void OnPinchUpdate(float scale, float rotation, Vector2 screenMidpoint) { /* 应用缩放和旋转 */ } private void OnPinchEnd() { /* 手势结束清理 */ } }关键改进点数据源更可靠Touch.activeTouches提供的触摸列表是稳定的我们直接通过Count属性判断数量无需担心数组越界或临时状态。使用screenPosition属性名更清晰。相位判断更灵活我们可以精确地根据touch.phase来决定何时重置手势逻辑。例如只有当两个触摸点都处于Moved阶段时才计算增量可以避免因手指按下时间不同步导致的初始跳动。4. 性能优化与内存管理实战宣称“零GC”是EnhancedTouch的一大卖点但如何在实际代码中确保这一点我们需要理解其原理并遵循最佳实践。4.1 理解“零GC”的实现EnhancedTouch将所有的触摸数据位置、阶段、ID等存储在一个预分配的非托管内存环形缓冲区中。Touch和Finger都是只读的struct结构体。当你访问Touch.activeTouches时它返回的是一个ReadOnlyArrayTouch这个数组是复用且预分配的。因此在每帧的Update循环中遍历这个数组不会产生任何新的托管堆内存分配。验证方法在Unity Profiler的CPU区域观察GC Alloc列。在纯EnhancedTouch的触摸处理循环中你应该看不到因触摸查询而产生的分配。4.2 避免引入意外的GC Alloc虽然API本身是零GC但你的使用方式可能引入分配。以下是一些常见陷阱和解决方案在循环中拼接字符串Debug.Log($Touch: {touch.touchId})在发布版本中应移除在开发调试时也要注意其性能影响因为字符串拼接会产生GC。使用LINQTouch.activeTouches.Where(t t.phase TouchPhase.Moved).ToList()会产生大量的GC。绝对禁止在每帧的触摸处理中使用LINQ。将Touch结构体装箱如果将Touch存入Listobject或作为接口参数传递会导致装箱分配。应始终使用其具体类型或泛型集合如ListTouch但注意Touch是struct存入列表是值拷贝通常也不需要这样做。在频繁调用的方法中创建新的委托或闭包例如如果你为了排序触摸而传入一个比较委托也会产生分配。对于需要按特定顺序处理触摸的情况例如总是处理最新的触摸可以手动进行简单的比较避免使用Array.Sort等需要委托的方法。正确的性能模式// 好的做法简单的for循环无GC var activeTouches Touch.activeTouches; for (int i 0; i activeTouches.Count; i) { var touch activeTouches[i]; // 这是值拷贝但Touch是小型struct开销极小 if (touch.phase UnityEngine.InputSystem.TouchPhase.Began) { // 处理逻辑 } } // 或者使用foreach对于ReadOnlyArrayforeach也不会产生GC foreach (var touch in Touch.activeTouches) { // 处理逻辑 }4.3 与UI系统的协作优化现代Unity UIEventSystem默认已经与新的Input System集成。如果你的游戏UI交互复杂如虚拟摇杆、技能按钮强烈建议直接使用Input System的Action和PlayerInput组件来驱动UI事件而不是用EnhancedTouch去轮询处理UI。为什么UI系统本身是基于事件的。用轮询的方式去检测UI点击你需要自己处理点击穿透、事件拦截、悬停状态等相当于重新造轮子且容易与UI原生事件冲突。怎么做为你的UI按钮创建Input Action Asset定义Point、Click等Action并通过PlayerInput组件或手动订阅事件的方式响应。这样UI交互由高效的事件系统处理而游戏内非UI的触摸交互如3D物体拖拽、手势识别再用EnhancedTouch轮询处理。两者通过不同的Input Action Map或优先级进行隔离架构清晰性能最优。注意事项如果你确实需要在EnhancedTouch循环中处理UI务必使用EventSystem.current.IsPointerOverGameObject(touch.touchId)注意此方法可能不直接兼容或通过GraphicRaycaster进行检测以避免输入冲突。但最佳实践仍是让专业的事件系统做专业的事。5. 调试技巧与常见问题排查迁移过程中你肯定会遇到各种问题。这里记录了我踩过的坑和解决方案。5.1 触摸完全没有响应检查1是否启用了EnhancedTouchSupport这是最常见的问题。确保EnhancedTouchSupport.Enable()在访问Touch.activeTouches之前被调用。最好在场景加载的早期如Awake或Start中启用。检查2Input System后台处理模式。在Edit - Project Settings - Input System Package中确保Update Mode不是设置为Process Events In Dynamic Update以外的模式而Active Input Handling应设置为Input System Package (New)。如果同时使用了旧输入系统可能需要设置为Both但这可能增加复杂性。检查3编辑器模拟。在编辑器中测试时默认是用鼠标模拟触摸。确保Input Debugger(Window - Analysis - Input Debugger) 中你的鼠标设备被正确识别并可以模拟触摸。你也可以在代码中调用TouchSimulation.Enable()来启用模拟。5.2 触摸相位Phase判断错误注意枚举命名空间EnhancedTouch.Touch.phase的类型是UnityEngine.InputSystem.TouchPhase而旧API是UnityEngine.TouchPhase。它们的枚举值Began,Moved,Stationary,Ended,Canceled名称相同但属于不同类型。直接比较 UnityEngine.TouchPhase.Began会导致编译错误。要么使用全限定名要么像之前示例那样起别名并直接使用TouchPhase.Began因为Touch别名已经指向了EnhancedTouch.Touch。理解Stationary当手指按住不动时会持续收到Stationary状态的触摸。如果你的逻辑只在Moved时响应长按操作就不会被处理。根据你的游戏需求决定是否要处理Stationary。5.3 多指触摸ID管理混乱touchIdvsfingerId在旧系统中我们使用touch.fingerId。在新系统中Touch有touchIdFinger有index。对于一个完整的触摸事件其touchId在整个生命周期内是唯一的。而Finger.index是手指的物理索引0-9。在大多数情况下跟踪touchId来管理一个特定的触摸事件就足够了。activeTouches与activeFingers的区别如果你想知道“屏幕上有几根手指”应该用Touch.activeFingers.Count。如果你想知道“有几个正在进行的触摸事件”应该用Touch.activeTouches.Count。一根手指快速连续点击会产生多个Touch每个都有自己的touchId但只对应一个Finger。5.4 在Unity Remote或真机上测试的差异编辑器与真机延迟在编辑器中通过鼠标模拟触摸响应速度极快。在真机上由于硬件和系统层面的处理可能会有细微延迟。对于需要极高反应速度的游戏如音游需要在真机上充分测试EnhancedTouch的响应延迟并与旧API对比。通常新系统更优但务必验证。触摸点数量不同设备支持的最大触摸点数不同。Touchscreen.current.touches数组的长度是固定的通常是10但Touch.activeTouches只包含活跃的触摸。你的手势逻辑应该能处理任意数量的触摸点并在超过设计支持数量时优雅降级例如只处理前两个手指进行缩放。5.5 输入冲突与事件吞噬当同时使用新的Input System UI输入模块和EnhancedTouch轮询时可能会发生一个触摸事件既触发了UI点击又被你的EnhancedTouch逻辑处理导致物体被选中时UI按钮也触发了。解决方案建立清晰的输入优先级。通常的规则是UI优先。在你的EnhancedTouch处理逻辑开头先进行UI射线检测。如果当前触摸点落在有效的UI元素上则跳过后续的游戏逻辑处理。using UnityEngine.EventSystems; private bool IsTouchOverUI(Vector2 screenPosition) { // 创建一个PointerEventData PointerEventData eventData new PointerEventData(EventSystem.current); eventData.position screenPosition; // 进行射线检测 ListRaycastResult results new ListRaycastResult(); EventSystem.current.RaycastAll(eventData, results); // 如果检测到任何结果且第一个结果的gameObject是UI则认为触摸在UI上 return results.Count 0 results[0].gameObject.layer LayerMask.NameToLayer(UI); } void Update() { foreach (var touch in Touch.activeTouches) { if (touch.phase TouchPhase.Began) { if (IsTouchOverUI(touch.screenPosition)) { // 触摸点在UI上不处理游戏逻辑 continue; } // 处理游戏内的触摸开始逻辑 HandleGameTouchBegan(touch); } } }迁移到EnhancedTouch不是一蹴而就的尤其是对于大型的、输入逻辑错综复杂的项目。建议采取渐进式策略先在新功能中使用EnhancedTouch然后逐步挑选和重构旧的、问题最多的输入模块并辅以充分的单元测试和真机测试。最终你会获得一个更健壮、更高效、也更易于维护的移动端输入系统为你的游戏体验打下坚实的基础。