
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.touches、Input.touchCount、Input.GetTouch(i)看起来像是一套托管数组但实际上数据来自引擎 C 原生层每帧刷新的一组触摸快照。你没法像改一个静态字段那样把Input.touches硬塞进去引擎每帧都会用原生数据覆盖回来。所以模拟的本质只有一条路在数据源头上让操作系统认为真的有手指落在目标窗口上剩下的翻译工作交给 Unity 自己的 Windows 输入后端。1.3 这篇内容解决什么问题、不解决什么问题我要做的是在 Windows 上运行的 Unity Editor 里通过系统级触摸注入让旧式 Input 系统完整读到多点触控数据流。它既不需要改业务代码也不需要额外硬件唯一要求是 Windows 8 以上的操作系统。使用新 Input SystemUnityEngine.InputSystem的项目不在本文讨论范围内虽然我可以顺手给你指一条新系统里已经有触摸模拟的捷径但它和旧式 Input 的数据管道不互通这一点后面专门讲。文章会按原理 → 主方案 → 坐标坑 → 工程化 → 替代路径的顺序展开代码部分以 C# 和 Win32 P/Invoke 为主你可以直接抄走改改就用。2. 旧式Input的触摸数据管道先搞明白你要骗过谁2.1 Input.GetTouch背后是原生后端不是托管数据要模拟你得先弄清楚旧式 Input 的数据从哪来。Input.GetTouch(i)返回的Touch结构体里fingerId、position、phase、tapCount、deltaTime、pressure、radius这些字段其实都是每一帧从引擎原生层拷贝过来的。引擎在 Application 主循环里会做一次输入采集把当前帧所有触摸点整理成数组托管侧的Input静态类只是这个数组的读卡器。这意味着两件事。第一你在帧中间去改Input.touches是不可能的下一帧原生层会把数据重新覆盖第二任何纯托管层面的反射、Hook、DLL 注入方案如果不触及原生输入采集都不可能真正骗过Input.GetTouch。市面上确实有人用 unsafe 指针去改底层数组但那属于踩引擎私有内存的地雷升级一次 Unity 版本就碎一次不建议碰。这里要顺带提醒Touch结构体里的pressure和radius在旧式 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_POINTER或WM_TOUCH两类触摸消息。也就是说当你的开发机插上一块真实触摸屏、或者用支持触摸的笔记本跑编辑器时旧式 Input 是能读到多指数据的——不是它不支持多指模拟而是它没有给你提供凭空捏造多指数据的现成开关。这样一来突破口就清楚了如果我能从操作系统层面把触摸事件注入到系统输入队列让 Unity 编辑器所在的窗口收到合法的触摸消息那么 Unity 原生后端就会把它当作真实触摸来处理Input.GetTouch自然就会返回对应数量的触点。整个过程完全不需要改动引擎任何内部状态业务代码也无法分辨这是真手指还是注入的假手指。3. 主方案Windows Touch Injection把触摸灌进Game视图3.1 InjectTouchInput到底是干什么的Windows 8 开始系统提供了一组触摸注入 API核心是InitializeTouchInjection和InjectTouchInput。很多做自动化测试和远程协助的工具都靠它来模拟触摸它产生的事件和真实触摸一样走 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否则初始化直接失败dwMode传TOUCH_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_CANCELED0x800不过旧式 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.mousePosition和Touch.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 PlayPlay 模式下最大化并且在进入 Play 模式前把 Game 视图拖成独立浮动窗口。这样窗口矩形几乎就等于你的渲染区域还差一个标题栏和顶部工具栏坐标误差降到最小适合做可复现的自动化。代价是你在调试时看不到其他 Editor 窗口但为了测试稳定性这个取舍值得。4.3 更稳的做法用鼠标校准两点求出仿射映射与其和反射、DPI、工具栏高度搏斗我更推荐一个暴力但可靠的校准法。既然你最终要用Input.mousePosition游戏坐标和 Win32 光标坐标OS 屏幕坐标建立对应关系那就让用户亲手提供两个已知对应点然后解出缩放系数和原点偏移。具体操作步骤是进入 Play Mode保持 Game 视图可见把鼠标移到 Game 视图内的左上角区域触发一次记录比如按快捷键或点 EditorWindow 上的按钮程序同时读取Input.mousePosition记为gameA和GetCursorPos记为osA再把鼠标移到右下角区域记录gameB和osB。两组对应点解一个线性映射// 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手势控制台设计有了注入能力和坐标换算下一步是把它变成能反复使用的编辑器窗口而不是每次临时写脚本。我做一个MultiTouchSimulatorWindow的EditorWindow左侧摆手势按钮右侧显示当前触摸状态。窗口顶部放几个常用手势的快捷按钮单指点击、双指捏合放大、双指捏合缩小、双指旋转、三指滑动。每个按钮对应一个预设的手势脚本点击后先做一次校准如果还没校准过然后把预先定义好的坐标序列灌进去。窗口布局大概长这样public class MultiTouchSimulatorWindow : EditorWindow { [MenuItem(Tools/Multi-Touch Simulator)] static void Open() { GetWindowMultiTouchSimulatorWindow(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单例内部用一个QueueGestureFrame和一个float计时器。5.2 手势序列录制与回放比写死手势更有价值的是录制。在真机上跑一遍问题手势然后把触摸流录下来回到编辑器里回放这是排查触摸 Bug 的杀手锏。录制的数据结构按照帧来组织public struct TouchFrame { public uint pointerId; public NativeTouchInjector.TouchPhaseEx phase; public Vector2 gamePos; // 游戏逻辑坐标 public float timestamp; } public class GestureRecord { public ListTouchFrame frames new ListTouchFrame(); }录制时你在业务代码里把Input.touches的每帧数据写到文件里记录fingerId、phase、position以及相对起始时间的时间戳。回放时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.GetWindowGameView(); 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 AppUSB 连接开发机编辑器里的游戏就可以收到手机的触摸输入。它确实支持多点触控但如果你把目标定成脱离真机在编辑器里模拟就冲突了——它必须有一台真机只是把触摸数据传回编辑器而已。实际体验中它还有两个烦人的问题。第一是延迟触摸经过无线或 USB 传输到编辑器体感比真机直接运行慢一拍做手势跟手性测试基本不可信第二是它会把整个手机的输入事件全量转发偶尔还会出现编辑器窗口没聚焦时触摸丢失的情况。它的场景更适合给别人演示一个没有触摸屏的桌面项目或者快速看一版 UI 在手机上的效果不适合做严格的手势逻辑回归。6.2 自定义InputWrapper能骗业务代码骗不了管道另一种常见路线是做一个静态类封装比如MyTouchInput.GetTouches()内部在编辑器里返回伪造数据在真机返回Input.touches。这招对项目还处于开发早期、所有触摸读取都走同一个入口的情况非常有效因为它完全不依赖操作系统跨平台、跨编辑器版本都稳定。但它有两个硬伤。第一它只能骗过那些通过这个入口读触摸的业务代码如果项目里有人写了原生的Input.GetTouch或者第三方插件直接读取 Input你的假触摸就穿帮了而新接手的同事大概率会在某个角落绕过你的封装。第二它伪造的是数据快照不是事件流。TouchPhase的转换、deltaTime的计算、tapCount的累积这些都得你自己维护维护成本随着手势复杂度线性上升最后你等于在重新发明一个输入后端。我的结论是只拿它给 UI 编辑器里的预览用或者没有任何原生依赖的纯逻辑单元测试一旦要测试完整链路输入采集 → 手势识别 → 业务响应还是回到系统级注入。6.3 不少人的错觉新Input系统的触摸模拟能喂给旧InputUnity 新 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 个触点我整个人是长舒了一口气的——从那以后我再也没有为了双指缩放手势把同事喊过来借手指了。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。