架构解析

输入系统 · 架构解析

覆盖:Assets\Revolution\Runtime\RevInput\(Core\ + Facade\ + Implementation\ + Support\)

本篇只讲"为什么":为什么这么设计、为什么这么写代码、每个决策换来了什么、代价是什么、边界画在哪。
想学怎么用 → 看同目录的《使用说明》,那里是手把手,本篇不重复。
面向小白:用大白话讲"不这么做会怎样",不复述代码。

源设计起源 · 为什么要做这个模块

一句话 一套框架要同时伺候键盘 / 鼠标 / 触屏 / 手柄,还要能改键、能被弹窗挡住、手感参数还得可调 —— 输入系统就是把这些"杂事"统一收口,业务只用动作名读输入。

本篇只讲为什么;怎么用见同目录《使用说明》。

0三句话速览(完整用法见《使用说明》)

① 绑定     RevInput.Bind("Jump", RevKey.Space, RevKey.JoystickButton0);   // 业务只认动作名,不认键位
② 订阅     RevInput.OnPressed("Jump", Jump, owner: this);               // 或 AddListener(this) 一次收全部
③ 退订     RevInput.OffAllOf(this);                                    // 事件 + 屏蔽一起清

就这三步。剩下的都是这三步背后的设计取舍。

1定位:通用输入系统,不是帧同步输入

先说清边界,否则后面每条设计都会被问"为什么不按帧同步那套来"。

维度为帧同步设计的输入方案本框架(通用)
输入去哪必须变成可序列化的帧命令(整数/定点载荷),由服务器排序分发就地给业务读(Pressed/Held/Axis),不需要网络层
精度方向必须量化成整数角度(追求确定性)保留模拟量(-1..1 浮点),可做速度/幅度
时间基准一切按帧号(且常以 30fps 折算)算节拍时间以参数进来:deltaTime 算持续、真实时间算窗口
可靠性连续量要 seq + 确认 + 补发 + 补停不需要(本地输入没有丢包)
设备覆盖触屏摇杆 + 手柄(PC 带宏)键盘 / 鼠标 / 触屏 / 手柄 全都要
手势往往被压成"一串整数方向"7 种手势:点击 / 双击 / 长按 / 拖动 / 滑动(八向+速度)/ 捏合 / 旋转
弹窗挡输入通常靠"切模式"粗粒度互斥可精确到指针的屏蔽栈 + 作用域
结论 可复用的是"机制"(设备抽象、来源仲裁、采集与判定分离、零分配、手感参数化),不可复用的是"为确定性付出的代价"(帧命令、整数载荷、帧号节拍、可靠性通道)。本模块只搬前者。

2分层与文件地图

RevInput/
├── Core/            纯 C#(零引擎依赖,可脱机断言)
│   ├── RevInputDefines    词汇表:设备类型 / 相位 / 手势 / 屏蔽 / 错误码 / 全部默认值
│   ├── RevInputCodes      键位码表(名字对齐 Unity KeyCode)+ RevKeyMask(零分配键位掩码)
│   ├── RevInputSnapshot   一帧快照:键位三份掩码 + 鼠标 + 指针数组(复用实例,零分配)
│   ├── RevInputBinding    一个动作的绑定(键位 / 鼠标键 / 轴)+ 预计算掩码
│   ├── RevInputActionTable 绑定表 + 全部动作状态(三态 / 轴 / 连发 / 缓冲 / 文本往返 / 冲突检测)
│   ├── RevGestureRecognizer 7 种手势的识别状态机(阈值全可调)
│   ├── RevInputBlock      屏蔽句柄
│   └── RevInputListener   事件驱动接入面:监听者接口 + 空实现基类(登记一次,输入推给你)
├── Facade/RevInput   唯一入口(业务只读这个)
├── Implementation/
│   ├── RevInputCore  内核:一帧的流水线 + 屏蔽栈 + 事件派发 + 失败上报
│   └── RevInputLog   日志出口(正常路径一条不打)
└── Support/          引擎适配(全模块唯一碰 UnityEngine 的地方)
    ├── RevInputDriver        隐藏宿主:每帧采集 + 失焦/切后台自动复位
    ├── RevInputUnityDevice   默认设备:键鼠 / 触屏 / 手柄 → 快照
    ├── RevInputUnityHooks    进 Play 复位 / 日志接线 / UI 命中判定
    └── RevInputScope         作用域:出块自动解除屏蔽 + 退订事件
一句验收口径 Core/ 与 Implementation/ 里没有任何 UnityEngine / KeyCode / Time. / Input. / #if UNITY_* —— 所以能把这些文件直接链接进普通 .NET 工程跑断言(见第 6 节)。

