Unity 的新输入系统(Input System)是 Unity 2019 年开始正式入包的一套输入方案,用来替代老旧的 Input Manager。我在项目里从“老一套”迁到新系统的时候,第一反应是:没事折腾什么?等真把 Action Map、Action、Binding 这些概念理顺之后,我只想说:真香。这篇文章不打算照着官方文档念,我会把安装、配置、代码接入、常见报错这些在实际项目中会用到的东西都拆开讲一遍,尽量给你一套可以直接照抄的流程。如果你正卡在“装上包但是没有反应”“新 API 一直报错”这种阶段,或者项目刚起步想一步到位,这篇文章应该能帮上忙。
1. 新输入系统到底替我们解决了什么
1.1 旧输入管理器的硬伤
新系统出来之前,Unity 的输入方式说白了就是Input.GetAxis("Horizontal")这一套。代码确实很简单,但真正做起项目来,你会撞上一堆暗坑:
- 字符串是硬编码的,
"Horizontal"拼错一个字母,编译器不会报错,游戏里就是没反应; - 键盘、手柄、移动触屏要分开适配,手柄玩家改键位还得自己写一套保存和读取逻辑;
- 复合操作,比如“按上+攻击”出必杀技,基本要靠自己拼状态机;
- VR、触控笔这类新设备的支持,旧系统几乎帮不上忙。
旧系统真正让人难受的,是“输入来源”和“游戏逻辑”完全耦合。你想要支持玩家自定义键位,必须自己写一套映射类,把“物理按键”翻译成“游戏动作”。很多项目里这套映射代码东一块西一块,最后变成谁也看不懂的状态。
新输入系统就是冲着这些痛点来的。它把“物理按键”和“语义动作”彻底拆开,绑定关系交给可视化编辑器,代码里只管消费“动作事件”。你不用再盯着W键想逻辑了,你想的是Move这个动作。
1.2 新系统的核心设计:解耦之后带来的连锁收益
新输入系统最重要的设计思路,就是把声音、键盘、手柄、触屏这些物理输入来源,和游戏内语义动作彻底解耦。
打个比方。旧系统像是餐厅后厨直接写死“今天用西门子的烤箱”,明天想换美的,整个后厨电路都要改。新系统则像是规定了“我要一台能烤到 200 度的烤箱”,至于哪个牌子、什么型号,配置表里随便换,后厨不动。
这个解耦带来的直接好处很实际:
- 换平台只换 Binding 配置,逻辑代码不动;
- 玩家改键不用重新编译,运行时用重绑定 API 就能完成;
- 多套操作方案(键鼠、手柄、触屏)可以通过 Control Scheme 随时切换;
- 输入事件是回调式的,不用每帧反复
GetAxis; - PlayerInput 组件原生支持本地多人,每个玩家绑定一套设备。
下面这个对比表,基本能概括两套系统的差异:
| 对比项 | 旧 Input Manager | 新 Input System |
|---|---|---|
| 输入来源 | 固定映射,改键困难 | Action + Binding,可视化配置,可运行时重映射 |
| 设备支持 | 键鼠为主,手柄触屏支持弱 | 键盘、鼠标、手柄、触屏、XR 控制器全覆盖 |
| 事件机制 | 每帧轮询 | started / performed / canceled 事件回调 |
| 多玩家 | 基本没有 | PlayerInput 原生支持 |
| 复合绑定 | 自己写逻辑 | 内置 2D Vector、Dpad、Button 等复合绑定 |
| 运行时可调试 | 弱 | Input Debugger 可视化调试 |
这段对你意味着什么?简单说,新输入系统学习成本比旧系统高一些,因为它不再是“写到哪算哪”的脚本,而是要先做配置,再用配置驱动代码。但一旦你适应这个节奏,后面所有输入相关的工作都会轻松很多。
2. 安装与启用:开工前必须过的两道关
2.1 通过 Package Manager 安装
新输入系统是官方 Package,不是默认内置到每个项目里的,但你不需要去官网下载什么插件,直接在包管理器里装就行。
操作路径:
- 打开菜单
Window > Package Manager; - 左上角包类型选
Unity Registry,搜索Input System; - 点
Install,装最新的稳定版; - 等编译完成。
如果你习惯用 manifest.json 管理依赖,也可以直接在Packages/manifest.json里加一行依赖:
{ "dependencies": { "com.unity.inputsystem": "1.7.0" } }Unity 启动时会自动解析并安装。我自己更推荐用 Package Manager 操作,因为你可以直接看到版本号、更新时间、依赖关系,心里更踏实。
装完之后,别急着写代码。还有一道关键的开关等着你。
2.2 Active Input Handling:新旧系统的总开关
装完包之后,进Project Settings > Player > Other Settings,往下拉,找到Active Input Handling。
这里有三个选项:
| 选项 | 含义 | 适用场景 |
|---|---|---|
| Input Manager (Old) | 只用旧系统 | 老项目短期不想动 |
| Input System Package (New) | 只用新系统 | 新项目,或旧代码已清理干净 |
| Both | 两套同时启用 | 迁移过渡期,新旧代码并存 |
我给的建议:
- 新项目,直接选
Input System Package (New),不要给自己留退路; - 如果项目里还有老代码在用
Input.GetKey,或者用了依赖旧 API 的第三方插件,就选Both过渡。
这里有一个极其容易踩的坑:改完Active Input Handling必须彻底重启 Unity Editor,不是退出场景、不是切回主窗口,而是把整个编辑器关掉重开。不重启就直接用新 API,你有大概率会撞到后面第六节里说的那个InvalidOperationException。
我同事以前装完包,改了设置不重启,在脚本里死活找不到UnityEngine.InputSystem这个命名空间,还怀疑是包没装上。其实包早就装好了,就是设置没生效。
提示:切换 Active Input Handling 之后,所有 C# 脚本会重新编译。如果项目很大,第一次编译会有点慢,这很正常,耐心等它编译完,不要中途去取消。
2.3 创建 Input Action Asset
新输入系统里,一切配置的中心是 Input Action Asset,也就是.inputactions文件。它把你游戏里会用到的所有动作和物理按键的映射关系,都放在这个文件里管理。
创建方式:
- 在 Project 窗口空白处右键;
- 选择
Create > Input Actions; - 生成一个
.inputactions文件,给它起个名字,比如GameInputActions。
双击打开这个文件,会弹出 Input Action 编辑面板。第一次打开你会看到默认生成了一个 Action Map 和一个 Action。别急着删,先理解面板上这几层概念,后面就好办了。
3. Action 资产:把三层概念一次捋清楚
3.1 Action Map:一组动作的集合
Action Map 是一组相关动作的集合。你可以想象成不同“游戏状态”下的操作面板:
Gameplay:移动、跳跃、攻击、交互;UI:导航、确认、返回;Menu:暂停菜单里的上下左右选择和确认。
把不同状态的动作放进不同 Map,切换状态时整体启用或禁用,会非常干净。
举个例子,玩家在战斗中按 Alt 弹出暂停菜单,如果GameplayMap 还在启用状态,角色可能还会继续移动,甚至还会开枪。所以切换状态时必须做整体切换:
playerInput.SwitchCurrentActionMap("UI");或者手动控制 Map 的启用与禁用:
inputActions.Gameplay.Disable(); inputActions.UI.Enable();在实际项目里,Map 别建太多。我见过有人把一个游戏的所有动作塞进一个 Map,第一个原因就是懒,第二是图省事,结果到了后期切换状态全是 bug。合理的 Map 划分,应该跟你的游戏状态机保持一致。
3.2 Action:游戏世界里的语义动作
Action 是真正传达给游戏逻辑的东西。比如Move动作,物理按键可以是 W/A/S/D,也可以是手柄左摇杆,但对游戏来说,它就是一个Vector2方向的输入。
Action 有三种类型:
| Action Type | 说明 | 典型用途 |
|---|---|---|
| Value | 持续读值,比如 Vector2、float | 移动轴、扳机力度 |
| Button | 瞬间触发事件(按下、抬起) | 跳跃、射击、交互 |
| Pass Through | 不过滤,原样透传输入 | 特殊姿态、调试数据采集 |
关键要搞清楚Value和Button的区别:
Button更强调“事件”。比如跳跃,你不是每帧去读“空格按没按”,而是等待一个“按下”事件被触发;Value更适合每帧采样。比如移动摇杆,你希望知道当前摇杆推了多少。
所以做移动,用Value+Vector2;做跳跃,用Button+ 监听performed事件。乱选类型会导致行为诡异,比如把移动做成了 Button,你会发现角色只能走一下停一下。
3.3 Binding:把物理输入映射到动作上
每个 Action 可以挂多个 Binding。以Move为例,你可以挂四个键盘 Binding(W/A/S/D),再挂一个手柄左摇杆 Binding,完全没问题。
在编辑面板里点Add Binding,会弹出一个设备通道列表,覆盖键盘、鼠标、手柄、触屏、XR 控制器等。找到你想要的按键或摇杆,选中即可。
这里有几个非常实用的配置经验:
- Binding 的
Composite类型里有一个2D Vector,可以把“上、下、左、右”四个方向绑到一个 Vector2 上,适合做通用平台移动; Interactions面板可以给 Action 添加Press、Hold、Tap等交互定义。比如Hold1 秒后再触发,适合做蓄力攻击;Processors面板可以在输入进入游戏逻辑前做处理,比如Normalize、Scale等。
配置完之后,一定要点Save Asset。我早期经常改完 Binding 不保存就切出去写代码,结果代码怎么都读不到输入,排查了半天,发现资产根本没存上,编辑器一刷新就全丢了。
3.4 Control Scheme:一键切换设备方案
Control Scheme 解决的是“同一份输入资产支持不同设备类型”的问题。比如玩家在电脑上用键鼠,插上手柄以后希望优先用手柄操作,拔掉手柄再自动回到键鼠。
在资产编辑器左上角Control Schemes里,可以新建KeyboardMouse和Gamepad两个方案。创建之后,每个 Binding 上都可以勾选它属于哪个方案。
运行时切换方案也很简单:
inputActions.bindingMask = InputBinding.MaskByGroup("Gamepad");如果你用了 PlayerInput 组件,设备插入和断开时它会自动处理切换。对于 PC 游戏,这基本是必备能力,不然玩家插了手柄还要自己去设置里选设备,体验会差很多。
4. 代码接入的三种主流方式
4.1 方式一:序列化字段拖引用
最直观的方式,适合快速原型。
在 MonoBehaviour 里声明InputActionReference:
using UnityEngine; using UnityEngine.InputSystem; public class PlayerMovement : MonoBehaviour { [SerializeField] private InputActionReference moveAction; [SerializeField] private InputActionReference jumpAction; private void OnEnable() { moveAction.action.Enable(); jumpAction.action.Enable(); } private void OnDisable() { moveAction.action.Disable(); } private void Update() { Vector2 move = moveAction.action.ReadValue<Vector2>(); transform.position += new Vector3(move.x, 0f, move.y) * Time.deltaTime; } }然后回到 Inspector,把.inputactions资产里对应的Move、Jump动作直接拖到字段上就行。
优点:简单直接,字段类型清晰,适合快速验证。缺点:一个字段只对应一个动作,游戏动作多了,字段数量会爆炸。
4.2 方式二:生成 C# Class
这是我个人最推荐的方式。选中.inputactions资产,在 Inspector 最下方勾选Generate C# Class,设置好类名和命名空间,点Apply,Unity 会自动生成一个以资产名为基础的类文件。
生成之后,代码里直接实例化这个类:
using UnityEngine; using UnityEngine.InputSystem; public class PlayerMovement : MonoBehaviour { private GameInputActions inputActions; private Vector2 moveInput; [SerializeField] private float moveSpeed = 5f; private void Awake() { inputActions = new GameInputActions(); } private void OnEnable() { inputActions.Gameplay.Move.performed += OnMove; inputActions.Gameplay.Move.canceled += OnMoveCanceled; inputActions.Gameplay.Jump.performed += OnJump; inputActions.Enable(); } private void OnDisable() { inputActions.Gameplay.Move.performed -= OnMove; inputActions.Gameplay.Move.canceled -= OnMoveCanceled; inputActions.Gameplay.Jump.performed -= OnJump; inputActions.Disable(); } private void Update() { Vector3 direction = new Vector3(moveInput.x, 0f, moveInput.y); transform.position += direction * (moveSpeed * Time.deltaTime); } private void OnMove(InputAction.CallbackContext context) { moveInput = context.ReadValue<Vector2>(); } private void OnMoveCanceled(InputAction.CallbackContext context) { moveInput = Vector2.zero; } private void OnJump(InputAction.CallbackContext context) { if (context.performed) { // 跳跃逻辑写到这 } } }生成类的核心价值是类型安全:动作名、方法名全是编译期检查,拼错了 IDE 立刻标红,比字符串方案靠谱一万倍。而且每个动作对应的性能开销也更可控。
4.3 方式三:PlayerInput 组件
如果你的项目需要本地多人、设备配对、控制方案切换,直接用PlayerInput组件最合适。
在场景里给玩家角色挂上PlayerInput组件,然后在 Inspector 里关联Input Actions资产。Behavior有三种选择:Send Messages、Invoke Unity Events、Invoke C Sharp Events。
以Send Messages为例,你只需要按动作名写同名方法:
public class PlayerController : MonoBehaviour { public void OnMove(InputAction.CallbackContext context) { Vector2 move = context.ReadValue<Vector2>(); // 移动逻辑 } public void OnJump(InputAction.CallbackContext context) { if (context.performed) { // 跳跃逻辑 } } }注意:Send Messages模式对方法签名有要求。方法名必须和 Action 名完全一致,参数必须是InputAction.CallbackContext。如果回调没触发,先检查名字是否对得上,再检查组件上有没有正确绑定 Action Map。
三种方式怎么选?我的建议:
- 小项目、原型、验证玩法:方式一;
- 正式项目、一个人维护代码:方式二,类型安全且代码清晰;
- 本地多人、设备频繁插拔、玩家加入离开:直接用 PlayerInput,省掉自己管理设备配对的痛苦。
5. 生命周期管理与事件回调的正确姿势
5.1 Enable 与 Disable 的配对时机
新输入系统有一个特性:动作在被Enable之前,是不会反馈任何输入的。这意味着你必须在恰当的时机启用动作。
最稳妥的生命周期搭配是:
Awake里创建实例;OnEnable里订阅事件并Enable;OnDisable里取消订阅并Disable;OnDestroy里做最后的清理,释放资源。
养成这个习惯,可以避免 MonoBehaviour 被禁用时角色还在响应输入。尤其做 UI 暂停菜单时,如果你不把GameplayMap 关掉,角色可能会在你读取菜单的时候继续移动,玩家体感会非常怪。
5.2 CallbackContext:事件背后的状态信息
每次动作触发事件,回调参数都是一个InputAction.CallbackContext,它是你读取输入状态的主要入口。
常用成员:
| 成员 | 含义 |
|---|---|
context.phase | 当前阶段:Started / Performed / Canceled |
context.started | 该帧动作开始 |
context.performed | 该帧动作完成触发 |
context.canceled | 该帧动作取消或松开 |
context.ReadValue<T>() | 读取当前动作的值 |
context.control | 触发事件的物理控件,可以判断来源设备 |
context.action | 当前动作对象 |
一个容易出错的地方是区分started和performed。
对于 Button 型动作,按下瞬间通常started和performed都会触发。如果你想在“按键被按下”时只处理一次,就在performed里做;如果你想处理“按下后持续按住”的逻辑,用started配合读取状态会更好。
另外,context.control在排查设备问题时很好用。比如玩家用手柄还是键鼠触发的事件,直接判断这个值就行,不用自己再维护一套设备状态。
5.3 轮询还是事件?别每帧 ReadValue 读个不停
新输入系统保留了ReadValue这种轮询方式,但如果在Update里对Value类型的动作频繁调用ReadValue,性能不会比旧系统好多少,依然每帧做设备状态查询和绑定解析。
更好的模式是事件驱动。
拿移动举例:
performed事件里,把最新移动向量保存到成员变量;canceled事件里,把移动向量清零;Update里只读取这个成员变量,用来驱动角色移动。
跳跃这种低频动作更简单,直接在performed回调里触发逻辑,连 Update 都不用来回读。
我见过有些项目把十几个动作全部在 Update 里ReadValue,逻辑混成一大坨,后面改成事件驱动,代码清晰了不少,性能也更稳定。当然,特殊场景还是有轮询需求的,比如你想在游戏启动时检测“这一帧玩家按了任意键进入主菜单”,直接用InputSystem.onAnyButtonPress更合适。
6. 常见问题与排查实例
6.1 报错:You are trying to read Input using the UnityEngine.Input class
这是新系统最著名的报错之一。暴力的意思是:你当前的Active Input Handling已经切到了新系统,但代码里还在调Input.GetKey、Input.GetAxis这类旧 API。
解决办法有三种:
- 把旧代码改成新 API;
- 在 Player Settings 把
Active Input Handling改成Both,让旧 API 重新可用; - 改完设置后重启编辑器。
如果你选Both,要注意:新旧系统同时启用会增加一层事件分发开销,虽然不大,但长期还是建议尽早把旧代码清理干净。
6.2 UI 点击没反应:EventSystem 组件要换
新系统下 UI 不响应点击,十有八九是 EventSystem 上挂的还是旧的Standalone Input Module。
需要换成Input System UI Input Module,这是新系统包自带的一个组件。
实际操作:
- 在场景中找到 EventSystem 对象,没有的话就创建一个空物体,挂上
EventSystem组件; - 删除旧的
Standalone Input Module; - 添加
UnityEngine.InputSystem.UI.InputSystemUIInputModule; - 确保 UI 元素在正确的 EventSystem 下。
这个坑非常常见。很多人装了新系统,场景里的 EventSystem 还是旧的,代码里没有报错,但鼠标点击、键盘导航全部失效,排查到最后才发现是组件不对。
注意:如果你的 EventSystem 上同时存在 Standalone Input Module 和新组件,也是不行的。移除旧的,只保留新的。
6.3 生成了 C# 类但脚本里引用不到
常见原因有三个:
- 勾选了
Generate C# Class但没点Apply,生成没有生效; - 类名和项目里已有脚本重名;
- 改了命名空间,但脚本里没有
using对应的命名空间。
解决方案是先检查生成出来的文件在哪里,通常在资产同级目录。找不到就取消勾选,重新勾选再 Apply 一次,强制重新生成。类名冲突的话,在 Inspector 里改一个不重复的类名,再 Apply。
6.4 手柄绑定无效,或者只有键鼠能用
先别怀疑代码,多数是 Binding 配置的问题。
打开资产编辑器,双击对应动作,确认:
- 手柄 Binding 确实存在;
- Binding 的 Control Scheme 没有把 Gamepad 排除掉;
- 手柄在系统层能被识别。
Windows 下如果手柄完全没事件,先打开Window > Analysis > Input Debugger,里面会列出当前所有已接入的输入设备及状态。如果 Input Debugger 里都看不到手柄,那是操作系统层面的设备识别问题,跟 Unity 无关。
如果调试器里能看到手柄但动作不触发,那大概率是 Binding 没配对。检查动作面板里的绑定树,看有没有匹配到手柄的通道。
6.5 多玩家场景下不同玩家互相抢输入
本地多人游戏中,两个玩家共用同一个动作资产,会导致设备输入被抢。正确做法是用PlayerInput组件生成多个玩家实例,并对每个实例做设备配对。
常用接口:
playerInput.user.AssignDevice(device):把某个设备分配给指定玩家;InputUser.PerformPairingDevice(device):把设备配对给某个用户。
如果不用 PlayerInput,就得自己写设备分发逻辑,根据每个设备发出的输入判断该响应哪个玩家。这个逻辑本身不难,但容易在边界情况上翻车,比如一个玩家拔了手柄、另一个玩家插了新手柄,状态错乱会非常头疼。
7. 旧项目迁移与性能优化建议
7.1 渐进式迁移:先 Both 后 New
我不建议把一个大型项目一次性切到新系统。比较稳的流程是这样的:
- 先把
Active Input Handling设为Both,让新旧代码共存; - 新建
.inputactions资产,先把主角的核心动作(移动、跳跃、交互)配好; - 用生成 C# 类的方式接入,逐模块替换旧输入代码;
- 确认所有旧的
Input.GetAxis、Input.GetKey调用清零后,再把Active Input Handling切到 New。
这个流程大概需要一到两周,具体看项目规模。最痛苦的是旧插件和商店资源,因为有些第三方资源内部用了旧 API,在Both模式下不报错,一旦切到 New 就会现原型。
我建议在切换前全局搜索Input.Get、Input.mousePosition这类调用,能改就改,不能改的插件要考虑是否放弃。
7.2 性能:别在 Update 里轮询,也别频繁 Enable / Disable
前面已经强调过,事件驱动优于每帧轮询。这里再补充一个点:不要频繁在运行时创建InputAction对象,也不要每帧反复调用 Enable 和 Disable。
Enable/Disable 内部有状态注册和事件分发的开销。如果这个调用出现在每帧逻辑里,会对性能产生明显影响。正确的做法是在状态切换时做整体的 Map 禁用/启用,比如打开暂停菜单时一次性把GameplayMap 禁用,关闭菜单时再启用。
还有一些细节:
- 不要在
Update里反复创建InputActionReference或者通过字符串查找动作; - 使用生成类时,动作实例已经固定,开销是可控的;
ReadValue<T>()比事件回调更贵,需要高频读取时尽量通过事件把值缓存下来。
7.3 善用 Input Debugger 和重绑定 API
开发阶段,Window > Analysis > Input Debugger是排查输入问题的神器。
它能列出所有已接入的设备、每个动作的当前状态、最近触发的事件。我看到它处理过的诡异问题包括:手柄摇杆漂移、按键冲突、设备插拔后事件丢失等。
玩家自定义键位功能,新系统自带重绑定 API:
var rebindOperation = moveAction.PerformInteractiveRebinding() .WithTargetBinding(bindingIndex) .Start();重绑定完成后,把结果保存到 PlayerPrefs 或存档里,下次启动时恢复。这套方案旧系统完全做不到,也是新系统真正值得迁移的理由之一。
我自己的项目里,玩家改键功能就是靠这个 API 做的,UI 上提示“按新按键”,然后调用重绑定,非常简单,省掉了自己管理按键映射的整块逻辑。
最后分享一点个人心得。从旧系统迁到新系统,我前两周确实很难受,整天在 Binding 里折腾,觉得“为什么这么麻烦”。但等第一个项目跑顺之后,后续所有输入相关工作都变得非常轻松:玩家改键、手柄切换、多玩家配对,全部从配置层和组件层解决,代码里几乎不需要写映射逻辑。
如果你正在一个新项目的起点,别犹豫,直接上 Input System;如果你在维护老项目,按第七节那个流程慢慢迁,不要一步跨太大。最后记住一点:遇到输入行为诡异,先开 Input Debugger,看设备有没有识别到、动作有没有触发,再决定改代码还是改配置。这个习惯,能帮你省下大量排查时间。