Unity编辑器模拟多点触控:基于Windows Touch Injection的完整方案
2026/9/18 10:48:49 网站建设 项目流程

1. 为什么非要在编辑器里模拟多点触控,真机不够用吗

1.1 真机调试的三个现实痛点

先给结论:Unity Editor 本身对多点触控的支持相当吝啬。旧式 Input 系统里那个Input.simulateTouchWithMouse,从引入那天起就只允许你模拟一根手指;想模拟双指捏合、三指滑动、长按加拖拽这类手势,默认情况下你几乎只有抱着真机调试一条路。

但真机调试的痛,做移动项目的人都懂。第一是迭代慢,每改一个手势参数就要打包或者进 Play Mode 再连设备,来回一趟少说几十秒,改个十几次人就麻了。第二是手势不可控,人手上做出来的双指缩放每次都不一样,你很难说清楚刚才那个拍照失败是因为代码问题还是因为手指速度不对;想复现一个偶发的触摸断连问题,可能在真机上搓半天也复现不出来。第三是硬件不是人人都有,小团队里做 UI 和做玩法的人可能共用一台安卓机,Windows 触屏笔记本也不是标配,赶上疫情远程办公,设备根本寄不到手边。

更麻烦的是,就算你手头有真实触摸屏,触摸链路本身也可能出幺蛾子。我见过某些 Linux 环境下 eGalaxTouch 驱动上报多点触控时抽风:第二根手指的坐标会突然跳变、抬起事件偶尔丢失,这种驱动层面的 Bug 不会被应用代码绕过,它会让你的纯真机调试陷入"到底是我的逻辑错了还是硬件错了"的泥潭。所以,把触摸数据源从物理世界抽离出来,在编辑器里构造一套可控的、可回放的模拟注入方案,才是我愿意投入时间做的事。

1.2 单指模拟与多指模拟之间隔着一条鸿沟

很多人一开始会觉得:既然Input.simulateTouchWithMouse能用鼠标假装触摸,那再接个手柄、再接个数位板,是不是就能凑出多指了?想多了。这个开关在 Input Manager 的底层实现里只维护了一条"伪触摸"数据,鼠标左键按下对应TouchPhase.Began,移动对应Moved,松开对应Ended,并且它只有一个固定fingerId = 0。你没有办法通过它再伪造一条fingerId = 1的数据,它压根没有留给多指的位置。

旧式 Input 系统对外暴露的 API 是Input.touchesInput.touchCountInput.GetTouch(i),看起来像是一套托管数组,但实际上数据来自引擎 C++ 原生层每帧刷新的一组触摸快照。你没法像改一个静态字段那样把Input.touches硬塞进去,引擎每帧都会用原生数据覆盖回来。所以模拟的本质只有一条路:在数据源头上让操作系统认为真的有手指落在目标窗口上,剩下的翻译工作交给 Unity 自己的 Windows 输入后端。

1.3 这篇内容解决什么问题、不解决什么问题

我要做的是:在 Windows 上运行的 Unity Editor 里,通过系统级触摸注入,让旧式 Input 系统完整读到多点触控数据流。它既不需要改业务代码,也不需要额外硬件,唯一要求是 Windows 8 以上的操作系统。使用新 Input System(UnityEngine.InputSystem)的项目不在本文讨论范围内,虽然我可以顺手给你指一条"新系统里已经有触摸模拟"的捷径,但它和旧式 Input 的数据管道不互通,这一点后面专门讲。

文章会按"原理 → 主方案 → 坐标坑 → 工程化 → 替代路径"的顺序展开,代码部分以 C# 和 Win32 P/Invoke 为主,你可以直接抄走改改就用。

2. 旧式Input的触摸数据管道:先搞明白你要骗过谁

2.1 Input.GetTouch背后是原生后端,不是托管数据

要模拟,你得先弄清楚旧式 Input 的数据从哪来。Input.GetTouch(i)返回的Touch结构体里,fingerIdpositionphasetapCountdeltaTimepressureradius这些字段,其实都是每一帧从引擎原生层拷贝过来的。引擎在 Application 主循环里会做一次输入采集,把当前帧所有触摸点整理成数组,托管侧的Input静态类只是这个数组的"读卡器"。

这意味着两件事。第一,你在帧中间去改Input.touches是不可能的,下一帧原生层会把数据重新覆盖;第二,任何纯托管层面的反射、Hook、DLL 注入方案,如果不触及原生输入采集,都不可能真正骗过Input.GetTouch。市面上确实有人用 unsafe 指针去改底层数组,但那属于踩引擎私有内存的地雷,升级一次 Unity 版本就碎一次,不建议碰。