3一帧到底做了什么

#步骤在哪关键点
1快照 Reset()(保留容量)RevInputCore.Tick定长掩码 + 复用数组 → 零分配
2设备 Poll(snapshot) 写原始输入RevInputUnityDevice设备坏了只隔离它自己(本帧按"无输入"处理)
3判相位 + 算轴(一次遍历算完所有动作)RevInputActionTable.Update位运算命中判定;屏蔽期间动作相位与轴一并按"无输入"处理,但仍记"按下时间"(缓冲不丢)
4手势识别(指针采样 → 7 种手势)RevGestureRecognizer.Update屏蔽期间只跟踪不产出,且不秋后算账
5派发动作事件(按下/抬起)RevInputCore就地 try/catch 隔离;不用 lambda(零闭包分配)
6派发手势事件RevInputCore同上
7派发连发事件(按住按节拍)RevInputCore只有配了 SetRepeat 的动作才有;逐动作查表命中才派
8派发轴事件(只在数值变化时)RevInputCore每个轴记"上次投递值";订阅后第一帧先给一次当前值(方便初始化移动/UI)
9投给监听者(按下 / 抬起 / 连发)RevInputCore按动作表遍历(不依赖有没有人订阅委托:只注册监听者也能收到);逐条隔离
10业务查询(RevInput.Pressed(...))Facade/RevInput全是读结果,不重算

顺序上有三个刻意安排:

4十一个关键设计决策

#决定为什么代价
1键位名与 Unity KeyCode 一一对齐(RevKey.Space …)适配层用 Enum.TryParse 建映射表,不手抄上百行数值(抄错一位就是"跳跃绑到退出"的线上事故)键位名不能随意改(已发布的改键存档按名字存)
2动作名而不是键位改键、屏蔽、缓冲全部在一个抽象上做;业务代码里没有键位字面量启动时要绑一次(或读存档)
3键位掩码 + "绑定并集"轮询每帧只测"绑定用到的键 ∪ 上一帧按住的键"(十几个),而不是无脑测一百多个键键位上限 256(掩码 4×64 位),够用且 MaskFits 会自检
4纯 C# 内核 + 一个采集委托手感参数(死区、双击窗口、滑动阈值、多指抢占)必须能脱机断言;引擎适配层只通过一个采集委托接进来(内核不认识任何引擎类型,也不认识"输入从哪来")内核不能用 Vector2(返回自定结构)、时间要当参数传
5相位只有 4 种(Down/Held/Up/None)少即是多:"双击""长按""连发"都是派生读数(PressedBuffered / Repeat / 手势),不是新相位业务要知道去哪儿找这些派生读数
6输入缓冲窗口(PressedBuffered(action, 0.15s))动作游戏标配的"提前按也算"——落到地面前的跳跃不再白按业务要显式选"用缓冲还是用严格按下"
7屏蔽精确到指针 + 屏蔽期间仍记缓冲① 弹窗挡世界输入但不能挡 ESC ② 屏蔽解除瞬间,缓冲窗口里的输入立刻生效(否则手感会莫名迟钝)屏蔽栈要留 owner(用于一行清理)
8手势八向 + 带速度;双指不做秋后算账斜向滑动是真实需求("左上闪避");而被屏蔽/双指期间开始的手势,抬手时不补发 Tap手势阈值需要按项目调(默认值只是起点)
9热路径零分配(就地问 try/catch,不传 lambda)Guard(() => …) 这类写法每次都要分配一个闭包;输入是逐帧热路径,600 帧零分配是硬指标异常上下文只带动作名(不带拼好的句子)
10失败给原因码,不打日志与框架其它模块一致的契约:正常路径一条日志不打;失败可订阅 RevInput.Failed,要诊断开 VerboseLog排查要主动订阅或开开关(不能指望控制台自己冒日志)
11事件驱动接入面(监听者 + 轴 / 连发事件)业务不该为了收输入而每帧轮询:AddListener 登记一次,按下 / 抬起 / 连发 / 轴变化 / 手势由框架推过来(和另一套输入体系的用法一致);轴只在数值变化时推,且订阅后先给一次当前值监听者按 owner 登记、销毁时一行清理;"事件驱动"与"轮询"两套写法并存(文档要把各自适用场景讲清)

5与参考实现的对照

参考实现是一套为帧同步设计的输入体系(分层清晰、手感观测做得很扎实)。这里逐条说明"借鉴了什么 / 为什么没搬 / 补了什么"。

5.1 借鉴过来的机制