这里要顺带提醒:Touch结构体里的pressureradius在旧式 Input 的很多平台后端里其实是不填充的,你用真机拿到的pressure也可能是默认值。所以模拟时你不需要纠结这些字段是否完全仿真,业务代码如果依赖了压力值,你更应该回头审视自己的设计,这在移动端本身就是个坑。

2.2 simulateTouchWithMouse为什么只能是单指

Input.simulateTouchWithMouse对应的底层逻辑,本质是在原生输入层做了一次"鼠标 → 触摸"的映射。它把鼠标的位置当作触摸坐标,把左键的状态翻译成触摸的 Begin/Moved/Ended 阶段。因为鼠标只有一个光标,这条伪造出来的触摸流永远不会出现第二个fingerId

有些同学会问:那我把Input.simulateMouseWithTouches也打开,是不是就能两个都有了?simulateMouseWithTouches是把触摸数据反向映射成鼠标,它和设备触摸是同一份数据源,两个开关不会增加触点数量。说白了,Unity 给你留的编辑器模拟口子只有一个触点,这是它的设计上限,不是配置问题。

2.3 突破口:编辑器在Windows上吃的是WM_POINTER/WM_TOUCH

既然 Unity 在 Windows 上运行时,原生输入后端要监听系统的消息队列,那自然会监听WM_POINTERWM_TOUCH两类触摸消息。也就是说,当你的开发机插上一块真实触摸屏、或者用支持触摸的笔记本跑编辑器时,旧式 Input 是能读到多指数据的——不是它不支持多指模拟,而是它没有给你提供"凭空捏造"多指数据的现成开关。

这样一来突破口就清楚了:如果我能从操作系统层面把触摸事件"注入"到系统输入队列,让 Unity 编辑器所在的窗口收到合法的触摸消息,那么 Unity 原生后端就会把它当作真实触摸来处理,Input.GetTouch自然就会返回对应数量的触点。整个过程完全不需要改动引擎任何内部状态,业务代码也无法分辨这是真手指还是注入的假手指。

3. 主方案:Windows Touch Injection把触摸灌进Game视图

3.1 InjectTouchInput到底是干什么的

Windows 8 开始,系统提供了一组触摸注入 API,核心是InitializeTouchInjectionInjectTouchInput。很多做自动化测试和远程协助的工具都靠它来模拟触摸,它产生的事件和真实触摸一样走 Pointer 消息通路,目标窗口根本无法区分。和SendInput不同的是,触摸注入需要你构造完整的POINTER_TOUCH_INFO结构,包括触点坐标、接触面积、压力、标志位等,灵活度很高。

它有几个特点决定它非常适合编辑器模拟场景。第一,不需要驱动级签名,普通应用进程就能调用;第二,支持同时注入最多 256 个触点,做十指操控都够用;第三,注入出去的事件会正常路由到鼠标/触摸热点所在的顶层窗口,所以只要你的 Game 视图位于坐标点上并且处于前台,Unity 就能收到。缺点也很明显:它只在 Windows 上有效,macOS 编辑器你得另想办法(比如 CGEvent 或模拟触摸板的私有 API,那又是一个大坑)。

3.2 P/Invoke封装与结构体定义

先把 Win32 层的 P/Invoke 写出来。这里结构体布局必须严格对齐原生定义,64 位编辑器下任何字段错位都会导致注入静默失败或者程序崩溃。下面是我验证过的版本:

using System; using System.Runtime.InteropServices; using UnityEngine; public static class NativeTouchInjector { public enum TouchPhaseEx { Began = 0, Moved = 1, Ended = 2, Canceled = 3 } [StructLayout(LayoutKind.Sequential)] public struct POINT { public int X; public int Y; } [StructLayout(LayoutKind.Sequential)] public struct RECT { public int Left; public int Top; public int Right; public int Bottom; } [StructLayout(LayoutKind.Sequential)] public struct POINTER_INFO { public uint pointerType; public uint pointerId; public uint frameId; public uint pointerFlags; public IntPtr sourceDevice; public IntPtr hwndTarget; public POINT ptPixelLocation; public POINT ptHimetricLocation; public POINT ptPixelLocationRaw; public POINT ptHimetricLocationRaw; public uint dwTime; public uint historyCount; public int inputData; public uint dwKeyStates; public ulong performanceCount; public uint buttonChangeType; public ushort buttonPressed; public ushort flagsEx; } [StructLayout(LayoutKind.Sequential)] public struct POINTER_TOUCH_INFO { public POINTER_INFO pointerInfo; public uint touchFlags; public uint touchMask; public RECT rcContact; public RECT rcContactRaw; public uint orientation; public uint pressure; } // 常量定义 private const uint PT_TOUCH = 2; private const uint POINTER_FLAG_DOWN = 0x00010000; private const uint POINTER_FLAG_UPDATE = 0x00020000; private const uint POINTER_FLAG_UP = 0x00040000; private const uint POINTER_FLAG_INRANGE = 0x00000002; private const uint POINTER_FLAG_INCONTACT = 0x00000004; private const uint POINTER_FLAG_PRIMARY = 0x00000200; private const uint TOUCH_MASK_CONTACTAREA = 0x00000001; private const uint TOUCH_MASK_ORIENTATION = 0x00000002; private const uint TOUCH_MASK_PRESSURE = 0x00000004; private const uint TOUCH_FEEDBACK_DEFAULT = 1; private static bool _initialized; private static uint _frameCounter; [DllImport("user32.dll", SetLastError = true)] private static extern bool InitializeTouchInjection(uint maxCount, uint dwMode); [DllImport("user32.dll", SetLastError = true)] private static extern bool InjectTouchInput(uint count, POINTER_TOUCH_INFO[] contacts); public static uint NextFrameId() { return ++_frameCounter; } public static void EnsureInitialized(uint maxTouches = 10) { if (_initialized) return; _initialized = InitializeTouchInjection(maxTouches, TOUCH_FEEDBACK_DEFAULT); if (!_initialized) { Debug.LogError($"[NativeTouchInjector] InitializeTouchInjection 失败, LastError={Marshal.GetLastWin32Error()}"); } } }

注意SetLastError = true一定要加,否则调失败的时候拿不到错误码。InitializeTouchInjection第一个参数是最大并发触点数量,建议设 10,不要设 0,否则初始化直接失败;dwModeTOUCH_FEEDBACK_DEFAULT,这个参数影响系统是否绘制触摸反馈圈,默认值即可。

3.3 把一根手指的完整生命周期翻译成Down/Move/Up

接下来是核心发送函数。每一次InjectTouchInput调用对应一根手指在某一帧的某个状态,你要做的就是根据手指阶段填充不同的pointerFlags

  • 按下:POINTER_FLAG_DOWN | POINTER_FLAG_INRANGE | POINTER_FLAG_INCONTACT
  • 移动:POINTER_FLAG_UPDATE | POINTER_FLAG_INRANGE | POINTER_FLAG_INCONTACT
  • 抬起:POINTER_FLAG_UP,注意抬起时不要带 INCONTACT
  • 取消:也可以发UP,或者带POINTER_FLAG_CANCELED(0x800,不过旧式 Input 通常只会把 TouchPhase 映射成 Ended/Canceled,实际差别不大)
public static void Send(uint pointerId, TouchPhaseEx phase, float x, float y, uint frameId = 0, uint pressure = 320, int contactRadius = 10) { EnsureInitialized(); var info = new POINTER_TOUCH_INFO(); info.pointerInfo.pointerType = PT_TOUCH; info.pointerInfo.pointerId = pointerId; info.pointerInfo.frameId = frameId != 0 ? frameId : NextFrameId(); info.pointerInfo.dwTime = (uint)Environment.TickCount; info.pointerInfo.historyCount = 1; info.pointerInfo.ptPixelLocation.X = (int)x; info.pointerInfo.ptPixelLocation.Y = (int)y; info.pointerInfo.pointerFlags = POINTER_FLAG_INRANGE; switch (phase) { case TouchPhaseEx.Began: info.pointerInfo.pointerFlags |= POINTER_FLAG_DOWN | POINTER_FLAG_INCONTACT; break; case TouchPhaseEx.Moved: info.pointerInfo.pointerFlags |= POINTER_FLAG_UPDATE | POINTER_FLAG_INCONTACT; break; case TouchPhaseEx.Ended: case TouchPhaseEx.Canceled: info.pointerInfo.pointerFlags |= POINTER_FLAG_UP; break; } if (pointerId == 0) { info.pointerInfo.pointerFlags |= POINTER_FLAG_PRIMARY; } info.touchFlags = 0; info.touchMask = TOUCH_MASK_CONTACTAREA | TOUCH_MASK_PRESSURE; info.rcContact.Left = (int)x - contactRadius; info.rcContact.Top = (int)y - contactRadius; info.rcContact.Right = (int)x + contactRadius; info.rcContact.Bottom = (int)y + contactRadius; info.orientation = 0; info.pressure = pressure; if (!InjectTouchInput(1, new[] { info })) { Debug.LogError($"[NativeTouchInjector] 注入失败, LastError={Marshal.GetLastWin32Error()}"); } }

pressure的取值范围是 0 到 1024,系统默认值是 320,不传也能用。rcContact是接触面积,如果你想模拟细笔尖就设 2 像素半径,模拟指头就设 10 到 20 像素半径。多数业务逻辑不关心这两项,但某些画板类应用会读radius,这时接触面积就很重要了。

还有一个细节:同一帧里多个触点同时按下时,frameId应该保持一致,这样才能让系统认为这些触点属于同一输入帧。所以我在 Send 里允许外部传入frameId,同时提供NextFrameId()方法,用法是:

uint frame = NativeTouchInjector.NextFrameId(); NativeTouchInjector.Send(0, TouchPhaseEx.Began, x1, y1, frame); NativeTouchInjector.Send(1, TouchPhaseEx.Began, x2, y2, frame);

如果每个触点各自NextFrameId(),某些手势识别库跨触点分析时可能看到"帧不同步"的现象,握过一次手之后就长记性了。

3.4 一个双指捏合的完整代码与验证

注入本身不依赖 Unity 的 Update,但你要在什么时机触发注入,取决于你的场景。先给一个最简单的验证脚本:在场景里放一个挂脚本的空物体,Update 里把Input.touches全部打出来:

void Update() { for (int i = 0; i < Input.touchCount; i++) { Touch t = Input.GetTouch(i); Debug.Log($"触摸索引{i}: fingerId={t.fingerId}, pos={t.position}, phase={t.phase}"); } }

然后用协程发一段双指捏合序列,我这里按 30 帧从两指间距 200 像素缩到 100 像素:

IEnumerator RunPinch(Vector2 center, float startHalfDist, float endHalfDist, int steps = 30) { // 按下:两指同时落下,要用同一个frameId uint frame = NativeTouchInjector.NextFrameId(); NativeTouchInjector.Send(0, TouchPhaseEx.Began, center.x - startHalfDist, center.y, frame); NativeTouchInjector.Send(1, TouchPhaseEx.Began, center.x + startHalfDist, center.y, frame); yield return null; for (int i = 1; i <= steps; i++) { float t = (float)i / steps; float d = Mathf.Lerp(startHalfDist, endHalfDist, t); NativeTouchInjector.Send(0, TouchPhaseEx.Moved, center.x - d, center.y); NativeTouchInjector.Send(1, TouchPhaseEx.Moved, center.x + d, center.y); yield return null; } NativeTouchInjector.Send(0, TouchPhaseEx.Ended, center.x - endHalfDist, center.y); NativeTouchInjector.Send(1, TouchPhaseEx.Ended, center.x + endHalfDist, center.y); yield return null; }

跑起来的第一个排查点往往是"为什么Input.touchCount一直是 0"。我碰到过的情况是编辑器没有处于前台,注入的触摸被路由到了其他窗口;或者坐标落在了 Game 视图之外。确保你的编辑器窗口在最前面、Game 视图可见且非最小化,然后再看坐标——接下来这一节,坐标换算才是真正的大头。

4. 坐标换算与窗口定位:9成失败案例都栽在这里

4.1 Unity内部坐标、OS屏幕坐标、物理像素三者的关系

这是整个方案里最容易让人抓狂的部分,因为它牵扯三套坐标:Unity 的 Input 坐标、Windows 的屏幕坐标、以及物理像素和逻辑像素。

先说 Windows 屏幕坐标。Win32 的窗口矩形和光标坐标默认以屏幕左上角为原点,X 向右,Y 向下。你的触摸注入坐标最终必须填这套坐标,单位是物理像素。

再说 Unity 里的 Game 视图。你在游戏代码里读到的Input.mousePositionTouch.position,坐标原点在 Game 视图渲染区域的左下角,Y 向上,数值范围对应 Game 视图当前的屏幕分辨率(比如 1920x1080)。这套坐标和你注入用的坐标,原点不同、Y 轴方向相反、缩放比例也不一定一样(因为窗口可能被拉伸)。

最常见的翻车姿势是:先用Screen.width/2算出游戏画面中心,想当然地注入到那个位置,结果Input.touchCount虽然变成了 1,但touch.position永远不在预期位置,或者干脆按到了 Unity 的工具栏上。所以注入之前,必须先算出 Game 视图内容区域在屏幕上的物理像素矩形,再做一个从"游戏逻辑坐标"到"OS 屏幕坐标"的映射。

4.2 用反射拿到GameView窗口矩形

Unity 没有公开 API 直接返回 Game 视图在屏幕上的位置,但GameView这个EditorWindow类型是存在的,我们可以通过类型名拿到它,再用EditorWindow.position获取窗口矩形。不过这里有个坑:EditorWindow.position返回的是编辑器内的逻辑坐标(points),不是绝对屏幕物理坐标,而且对于停靠(Docked)窗口,它的坐标是相对其所在面板的。我实际用的是下面这套组合拳:

private static Rect GetGameViewScreenRect() { var gameViewType = System.Type.GetType("UnityEditor.GameView, UnityEditor"); if (gameViewType == null) return Rect.zero; var gameView = EditorWindow.GetWindow(gameViewType); if (gameView == null) return Rect.zero; // position 是编辑器逻辑坐标下的窗口位置 var windowPos = gameView.position; // 用GUIToScreenPoint把逻辑坐标转成屏幕坐标系(逻辑像素) Vector2 screenPivot = EditorGUIUtility.GUIToScreenPoint(new Vector2(windowPos.x, windowPos.y)); float scale = GetDpiScale(); // 转成物理像素 return new Rect( screenPivot.x * scale, screenPivot.y * scale, windowPos.width * scale, windowPos.height * scale ); } private static float GetDpiScale() { // 一个常见但不算可靠的取法:用Screen.dpi/96 // 更稳的办法是查进程DPI Awareness和监控器DPI,这里做简化 using (System.Drawing.Graphics g = System.Drawing.Graphics.FromHwnd(IntPtr.Zero)) { return g.DpiX / 96f; } }

System.Drawing.Graphics.FromHwnd拿到的 DPI 会受到进程 DPI 感知级别影响,Unity 编辑器默认是 System DPI Aware,所以大多数情况下能对上。但如果你在 Windows 显示设置里改了缩放比例,或者跨了不同 DPI 的多显示器,这套算出来的窗口矩形仍然可能偏几像素到几十像素。别慌,原因是停靠窗口的面板坐标还包含了工具栏高度和 Game 视图自身的 toolbar 区域。

更省事的思路是:直接把 Game 视图设置成"Maximize on Play"(Play 模式下最大化),并且在进入 Play 模式前把 Game 视图拖成独立浮动窗口。这样窗口矩形几乎就等于你的渲染区域(还差一个标题栏和顶部工具栏),坐标误差降到最小,适合做可复现的自动化。代价是你在调试时看不到其他 Editor 窗口,但为了测试稳定性,这个取舍值得。

4.3 更稳的做法:用鼠标校准两点求出仿射映射

与其和反射、DPI、工具栏高度搏斗,我更推荐一个"暴力但可靠"的校准法。既然你最终要用Input.mousePosition(游戏坐标)和 Win32 光标坐标(OS 屏幕坐标)建立对应关系,那就让用户亲手提供两个已知对应点,然后解出缩放系数和原点偏移。

具体操作步骤是:进入 Play Mode,保持 Game 视图可见,把鼠标移到 Game 视图内的"左上角"区域,触发一次记录(比如按快捷键或点 EditorWindow 上的按钮),程序同时读取Input.mousePosition(记为gameA)和GetCursorPos(记为osA);再把鼠标移到"右下角"区域,记录gameBosB。两组对应点解一个线性映射:

// gameA/gameB: Unity坐标(左下原点,Y朝上) // osA/osB: Win32屏幕坐标(左上原点,Y朝下) Vector2 scale = new Vector2( (osB.x - osA.x) / (gameB.x - gameA.x), (osB.y - osA.y) / (gameB.y - gameA.y) ); Vector2 origin = new Vector2( osA.x - scale.x * gameA.x, osA.y - scale.y * gameA.y ); // 游戏坐标 -> OS屏幕坐标 Vector2 GameToOS(Vector2 gamePos) { return new Vector2(origin.x + scale.x * gamePos.x, origin.y + scale.y * gamePos.y); }

注意scale.y一定是负数,因为游戏坐标 Y 向上而屏幕坐标 Y 向下,如果你算出来是正数,说明两个点选的位置有问题。这个方法天然吸收了工具栏偏移、DPI 缩放、窗口停靠等所有误差,只要两组校准点选在 Game 视图的渲染区域内,它给出的映射就是准的。

GetCursorPos的 P/Invoke 很简单:

[DllImport("user32.dll")] static extern bool GetCursorPos(out POINT lpPoint);

校准点要尽量拉开距离,越靠近 Game 视图的两个对角,缩放系数越准。我一般会让用户点"左上"和"右下"两个点,如果 Game 视图不是矩形裁切导致左右不对称,也可以选"左下"和"右上",但通常不需要。

4.4 DPI缩放与多显示器

校准法虽然解决了大部分问题,但有两个场景它救不了:一是 Windows 显示设置里拖动窗口跨到了不同 DPI 的显示器,二是 Unity 编辑器的 UI 缩放和 Windows 系统缩放不一致。前者会导致同一窗口在不同显示器上物理像素变化,后者会让你用EditorWindow.position推算时出现系统性偏移。

我的建议是:凡是做自动化测试的记录和回放,固定只在一台显示器、固定缩放比例、固定 Game 视图分辨率。开发机是 4K 屏 + 200% 缩放,测试机是 1080p + 100% 缩放,那么录制的手势坐标可能完全对不上。更稳妥的做法是录制时保存"游戏逻辑坐标"而非"OS 屏幕坐标",回放前再对当前机器的 DPI 和执行环境重新校准确认一次。这些都是拿血泪换来的,配置清单我放到下一节。

5. 手势控制台与自动化测试:让模拟变成工程能力

5.1 EditorWindow手势控制台设计

有了注入能力和坐标换算,下一步是把它变成能反复使用的编辑器窗口,而不是每次临时写脚本。我做一个MultiTouchSimulatorWindowEditorWindow,左侧摆手势按钮,右侧显示当前触摸状态。

窗口顶部放几个常用手势的快捷按钮:单指点击、双指捏合放大、双指捏合缩小、双指旋转、三指滑动。每个按钮对应一个预设的"手势脚本",点击后先做一次校准(如果还没校准过),然后把预先定义好的坐标序列灌进去。

窗口布局大概长这样:

public class MultiTouchSimulatorWindow : EditorWindow { [MenuItem("Tools/Multi-Touch Simulator")] static void Open() { GetWindow<MultiTouchSimulatorWindow>("MultiTouch Sim"); } private void OnGUI() { if (GUILayout.Button("校准:鼠标移到Game视图左上角后点这里")) { CalibrationManager.CapturePointA(); } if (GUILayout.Button("校准:鼠标移到Game视图右下角后点这里")) { CalibrationManager.CapturePointB(); } GUILayout.Space(8); if (GUILayout.Button("单指点击")) { GesturePlayer.Play(GestureLibrary.Tap()); } if (GUILayout.Button("双指捏合缩小")) { GesturePlayer.Play(GestureLibrary.Pinch(120f, 40f)); } if (GUILayout.Button("双指捏合放大")) { GesturePlayer.Play(GestureLibrary.Pinch(40f, 120f)); } if (GUILayout.Button("双指旋转")) { GesturePlayer.Play(GestureLibrary.Rotate(60f)); } } }

注意EditorWindow里如果用协程播放手势,不要直接StartCoroutine,编辑器窗口没有 MonoBehaviour 生命周期,需要用EditorApplication.update按帧推进状态机,或者引入EditorCoroutines包。我倾向自己维护一个GesturePlayer单例,内部用一个Queue<GestureFrame>和一个float计时器。

5.2 手势序列录制与回放

比写死手势更有价值的是录制。在真机上跑一遍问题手势,然后把触摸流录下来,回到编辑器里回放,这是排查触摸 Bug 的杀手锏。录制的数据结构按照"帧"来组织:

public struct TouchFrame { public uint pointerId; public NativeTouchInjector.TouchPhaseEx phase; public Vector2 gamePos; // 游戏逻辑坐标 public float timestamp; } public class GestureRecord { public List<TouchFrame> frames = new List<TouchFrame>(); }

录制时,你在业务代码里把Input.touches的每帧数据写到文件里:记录fingerIdphaseposition,以及相对起始时间的时间戳。回放时,GesturePlayer根据时间戳推进,把gamePos转成 OS 屏幕坐标,再调用NativeTouchInjector.Send

这里有个容易忽略的点:回放前所有坐标都要先做一次校准,而且最好在同样的 Game 视图分辨率下回放。如果录制时是竖屏 1080x1920,回放时 Game 视图切成了横屏 1920x1080,手势形状就完全走样了。我项目里的做法是录制文件头里写入分辨率,回放前检查当前 Game 视图分辨率是否匹配,不匹配就弹窗强制用户切换,否则后面排查全是浪费时间。

5.3 PlayMode测试里的多点触控断言

把注入接到 Unity Test Framework 里,就能写出可重复的多点触控自动化测试。测试脚本需要放在PlayMode测试程序集,并且引用UnityEditor.TestTools。一个测试双指缩放改变相机 FOV 的例子:

using System.Collections; using NUnit.Framework; using UnityEditor; using UnityEditor.TestTools; using UnityEngine; using UnityEngine.TestTools; public class MultiTouchPlayModeTests { [UnityTest] public IEnumerator TwoFingerPinch_ChangesOrthographicSize() { // 确保Game视图最大化、分辨率一致 var gameView = EditorWindow.GetWindow<GameView>(); gameView.maximizeOnPlay = true; gameView.Focus(); // 等Play Mode真正启动 yield return new EnterPlayMode(); yield return null; // 假设已通过CalibrationManager拿到映射 var map = CalibrationManager.CurrentMapping; var center = map.GameToOS(new Vector2(Screen.width / 2f, Screen.height / 2f)); float halfStart = 100f; float halfEnd = 200f; var camera = Camera.main; float fovBefore = camera.fieldOfView; uint frame = NativeTouchInjector.NextFrameId(); NativeTouchInjector.Send(0, NativeTouchInjector.TouchPhaseEx.Began, center.x - halfStart, center.y, frame); NativeTouchInjector.Send(1, NativeTouchInjector.TouchPhaseEx.Began, center.x + halfStart, center.y, frame); yield return null; NativeTouchInjector.Send(0, NativeTouchInjector.TouchPhaseEx.Moved, center.x - halfEnd, center.y); NativeTouchInjector.Send(1, NativeTouchInjector.TouchPhaseEx.Moved, center.x + halfEnd, center.y); yield return null; NativeTouchInjector.Send(0, NativeTouchInjector.TouchPhaseEx.Ended, center.x - halfEnd, center.y); NativeTouchInjector.Send(1, NativeTouchInjector.TouchPhaseEx.Ended, center.x + halfEnd, center.y); yield return new ExitPlayMode(); Assert.Greater(camera.fieldOfView, fovBefore, "双指外扩应该放大FOV,但业务逻辑没生效"); } }

注意测试里GameView这个类型同样来自反射或UnityEditor命名空间内的公开类型(不同 Unity 版本可见性不同),我在脚本里统一用一个GameViewHelper封装所有反射调用,避免测试代码散落一堆Type.GetType。如果某些版本里GameView.maximizeOnPlay不能直接访问,就退回到 SerializedObject 设置同名属性。

这样的测试跑起来之后,相当于把"手势"从玄学变成了可断言的对象。我可以直接告诉同事:捏合手势的回归测试挂在 CI 上了,谁改了相机缩放逻辑,跑一遍TwoFingerPinch_ChangesOrthographicSize就能抓出来。这对移动项目来说是实打实的工程价值。

5.4 让这套东西在不同机器上稳定运行的配置清单

模拟方案最大的敌人是环境差异。我在多个项目里折腾过之后,总结出一份避坑配置清单,照着做基本不会出幺蛾子:

配置项建议值原因
Windows 缩放固定 100% 或 200%,不要用 125%/150%非整数缩放会让像素换算出现半个像素误差
Game 视图分辨率固定项目常用分辨率(如 1080x1920)回放坐标依赖录制分辨率
Maximize on Play开启减少窗口矩形计算误差
多显示器测试时只保留主显示器跨 DPI 拖动会导致坐标偏移
校准时机每次打开编辑器、每次切换显示器后都重新校准窗口布局变化会影响窗口矩形
注入时机只在 Play Mode 下注入Edit Mode 下没有 Game 视图内容区,注入了也白搭
Editor 语言/主题不影响,但主题变化可能改变 toolbar 高度如果依赖反射拿工具栏高度,建议用校准法绕过

配置清单本身不是万能的,它只是把变量控制住。真正突然冒出来的问题,往往出在"我以为配置对了但实际没对"的情况,所以我把校准功能做成了一个高频可见的菜单按钮,每次调试前面 10 秒先校准,后面能省下一小时。

6. 替代路径与硬件级翻车实录:哪些能救急哪些是错觉

6.1 Unity Remote没那么香

Unity Remote 是官方提供的移动设备预览方案:手机上装 Unity Remote App,USB 连接开发机,编辑器里的游戏就可以收到手机的触摸输入。它确实支持多点触控,但如果你把目标定成"脱离真机在编辑器里模拟"就冲突了——它必须有一台真机,只是把触摸数据传回编辑器而已。

实际体验中它还有两个烦人的问题。第一是延迟,触摸经过无线或 USB 传输到编辑器,体感比真机直接运行慢一拍,做手势跟手性测试基本不可信;第二是它会把整个手机的输入事件全量转发,偶尔还会出现编辑器窗口没聚焦时触摸丢失的情况。它的场景更适合"给别人演示一个没有触摸屏的桌面项目"或者"快速看一版 UI 在手机上的效果",不适合做严格的手势逻辑回归。

6.2 自定义InputWrapper:能骗业务代码,骗不了管道

另一种常见路线是做一个静态类封装,比如MyTouchInput.GetTouches(),内部在编辑器里返回伪造数据,在真机返回Input.touches。这招对"项目还处于开发早期、所有触摸读取都走同一个入口"的情况非常有效,因为它完全不依赖操作系统,跨平台、跨编辑器版本都稳定。

但它有两个硬伤。第一,它只能骗过那些通过这个入口读触摸的业务代码;如果项目里有人写了原生的Input.GetTouch,或者第三方插件直接读取 Input,你的"假触摸"就穿帮了,而新接手的同事大概率会在某个角落绕过你的封装。第二,它伪造的是数据快照,不是事件流。TouchPhase的转换、deltaTime的计算、tapCount的累积,这些都得你自己维护,维护成本随着手势复杂度线性上升,最后你等于在重新发明一个输入后端。

我的结论是:只拿它给 UI 编辑器里的预览用,或者没有任何原生依赖的纯逻辑单元测试;一旦要测试完整链路(输入采集 → 手势识别 → 业务响应),还是回到系统级注入。

6.3 不少人的错觉:新Input系统的触摸模拟能喂给旧Input?

Unity 新 Input System 自带了一个触摸模拟功能,叫 "Simulate Touch Input From Mouse",可以按住 Ctrl 加鼠标左键模拟第二根手指,按住 Alt 加鼠标左键模拟第三根手指,看起来好像已经解决了多点触控模拟的问题。

但这里有个关键前提:它只对使用UnityEngine.InputSystem的代码生效。新 Input System 的触摸模拟是在托管层创建了一个虚拟输入设备,事件只进新 Input System 的事件管道,不会变成 Windows 的WM_POINTER消息,因此旧式 Input 的Input.GetTouch完全看不到这些数据。哪怕你把 Player Settings 里的 Active Input Handling 设成Both,让两套系统同时启用,新系统的"虚拟触摸"也不会出现在旧系统的Input.touches里,两边只是共享真实硬件输入源而已。

如果你的项目已经在用新 Input System,那它的模拟方案值得用,但记得它模拟的触点是有上限的,而且对压力、接触面积的支持很有限。如果你像本文场景一样必须留在旧式 Input,就别指望这条捷径了。

6.4 硬件端的反面教材:驱动Bug提醒我们为什么需要模拟

最后聊一个我在实际项目中遇到的硬件翻车事件,也是我下定决心做这套模拟工具的直接导火索。当时在 Linux 的某台一体机上调试触摸交互,设备用的 eGalaxTouch 触控屏驱动,单指一切正常,但只要两根手指同时落下,第二根手指的坐标就开始抽风:一会儿跳变到屏幕边缘,一会儿抬起事件压根不触发,导致页面上残留一个永远按住的假触点。应用层用Input.touchCount排查是正常的,但坐标数据已经不可信了,最终定位到是驱动上报的多点触控协议数据有瑕疵,和我们的业务代码无关。

这种问题说明了一个残酷的事实:即使你严格遵守了所有输入规范,硬件和驱动层面的实现也可能喂给你脏数据。如果调试时没有一套独立于硬件的模拟注入工具,你很难分清"应用层收到的是垃圾数据"和"应用层处理垃圾数据出了 Bug"这两件事。模拟注入的价值不只是省一台真机,它更是你手上唯一一个"数据源绝对干净、手势绝对可复现"的实验台。等模拟方案把业务逻辑验证到没问题之后,再上真机做最终验收,你会发现问题定位快得多。

我到现在依然保留真机测试环节,但从不在没有模拟验证的情况下直接上真机。编辑器里先把手势跑顺,再用模拟注入跑一遍自动化回归,最后真机抽查驱动兼容性,这套流程下来,多指手势相关的 Bug 基本能在冒烟阶段就暴露干净。最后说句实在话:这套方案第一次跑通时,看到Input.touchCount同时蹦出 2 个触点,我整个人是长舒了一口气的——从那以后,我再也没有为了双指缩放手势把同事喊过来借手指了。

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

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

立即咨询