机制在本框架里落成
采集与业务分离(组件只报数据,判定在别处)采集层写快照、内核判定 —— 两层之间只有一个采集委托
手势识别的参数化(阈值可调、可迭代)RevGestureRecognizer 的 8 个阈值字段,全部可运行时改
连续量要有"来源标记"避免多来源打架本模块简化成"屏蔽栈 + 设备替换"(本地输入没有"触屏 vs 手柄同时给同一根轴"的带宽问题)
零 GC 纪律掩码 / 复用数组 / 就地 try-catch / 无 LINQ
"隐藏复位"防卡死(界面隐藏时复位摇杆)升级为失焦 / 切后台自动复位 + ResetAll 手动兜底
观测埋点要带上下文(不是裸事件)RevGestureEvent 自带位置/起点/位移/速度/时长;动作状态自带 HeldSeconds / LastPressedFrame

5.2 刻意没有搬过来的

帧同步的做法为什么不要
输入必须变成 FrameCommand(命令工厂 + ID 特性 + Send)通用项目没有那个同步层;凭空引入间接层只会让每处输入都要绕一圈
载荷禁止浮点(只能短整型/定点)会丢掉模拟量:速度、幅度、镜头相对位移、鼠标 delta 都需要浮点
以帧号(30fps 折算)为节拍(6 帧补发 / 15 帧补停 / 30 帧心跳)通用项目帧率自由;这套节拍在这里只会制造"随时间漂移的阈值"
可靠性通道(seq + 确认 + 补发)本地输入没有丢包,可靠性是纯成本
用"模式状态机 + 一堆 bool 子开关"表达玩法差异会布尔组合爆炸;本模块用"绑定 + 屏蔽 + 手势阈值"表达差异
采集层绑在具体 UI 框架组件上会让输入模块依赖 UI 系统;本模块的采集层与内核之间只有一个采集委托

5.3 参考实现缺、本模块补上的(这类最多)

补的东西解决的问题
键盘 / 鼠标支持通用项目的第一需求
多点触控的 id 出口只报"位置"的多点等于没有多点:屏蔽单指、双指手势都要 id
7 种手势 + 八向滑动触屏项目的日常需求
输入缓冲窗口手感问题里最高频的一类
失焦 / 切后台复位"切回来角色一直跑"的解药
可精确到指针的屏蔽栈弹窗叠弹窗、教程遮罩、拖镜头的手指互不干扰
可断言的内核死区 / 双击窗口 / 滑动阈值 / 多指抢占这些最容易写错的地方,必须能脱机测
绑定文本往返 + 冲突检测玩家改键要能存、要能查冲突

6验收(都是跑出来的)

项结果
Revolution.Runtime 编译0 错误 0 警告(含新模块 15 个文件 / 3,643 行)
Revolution.Editor 编译0 错误(4 条警告是 Unity 测试包自带的旧警告)
纯 C# 依赖检查Core/ Implementation/ Facade/ 里零引擎标识符;#if UNITY_* 共 0 处
工程外行为断言86 / 86 通过(含事件驱动接入 18 条:监听者投递 / 轴只在变化时推 / 连发 / owner 一行清理 / 异常隔离;临时工程链接真实源码 + 一段脚本直接写快照喂帧,跑完即删)
断言覆盖掩码 / 绑定 / 三态 / 轴与死区 / 缓冲窗口 / 连发节拍 / 文本往返无损 / 坏存档拒绝且不落地 / 冲突检测 / 鼠标 / 八向方向 / 7 种手势 / 双指不秋后算账 / 屏蔽精确到指针 / 屏蔽期间保留缓冲 / 作用域出块自动解除 / 事件派发与 owner 清理 / 复位 / 失败原因码 / 600 帧零分配
断言抓到的真 bug① RevInputScope.Dispose 先置位再调用导致清理被跳过(出块后屏蔽没解除 ✗)② PointerCount 没过滤被屏蔽的手指(与 Pointer(index) 下标不一致 ✗)—— 都在提交前修掉

7本期没做的(可扩展)

项说明
虚拟摇杆 UI 组件目前给的是"轴 + 手势 + 屏蔽"的原语;摇杆外观/跟手/防误触属于 UI 层,建议在 UI 系统里实现(用本模块的指针与屏蔽接口)
手柄摇杆的默认轴名映射现在要走 BindNamedAxis("MoveX", "Horizontal")(引擎默认自带);直接内置会在没配轴的工程里刷 Warn
改键 UI绑定文本与冲突检测已经就绪(SaveBindings / LoadBindings / FindConflicts),UI 归业务
Input System 包适配换包只需改 RevInputUnityDevice(采集层)一个文件,内核完全不用动